A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
72
fork

Configure Feed

Select the types of activity you want to include in your feed.

large list of ui fixes for accessibility/hardening etc.

+2466 -1154
+1 -1
.tangled/workflows/lint.yaml
··· 5 5 branch: ["main"] 6 6 7 7 engine: kubernetes 8 - image: golang:1.25-trixie 8 + image: golang:1.26-trixie 9 9 architecture: amd64 10 10 11 11 steps:
+1 -1
.tangled/workflows/release-credential-helper.yml
··· 12 12 tag: ["v*"] 13 13 14 14 engine: kubernetes 15 - image: golang:1.25-trixie 15 + image: golang:1.26-trixie 16 16 architecture: amd64 17 17 18 18 environment:
+1 -1
.tangled/workflows/tests.yml
··· 5 5 branch: ["main"] 6 6 7 7 engine: kubernetes 8 - image: golang:1.25-trixie 8 + image: golang:1.26-trixie 9 9 architecture: amd64 10 10 11 11 steps:
+6 -1
Dockerfile.appview
··· 18 18 RUN npm ci 19 19 RUN go generate ./... 20 20 21 + # Legal "Last updated" dates — pass from host (see Makefile docker-appview 22 + # target). Empty falls back to the hardcoded default in legal.go. 23 + ARG PRIVACY_DATE="" 24 + ARG TERMS_DATE="" 25 + 21 26 RUN CGO_ENABLED=1 go build \ 22 - -ldflags="-s -w -linkmode external -extldflags '-static'" \ 27 + -ldflags="-s -w -linkmode external -extldflags '-static' -X 'atcr.io/pkg/appview/handlers.privacyLastUpdated=${PRIVACY_DATE}' -X 'atcr.io/pkg/appview/handlers.termsLastUpdated=${TERMS_DATE}'" \ 23 28 -tags sqlite_omit_load_extension \ 24 29 -trimpath \ 25 30 -o atcr-appview ./cmd/appview
+26 -3
Makefile
··· 31 31 32 32 build: build-appview build-hold build-credential-helper ## Build all binaries 33 33 34 + # Legal page "Last updated" dates come from the git commit date of the page 35 + # templates. Empty values (e.g., Docker builds without .git) fall back to the 36 + # hardcoded default in legal.go. 37 + LEGAL_PKG := atcr.io/pkg/appview/handlers 38 + PRIVACY_DATE := $(shell git log -1 --format=%cs -- pkg/appview/templates/pages/privacy.html 2>/dev/null) 39 + TERMS_DATE := $(shell git log -1 --format=%cs -- pkg/appview/templates/pages/terms.html 2>/dev/null) 40 + APPVIEW_LDFLAGS := -X '$(LEGAL_PKG).privacyLastUpdated=$(PRIVACY_DATE)' -X '$(LEGAL_PKG).termsLastUpdated=$(TERMS_DATE)' 41 + 34 42 build-appview: $(GENERATED_ASSETS) ## Build appview binary only 35 43 @echo "→ Building appview..." 36 44 @mkdir -p bin 37 - go build -o bin/atcr-appview ./cmd/appview 45 + go build -ldflags="$(APPVIEW_LDFLAGS)" -o bin/atcr-appview ./cmd/appview 38 46 39 47 build-hold: $(GENERATED_ASSETS) ## Build hold binary only 40 48 @echo "→ Building hold..." ··· 69 77 70 78 .PHONY: check-golangci-lint 71 79 check-golangci-lint: 72 - @which golangci-lint > /dev/null || (echo "→ Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest) 80 + @LINT_PKG=github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest; \ 81 + CUR_GO=$$(go version | grep -oE 'go[0-9]+\.[0-9]+' | head -1 | sed 's/^go//'); \ 82 + if ! command -v golangci-lint > /dev/null 2>&1; then \ 83 + echo "→ Installing golangci-lint..."; \ 84 + go install $$LINT_PKG; \ 85 + else \ 86 + LINT_GO=$$(golangci-lint --version 2>&1 | grep -oE 'built with go[0-9]+\.[0-9]+' | head -1 | sed 's/^built with go//'); \ 87 + if [ -n "$$LINT_GO" ] && [ "$$LINT_GO" != "$$CUR_GO" ] && \ 88 + [ "$$(printf '%s\n%s\n' $$LINT_GO $$CUR_GO | sort -V | head -1)" = "$$LINT_GO" ]; then \ 89 + echo "→ golangci-lint built with go$$LINT_GO but project targets go$$CUR_GO — reinstalling..."; \ 90 + go install $$LINT_PKG; \ 91 + fi; \ 92 + fi 73 93 74 94 lint: check-golangci-lint ## Run golangci-lint 75 95 @echo "→ Running golangci-lint..." ··· 97 117 98 118 docker-appview: ## Build appview Docker image 99 119 @echo "→ Building appview Docker image..." 100 - docker build -f Dockerfile.appview -t atcr.io/atcr.io/appview:latest . 120 + docker build -f Dockerfile.appview \ 121 + --build-arg PRIVACY_DATE=$(PRIVACY_DATE) \ 122 + --build-arg TERMS_DATE=$(TERMS_DATE) \ 123 + -t atcr.io/atcr.io/appview:latest . 101 124 102 125 docker-hold: ## Build hold Docker image 103 126 @echo "→ Building hold Docker image..."
+4 -3
config-appview.example.yaml
··· 52 52 libsql_auth_token: "" 53 53 # How often to sync with remote libSQL server. Default: 60s. 54 54 libsql_sync_interval: 1m0s 55 + # Source code URL displayed in the footer "Source" link. Defaults to the upstream ATCR project. 56 + source_url: https://tangled.org/evan.jarrett.net/at-container-registry 55 57 # Health check and cache settings. 56 58 health: 57 59 # How long to cache hold health check results. ··· 74 76 relay_endpoints: 75 77 - https://relay1.us-east.bsky.network 76 78 - https://relay1.us-west.bsky.network 77 - - https://relay.waow.tech 78 79 # JWT authentication settings. 79 80 auth: 80 81 # RSA private key for signing registry JWTs issued to Docker clients. ··· 100 101 # ISO 4217 currency code (e.g. "usd"). 101 102 currency: usd 102 103 # Redirect URL after successful checkout. Use {base_url} placeholder. 103 - success_url: '{base_url}/settings#billing' 104 + success_url: '{base_url}/settings/billing' 104 105 # Redirect URL after cancelled checkout. Use {base_url} placeholder. 105 - cancel_url: '{base_url}/settings#billing' 106 + cancel_url: '{base_url}/settings/billing' 106 107 # Subscription tiers ordered by rank (lowest to highest). 107 108 tiers: 108 109 - # Tier name. Position in list determines rank (0-based).
+2 -2
docs/BILLING_REFACTOR.md
··· 206 206 billing: 207 207 enabled: true 208 208 currency: usd 209 - success_url: "{base_url}/settings#storage" 210 - cancel_url: "{base_url}/settings#storage" 209 + success_url: "{base_url}/settings/billing" 210 + cancel_url: "{base_url}/settings/billing" 211 211 tiers: 212 212 - name: "Free" 213 213 # No stripe_price = free tier
+6 -2
pkg/appview/config.go
··· 83 83 84 84 // How often to sync with the remote libSQL server. 85 85 LibsqlSyncInterval time.Duration `yaml:"libsql_sync_interval" comment:"How often to sync with remote libSQL server. Default: 60s."` 86 + 87 + // Source code URL displayed in the footer "Source" link. 88 + SourceURL string `yaml:"source_url" comment:"Source code URL displayed in the footer \"Source\" link. Defaults to the upstream ATCR project."` 86 89 } 87 90 88 91 // HealthConfig defines health check and cache settings ··· 162 165 v.SetDefault("ui.libsql_sync_url", "") 163 166 v.SetDefault("ui.libsql_auth_token", "") 164 167 v.SetDefault("ui.libsql_sync_interval", "60s") 168 + v.SetDefault("ui.source_url", "https://tangled.org/evan.jarrett.net/at-container-registry") 165 169 166 170 // Health defaults 167 171 v.SetDefault("health.cache_ttl", "15m") ··· 216 220 217 221 // Populate example billing tiers so operators see the structure 218 222 cfg.Billing.Currency = "usd" 219 - cfg.Billing.SuccessURL = "{base_url}/settings#billing" 220 - cfg.Billing.CancelURL = "{base_url}/settings#billing" 223 + cfg.Billing.SuccessURL = "{base_url}/settings/billing" 224 + cfg.Billing.CancelURL = "{base_url}/settings/billing" 221 225 cfg.Billing.OwnerBadge = true 222 226 cfg.Billing.Tiers = []billing.BillingTierConfig{ 223 227 {Name: "deckhand", Description: "Get started with basic storage", MaxWebhooks: 1},
+1
pkg/appview/handlers/base.go
··· 46 46 ClientName string // Full name: "AT Container Registry" 47 47 ClientShortName string // Short name: "ATCR" 48 48 AIAdvisorEnabled bool // True when Claude API key is configured 49 + SourceURL string // Source code URL for the footer "Source" link 49 50 }
+4
pkg/appview/handlers/common.go
··· 18 18 ClientShortName string // Brand name for templates (e.g., "ATCR") 19 19 OciClient string // Preferred OCI client for pull commands (e.g., "docker", "podman") 20 20 AIAdvisorEnabled bool // True when AI Image Advisor is available 21 + SourceURL string // Source code URL for the footer "Source" link 22 + CurrentPath string // Request path (used for OAuth return_to) 21 23 } 22 24 23 25 // NewPageData creates a PageData struct with common fields populated from the request ··· 36 38 ClientShortName: h.ClientShortName, 37 39 OciClient: ociClient, 38 40 AIAdvisorEnabled: h.AIAdvisorEnabled, 41 + SourceURL: h.SourceURL, 42 + CurrentPath: r.URL.RequestURI(), 39 43 } 40 44 } 41 45
+1 -1
pkg/appview/handlers/device.go
··· 527 527 <h1>✓ Device Authorized!</h1> 528 528 <p>Device <strong>{{.DeviceName}}</strong> has been successfully authorized.</p> 529 529 <p>You can now close this window and return to your terminal.</p> 530 - <p><a href="/settings#devices">View your authorized devices</a></p> 530 + <p><a href="/settings/devices">View your authorized devices</a></p> 531 531 </div> 532 532 </body> 533 533 </html>
+52 -10
pkg/appview/handlers/diff.go
··· 183 183 } 184 184 185 185 func addToSevCount(s *vulnSummary, severity string) { 186 - switch severity { 187 - case "Critical": 186 + // Normalize to canonical casing so "CRITICAL", "critical", "Crit" all land 187 + // in the same bucket. Unknown severities count toward the total but don't 188 + // bump any bucket — the template renders them as "Unknown" via the 189 + // severityLabel helper. 190 + switch strings.ToLower(strings.TrimSpace(severity)) { 191 + case "critical", "crit", "c": 188 192 s.Critical++ 189 - case "High": 193 + case "high", "h": 190 194 s.High++ 191 - case "Medium": 195 + case "medium", "med", "m": 192 196 s.Medium++ 193 - case "Low": 197 + case "low", "l": 194 198 s.Low++ 195 199 } 196 200 s.Total++ ··· 387 391 }() 388 392 wg.Wait() 389 393 390 - if fromData.err != nil || toData.err != nil { 391 - RenderNotFound(w, r, &h.BaseUIHandler) 392 - return 394 + // Track per-side fetch failures so we render the page with an inline 395 + // alert naming which tag failed, instead of a generic 404 that makes 396 + // users guess whether they typoed a tag or hit a transient outage. 397 + // fromData.manifest / toData.manifest is nil only when the re-fetch at 398 + // the top of fetchManifest hit a DB error (the tag resolution earlier 399 + // already ruled out typos). 400 + fromFailed := fromData.err != nil || fromData.manifest == nil 401 + toFailed := toData.err != nil || toData.manifest == nil 402 + 403 + // Fall back to the top-level manifest we already fetched so the page 404 + // still has something to render for tag labels and metadata. 405 + if fromFailed { 406 + fromData.manifest = fromManifest 407 + } 408 + if toFailed { 409 + toData.manifest = toManifest 393 410 } 394 411 395 412 // Compute diffs 396 413 layerDiff := computeLayerDiff(fromData.layers, toData.layers) 397 414 415 + // ScanStatus distinguishes why vuln data may be missing: "ok" when both 416 + // sides returned clean scan results; "no-data" when a scan was never 417 + // recorded; "hold-unreachable" when we couldn't reach the hold to ask. 418 + // The template branches on these so users can tell "not scanned yet" 419 + // from "hold offline" at a glance. 420 + fromScanStatus := "ok" 421 + toScanStatus := "ok" 422 + if fromData.vulnData == nil { 423 + fromScanStatus = "hold-unreachable" 424 + } else if fromData.vulnData.Error != "" { 425 + fromScanStatus = "no-data" 426 + } 427 + if toData.vulnData == nil { 428 + toScanStatus = "hold-unreachable" 429 + } else if toData.vulnData.Error != "" { 430 + toScanStatus = "no-data" 431 + } 432 + 398 433 var vulnDiff []VulnDiffEntry 399 - hasVulnData := fromData.vulnData != nil && toData.vulnData != nil && 400 - fromData.vulnData.Error == "" && toData.vulnData.Error == "" 434 + hasVulnData := fromScanStatus == "ok" && toScanStatus == "ok" 401 435 if hasVulnData { 402 436 vulnDiff = computeVulnDiff(fromData.vulnData.Matches, toData.vulnData.Matches) 403 437 } ··· 448 482 NewVulns []vulnMatch 449 483 UnchangedVulns []vulnMatch 450 484 HasVulnData bool 485 + FromScanStatus string 486 + ToScanStatus string 487 + FromFailed bool 488 + ToFailed bool 451 489 IsMultiArch bool 452 490 CommonPlatforms []db.PlatformInfo 453 491 SelectedPlatform string ··· 468 506 NewVulns: newVulns, 469 507 UnchangedVulns: unchangedVulns, 470 508 HasVulnData: hasVulnData, 509 + FromScanStatus: fromScanStatus, 510 + ToScanStatus: toScanStatus, 511 + FromFailed: fromFailed, 512 + ToFailed: toFailed, 471 513 IsMultiArch: isMultiArch, 472 514 CommonPlatforms: commonPlatforms, 473 515 SelectedPlatform: selectedPlatform,
+82 -38
pkg/appview/handlers/digest_content.go
··· 4 4 "log/slog" 5 5 "net/http" 6 6 "strings" 7 + "sync" 7 8 8 9 "atcr.io/pkg/appview/db" 9 10 "atcr.io/pkg/appview/holdclient" ··· 21 22 identifier := chi.URLParam(r, "handle") 22 23 wildcard := strings.TrimPrefix(chi.URLParam(r, "*"), "/") 23 24 24 - // The wildcard is the repository name 25 25 repository := wildcard 26 - 27 - // The platform digest comes from query param 28 26 digest := r.URL.Query().Get("digest") 29 27 if digest == "" || repository == "" { 30 28 http.Error(w, "missing parameters", http.StatusBadRequest) 31 29 return 32 30 } 33 31 34 - // Resolve identity 35 32 did, _, _, err := atproto.ResolveIdentity(r.Context(), identifier) 36 33 if err != nil { 37 34 http.Error(w, "not found", http.StatusNotFound) 38 35 return 39 36 } 40 37 41 - // Fetch manifest details for the platform digest 42 38 manifest, err := db.GetManifestDetail(h.ReadOnlyDB, did, repository, digest) 43 39 if err != nil { 44 40 http.Error(w, "manifest not found", http.StatusNotFound) 45 41 return 46 42 } 47 43 48 - // Fetch layers from DB 49 - var layers []LayerDetail 50 - var vulnData *vulnDetailsData 51 - 52 44 dbLayers, err := db.GetLayersForManifest(h.ReadOnlyDB, manifest.ID) 53 45 if err != nil { 54 46 slog.Warn("Failed to fetch layers", "error", err) 55 47 } 56 48 57 - // Resolve hold endpoint (follow successor if migrated) 58 49 hold, holdErr := ResolveHold(r.Context(), h.ReadOnlyDB, manifest.HoldEndpoint) 50 + holdReachable := holdErr == nil 59 51 60 - // Fetch OCI image config from hold for layer history 61 - if holdErr == nil { 62 - config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, digest) 63 - if err == nil { 64 - layers = buildLayerDetails(config.History, dbLayers) 65 - } else { 66 - slog.Warn("Failed to fetch image config", "error", err, 67 - "holdEndpoint", manifest.HoldEndpoint, "manifestDigest", digest) 68 - layers = buildLayerDetails(nil, dbLayers) 69 - } 52 + // Parallelize the three hold fetches. They're independent and each 53 + // takes a network round-trip; serial runs add up on slow links. 54 + var ( 55 + layers []LayerDetail 56 + vulnData *vulnDetailsData 57 + sbomData *sbomDetailsData 58 + configFetchError bool 59 + ) 60 + 61 + if holdReachable { 62 + var wg sync.WaitGroup 63 + wg.Add(3) 64 + 65 + go func() { 66 + defer wg.Done() 67 + config, err := holdclient.FetchImageConfig(r.Context(), hold.URL, digest) 68 + if err == nil { 69 + layers = buildLayerDetails(config.History, dbLayers) 70 + } else { 71 + slog.Warn("Failed to fetch image config", "error", err, 72 + "holdEndpoint", manifest.HoldEndpoint, "manifestDigest", digest) 73 + layers = buildLayerDetails(nil, dbLayers) 74 + configFetchError = true 75 + } 76 + }() 77 + 78 + go func() { 79 + defer wg.Done() 80 + vd := FetchVulnDetails(r.Context(), hold.DID, digest) 81 + vulnData = &vd 82 + }() 83 + 84 + go func() { 85 + defer wg.Done() 86 + sd := FetchSbomDetails(r.Context(), hold.DID, digest) 87 + sbomData = &sd 88 + }() 89 + 90 + wg.Wait() 70 91 } else { 71 92 layers = buildLayerDetails(nil, dbLayers) 72 93 } 73 94 74 - // Fetch vulnerability and SBOM details 75 - var sbomData *sbomDetailsData 76 - if holdErr == nil { 77 - vd := FetchVulnDetails(r.Context(), hold.DID, digest) 78 - vulnData = &vd 79 - sd := FetchSbomDetails(r.Context(), hold.DID, digest) 80 - sbomData = &sd 95 + // VulnReason / SbomReason let the template branch distinctly on why 96 + // data is missing instead of collapsing three causes into a generic 97 + // "not available" message. 98 + // ok — data is present 99 + // hold-unreachable — we couldn't reach the hold 100 + // not-scanned — hold is up but no scan record exists 101 + // fetch-failed — scan record fetch failed on the hold 102 + vulnReason := "ok" 103 + if !holdReachable { 104 + vulnReason = "hold-unreachable" 105 + } else if vulnData == nil || vulnData.Error == "never-scanned" { 106 + vulnReason = "not-scanned" 107 + } else if vulnData.Error != "" { 108 + vulnReason = "fetch-failed" 109 + } 110 + 111 + sbomReason := "ok" 112 + if !holdReachable { 113 + sbomReason = "hold-unreachable" 114 + } else if sbomData == nil || sbomData.Error == "never-scanned" { 115 + sbomReason = "not-scanned" 116 + } else if sbomData.Error != "" { 117 + sbomReason = "fetch-failed" 81 118 } 82 119 83 120 data := struct { 84 - Layers []LayerDetail 85 - VulnData *vulnDetailsData 86 - SbomData *sbomDetailsData 121 + Layers []LayerDetail 122 + VulnData *vulnDetailsData 123 + SbomData *sbomDetailsData 124 + HoldReachable bool 125 + ConfigFetchError bool 126 + VulnReason string 127 + SbomReason string 87 128 }{ 88 - Layers: layers, 89 - VulnData: vulnData, 90 - SbomData: sbomData, 129 + Layers: layers, 130 + VulnData: vulnData, 131 + SbomData: sbomData, 132 + HoldReachable: holdReachable, 133 + ConfigFetchError: configFetchError, 134 + VulnReason: vulnReason, 135 + SbomReason: sbomReason, 91 136 } 92 137 93 138 w.Header().Set("Content-Type", "text/html") 94 139 95 - // Support rendering individual sections for repo page tabs 96 140 section := r.URL.Query().Get("section") 97 141 switch section { 98 142 case "layers": 99 143 if err := h.Templates.ExecuteTemplate(w, "layers-section", data); err != nil { 100 144 slog.Warn("Failed to render layers section", "error", err) 101 - http.Error(w, err.Error(), http.StatusInternalServerError) 145 + RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render layers", err) 102 146 } 103 147 case "vulns": 104 148 if err := h.Templates.ExecuteTemplate(w, "vulns-section", data); err != nil { 105 149 slog.Warn("Failed to render vulns section", "error", err) 106 - http.Error(w, err.Error(), http.StatusInternalServerError) 150 + RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render vulnerabilities", err) 107 151 } 108 152 case "sbom": 109 153 if err := h.Templates.ExecuteTemplate(w, "sbom-section", data); err != nil { 110 154 slog.Warn("Failed to render sbom section", "error", err) 111 - http.Error(w, err.Error(), http.StatusInternalServerError) 155 + RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render SBOM", err) 112 156 } 113 157 default: 114 158 if err := h.Templates.ExecuteTemplate(w, "digest-content", data); err != nil { 115 159 slog.Warn("Failed to render digest content", "error", err) 116 - http.Error(w, err.Error(), http.StatusInternalServerError) 160 + RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render content", err) 117 161 } 118 162 } 119 163 }
+32
pkg/appview/handlers/errors.go
··· 1 1 package handlers 2 2 3 3 import ( 4 + "encoding/json" 5 + "log/slog" 4 6 "net/http" 5 7 ) 6 8 ··· 36 38 http.Error(w, "Page not found", http.StatusNotFound) 37 39 } 38 40 } 41 + 42 + // RenderHTMXError sends an error response suitable for htmx. For htmx requests 43 + // it sets an HX-Trigger header so the client fires a toast event; the JS 44 + // fallback in app.js will show a generic toast even without the header. 45 + // For non-htmx requests it falls back to http.Error. serverErr is logged but 46 + // never exposed to the user — pass userMsg for anything screen-readable. 47 + func RenderHTMXError(w http.ResponseWriter, r *http.Request, status int, userMsg string, serverErr error) { 48 + if serverErr != nil { 49 + slog.Error("htmx handler error", 50 + "path", r.URL.Path, 51 + "status", status, 52 + "err", serverErr, 53 + ) 54 + } 55 + if userMsg == "" { 56 + userMsg = http.StatusText(status) 57 + } 58 + if r.Header.Get("HX-Request") == "true" { 59 + trigger := map[string]map[string]string{ 60 + "toast": {"message": userMsg, "type": "error"}, 61 + } 62 + if b, err := json.Marshal(trigger); err == nil { 63 + w.Header().Set("HX-Trigger", string(b)) 64 + } 65 + w.Header().Set("HX-Reswap", "none") 66 + w.WriteHeader(status) 67 + return 68 + } 69 + http.Error(w, userMsg, status) 70 + }
+12 -6
pkg/appview/handlers/home.go
··· 4 4 package handlers 5 5 6 6 import ( 7 - "log" 7 + "log/slog" 8 8 "net/http" 9 9 10 10 "atcr.io/pkg/appview/db" ··· 17 17 } 18 18 19 19 func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 20 - // Get current user DID (empty string if not logged in) 21 20 var currentUserDID string 22 21 if user := middleware.GetUser(r); user != nil { 23 22 currentUserDID = user.DID 24 23 } 25 24 26 - // Fetch featured repositories (top 6 by score - carousel cycles through them) 25 + // Track whether either card query failed so the page can surface a 26 + // distinct error banner instead of the "no repos yet" empty state. 27 + // Partial failures still render whatever did succeed. 28 + var queryError bool 29 + 27 30 featuredCards, err := db.GetRepoCards(h.ReadOnlyDB, 6, currentUserDID, db.SortByScore) 28 31 if err != nil { 29 - log.Printf("Error fetching featured repos: %v", err) 32 + slog.Error("home: fetch featured repos", "err", err) 30 33 featuredCards = []db.RepoCardData{} 34 + queryError = true 31 35 } 32 36 db.SetRegistryURL(featuredCards, h.RegistryURL) 33 37 34 - // Fetch recently updated repositories (top 18 by last push - 6 rows at 3-col lg) 35 38 recentCards, err := db.GetRepoCards(h.ReadOnlyDB, 18, currentUserDID, db.SortByLastUpdate) 36 39 if err != nil { 37 - log.Printf("Error fetching recent repos: %v", err) 40 + slog.Error("home: fetch recent repos", "err", err) 38 41 recentCards = []db.RepoCardData{} 42 + queryError = true 39 43 } 40 44 db.SetRegistryURL(recentCards, h.RegistryURL) 41 45 ··· 48 52 Meta *PageMeta 49 53 FeaturedRepos []db.RepoCardData 50 54 RecentRepos []db.RepoCardData 55 + HasError bool 51 56 }{ 52 57 PageData: pageData, 53 58 Meta: NewPageMeta( ··· 63 68 ), 64 69 FeaturedRepos: featuredCards, 65 70 RecentRepos: recentCards, 71 + HasError: queryError, 66 72 } 67 73 68 74 if err := h.Templates.ExecuteTemplate(w, "home", data); err != nil {
+16 -6
pkg/appview/handlers/image_advisor.go
··· 40 40 type imageAdvisorData struct { 41 41 Suggestions []advisorSuggestion 42 42 Error string 43 + // Model is shown in the results footer so users can attribute the 44 + // suggestions to a specific model without us hardcoding it in the template. 45 + Model string 43 46 } 47 + 48 + // advisorModel is the Claude model used for image suggestions. Kept in one 49 + // place so the API call and the template footer stay in sync. 50 + const advisorModel = "claude-haiku-4-5-20251001" 51 + const advisorModelDisplay = "Claude Haiku 4.5" 44 52 45 53 // OCI config types for full image config parsing 46 54 type advisorOCIConfig struct { ··· 168 176 suggestions, err := parseAdvisorResponse(cachedJSON) 169 177 if err == nil { 170 178 slog.Debug("Serving cached advisor suggestions", "digest", digest) 171 - h.renderResults(w, imageAdvisorData{Suggestions: suggestions}) 179 + h.renderResults(w, imageAdvisorData{Suggestions: suggestions, Model: advisorModelDisplay}) 172 180 return 173 181 } 174 182 slog.Debug("Cached advisor data unparseable, fetching fresh", "digest", digest) ··· 217 225 var promptBuf strings.Builder 218 226 generateAdvisorPrompt(&promptBuf, report) 219 227 220 - // Call Claude API 228 + // Call Claude API. The raw error often contains upstream HTTP body text 229 + // which we must not surface to the user (potential secrets/PII). Log the 230 + // detail; show a stable, sanitized message. 221 231 responseText, err := callClaudeAPI(ctx, h.ClaudeAPIKey, promptBuf.String()) 222 232 if err != nil { 223 233 slog.Warn("Claude API call failed", "error", err) 224 - h.renderResults(w, imageAdvisorData{Error: "AI service request failed: " + err.Error()}) 234 + h.renderResults(w, imageAdvisorData{Error: "The AI service couldn't generate suggestions right now. Please try again in a minute."}) 225 235 return 226 236 } 227 237 ··· 229 239 suggestions, err := parseAdvisorResponse(responseText) 230 240 if err != nil { 231 241 slog.Warn("Failed to parse advisor response", "error", err, "response", responseText) 232 - h.renderResults(w, imageAdvisorData{Error: "Failed to parse AI response"}) 242 + h.renderResults(w, imageAdvisorData{Error: "We got a response from the AI service but couldn't read it. Please try again."}) 233 243 return 234 244 } 235 245 ··· 238 248 slog.Warn("Failed to cache advisor suggestions", "error", err) 239 249 } 240 250 241 - h.renderResults(w, imageAdvisorData{Suggestions: suggestions}) 251 + h.renderResults(w, imageAdvisorData{Suggestions: suggestions, Model: advisorModelDisplay}) 242 252 } 243 253 244 254 func (h *ImageAdvisorHandler) renderResults(w http.ResponseWriter, data imageAdvisorData) { ··· 583 593 // callClaudeAPI sends the prompt to Claude Haiku using tool use and returns the structured JSON. 584 594 func callClaudeAPI(ctx context.Context, apiKey, prompt string) (string, error) { 585 595 reqBody := map[string]any{ 586 - "model": "claude-haiku-4-5-20251001", 596 + "model": advisorModel, 587 597 "max_tokens": 2048, 588 598 "system": "Analyze the container image data. Provide actionable suggestions sorted by impact (highest first).", 589 599 "tools": []map[string]any{{
+44 -5
pkg/appview/handlers/legal.go
··· 2 2 3 3 import ( 4 4 "net/http" 5 + "time" 5 6 ) 6 7 7 - // LegalPageData contains data for legal pages (terms, privacy) 8 + // LegalPageData contains data for legal pages (terms, privacy). 8 9 type LegalPageData struct { 9 10 PageData 10 11 Meta *PageMeta 11 12 CompanyName string 12 13 Jurisdiction string 14 + LastUpdated string 15 + } 16 + 17 + // legalDefaults applies sensible fallbacks for operators who haven't set 18 + // CompanyName/Jurisdiction in config. 19 + func legalDefaults(company, jurisdiction string) (string, string) { 20 + if company == "" { 21 + company = "the Service" 22 + } 23 + if jurisdiction == "" { 24 + jurisdiction = "United States" 25 + } 26 + return company, jurisdiction 27 + } 28 + 29 + // Stamped at build time from the git commit date of the corresponding page 30 + // template via -ldflags -X (see Makefile). Empty falls back to legalFallbackDate 31 + // for bare `go build` / builds without a .git directory. 32 + var ( 33 + privacyLastUpdated string 34 + termsLastUpdated string 35 + ) 36 + 37 + const legalFallbackDate = "April 2026" 38 + 39 + func formatLegalDate(raw string) string { 40 + if raw == "" { 41 + return legalFallbackDate 42 + } 43 + t, err := time.Parse("2006-01-02", raw) 44 + if err != nil { 45 + return raw 46 + } 47 + return t.Format("January 2, 2006") 13 48 } 14 49 15 50 // PrivacyPolicyHandler handles the /privacy page ··· 24 59 ).WithCanonical("https://" + h.SiteURL + "/privacy"). 25 60 WithSiteName(h.ClientShortName) 26 61 62 + company, jurisdiction := legalDefaults(h.CompanyName, h.Jurisdiction) 27 63 data := LegalPageData{ 28 64 PageData: NewPageData(r, &h.BaseUIHandler), 29 65 Meta: meta, 30 - CompanyName: h.CompanyName, 31 - Jurisdiction: h.Jurisdiction, 66 + CompanyName: company, 67 + Jurisdiction: jurisdiction, 68 + LastUpdated: formatLegalDate(privacyLastUpdated), 32 69 } 33 70 34 71 if err := h.Templates.ExecuteTemplate(w, "privacy", data); err != nil { ··· 49 86 ).WithCanonical("https://" + h.SiteURL + "/terms"). 50 87 WithSiteName(h.ClientShortName) 51 88 89 + company, jurisdiction := legalDefaults(h.CompanyName, h.Jurisdiction) 52 90 data := LegalPageData{ 53 91 PageData: NewPageData(r, &h.BaseUIHandler), 54 92 Meta: meta, 55 - CompanyName: h.CompanyName, 56 - Jurisdiction: h.Jurisdiction, 93 + CompanyName: company, 94 + Jurisdiction: jurisdiction, 95 + LastUpdated: formatLegalDate(termsLastUpdated), 57 96 } 58 97 59 98 if err := h.Templates.ExecuteTemplate(w, "terms", data); err != nil {
+44 -10
pkg/appview/handlers/manifest_health.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "log/slog" 7 + "net" 6 8 "net/http" 7 9 "net/url" 10 + "strings" 8 11 "time" 9 12 ) 10 13 14 + // classifyHealthError maps a CheckHealth error into a short reason code that 15 + // the template turns into a distinct tooltip. Prevents the badge from 16 + // collapsing every failure mode into a generic "Offline". 17 + // 18 + // Returns one of: "dns", "tls", "refused", "timeout", "http", "unknown" 19 + // (empty string when err is nil). 20 + func classifyHealthError(err error) string { 21 + if err == nil { 22 + return "" 23 + } 24 + var dnsErr *net.DNSError 25 + if errors.As(err, &dnsErr) { 26 + return "dns" 27 + } 28 + msg := strings.ToLower(err.Error()) 29 + if strings.Contains(msg, "x509") || strings.Contains(msg, "tls:") || strings.Contains(msg, "certificate") { 30 + return "tls" 31 + } 32 + if strings.Contains(msg, "connection refused") { 33 + return "refused" 34 + } 35 + if strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline exceeded") { 36 + return "timeout" 37 + } 38 + if strings.Contains(msg, "status") || strings.Contains(msg, "http") { 39 + return "http" 40 + } 41 + return "unknown" 42 + } 43 + 11 44 // ManifestHealthHandler handles HTMX polling for manifest health status 12 45 type ManifestHealthHandler struct { 13 46 BaseUIHandler ··· 32 65 cached := h.HealthChecker.GetCachedStatus(endpoint) 33 66 if cached != nil { 34 67 // Cache hit - return final status 35 - h.renderBadge(w, endpoint, cached.Reachable, false) 68 + h.renderBadge(w, endpoint, cached.Reachable, false, "") 36 69 return 37 70 } 38 71 ··· 43 76 reachable, err := h.HealthChecker.CheckHealth(ctx, endpoint) 44 77 45 78 // Check for HTTP errors first (connection refused, network unreachable, etc.) 46 - // This ensures we catch real failures even when timing aligns with context timeout 79 + // This ensures we catch real failures even when timing aligns with context timeout. 47 80 if err != nil { 48 - // Error - mark as unreachable 49 - h.renderBadge(w, endpoint, false, false) 81 + h.renderBadge(w, endpoint, false, false, classifyHealthError(err)) 50 82 } else if ctx.Err() == context.DeadlineExceeded { 51 - // Context timed out but no HTTP error yet - still pending 52 - h.renderBadge(w, endpoint, false, true) 83 + h.renderBadge(w, endpoint, false, true, "") 53 84 } else { 54 - // Success 55 - h.renderBadge(w, endpoint, reachable, false) 85 + h.renderBadge(w, endpoint, reachable, false, "") 56 86 } 57 87 } 58 88 59 - // renderBadge renders the appropriate badge HTML snippet 60 - func (h *ManifestHealthHandler) renderBadge(w http.ResponseWriter, endpoint string, reachable, pending bool) { 89 + // renderBadge renders the appropriate badge HTML snippet. Reason is one of the 90 + // classifyHealthError codes ("dns", "tls", "refused", "timeout", "http", 91 + // "unknown") or empty for success / pending states. 92 + func (h *ManifestHealthHandler) renderBadge(w http.ResponseWriter, endpoint string, reachable, pending bool, reason string) { 61 93 w.Header().Set("Content-Type", "text/html") 62 94 63 95 data := struct { 64 96 Pending bool 65 97 Reachable bool 98 + Reason string 66 99 RetryURL string 67 100 }{ 68 101 Pending: pending, 69 102 Reachable: reachable, 103 + Reason: reason, 70 104 RetryURL: url.QueryEscape(endpoint), 71 105 } 72 106
+24 -4
pkg/appview/handlers/meta.go
··· 3 3 // PageMeta holds all metadata for a page's <head> section. 4 4 // Use the builder methods to construct it with a fluent API. 5 5 type PageMeta struct { 6 - Title string // Page title (required) 7 - Description string // Meta description (required) 6 + Title string // Page title (required; empty falls back to SiteName in template) 7 + Description string // Meta description (required; empty omits the tag entirely) 8 8 Canonical string // Canonical URL (optional) 9 9 Robots string // Robots directive, e.g. "noindex" (optional, defaults to "index, follow") 10 10 OGType string // OpenGraph type, defaults to "website" 11 11 OGImage string // OpenGraph image URL (optional) 12 + OGImageAlt string // OpenGraph image alt text — improves social-share a11y 13 + OGLocale string // OpenGraph locale (e.g. "en_US"); blank falls back in template 12 14 TwitterCard string // Twitter card type, defaults to "summary_large_image" 13 - SiteName string // Site name for og:site_name (optional, defaults to "ATCR") 15 + SiteName string // Site name for og:site_name (falls back to "ATCR" in template) 14 16 JSONLD []any // JSON-LD structured data objects (optional) 15 17 } 16 18 17 19 // NewPageMeta creates a new PageMeta with required fields and sensible defaults. 20 + // Callers should not pass empty title/description — the template falls back to 21 + // the SiteName for missing title and omits missing description, but those are 22 + // last-resort defenses. 18 23 func NewPageMeta(title, description string) *PageMeta { 19 24 return &PageMeta{ 20 25 Title: title, ··· 36 41 return m 37 42 } 38 43 44 + // WithOGImageAlt sets the alt text for the OpenGraph image. Strongly recommended 45 + // when OGImage is set — screen readers on social platforms read this out. 46 + func (m *PageMeta) WithOGImageAlt(alt string) *PageMeta { 47 + m.OGImageAlt = alt 48 + return m 49 + } 50 + 51 + // WithOGLocale overrides the default "en_US" locale. 52 + func (m *PageMeta) WithOGLocale(locale string) *PageMeta { 53 + m.OGLocale = locale 54 + return m 55 + } 56 + 39 57 // WithOGType sets the OpenGraph type (e.g., "website", "profile", "article"). 40 58 func (m *PageMeta) WithOGType(ogType string) *PageMeta { 41 59 m.OGType = ogType ··· 54 72 return m 55 73 } 56 74 57 - // WithSiteName sets the site name for og:site_name. 75 + // WithSiteName sets the site name for og:site_name. Pass the caller's 76 + // ClientShortName — forgetting this on a branded deployment (e.g. Seamark) 77 + // leaks "ATCR" into social previews. 58 78 func (m *PageMeta) WithSiteName(name string) *PageMeta { 59 79 m.SiteName = name 60 80 return m
+42 -30
pkg/appview/handlers/repository.go
··· 182 182 repo.Version = metadata["org.opencontainers.image.version"] 183 183 } 184 184 185 - // Fetch stats 185 + // Fetch stats. Track availability separately so the template can render 186 + // "—" or hide the stats row instead of showing zeros that masquerade as 187 + // real counts. 186 188 stats, err := db.GetRepositoryStats(h.ReadOnlyDB, owner.DID, repository) 189 + statsAvailable := err == nil 187 190 if err != nil { 188 191 slog.Warn("Failed to fetch repository stats", "error", err) 189 192 stats = &db.RepositoryStats{StarCount: 0} ··· 210 213 isOwner = (user.DID == owner.DID) 211 214 } 212 215 213 - // Fetch README content from repo page record or annotations 216 + // Fetch README content from repo page record or annotations. 217 + // ReadmeFetchFailed distinguishes "owner never provided a README" (show 218 + // CTA to add one) from "we tried to fetch the configured README and it 219 + // failed" (show retry CTA instead). 214 220 var readmeHTML template.HTML 215 221 var rawDescription string 222 + var readmeFetchFailed bool 216 223 217 224 repoPage, err := db.GetRepoPage(h.ReadOnlyDB, owner.DID, repository) 218 225 if err == nil && repoPage != nil { ··· 238 245 } 239 246 } 240 247 if readmeURL != "" { 241 - // Fetch raw markdown for editor pre-fill, then render 242 248 rawBytes, fetchErr := h.ReadmeFetcher.FetchRaw(r.Context(), readmeURL) 243 249 if fetchErr != nil { 244 250 slog.Debug("Failed to fetch README from URL", "url", readmeURL, "error", fetchErr) 251 + readmeFetchFailed = true 245 252 } else { 246 253 rawDescription = string(rawBytes) 247 254 html, renderErr := h.ReadmeFetcher.RenderMarkdown(rawBytes) 248 255 if renderErr != nil { 249 256 slog.Debug("Failed to render fetched README", "url", readmeURL, "error", renderErr) 257 + readmeFetchFailed = true 250 258 } else { 251 259 readmeHTML = template.HTML(html) 252 260 } ··· 299 307 300 308 data := struct { 301 309 PageData 302 - Meta *PageMeta 303 - Owner *db.User 304 - Repository *db.Repository 305 - AllTags []string 306 - SelectedTag *SelectedTagData 307 - Stats *db.RepositoryStats 308 - TagCount int 309 - IsStarred bool 310 - IsOwner bool 311 - ReadmeHTML template.HTML 312 - RawDescription string 313 - ArtifactType string 314 - NonDefaultHolds []string 310 + Meta *PageMeta 311 + Owner *db.User 312 + Repository *db.Repository 313 + AllTags []string 314 + SelectedTag *SelectedTagData 315 + Stats *db.RepositoryStats 316 + StatsAvailable bool 317 + TagCount int 318 + IsStarred bool 319 + IsOwner bool 320 + ReadmeHTML template.HTML 321 + ReadmeFetchFailed bool 322 + RawDescription string 323 + ArtifactType string 324 + NonDefaultHolds []string 315 325 }{ 316 - PageData: NewPageData(r, &h.BaseUIHandler), 317 - Meta: meta, 318 - Owner: owner, 319 - Repository: repo, 320 - AllTags: allTags, 321 - SelectedTag: selectedTag, 322 - Stats: stats, 323 - TagCount: tagCount, 324 - IsStarred: isStarred, 325 - IsOwner: isOwner, 326 - ReadmeHTML: readmeHTML, 327 - RawDescription: rawDescription, 328 - ArtifactType: artifactType, 329 - NonDefaultHolds: nonDefaultHolds, 326 + PageData: NewPageData(r, &h.BaseUIHandler), 327 + Meta: meta, 328 + Owner: owner, 329 + Repository: repo, 330 + AllTags: allTags, 331 + SelectedTag: selectedTag, 332 + Stats: stats, 333 + StatsAvailable: statsAvailable, 334 + TagCount: tagCount, 335 + IsStarred: isStarred, 336 + IsOwner: isOwner, 337 + ReadmeHTML: readmeHTML, 338 + ReadmeFetchFailed: readmeFetchFailed, 339 + RawDescription: rawDescription, 340 + ArtifactType: artifactType, 341 + NonDefaultHolds: nonDefaultHolds, 330 342 } 331 343 332 344 // If the owner has disabled AI advisor in their profile, hide the button
+25 -6
pkg/appview/handlers/scan_result.go
··· 25 25 } 26 26 27 27 // vulnBadgeData is the template data for the vuln-badge partial. 28 + // The badge renders one of four states, in priority order: 29 + // 1. Error — we couldn't reach the hold at all (network/5xx) 30 + // 2. NotScanned — hold reachable, no scan record for this digest (404) 31 + // 3. ScanFailed — scan record exists but the scanner didn't produce an SBOM 32 + // 4. Found — scan succeeded; render tier counts (or "Clean" when zero) 33 + // 34 + // These states must stay distinct so users can tell "hold is down" from 35 + // "this hasn't been scanned yet" from "scanner errored on this image". 28 36 type vulnBadgeData struct { 29 37 Critical int64 30 38 High int64 ··· 32 40 Low int64 33 41 Total int64 34 42 ScannedAt string 35 - Found bool // true if scan record exists 36 - Error bool // true if hold unreachable or error 37 - ScanFailed bool // true if scan record exists but scan failed (no blobs) 43 + Found bool // true if scan record exists and succeeded 44 + Error bool // true if hold unreachable (network/5xx) 45 + NotScanned bool // true if hold is up but no scan record (404) 46 + ScanFailed bool // true if scan record exists but scan failed (no SBOM) 38 47 Digest string // for the detail modal link 39 48 HoldEndpoint string // for the detail modal link 40 49 } ··· 87 96 defer resp.Body.Close() 88 97 89 98 if resp.StatusCode == http.StatusNotFound { 90 - // No scan record — scanning disabled or not yet scanned. Render nothing. 91 - h.renderBadge(w, vulnBadgeData{Error: true}) 99 + // Hold is reachable but has no scan record — not yet scanned, or 100 + // the image was pushed before scanning was enabled. 101 + h.renderBadge(w, vulnBadgeData{NotScanned: true}) 92 102 return 93 103 } 94 104 ··· 160 170 } 161 171 defer resp.Body.Close() 162 172 173 + if resp.StatusCode == http.StatusNotFound { 174 + return vulnBadgeData{NotScanned: true} 175 + } 163 176 if resp.StatusCode != http.StatusOK { 164 177 return vulnBadgeData{Error: true} 165 178 } ··· 214 227 if err != nil { 215 228 slog.Debug("Failed to resolve hold for batch scan", "holdEndpoint", holdEndpoint, "error", err) 216 229 w.Header().Set("Content-Type", "text/html") 230 + // Emit "not scanned" badge for every digest so the placeholder resolves visibly. 231 + var buf bytes.Buffer 232 + if err := h.Templates.ExecuteTemplate(&buf, "vuln-badge", vulnBadgeData{Error: true}); err != nil { 233 + slog.Warn("Failed to render vuln-badge placeholder", "error", err) 234 + } 217 235 for _, d := range digests { 218 - fmt.Fprintf(w, `<span id="scan-badge-%s" hx-swap-oob="outerHTML"></span>`, template.HTMLEscapeString(d)) 236 + fmt.Fprintf(w, `<span id="scan-badge-%s" hx-swap-oob="outerHTML">%s</span>`, 237 + template.HTMLEscapeString(d), buf.String()) 219 238 } 220 239 return 221 240 }
+10 -7
pkg/appview/handlers/scan_result_test.go
··· 165 165 166 166 body := strings.TrimSpace(rr.Body.String()) 167 167 168 - // 404 = no scan record. Should render NOTHING — not "Scan pending". 169 - if body != "" { 170 - t.Errorf("Expected empty body for 404, got: %q", body) 168 + // 404 = no scan record yet. Renders a visible "Not scanned" placeholder 169 + // so the htmx target resolves instead of staying empty forever. 170 + if !strings.Contains(body, "Not scanned") { 171 + t.Errorf("Expected 'Not scanned' placeholder for 404, got: %q", body) 171 172 } 172 173 } 173 174 ··· 189 190 190 191 body := strings.TrimSpace(rr.Body.String()) 191 192 192 - if body != "" { 193 - t.Errorf("Expected empty body for hold error, got: %q", body) 193 + // Hold reachable but returned 5xx — distinct from "not scanned". 194 + if !strings.Contains(body, "Hold offline") { 195 + t.Errorf("Expected 'Hold offline' badge for hold error, got: %q", body) 194 196 } 195 197 } 196 198 ··· 207 209 208 210 body := strings.TrimSpace(rr.Body.String()) 209 211 210 - if body != "" { 211 - t.Errorf("Expected empty body for unreachable hold, got: %q", body) 212 + // Network-unreachable hold — also distinct from "not scanned". 213 + if !strings.Contains(body, "Hold offline") { 214 + t.Errorf("Expected 'Hold offline' badge for unreachable hold, got: %q", body) 212 215 } 213 216 } 214 217
+92 -59
pkg/appview/handlers/search.go
··· 9 9 "atcr.io/pkg/appview/middleware" 10 10 ) 11 11 12 - // SearchHandler handles the search page 12 + // searchPageSize is the per-page result count for both initial render and 13 + // "Load More" pagination. Kept consistent so noscript and htmx paths agree. 14 + const searchPageSize = 50 15 + 16 + // searchResults holds the data shared by the full-page and partial renders. 17 + // Pulled out so SearchHandler can server-render the first page inline and 18 + // SearchResultsHandler can emit just the partial for htmx Load More. 19 + type searchResults struct { 20 + PageData 21 + Repositories []db.RepoCardData 22 + SearchQuery string 23 + HasMore bool 24 + NextOffset int 25 + // HasError is true when the DB query failed. Template branches to the 26 + // shared error state rather than the empty-results copy. 27 + HasError bool 28 + } 29 + 30 + func (h *BaseUIHandler) runSearch(r *http.Request, query string, offset int) (searchResults, error) { 31 + pageData := NewPageData(r, h) 32 + 33 + var currentUserDID string 34 + if user := middleware.GetUser(r); user != nil { 35 + currentUserDID = user.DID 36 + } 37 + 38 + repos, total, err := db.SearchRepositories(h.ReadOnlyDB, query, searchPageSize, offset, currentUserDID) 39 + if err != nil { 40 + return searchResults{ 41 + PageData: pageData, 42 + SearchQuery: query, 43 + HasError: true, 44 + }, err 45 + } 46 + 47 + db.SetRegistryURL(repos, h.RegistryURL) 48 + db.SetOciClient(repos, pageData.OciClient) 49 + 50 + return searchResults{ 51 + PageData: pageData, 52 + Repositories: repos, 53 + SearchQuery: query, 54 + HasMore: offset+searchPageSize < total, 55 + NextOffset: offset + searchPageSize, 56 + }, nil 57 + } 58 + 59 + // SearchHandler handles the search page. When a query is provided, it runs 60 + // the search server-side so the page works without JavaScript; htmx only 61 + // takes over for the "Load More" pagination link. 13 62 type SearchHandler struct { 14 63 BaseUIHandler 15 64 } 16 65 17 66 func (h *SearchHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 18 - query := r.URL.Query().Get("q") 67 + query := strings.TrimSpace(r.URL.Query().Get("q")) 68 + if len(query) > 200 { 69 + query = query[:200] 70 + } 19 71 20 - // Build page meta 21 72 title := "Search - " + h.ClientShortName 22 73 description := "Search for container images on " + h.ClientShortName + ", the decentralized container registry" 23 74 canonical := "https://" + h.SiteURL + "/search" ··· 29 80 30 81 meta := NewPageMeta(title, description).WithCanonical(canonical).WithSiteName(h.ClientShortName) 31 82 83 + var results searchResults 84 + if query != "" { 85 + var err error 86 + results, err = h.runSearch(r, query, 0) 87 + if err != nil { 88 + // Don't 500 the whole page — render it with the error-state 89 + // partial so the search form stays usable. 90 + results.HasError = true 91 + } 92 + } else { 93 + results.PageData = NewPageData(r, &h.BaseUIHandler) 94 + } 95 + 32 96 data := struct { 33 97 PageData 34 98 Meta *PageMeta 35 99 SearchQuery string 100 + Results searchResults 36 101 }{ 37 - PageData: NewPageData(r, &h.BaseUIHandler), 102 + PageData: results.PageData, 38 103 Meta: meta, 39 104 SearchQuery: query, 105 + Results: results, 40 106 } 41 107 42 108 if err := h.Templates.ExecuteTemplate(w, "search", data); err != nil { ··· 45 111 } 46 112 } 47 113 48 - // SearchResultsHandler handles the HTMX request for search results 114 + // SearchResultsHandler serves the search-results partial for htmx Load More 115 + // pagination. Returns just the grid fragment, not a full page. 49 116 type SearchResultsHandler struct { 50 117 BaseUIHandler 51 118 } 52 119 53 120 func (h *SearchResultsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 54 - query := r.URL.Query().Get("q") 121 + query := strings.TrimSpace(r.URL.Query().Get("q")) 122 + if len(query) > 200 { 123 + query = query[:200] 124 + } 55 125 56 - // Validate and sanitize input 57 - query = strings.TrimSpace(query) 58 126 if query == "" { 59 - // Return empty results if no query 60 - data := struct { 61 - PageData 62 - Repositories []db.RepoCardData 63 - SearchQuery string 64 - HasMore bool 65 - NextOffset int 66 - }{ 67 - PageData: NewPageData(r, &h.BaseUIHandler), 68 - Repositories: []db.RepoCardData{}, 69 - SearchQuery: "", 70 - HasMore: false, 127 + empty := searchResults{ 128 + PageData: NewPageData(r, &h.BaseUIHandler), 129 + SearchQuery: "", 71 130 } 72 - 73 - if err := h.Templates.ExecuteTemplate(w, "search-results.html", data); err != nil { 74 - http.Error(w, err.Error(), http.StatusInternalServerError) 131 + if err := h.Templates.ExecuteTemplate(w, "search-results", empty); err != nil { 132 + RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render results", err) 75 133 } 76 134 return 77 135 } 78 136 79 - // Limit query length to prevent abuse 80 - if len(query) > 200 { 81 - query = query[:200] 82 - } 83 - 84 - limit := 50 85 137 offset := 0 86 - 87 138 if o := r.URL.Query().Get("offset"); o != "" { 88 139 offset, _ = strconv.Atoi(o) 89 140 } 90 141 91 - // Get current user DID (empty string if not logged in) 92 - var currentUserDID string 93 - if user := middleware.GetUser(r); user != nil { 94 - currentUserDID = user.DID 95 - } 96 - 97 - repos, total, err := db.SearchRepositories(h.ReadOnlyDB, query, limit, offset, currentUserDID) 142 + results, err := h.runSearch(r, query, offset) 98 143 if err != nil { 99 - http.Error(w, err.Error(), http.StatusInternalServerError) 144 + RenderHTMXError(w, r, http.StatusInternalServerError, "Search is temporarily unavailable", err) 100 145 return 101 146 } 102 147 103 - // Set registry URL and OCI client on all cards 104 - db.SetRegistryURL(repos, h.RegistryURL) 105 - pageData := NewPageData(r, &h.BaseUIHandler) 106 - db.SetOciClient(repos, pageData.OciClient) 107 - 108 - data := struct { 109 - PageData 110 - Repositories []db.RepoCardData 111 - SearchQuery string 112 - HasMore bool 113 - NextOffset int 114 - }{ 115 - PageData: pageData, 116 - Repositories: repos, 117 - SearchQuery: query, 118 - HasMore: offset+limit < total, 119 - NextOffset: offset + limit, 148 + // Load More requests (offset > 0) render just the new cards plus a 149 + // replacement Load More button via card-grid-append. Cards are OOB-swapped 150 + // into the existing grid so the old grid, cards, and scroll position stay 151 + // put. The primary outerHTML swap replaces the old Load More wrapper. 152 + template := "search-results" 153 + if offset > 0 { 154 + template = "card-grid-append-search" 120 155 } 121 - 122 - if err := h.Templates.ExecuteTemplate(w, "search-results.html", data); err != nil { 123 - http.Error(w, err.Error(), http.StatusInternalServerError) 124 - return 156 + if err := h.Templates.ExecuteTemplate(w, template, results); err != nil { 157 + RenderHTMXError(w, r, http.StatusInternalServerError, "Could not render results", err) 125 158 } 126 159 }
+194 -131
pkg/appview/handlers/settings.go
··· 31 31 IsActive bool `json:"isActive"` 32 32 } 33 33 34 - // SettingsHandler handles the settings page 34 + // SettingsHandler handles the settings page — dispatches per-tab. 35 35 type SettingsHandler struct { 36 36 BaseUIHandler 37 37 } 38 38 39 - func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 40 - user := middleware.GetUser(r) 41 - if user == nil { 42 - http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound) 43 - return 39 + // settingsTab describes a tab entry rendered in the tablist. 40 + type settingsTab struct { 41 + Slug string 42 + Label string 43 + Icon string 44 + } 45 + 46 + func settingsTabs() []settingsTab { 47 + return []settingsTab{ 48 + {Slug: "user", Label: "User", Icon: "user"}, 49 + {Slug: "billing", Label: "Billing", Icon: "credit-card"}, 50 + {Slug: "storage", Label: "Storage", Icon: "hard-drive"}, 51 + {Slug: "devices", Label: "Devices", Icon: "terminal"}, 52 + {Slug: "webhooks", Label: "Webhooks", Icon: "webhook"}, 53 + {Slug: "advanced", Label: "Advanced", Icon: "shield-check"}, 44 54 } 55 + } 45 56 46 - // Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety) 47 - client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 57 + var validSettingsTabs = map[string]bool{ 58 + "user": true, "storage": true, "billing": true, 59 + "devices": true, "webhooks": true, "advanced": true, 60 + } 48 61 49 - // Fetch sailor profile 50 - profile, err := storage.GetProfile(r.Context(), client) 51 - if err != nil { 52 - // Error fetching profile - log out user 53 - slog.Warn("Failed to fetch profile, logging out", "component", "settings", "did", user.DID, "error", err) 54 - http.Redirect(w, r, "/auth/logout", http.StatusFound) 55 - return 56 - } 62 + // settingsProfile is the sidebar identity info shared across all tabs. 63 + type settingsProfile struct { 64 + Handle string 65 + DID string 66 + PDSEndpoint string 67 + DefaultHold string 68 + AutoRemoveUntagged bool 69 + OciClient string 70 + AIAdvisorEnabled bool 71 + HasAIAdvisorAccess bool 72 + } 57 73 58 - if profile == nil { 59 - // Profile doesn't exist yet (404) - user needs to log out and back in to create it 60 - slog.Warn("Profile doesn't exist, logging out", "component", "settings", "did", user.DID) 61 - http.Redirect(w, r, "/auth/logout", http.StatusFound) 62 - return 63 - } 74 + // settingsPageData is the struct passed to the settings shell + panel templates. 75 + // MemberHolds are holds where the user is already owner/crew; EligibleHolds 76 + // are ones they can opt-in to join. Splitting them upstream keeps the 77 + // hold_selector template from doing filter-the-same-list-twice gymnastics. 78 + type settingsPageData struct { 79 + PageData 80 + Meta *PageMeta 81 + ActiveTab string 82 + Tabs []settingsTab 83 + Profile settingsProfile 84 + ActiveHold *HoldDisplay 85 + OtherHolds []HoldDisplay 86 + MemberHolds []HoldDisplay 87 + EligibleHolds []HoldDisplay 88 + WebhooksData webhooksTemplateData 89 + Subscription SubscriptionDisplay 90 + } 91 + 92 + // ServeHTTP redirects /settings to /settings/user. 93 + func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 94 + http.Redirect(w, r, "/settings/user", http.StatusFound) 95 + } 96 + 97 + // ServeTab returns an http.Handler for a specific settings tab. 98 + // If HX-Request is set, only the panel fragment is rendered. 99 + func (h *SettingsHandler) ServeTab(tab string) http.HandlerFunc { 100 + return func(w http.ResponseWriter, r *http.Request) { 101 + if !validSettingsTabs[tab] { 102 + http.NotFound(w, r) 103 + return 104 + } 64 105 65 - slog.Debug("Fetched profile", "component", "settings", "did", user.DID, "default_hold", profile.DefaultHold) 106 + user := middleware.GetUser(r) 107 + if user == nil { 108 + http.Redirect(w, r, "/auth/oauth/login?return_to=/settings/"+tab, http.StatusFound) 109 + return 110 + } 66 111 67 - // Get available holds 68 - var activeHold *HoldDisplay 69 - var otherHolds, allHolds []HoldDisplay 112 + client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 70 113 71 - if h.DB != nil { 72 - availableHolds, err := db.GetAvailableHolds(h.DB, user.DID) 114 + profile, err := storage.GetProfile(r.Context(), client) 73 115 if err != nil { 74 - slog.Warn("Failed to get available holds", "component", "settings", "did", user.DID, "error", err) 75 - } else { 76 - for _, hold := range availableHolds { 77 - display := HoldDisplay{ 78 - DID: hold.HoldDID, 79 - DisplayName: resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, hold.HoldDID), 80 - Region: hold.Region, 81 - Membership: hold.Membership, 82 - IsActive: hold.HoldDID == profile.DefaultHold, 83 - } 116 + slog.Warn("Failed to fetch profile, logging out", "component", "settings", "did", user.DID, "error", err) 117 + http.Redirect(w, r, "/auth/logout", http.StatusFound) 118 + return 119 + } 120 + if profile == nil { 121 + slog.Warn("Profile doesn't exist, logging out", "component", "settings", "did", user.DID) 122 + http.Redirect(w, r, "/auth/logout", http.StatusFound) 123 + return 124 + } 84 125 85 - // Parse permissions JSON if present 86 - if hold.Permissions != "" { 87 - if err := json.Unmarshal([]byte(hold.Permissions), &display.Permissions); err != nil { 88 - slog.Warn("Failed to parse permissions JSON", "component", "settings", "did", user.DID, "hold_did", hold.HoldDID, "error", err) 89 - } 90 - } 126 + meta := NewPageMeta( 127 + "Settings - "+h.ClientShortName, 128 + "Manage your "+h.ClientShortName+" account settings, authorized devices, and storage preferences", 129 + ).WithRobots("noindex"). 130 + WithSiteName(h.ClientShortName) 91 131 92 - // Check health status (uses cache if available, otherwise pings on-demand) 93 - if h.HealthChecker != nil { 94 - if status := h.HealthChecker.GetStatus(r.Context(), hold.HoldDID); status != nil { 95 - if status.Reachable { 96 - display.Status = "online" 97 - } else { 98 - display.Status = "offline" 99 - } 100 - } 101 - } 132 + data := settingsPageData{ 133 + PageData: NewPageData(r, &h.BaseUIHandler), 134 + Meta: meta, 135 + ActiveTab: tab, 136 + Tabs: settingsTabs(), 137 + Profile: settingsProfile{ 138 + Handle: user.Handle, 139 + DID: user.DID, 140 + PDSEndpoint: user.PDSEndpoint, 141 + DefaultHold: profile.DefaultHold, 142 + AutoRemoveUntagged: profile.AutoRemoveUntagged, 143 + OciClient: profile.OciClient, 144 + AIAdvisorEnabled: profile.AIAdvisorEnabled == nil || *profile.AIAdvisorEnabled, 145 + }, 146 + } 147 + if h.BillingManager != nil { 148 + data.Profile.HasAIAdvisorAccess = h.BillingManager.HasAIAdvisor(user.DID) 149 + } 102 150 103 - // All holds go in dropdown list 104 - allHolds = append(allHolds, display) 151 + // Per-tab data fetch. 152 + switch tab { 153 + case "storage": 154 + data.ActiveHold, data.OtherHolds, data.MemberHolds, data.EligibleHolds = h.buildHoldsData(r.Context(), user.DID, profile.DefaultHold) 155 + case "billing": 156 + data.Subscription = h.buildSubscriptionDisplay(user.DID) 157 + case "webhooks": 158 + data.WebhooksData = h.buildWebhooksData(user.DID) 159 + } 105 160 106 - // Separate active from other member holds (skip eligible) 107 - if hold.Membership != "eligible" { 108 - if display.IsActive { 109 - holdCopy := display 110 - activeHold = &holdCopy 111 - } else { 112 - otherHolds = append(otherHolds, display) 113 - } 114 - } 115 - } 161 + // htmx partial: render just the panel. 162 + tmplName := "settings" 163 + if r.Header.Get("HX-Request") == "true" { 164 + tmplName = "settings-panel" 165 + } 166 + if err := h.Templates.ExecuteTemplate(w, tmplName, data); err != nil { 167 + http.Error(w, err.Error(), http.StatusInternalServerError) 168 + return 116 169 } 117 170 } 171 + } 118 172 119 - // Fetch webhooks (local DB read) 120 - webhooksData := h.buildWebhooksData(user.DID) 173 + // buildHoldsData resolves the current user's holds for the storage tab. 174 + // Returns: the currently-active hold (if any), non-active member holds, the 175 + // full member-hold list (including active, for selector rendering), and 176 + // eligible holds (the user could join but isn't yet a member of). 177 + func (h *SettingsHandler) buildHoldsData(ctx context.Context, userDID, defaultHold string) (*HoldDisplay, []HoldDisplay, []HoldDisplay, []HoldDisplay) { 178 + if h.DB == nil { 179 + return nil, nil, nil, nil 180 + } 121 181 122 - // Fetch subscription info (Stripe with in-memory cache) 123 - subscriptionData := h.buildSubscriptionDisplay(user.DID) 182 + availableHolds, err := db.GetAvailableHolds(h.DB, userDID) 183 + if err != nil { 184 + slog.Warn("Failed to get available holds", "component", "settings", "did", userDID, "error", err) 185 + return nil, nil, nil, nil 186 + } 124 187 125 - meta := NewPageMeta( 126 - "Settings - "+h.ClientShortName, 127 - "Manage your "+h.ClientShortName+" account settings, authorized devices, and storage preferences", 128 - ).WithRobots("noindex"). 129 - WithSiteName(h.ClientShortName) 188 + var activeHold *HoldDisplay 189 + var otherHolds, memberHolds, eligibleHolds []HoldDisplay 130 190 131 - data := struct { 132 - PageData 133 - Meta *PageMeta 134 - Profile struct { 135 - Handle string 136 - DID string 137 - PDSEndpoint string 138 - DefaultHold string 139 - AutoRemoveUntagged bool 140 - OciClient string 141 - AIAdvisorEnabled bool 142 - HasAIAdvisorAccess bool // billing tier grants access 191 + for _, hold := range availableHolds { 192 + display := HoldDisplay{ 193 + DID: hold.HoldDID, 194 + DisplayName: resolveHoldDisplayName(ctx, &h.BaseUIHandler, hold.HoldDID), 195 + Region: hold.Region, 196 + Membership: hold.Membership, 197 + IsActive: hold.HoldDID == defaultHold, 143 198 } 144 - ActiveHold *HoldDisplay 145 - OtherHolds []HoldDisplay 146 - AllHolds []HoldDisplay 147 - WebhooksData webhooksTemplateData 148 - Subscription SubscriptionDisplay 149 - }{ 150 - PageData: NewPageData(r, &h.BaseUIHandler), 151 - Meta: meta, 152 - ActiveHold: activeHold, 153 - OtherHolds: otherHolds, 154 - AllHolds: allHolds, 155 - WebhooksData: webhooksData, 156 - Subscription: subscriptionData, 157 - } 158 199 159 - data.Profile.Handle = user.Handle 160 - data.Profile.DID = user.DID 161 - data.Profile.PDSEndpoint = user.PDSEndpoint 162 - data.Profile.DefaultHold = profile.DefaultHold 163 - data.Profile.AutoRemoveUntagged = profile.AutoRemoveUntagged 164 - data.Profile.OciClient = profile.OciClient 165 - data.Profile.AIAdvisorEnabled = profile.AIAdvisorEnabled == nil || *profile.AIAdvisorEnabled 166 - if h.BillingManager != nil { 167 - data.Profile.HasAIAdvisorAccess = h.BillingManager.HasAIAdvisor(user.DID) 200 + if hold.Permissions != "" { 201 + if err := json.Unmarshal([]byte(hold.Permissions), &display.Permissions); err != nil { 202 + slog.Warn("Failed to parse permissions JSON", "component", "settings", "did", userDID, "hold_did", hold.HoldDID, "error", err) 203 + } 204 + } 205 + 206 + if h.HealthChecker != nil { 207 + if status := h.HealthChecker.GetStatus(ctx, hold.HoldDID); status != nil { 208 + if status.Reachable { 209 + display.Status = "online" 210 + } else { 211 + display.Status = "offline" 212 + } 213 + } 214 + } 215 + 216 + if hold.Membership == "eligible" { 217 + eligibleHolds = append(eligibleHolds, display) 218 + continue 219 + } 220 + 221 + memberHolds = append(memberHolds, display) 222 + if display.IsActive { 223 + holdCopy := display 224 + activeHold = &holdCopy 225 + } else { 226 + otherHolds = append(otherHolds, display) 227 + } 168 228 } 169 229 170 - if err := h.Templates.ExecuteTemplate(w, "settings", data); err != nil { 171 - http.Error(w, err.Error(), http.StatusInternalServerError) 172 - return 173 - } 230 + return activeHold, otherHolds, memberHolds, eligibleHolds 174 231 } 175 232 176 233 // webhooksTemplateData is the data passed to the webhooks_list template. ··· 248 305 IsCurrent: tier.IsCurrent, 249 306 } 250 307 if tier.PriceCentsMonthly > 0 { 251 - td.PriceMonthly = fmt.Sprintf("$%d/mo", tier.PriceCentsMonthly/100) 308 + if tier.PriceCentsMonthly%100 == 0 { 309 + td.PriceMonthly = fmt.Sprintf("$%d/mo", tier.PriceCentsMonthly/100) 310 + } else { 311 + td.PriceMonthly = fmt.Sprintf("$%.2f/mo", float64(tier.PriceCentsMonthly)/100.0) 312 + } 252 313 } 253 314 if tier.PriceCentsYearly > 0 { 254 - td.PriceYearly = fmt.Sprintf("$%d/yr", tier.PriceCentsYearly/100) 315 + if tier.PriceCentsYearly%100 == 0 { 316 + td.PriceYearly = fmt.Sprintf("$%d/yr", tier.PriceCentsYearly/100) 317 + } else { 318 + td.PriceYearly = fmt.Sprintf("$%.2f/yr", float64(tier.PriceCentsYearly)/100.0) 319 + } 255 320 } 256 321 display.Tiers = append(display.Tiers, td) 257 322 } ··· 331 396 } 332 397 333 398 if !hasAccess { 334 - w.Header().Set("Content-Type", "text/html") 335 - if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{ 336 - "Type": "error", 337 - "Message": "You don't have access to this hold", 338 - }); err != nil { 339 - http.Error(w, err.Error(), http.StatusInternalServerError) 340 - } 399 + // hx-swap="none" on the selector form means an inline alert 400 + // would be discarded — route through RenderHTMXError so 401 + // the client-side toast handler fires instead. 402 + RenderHTMXError(w, r, http.StatusForbidden, "You don't have access to this hold", nil) 341 403 return 342 404 } 343 405 } ··· 359 421 360 422 // Save profile 361 423 if err := storage.UpdateProfile(r.Context(), client, profile); err != nil { 362 - http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError) 424 + RenderHTMXError(w, r, http.StatusInternalServerError, "Couldn't update your default hold", err) 363 425 return 364 426 } 365 427 ··· 381 443 } 382 444 } 383 445 446 + // Fire a success toast via HX-Trigger in addition to the HX-Refresh — the 447 + // page reloads so the user sees the new hold applied, and the toast 448 + // confirms the action took effect. 449 + trigger, _ := json.Marshal(map[string]map[string]string{ 450 + "toast": {"message": "Default hold updated", "type": "success"}, 451 + }) 452 + w.Header().Set("HX-Trigger", string(trigger)) 384 453 w.Header().Set("HX-Refresh", "true") 385 - w.Header().Set("Content-Type", "text/html") 386 - if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{ 387 - "Type": "success", 388 - "Message": "Default hold updated successfully!", 389 - }); err != nil { 390 - slog.Warn("Failed to render alert", "error", err) 391 - } 454 + w.WriteHeader(http.StatusNoContent) 392 455 } 393 456 394 457 // UpdateAutoRemoveUntaggedHandler handles toggling the auto-remove-untagged setting
+11 -2
pkg/appview/handlers/storage.go
··· 153 153 154 154 func (h *StorageHandler) renderError(w http.ResponseWriter, message string) { 155 155 w.Header().Set("Content-Type", "text/html") 156 - fmt.Fprintf(w, `<div class="storage-error"><i data-lucide="alert-circle"></i> %s</div>`, message) 156 + // Route through the alert partial so the error matches the rest of the 157 + // UI; previous hand-rolled markup referenced a non-existent 158 + // `storage-error` class. 159 + if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{ 160 + "Type": "error", 161 + "Message": message, 162 + }); err != nil { 163 + slog.Error("Failed to render storage alert", "error", err) 164 + fmt.Fprintf(w, `<p class="text-sm text-error">%s</p>`, message) 165 + } 157 166 } 158 167 159 168 func (h *StorageHandler) renderNoHold(w http.ResponseWriter) { 160 169 w.Header().Set("Content-Type", "text/html") 161 - 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>`) 170 + 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>`) 162 171 } 163 172 164 173 // humanizeBytes converts bytes to human-readable format
+1 -1
pkg/appview/handlers/subscription.go
··· 94 94 if r.TLS == nil { 95 95 scheme = "http" 96 96 } 97 - returnURL := scheme + "://" + h.SiteURL + "/settings#billing" 97 + returnURL := scheme + "://" + h.SiteURL + "/settings/billing" 98 98 99 99 resp, err := h.BillingManager.GetBillingPortalURL(user.DID, returnURL) 100 100 if err != nil {
+10 -3
pkg/appview/handlers/user.go
··· 1 1 package handlers 2 2 3 3 import ( 4 - "log" 4 + "log/slog" 5 5 "net/http" 6 6 7 7 "atcr.io/pkg/appview/db" ··· 54 54 currentUserDID = user.DID 55 55 } 56 56 57 - // Fetch repository cards for this user 57 + // Fetch repository cards. Track the error separately so the template can 58 + // render a distinct error state ("couldn't load their images") rather 59 + // than the empty profile copy ("no images yet"), which implies no push 60 + // has ever happened. 61 + var cardsErr bool 58 62 cards, err := db.GetUserRepoCards(h.ReadOnlyDB, viewedUser.DID, currentUserDID) 59 63 if err != nil { 60 - log.Printf("Error fetching repo cards for user %s: %v", viewedUser.DID, err) 64 + slog.Error("user: fetch repo cards", "did", viewedUser.DID, "err", err) 61 65 cards = []db.RepoCardData{} 66 + cardsErr = true 62 67 } 63 68 db.SetRegistryURL(cards, h.RegistryURL) 64 69 ··· 86 91 Repositories []db.RepoCardData 87 92 HasProfile bool 88 93 SupporterBadge string 94 + HasError bool 89 95 }{ 90 96 PageData: pageData, 91 97 Meta: meta, ··· 93 99 Repositories: cards, 94 100 HasProfile: hasProfile, 95 101 SupporterBadge: supporterBadge, 102 + HasError: cardsErr, 96 103 } 97 104 98 105 if err := h.Templates.ExecuteTemplate(w, "user", data); err != nil {
+5 -3
pkg/appview/handlers/vuln_details.go
··· 240 240 }) 241 241 242 242 h.renderDetails(w, vulnDetailsData{ 243 - Matches: matches, 244 - Summary: summary, 245 - ScannedAt: scanRecord.ScannedAt, 243 + Matches: matches, 244 + Summary: summary, 245 + ScannedAt: scanRecord.ScannedAt, 246 + Digest: digest, 247 + HoldEndpoint: holdDID, 246 248 }) 247 249 } 248 250
+28 -14
pkg/appview/handlers/webhooks.go
··· 105 105 // Tier enforcement 106 106 limits := h.getWebhookLimits(user.DID) 107 107 108 - // Check webhook count limit 109 - count, err := db.CountWebhooks(h.ReadOnlyDB, user.DID) 108 + // Dedupe: refuse to add a second webhook with the same URL for this user. 109 + // A duplicate is almost always an accidental double-submit and creates 110 + // confusing behavior (same payload fires twice, separate delete buttons). 111 + existing, err := db.ListWebhooks(h.ReadOnlyDB, user.DID) 110 112 if err != nil { 111 - h.renderWebhookError(w, "Failed to check webhook count") 113 + h.renderWebhookError(w, "Failed to check existing webhooks") 112 114 return 113 115 } 114 - if limits.Max >= 0 && count >= limits.Max { 116 + for _, ex := range existing { 117 + if ex.URL == webhookURL { 118 + h.renderWebhookError(w, "A webhook with this URL is already configured") 119 + return 120 + } 121 + } 122 + 123 + if limits.Max >= 0 && len(existing) >= limits.Max { 115 124 h.renderWebhookError(w, "Webhook limit reached") 116 125 return 117 126 } ··· 213 222 // ---- Shared helpers ---- 214 223 215 224 // getWebhookLimits returns the webhook limits for a user based on their billing tier. 225 + // When the billing manager is absent or disabled we treat the deployment as 226 + // "all features free": unlimited webhooks and all trigger types allowed. 227 + // Without this, self-hosted instances without billing config silently capped 228 + // users at 1 webhook with restricted triggers. 216 229 func (h *BaseUIHandler) getWebhookLimits(userDID string) webhookLimits { 230 + if h.BillingManager == nil || !h.BillingManager.Enabled() { 231 + return webhookLimits{Max: -1, AllTriggers: true} 232 + } 217 233 limits := webhookLimits{Max: 1} 218 - if h.BillingManager != nil { 219 - if h.BillingManager.Enabled() { 220 - limits.Max, limits.AllTriggers = h.BillingManager.GetWebhookLimits(userDID) 221 - } 222 - limits.PaidTierName = h.BillingManager.GetFirstTierWithAllTriggers() 223 - } 234 + limits.Max, limits.AllTriggers = h.BillingManager.GetWebhookLimits(userDID) 235 + limits.PaidTierName = h.BillingManager.GetFirstTierWithAllTriggers() 224 236 return limits 225 237 } 226 238 ··· 274 286 275 287 type triggerInfo struct { 276 288 Name string 289 + FormName string // form field name, e.g. "trigger_push" — set so templates don't need a ternary 277 290 Bit int 278 291 Label string 279 292 Description string ··· 282 295 } 283 296 284 297 // webhookTriggerInfo returns the canonical list of webhook trigger types. 298 + // FormName is the HTML form field name (kept in sync with handler parsing). 285 299 func webhookTriggerInfo() []triggerInfo { 286 300 return []triggerInfo{ 287 - {Name: "push", Bit: webhooks.TriggerPush, Label: "Image push", Description: "When an image is pushed to your repository", AlwaysAvailable: true}, 288 - {Name: "scan:first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, 289 - {Name: "scan:all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, 290 - {Name: "scan:changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, 301 + {Name: "push", FormName: "trigger_push", Bit: webhooks.TriggerPush, Label: "Image push", Description: "When an image is pushed to your repository", AlwaysAvailable: true}, 302 + {Name: "scan:first", FormName: "trigger_first", Bit: webhooks.TriggerFirst, Label: "First scan", Description: "When an image is scanned for the first time", AlwaysAvailable: true}, 303 + {Name: "scan:all", FormName: "trigger_all", Bit: webhooks.TriggerAll, Label: "Every scan", Description: "On every scan completion"}, 304 + {Name: "scan:changed", FormName: "trigger_changed", Bit: webhooks.TriggerChanged, Label: "Vulnerability change", Description: "When vulnerability counts change"}, 291 305 } 292 306 } 293 307
+4 -2
pkg/appview/public/icons.svg
··· 19 19 <symbol id="container" viewBox="0 0 24 24"><path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z"/><path d="M10 21.9V14L2.1 9.1"/><path d="m10 14 11.9-6.9"/><path d="M14 19.8v-8.1"/><path d="M18 17.5V9.4"/></symbol> 20 20 <symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol> 21 21 <symbol id="cpu" viewBox="0 0 24 24"><path d="M12 20v2"/><path d="M12 2v2"/><path d="M17 20v2"/><path d="M17 2v2"/><path d="M2 12h2"/><path d="M2 17h2"/><path d="M2 7h2"/><path d="M20 12h2"/><path d="M20 17h2"/><path d="M20 7h2"/><path d="M7 20v2"/><path d="M7 2v2"/><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/></symbol> 22 - <symbol id="credit-card" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></symbol> 23 22 <symbol id="database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></symbol> 24 23 <symbol id="download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol> 25 24 <symbol id="external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol> ··· 42 41 <symbol id="loader" viewBox="0 0 24 24"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></symbol> 43 42 <symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol> 44 43 <symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol> 44 + <symbol id="package" viewBox="0 0 24 24"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><polyline points="3.29 7 12 12 20.71 7"/><path d="m7.5 4.27 9 5.15"/></symbol> 45 + <symbol id="pause" viewBox="0 0 24 24"><rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/></symbol> 45 46 <symbol id="pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol> 47 + <symbol id="play" viewBox="0 0 24 24"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/></symbol> 46 48 <symbol id="plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol> 47 49 <symbol id="radio-tower" viewBox="0 0 24 24"><path d="M4.9 16.1C1 12.2 1 5.8 4.9 1.9"/><path d="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5"/><circle cx="12" cy="9" r="2"/><path d="M16.2 4.8c2 2 2.26 5.11.8 7.47"/><path d="M19.1 1.9a9.96 9.96 0 0 1 0 14.1"/><path d="M9.5 18h5"/><path d="m8 22 4-11 4 11"/></symbol> 48 50 <symbol id="refresh-ccw" viewBox="0 0 24 24"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></symbol> ··· 65 67 <symbol id="upload" viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></symbol> 66 68 <symbol id="user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol> 67 69 <symbol id="user-plus" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/></symbol> 68 - <symbol id="webhook" viewBox="0 0 24 24"><path d="M18 16.98h-5.99c-1.1 0-1.95.94-2.48 1.9A4 4 0 0 1 2 17c.01-.7.2-1.4.57-2"/><path d="m6 17 3.13-5.78c.53-.97.1-2.18-.5-3.1a4 4 0 1 1 6.89-4.06"/><path d="m12 6 3.13 5.73C15.66 12.7 16.9 13 18 13a4 4 0 0 1 0 8"/></symbol> 70 + <symbol id="wifi-off" viewBox="0 0 24 24"><path d="M12 20h.01"/><path d="M8.5 16.429a5 5 0 0 1 7 0"/><path d="M5 12.859a10 10 0 0 1 5.17-2.69"/><path d="M19 12.859a10 10 0 0 0-2.007-1.523"/><path d="M2 8.82a15 15 0 0 1 4.177-2.643"/><path d="M22 8.82a15 15 0 0 0-11.288-3.764"/><path d="m2 2 20 20"/></symbol> 69 71 <symbol id="x-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></symbol> 70 72 <symbol id="helm" viewBox="0 0 24 24"><path d="M12.337 0c-.475 0-.861 1.016-.861 2.269 0 .527.069 1.011.183 1.396a8.514 8.514 0 0 0-3.961 1.22 5.229 5.229 0 0 0-.595-1.093c-.606-.866-1.34-1.436-1.79-1.43a.381.381 0 0 0-.217.066c-.39.273-.123 1.326.596 2.353.267.381.559.705.84.948a8.683 8.683 0 0 0-1.528 1.716h1.734a7.179 7.179 0 0 1 5.381-2.421 7.18 7.18 0 0 1 5.382 2.42h1.733a8.687 8.687 0 0 0-1.32-1.53c.35-.249.735-.643 1.078-1.133.719-1.027.986-2.08.596-2.353a.382.382 0 0 0-.217-.065c-.45-.007-1.184.563-1.79 1.43a4.897 4.897 0 0 0-.676 1.325 8.52 8.52 0 0 0-3.899-1.42c.12-.39.193-.887.193-1.429 0-1.253-.386-2.269-.862-2.269zM1.624 9.443v5.162h1.358v-1.968h1.64v1.968h1.357V9.443H4.62v1.838H2.98V9.443zm5.912 0v5.162h3.21v-1.108H8.893v-.95h1.64v-1.142h-1.64v-.84h1.853V9.443zm4.698 0v5.162h3.218v-1.362h-1.86v-3.8zm4.706 0v5.162h1.364v-2.643l1.357 1.225 1.35-1.232v2.65h1.365V9.443h-.614l-2.1 1.914-2.109-1.914zm-11.82 7.28a8.688 8.688 0 0 0 1.412 1.548 5.206 5.206 0 0 0-.841.948c-.719 1.027-.985 2.08-.596 2.353.39.273 1.289-.338 2.007-1.364a5.23 5.23 0 0 0 .595-1.092 8.514 8.514 0 0 0 3.961 1.219 5.01 5.01 0 0 0-.183 1.396c0 1.253.386 2.269.861 2.269.476 0 .862-1.016.862-2.269 0-.542-.072-1.04-.193-1.43a8.52 8.52 0 0 0 3.9-1.42c.121.4.352.865.675 1.327.719 1.026 1.617 1.637 2.007 1.364.39-.273.123-1.326-.596-2.353-.343-.49-.727-.885-1.077-1.135a8.69 8.69 0 0 0 1.202-1.36h-1.771a7.174 7.174 0 0 1-5.227 2.252 7.174 7.174 0 0 1-5.226-2.252z" fill="currentColor" stroke="none"/></symbol> 71 73 </svg>
+14 -14
pkg/appview/public/js/bundle.min.js
··· 1 - var xe=(function(){"use strict";let htmx={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){return getInputValues(e,t||"post").values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:!0,historyCacheSize:10,refreshOnHistoryMiss:!1,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:!0,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:!0,allowScriptTags:!0,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:!1,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:!1,getCacheBusterParam:!1,globalViewTransitions:!1,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:!0,ignoreTitle:!1,scrollIntoViewOnBoost:!0,triggerSpecsCache:null,disableInheritance:!1,responseHandling:[{code:"204",swap:!1},{code:"[23]..",swap:!0},{code:"[45]..",swap:!1,error:!0}],allowNestedOobSwaps:!0,historyRestoreAsHxRequest:!0,reportValidityOfForms:!1},parseInterval:null,location,_:null,version:"2.0.8"};htmx.onLoad=onLoadHelper,htmx.process=processNode,htmx.on=addEventListenerImpl,htmx.off=removeEventListenerImpl,htmx.trigger=triggerEvent,htmx.ajax=ajaxHelper,htmx.find=find,htmx.findAll=findAll,htmx.closest=closest,htmx.remove=removeElement,htmx.addClass=addClassToElement,htmx.removeClass=removeClassFromElement,htmx.toggleClass=toggleClassOnElement,htmx.takeClass=takeClassForElement,htmx.swap=swap,htmx.defineExtension=defineExtension,htmx.removeExtension=removeExtension,htmx.logAll=logAll,htmx.logNone=logNone,htmx.parseInterval=parseInterval,htmx._=internalEval;let internalAPI={addTriggerHandler,bodyContains,canAccessLocalStorage,findThisElement,filterValues,swap,hasAttribute,getAttributeValue,getClosestAttributeValue,getClosestMatch,getExpressionVars,getHeaders,getInputValues,getInternalData,getSwapSpecification,getTriggerSpecs,getTarget,makeFragment,mergeObjects,makeSettleInfo,oobSwap,querySelectorExt,settleImmediately,shouldCancel,triggerEvent,triggerErrorEvent,withExtensions},VERBS=["get","post","put","delete","patch"],VERB_SELECTOR=VERBS.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function parseInterval(e){if(e==null)return;let t=NaN;return e.slice(-2)=="ms"?t=parseFloat(e.slice(0,-2)):e.slice(-1)=="s"?t=parseFloat(e.slice(0,-1))*1e3:e.slice(-1)=="m"?t=parseFloat(e.slice(0,-1))*1e3*60:t=parseFloat(e),isNaN(t)?void 0:t}function getRawAttribute(e,t){return e instanceof Element&&e.getAttribute(t)}function hasAttribute(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function getAttributeValue(e,t){return getRawAttribute(e,t)||getRawAttribute(e,"data-"+t)}function parentElt(e){let t=e.parentElement;return!t&&e.parentNode instanceof ShadowRoot?e.parentNode:t}function getDocument(){return document}function getRootNode(e,t){return e.getRootNode?e.getRootNode({composed:t}):getDocument()}function getClosestMatch(e,t){for(;e&&!t(e);)e=parentElt(e);return e||null}function getAttributeValueWithDisinheritance(e,t,n){let r=getAttributeValue(t,n),o=getAttributeValue(t,"hx-disinherit");var s=getAttributeValue(t,"hx-inherit");if(e!==t){if(htmx.config.disableInheritance)return s&&(s==="*"||s.split(" ").indexOf(n)>=0)?r:null;if(o&&(o==="*"||o.split(" ").indexOf(n)>=0))return"unset"}return r}function getClosestAttributeValue(e,t){let n=null;if(getClosestMatch(e,function(r){return!!(n=getAttributeValueWithDisinheritance(e,asElement(r),t))}),n!=="unset")return n}function matches(e,t){return e instanceof Element&&e.matches(t)}function getStartTag(e){let n=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i.exec(e);return n?n[1].toLowerCase():""}function parseHTML(e){return"parseHTMLUnsafe"in Document?Document.parseHTMLUnsafe(e):new DOMParser().parseFromString(e,"text/html")}function takeChildrenFor(e,t){for(;t.childNodes.length>0;)e.append(t.childNodes[0])}function duplicateScript(e){let t=getDocument().createElement("script");return forEach(e.attributes,function(n){t.setAttribute(n.name,n.value)}),t.textContent=e.textContent,t.async=!1,htmx.config.inlineScriptNonce&&(t.nonce=htmx.config.inlineScriptNonce),t}function isJavaScriptScriptNode(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function normalizeScriptTags(e){Array.from(e.querySelectorAll("script")).forEach(t=>{if(isJavaScriptScriptNode(t)){let n=duplicateScript(t),r=t.parentNode;try{r.insertBefore(n,t)}catch(o){logError(o)}finally{t.remove()}}})}function makeFragment(e){let t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,""),n=getStartTag(t),r;if(n==="html"){r=new DocumentFragment;let s=parseHTML(e);takeChildrenFor(r,s.body),r.title=s.title}else if(n==="body"){r=new DocumentFragment;let s=parseHTML(t);takeChildrenFor(r,s.body),r.title=s.title}else{let s=parseHTML('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=s.querySelector("template").content,r.title=s.title;var o=r.querySelector("title");o&&o.parentNode===r&&(o.remove(),r.title=o.innerText)}return r&&(htmx.config.allowScriptTags?normalizeScriptTags(r):r.querySelectorAll("script").forEach(s=>s.remove())),r}function maybeCall(e){e&&e()}function isType(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function isFunction(e){return typeof e=="function"}function isRawObject(e){return isType(e,"Object")}function getInternalData(e){let t="htmx-internal-data",n=e[t];return n||(n=e[t]={}),n}function toArray(e){let t=[];if(e)for(let n=0;n<e.length;n++)t.push(e[n]);return t}function forEach(e,t){if(e)for(let n=0;n<e.length;n++)t(e[n])}function isScrolledIntoView(e){let t=e.getBoundingClientRect(),n=t.top,r=t.bottom;return n<window.innerHeight&&r>=0}function bodyContains(e){return e.getRootNode({composed:!0})===document}function splitOnWhitespace(e){return e.trim().split(/\s+/)}function mergeObjects(e,t){for(let n in t)t.hasOwnProperty(n)&&(e[n]=t[n]);return e}function parseJSON(e){try{return JSON.parse(e)}catch(t){return logError(t),null}}function canAccessLocalStorage(){let e="htmx:sessionStorageTest";try{return sessionStorage.setItem(e,e),sessionStorage.removeItem(e),!0}catch{return!1}}function normalizePath(e){let t=new URL(e,"http://x");return t&&(e=t.pathname+t.search),e!="/"&&(e=e.replace(/\/+$/,"")),e}function internalEval(str){return maybeEval(getDocument().body,function(){return eval(str)})}function onLoadHelper(e){return htmx.on("htmx:load",function(n){e(n.detail.elt)})}function logAll(){htmx.logger=function(e,t,n){console&&console.log(t,e,n)}}function logNone(){htmx.logger=null}function find(e,t){return typeof e!="string"?e.querySelector(t):find(getDocument(),e)}function findAll(e,t){return typeof e!="string"?e.querySelectorAll(t):findAll(getDocument(),e)}function getWindow(){return window}function removeElement(e,t){e=resolveTarget(e),t?getWindow().setTimeout(function(){removeElement(e),e=null},t):parentElt(e).removeChild(e)}function asElement(e){return e instanceof Element?e:null}function asHtmlElement(e){return e instanceof HTMLElement?e:null}function asString(e){return typeof e=="string"?e:null}function asParentNode(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function addClassToElement(e,t,n){e=asElement(resolveTarget(e)),e&&(n?getWindow().setTimeout(function(){addClassToElement(e,t),e=null},n):e.classList&&e.classList.add(t))}function removeClassFromElement(e,t,n){let r=asElement(resolveTarget(e));r&&(n?getWindow().setTimeout(function(){removeClassFromElement(r,t),r=null},n):r.classList&&(r.classList.remove(t),r.classList.length===0&&r.removeAttribute("class")))}function toggleClassOnElement(e,t){e=resolveTarget(e),e.classList.toggle(t)}function takeClassForElement(e,t){e=resolveTarget(e),forEach(e.parentElement.children,function(n){removeClassFromElement(n,t)}),addClassToElement(asElement(e),t)}function closest(e,t){return e=asElement(resolveTarget(e)),e?e.closest(t):null}function startsWith(e,t){return e.substring(0,t.length)===t}function endsWith(e,t){return e.substring(e.length-t.length)===t}function normalizeSelector(e){let t=e.trim();return startsWith(t,"<")&&endsWith(t,"/>")?t.substring(1,t.length-2):t}function querySelectorAllExt(e,t,n){if(t.indexOf("global ")===0)return querySelectorAllExt(e,t.slice(7),!0);e=resolveTarget(e);let r=[];{let i=0,l=0;for(let a=0;a<t.length;a++){let c=t[a];if(c===","&&i===0){r.push(t.substring(l,a)),l=a+1;continue}c==="<"?i++:c==="/"&&a<t.length-1&&t[a+1]===">"&&i--}l<t.length&&r.push(t.substring(l))}let o=[],s=[];for(;r.length>0;){let i=normalizeSelector(r.shift()),l;i.indexOf("closest ")===0?l=closest(asElement(e),normalizeSelector(i.slice(8))):i.indexOf("find ")===0?l=find(asParentNode(e),normalizeSelector(i.slice(5))):i==="next"||i==="nextElementSibling"?l=asElement(e).nextElementSibling:i.indexOf("next ")===0?l=scanForwardQuery(e,normalizeSelector(i.slice(5)),!!n):i==="previous"||i==="previousElementSibling"?l=asElement(e).previousElementSibling:i.indexOf("previous ")===0?l=scanBackwardsQuery(e,normalizeSelector(i.slice(9)),!!n):i==="document"?l=document:i==="window"?l=window:i==="body"?l=document.body:i==="root"?l=getRootNode(e,!!n):i==="host"?l=e.getRootNode().host:s.push(i),l&&o.push(l)}if(s.length>0){let i=s.join(","),l=asParentNode(getRootNode(e,!!n));o.push(...toArray(l.querySelectorAll(i)))}return o}var scanForwardQuery=function(e,t,n){let r=asParentNode(getRootNode(e,n)).querySelectorAll(t);for(let o=0;o<r.length;o++){let s=r[o];if(s.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_PRECEDING)return s}},scanBackwardsQuery=function(e,t,n){let r=asParentNode(getRootNode(e,n)).querySelectorAll(t);for(let o=r.length-1;o>=0;o--){let s=r[o];if(s.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING)return s}};function querySelectorExt(e,t){return typeof e!="string"?querySelectorAllExt(e,t)[0]:querySelectorAllExt(getDocument().body,e)[0]}function resolveTarget(e,t){return typeof e=="string"?find(asParentNode(t)||document,e):e}function processEventArgs(e,t,n,r){return isFunction(t)?{target:getDocument().body,event:asString(e),listener:t,options:n}:{target:resolveTarget(e),event:asString(t),listener:n,options:r}}function addEventListenerImpl(e,t,n,r){return ready(function(){let s=processEventArgs(e,t,n,r);s.target.addEventListener(s.event,s.listener,s.options)}),isFunction(t)?t:n}function removeEventListenerImpl(e,t,n){return ready(function(){let r=processEventArgs(e,t,n);r.target.removeEventListener(r.event,r.listener)}),isFunction(t)?t:n}let DUMMY_ELT=getDocument().createElement("output");function findAttributeTargets(e,t){let n=getClosestAttributeValue(e,t);if(n){if(n==="this")return[findThisElement(e,t)];{let r=querySelectorAllExt(e,n);if(/(^|,)(\s*)inherit(\s*)($|,)/.test(n)){let s=asElement(getClosestMatch(e,function(i){return i!==e&&hasAttribute(asElement(i),t)}));s&&r.push(...findAttributeTargets(s,t))}return r.length===0?(logError('The selector "'+n+'" on '+t+" returned no matches!"),[DUMMY_ELT]):r}}}function findThisElement(e,t){return asElement(getClosestMatch(e,function(n){return getAttributeValue(asElement(n),t)!=null}))}function getTarget(e){let t=getClosestAttributeValue(e,"hx-target");return t?t==="this"?findThisElement(e,"hx-target"):querySelectorExt(e,t):getInternalData(e).boosted?getDocument().body:e}function shouldSettleAttribute(e){return htmx.config.attributesToSettle.includes(e)}function cloneAttributes(e,t){forEach(Array.from(e.attributes),function(n){!t.hasAttribute(n.name)&&shouldSettleAttribute(n.name)&&e.removeAttribute(n.name)}),forEach(t.attributes,function(n){shouldSettleAttribute(n.name)&&e.setAttribute(n.name,n.value)})}function isInlineSwap(e,t){let n=getExtensions(t);for(let r=0;r<n.length;r++){let o=n[r];try{if(o.isInlineSwap(e))return!0}catch(s){logError(s)}}return e==="outerHTML"}function oobSwap(e,t,n,r){r=r||getDocument();let o="#"+CSS.escape(getRawAttribute(t,"id")),s="outerHTML";e==="true"||(e.indexOf(":")>0?(s=e.substring(0,e.indexOf(":")),o=e.substring(e.indexOf(":")+1)):s=e),t.removeAttribute("hx-swap-oob"),t.removeAttribute("data-hx-swap-oob");let i=querySelectorAllExt(r,o,!1);return i.length?(forEach(i,function(l){let a,c=t.cloneNode(!0);a=getDocument().createDocumentFragment(),a.appendChild(c),isInlineSwap(s,l)||(a=asParentNode(c));let d={shouldSwap:!0,target:l,fragment:a};triggerEvent(l,"htmx:oobBeforeSwap",d)&&(l=d.target,d.shouldSwap&&(handlePreservedElements(a),swapWithStyle(s,l,l,a,n),restorePreservedElements()),forEach(n.elts,function(u){triggerEvent(u,"htmx:oobAfterSwap",d)}))}),t.parentNode.removeChild(t)):(t.parentNode.removeChild(t),triggerErrorEvent(getDocument().body,"htmx:oobErrorNoTarget",{content:t})),e}function restorePreservedElements(){let e=find("#--htmx-preserve-pantry--");if(e){for(let t of[...e.children]){let n=find("#"+t.id);n.parentNode.moveBefore(t,n),n.remove()}e.remove()}}function handlePreservedElements(e){forEach(findAll(e,"[hx-preserve], [data-hx-preserve]"),function(t){let n=getAttributeValue(t,"id"),r=getDocument().getElementById(n);if(r!=null)if(t.moveBefore){let o=find("#--htmx-preserve-pantry--");o==null&&(getDocument().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>"),o=find("#--htmx-preserve-pantry--")),o.moveBefore(r,null)}else t.parentNode.replaceChild(r,t)})}function handleAttributes(e,t,n){forEach(t.querySelectorAll("[id]"),function(r){let o=getRawAttribute(r,"id");if(o&&o.length>0){let s=o.replace("'","\\'"),i=r.tagName.replace(":","\\:"),l=asParentNode(e),a=l&&l.querySelector(i+"[id='"+s+"']");if(a&&a!==l){let c=r.cloneNode();cloneAttributes(r,a),n.tasks.push(function(){cloneAttributes(r,c)})}}})}function makeAjaxLoadTask(e){return function(){removeClassFromElement(e,htmx.config.addedClass),processNode(asElement(e)),processFocus(asParentNode(e)),triggerEvent(e,"htmx:load")}}function processFocus(e){let t="[autofocus]",n=asHtmlElement(matches(e,t)?e:e.querySelector(t));n?.focus()}function insertNodesBefore(e,t,n,r){for(handleAttributes(e,n,r);n.childNodes.length>0;){let o=n.firstChild;addClassToElement(asElement(o),htmx.config.addedClass),e.insertBefore(o,t),o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE&&r.tasks.push(makeAjaxLoadTask(o))}}function stringHash(e,t){let n=0;for(;n<e.length;)t=(t<<5)-t+e.charCodeAt(n++)|0;return t}function attributeHash(e){let t=0;for(let n=0;n<e.attributes.length;n++){let r=e.attributes[n];r.value&&(t=stringHash(r.name,t),t=stringHash(r.value,t))}return t}function deInitOnHandlers(e){let t=getInternalData(e);if(t.onHandlers){for(let n=0;n<t.onHandlers.length;n++){let r=t.onHandlers[n];removeEventListenerImpl(e,r.event,r.listener)}delete t.onHandlers}}function deInitNode(e){let t=getInternalData(e);t.timeout&&clearTimeout(t.timeout),t.listenerInfos&&forEach(t.listenerInfos,function(n){n.on&&removeEventListenerImpl(n.on,n.trigger,n.listener)}),deInitOnHandlers(e),forEach(Object.keys(t),function(n){n!=="firstInitCompleted"&&delete t[n]})}function cleanUpElement(e){triggerEvent(e,"htmx:beforeCleanupElement"),deInitNode(e),forEach(e.children,function(t){cleanUpElement(t)})}function swapOuterHTML(e,t,n){if(e.tagName==="BODY")return swapInnerHTML(e,t,n);let r,o=e.previousSibling,s=parentElt(e);if(s){for(insertNodesBefore(s,e,t,n),o==null?r=s.firstChild:r=o.nextSibling,n.elts=n.elts.filter(function(i){return i!==e});r&&r!==e;)r instanceof Element&&n.elts.push(r),r=r.nextSibling;cleanUpElement(e),e.remove()}}function swapAfterBegin(e,t,n){return insertNodesBefore(e,e.firstChild,t,n)}function swapBeforeBegin(e,t,n){return insertNodesBefore(parentElt(e),e,t,n)}function swapBeforeEnd(e,t,n){return insertNodesBefore(e,null,t,n)}function swapAfterEnd(e,t,n){return insertNodesBefore(parentElt(e),e.nextSibling,t,n)}function swapDelete(e){cleanUpElement(e);let t=parentElt(e);if(t)return t.removeChild(e)}function swapInnerHTML(e,t,n){let r=e.firstChild;if(insertNodesBefore(e,r,t,n),r){for(;r.nextSibling;)cleanUpElement(r.nextSibling),e.removeChild(r.nextSibling);cleanUpElement(r),e.removeChild(r)}}function swapWithStyle(e,t,n,r,o){switch(e){case"none":return;case"outerHTML":swapOuterHTML(n,r,o);return;case"afterbegin":swapAfterBegin(n,r,o);return;case"beforebegin":swapBeforeBegin(n,r,o);return;case"beforeend":swapBeforeEnd(n,r,o);return;case"afterend":swapAfterEnd(n,r,o);return;case"delete":swapDelete(n);return;default:var s=getExtensions(t);for(let i=0;i<s.length;i++){let l=s[i];try{let a=l.handleSwap(e,n,r,o);if(a){if(Array.isArray(a))for(let c=0;c<a.length;c++){let d=a[c];d.nodeType!==Node.TEXT_NODE&&d.nodeType!==Node.COMMENT_NODE&&o.tasks.push(makeAjaxLoadTask(d))}return}}catch(a){logError(a)}}e==="innerHTML"?swapInnerHTML(n,r,o):swapWithStyle(htmx.config.defaultSwapStyle,t,n,r,o)}}function findAndSwapOobElements(e,t,n){var r=findAll(e,"[hx-swap-oob], [data-hx-swap-oob]");return forEach(r,function(o){if(htmx.config.allowNestedOobSwaps||o.parentElement===null){let s=getAttributeValue(o,"hx-swap-oob");s!=null&&oobSwap(s,o,t,n)}else o.removeAttribute("hx-swap-oob"),o.removeAttribute("data-hx-swap-oob")}),r.length>0}function swap(e,t,n,r){r||(r={});let o=null,s=null,i=function(){maybeCall(r.beforeSwapCallback),e=resolveTarget(e);let c=r.contextElement?getRootNode(r.contextElement,!1):getDocument(),d=document.activeElement,u={};u={elt:d,start:d?d.selectionStart:null,end:d?d.selectionEnd:null};let f=makeSettleInfo(e);if(n.swapStyle==="textContent")e.textContent=t;else{let h=makeFragment(t);if(f.title=r.title||h.title,r.historyRequest&&(h=h.querySelector("[hx-history-elt],[data-hx-history-elt]")||h),r.selectOOB){let y=r.selectOOB.split(",");for(let p=0;p<y.length;p++){let w=y[p].split(":",2),T=w[0].trim();T.indexOf("#")===0&&(T=T.substring(1));let v=w[1]||"true",E=h.querySelector("#"+T);E&&oobSwap(v,E,f,c)}}if(findAndSwapOobElements(h,f,c),forEach(findAll(h,"template"),function(y){y.content&&findAndSwapOobElements(y.content,f,c)&&y.remove()}),r.select){let y=getDocument().createDocumentFragment();forEach(h.querySelectorAll(r.select),function(p){y.appendChild(p)}),h=y}handlePreservedElements(h),swapWithStyle(n.swapStyle,r.contextElement,e,h,f),restorePreservedElements()}if(u.elt&&!bodyContains(u.elt)&&getRawAttribute(u.elt,"id")){let h=document.getElementById(getRawAttribute(u.elt,"id")),y={preventScroll:n.focusScroll!==void 0?!n.focusScroll:!htmx.config.defaultFocusScroll};if(h){if(u.start&&h.setSelectionRange)try{h.setSelectionRange(u.start,u.end)}catch{}h.focus(y)}}e.classList.remove(htmx.config.swappingClass),forEach(f.elts,function(h){h.classList&&h.classList.add(htmx.config.settlingClass),triggerEvent(h,"htmx:afterSwap",r.eventInfo)}),maybeCall(r.afterSwapCallback),n.ignoreTitle||handleTitle(f.title);let m=function(){if(forEach(f.tasks,function(h){h.call()}),forEach(f.elts,function(h){h.classList&&h.classList.remove(htmx.config.settlingClass),triggerEvent(h,"htmx:afterSettle",r.eventInfo)}),r.anchor){let h=asElement(resolveTarget("#"+r.anchor));h&&h.scrollIntoView({block:"start",behavior:"auto"})}updateScrollState(f.elts,n),maybeCall(r.afterSettleCallback),maybeCall(o)};n.settleDelay>0?getWindow().setTimeout(m,n.settleDelay):m()},l=htmx.config.globalViewTransitions;n.hasOwnProperty("transition")&&(l=n.transition);let a=r.contextElement||getDocument();if(l&&triggerEvent(a,"htmx:beforeTransition",r.eventInfo)&&typeof Promise<"u"&&document.startViewTransition){let c=new Promise(function(u,f){o=u,s=f}),d=i;i=function(){document.startViewTransition(function(){return d(),c})}}try{n?.swapDelay&&n.swapDelay>0?getWindow().setTimeout(i,n.swapDelay):i()}catch(c){throw triggerErrorEvent(a,"htmx:swapError",r.eventInfo),maybeCall(s),c}}function handleTriggerHeader(e,t,n){let r=e.getResponseHeader(t);if(r.indexOf("{")===0){let o=parseJSON(r);for(let s in o)if(o.hasOwnProperty(s)){let i=o[s];isRawObject(i)?n=i.target!==void 0?i.target:n:i={value:i},triggerEvent(n,s,i)}}else{let o=r.split(",");for(let s=0;s<o.length;s++)triggerEvent(n,o[s].trim(),[])}}let WHITESPACE=/\s/,WHITESPACE_OR_COMMA=/[\s,]/,SYMBOL_START=/[_$a-zA-Z]/,SYMBOL_CONT=/[_$a-zA-Z0-9]/,STRINGISH_START=['"',"'","/"],NOT_WHITESPACE=/[^\s]/,COMBINED_SELECTOR_START=/[{(]/,COMBINED_SELECTOR_END=/[})]/;function tokenizeString(e){let t=[],n=0;for(;n<e.length;){if(SYMBOL_START.exec(e.charAt(n))){for(var r=n;SYMBOL_CONT.exec(e.charAt(n+1));)n++;t.push(e.substring(r,n+1))}else if(STRINGISH_START.indexOf(e.charAt(n))!==-1){let o=e.charAt(n);var r=n;for(n++;n<e.length&&e.charAt(n)!==o;)e.charAt(n)==="\\"&&n++,n++;t.push(e.substring(r,n+1))}else{let o=e.charAt(n);t.push(o)}n++}return t}function isPossibleRelativeReference(e,t,n){return SYMBOL_START.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function maybeGenerateConditional(e,t,n){if(t[0]==="["){t.shift();let r=1,o=" return (function("+n+"){ return (",s=null;for(;t.length>0;){let i=t[0];if(i==="]"){if(r--,r===0){s===null&&(o=o+"true"),t.shift(),o+=")})";try{let l=maybeEval(e,function(){return Function(o)()},function(){return!0});return l.source=o,l}catch(l){return triggerErrorEvent(getDocument().body,"htmx:syntax:error",{error:l,source:o}),null}}}else i==="["&&r++;isPossibleRelativeReference(i,s,n)?o+="(("+n+"."+i+") ? ("+n+"."+i+") : (window."+i+"))":o=o+i,s=t.shift()}}}function consumeUntil(e,t){let n="";for(;e.length>0&&!t.test(e[0]);)n+=e.shift();return n}function consumeCSSSelector(e){let t;return e.length>0&&COMBINED_SELECTOR_START.test(e[0])?(e.shift(),t=consumeUntil(e,COMBINED_SELECTOR_END).trim(),e.shift()):t=consumeUntil(e,WHITESPACE_OR_COMMA),t}let INPUT_SELECTOR="input, textarea, select";function parseAndCacheTrigger(e,t,n){let r=[],o=tokenizeString(t);do{consumeUntil(o,NOT_WHITESPACE);let l=o.length,a=consumeUntil(o,/[,\[\s]/);if(a!=="")if(a==="every"){let c={trigger:"every"};consumeUntil(o,NOT_WHITESPACE),c.pollInterval=parseInterval(consumeUntil(o,/[,\[\s]/)),consumeUntil(o,NOT_WHITESPACE);var s=maybeGenerateConditional(e,o,"event");s&&(c.eventFilter=s),r.push(c)}else{let c={trigger:a};var s=maybeGenerateConditional(e,o,"event");for(s&&(c.eventFilter=s),consumeUntil(o,NOT_WHITESPACE);o.length>0&&o[0]!==",";){let u=o.shift();if(u==="changed")c.changed=!0;else if(u==="once")c.once=!0;else if(u==="consume")c.consume=!0;else if(u==="delay"&&o[0]===":")o.shift(),c.delay=parseInterval(consumeUntil(o,WHITESPACE_OR_COMMA));else if(u==="from"&&o[0]===":"){if(o.shift(),COMBINED_SELECTOR_START.test(o[0]))var i=consumeCSSSelector(o);else{var i=consumeUntil(o,WHITESPACE_OR_COMMA);if(i==="closest"||i==="find"||i==="next"||i==="previous"){o.shift();let m=consumeCSSSelector(o);m.length>0&&(i+=" "+m)}}c.from=i}else u==="target"&&o[0]===":"?(o.shift(),c.target=consumeCSSSelector(o)):u==="throttle"&&o[0]===":"?(o.shift(),c.throttle=parseInterval(consumeUntil(o,WHITESPACE_OR_COMMA))):u==="queue"&&o[0]===":"?(o.shift(),c.queue=consumeUntil(o,WHITESPACE_OR_COMMA)):u==="root"&&o[0]===":"?(o.shift(),c[u]=consumeCSSSelector(o)):u==="threshold"&&o[0]===":"?(o.shift(),c[u]=consumeUntil(o,WHITESPACE_OR_COMMA)):triggerErrorEvent(e,"htmx:syntax:error",{token:o.shift()});consumeUntil(o,NOT_WHITESPACE)}r.push(c)}o.length===l&&triggerErrorEvent(e,"htmx:syntax:error",{token:o.shift()}),consumeUntil(o,NOT_WHITESPACE)}while(o[0]===","&&o.shift());return n&&(n[t]=r),r}function getTriggerSpecs(e){let t=getAttributeValue(e,"hx-trigger"),n=[];if(t){let r=htmx.config.triggerSpecsCache;n=r&&r[t]||parseAndCacheTrigger(e,t,r)}return n.length>0?n:matches(e,"form")?[{trigger:"submit"}]:matches(e,'input[type="button"], input[type="submit"]')?[{trigger:"click"}]:matches(e,INPUT_SELECTOR)?[{trigger:"change"}]:[{trigger:"click"}]}function cancelPolling(e){getInternalData(e).cancelled=!0}function processPolling(e,t,n){let r=getInternalData(e);r.timeout=getWindow().setTimeout(function(){bodyContains(e)&&r.cancelled!==!0&&(maybeFilterEvent(n,e,makeEvent("hx:poll:trigger",{triggerSpec:n,target:e}))||t(e),processPolling(e,t,n))},n.pollInterval)}function isLocalLink(e){return location.hostname===e.hostname&&getRawAttribute(e,"href")&&getRawAttribute(e,"href").indexOf("#")!==0}function eltIsDisabled(e){return closest(e,htmx.config.disableSelector)}function boostElement(e,t,n){if(e instanceof HTMLAnchorElement&&isLocalLink(e)&&(e.target===""||e.target==="_self")||e.tagName==="FORM"&&String(getRawAttribute(e,"method")).toLowerCase()!=="dialog"){t.boosted=!0;let r,o;if(e.tagName==="A")r="get",o=getRawAttribute(e,"href");else{let s=getRawAttribute(e,"method");r=s?s.toLowerCase():"get",o=getRawAttribute(e,"action"),(o==null||o==="")&&(o=location.href),r==="get"&&o.includes("?")&&(o=o.replace(/\?[^#]+/,""))}n.forEach(function(s){addEventListener(e,function(i,l){let a=asElement(i);if(eltIsDisabled(a)){cleanUpElement(a);return}issueAjaxRequest(r,o,a,l)},t,s,!0)})}}function shouldCancel(e,t){if(e.type==="submit"&&t.tagName==="FORM")return!0;if(e.type==="click"){let n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit")return!0;let r=t.closest("a"),o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href")))return!0}return!1}function ignoreBoostedAnchorCtrlClick(e,t){return getInternalData(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function maybeFilterEvent(e,t,n){let r=e.eventFilter;if(r)try{return r.call(t,n)!==!0}catch(o){let s=r.source;return triggerErrorEvent(getDocument().body,"htmx:eventFilter:error",{error:o,source:s}),!0}return!1}function addEventListener(e,t,n,r,o){let s=getInternalData(e),i;r.from?i=querySelectorAllExt(e,r.from):i=[e],r.changed&&("lastValue"in s||(s.lastValue=new WeakMap),i.forEach(function(l){s.lastValue.has(r)||s.lastValue.set(r,new WeakMap),s.lastValue.get(r).set(l,l.value)})),forEach(i,function(l){let a=function(c){if(!bodyContains(e)){l.removeEventListener(r.trigger,a);return}if(ignoreBoostedAnchorCtrlClick(e,c)||((o||shouldCancel(c,l))&&c.preventDefault(),maybeFilterEvent(r,e,c)))return;let d=getInternalData(c);if(d.triggerSpec=r,d.handledFor==null&&(d.handledFor=[]),d.handledFor.indexOf(e)<0){if(d.handledFor.push(e),r.consume&&c.stopPropagation(),r.target&&c.target&&!matches(asElement(c.target),r.target))return;if(r.once){if(s.triggeredOnce)return;s.triggeredOnce=!0}if(r.changed){let u=c.target,f=u.value,m=s.lastValue.get(r);if(m.has(u)&&m.get(u)===f)return;m.set(u,f)}if(s.delayed&&clearTimeout(s.delayed),s.throttle)return;r.throttle>0?s.throttle||(triggerEvent(e,"htmx:trigger"),t(e,c),s.throttle=getWindow().setTimeout(function(){s.throttle=null},r.throttle)):r.delay>0?s.delayed=getWindow().setTimeout(function(){triggerEvent(e,"htmx:trigger"),t(e,c)},r.delay):(triggerEvent(e,"htmx:trigger"),t(e,c))}};n.listenerInfos==null&&(n.listenerInfos=[]),n.listenerInfos.push({trigger:r.trigger,listener:a,on:l}),l.addEventListener(r.trigger,a)})}let windowIsScrolling=!1,scrollHandler=null;function initScrollHandler(){scrollHandler||(scrollHandler=function(){windowIsScrolling=!0},window.addEventListener("scroll",scrollHandler),window.addEventListener("resize",scrollHandler),setInterval(function(){windowIsScrolling&&(windowIsScrolling=!1,forEach(getDocument().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){maybeReveal(e)}))},200))}function maybeReveal(e){!hasAttribute(e,"data-hx-revealed")&&isScrolledIntoView(e)&&(e.setAttribute("data-hx-revealed","true"),getInternalData(e).initHash?triggerEvent(e,"revealed"):e.addEventListener("htmx:afterProcessNode",function(){triggerEvent(e,"revealed")},{once:!0}))}function loadImmediately(e,t,n,r){let o=function(){n.loaded||(n.loaded=!0,triggerEvent(e,"htmx:trigger"),t(e))};r>0?getWindow().setTimeout(o,r):o()}function processVerbs(e,t,n){let r=!1;return forEach(VERBS,function(o){if(hasAttribute(e,"hx-"+o)){let s=getAttributeValue(e,"hx-"+o);r=!0,t.path=s,t.verb=o,n.forEach(function(i){addTriggerHandler(e,i,t,function(l,a){let c=asElement(l);if(eltIsDisabled(c)){cleanUpElement(c);return}issueAjaxRequest(o,s,c,a)})})}}),r}function addTriggerHandler(e,t,n,r){if(t.trigger==="revealed")initScrollHandler(),addEventListener(e,r,n,t),maybeReveal(asElement(e));else if(t.trigger==="intersect"){let o={};t.root&&(o.root=querySelectorExt(e,t.root)),t.threshold&&(o.threshold=parseFloat(t.threshold)),new IntersectionObserver(function(i){for(let l=0;l<i.length;l++)if(i[l].isIntersecting){triggerEvent(e,"intersect");break}},o).observe(asElement(e)),addEventListener(asElement(e),r,n,t)}else!n.firstInitCompleted&&t.trigger==="load"?maybeFilterEvent(t,e,makeEvent("load",{elt:e}))||loadImmediately(asElement(e),r,n,t.delay):t.pollInterval>0?(n.polling=!0,processPolling(asElement(e),r,t)):addEventListener(e,r,n,t)}function shouldProcessHxOn(e){let t=asElement(e);if(!t)return!1;let n=t.attributes;for(let r=0;r<n.length;r++){let o=n[r].name;if(startsWith(o,"hx-on:")||startsWith(o,"data-hx-on:")||startsWith(o,"hx-on-")||startsWith(o,"data-hx-on-"))return!0}return!1}let HX_ON_QUERY=new XPathEvaluator().createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function processHXOnRoot(e,t){shouldProcessHxOn(e)&&t.push(asElement(e));let n=HX_ON_QUERY.evaluate(e),r=null;for(;r=n.iterateNext();)t.push(asElement(r))}function findHxOnWildcardElements(e){let t=[];if(e instanceof DocumentFragment)for(let n of e.childNodes)processHXOnRoot(n,t);else processHXOnRoot(e,t);return t}function findElementsToProcess(e){if(e.querySelectorAll){let n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]",r=[];for(let s in extensions){let i=extensions[s];if(i.getSelectors){var t=i.getSelectors();t&&r.push(t)}}return e.querySelectorAll(VERB_SELECTOR+n+", form, [type='submit'], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(s=>", "+s).join(""))}else return[]}function maybeSetLastButtonClicked(e){let t=getTargetButton(e.target),n=getRelatedFormData(e);n&&(n.lastButtonClicked=t)}function maybeUnsetLastButtonClicked(e){let t=getRelatedFormData(e);t&&(t.lastButtonClicked=null)}function getTargetButton(e){return closest(asElement(e),"button, input[type='submit']")}function getRelatedForm(e){return e.form||closest(e,"form")}function getRelatedFormData(e){let t=getTargetButton(e.target);if(!t)return;let n=getRelatedForm(t);if(n)return getInternalData(n)}function initButtonTracking(e){e.addEventListener("click",maybeSetLastButtonClicked),e.addEventListener("focusin",maybeSetLastButtonClicked),e.addEventListener("focusout",maybeUnsetLastButtonClicked)}function addHxOnEventHandler(e,t,n){let r=getInternalData(e);Array.isArray(r.onHandlers)||(r.onHandlers=[]);let o,s=function(i){maybeEval(e,function(){eltIsDisabled(e)||(o||(o=new Function("event",n)),o.call(e,i))})};e.addEventListener(t,s),r.onHandlers.push({event:t,listener:s})}function processHxOnWildcard(e){deInitOnHandlers(e);for(let t=0;t<e.attributes.length;t++){let n=e.attributes[t].name,r=e.attributes[t].value;if(startsWith(n,"hx-on")||startsWith(n,"data-hx-on")){let o=n.indexOf("-on")+3,s=n.slice(o,o+1);if(s==="-"||s===":"){let i=n.slice(o+1);startsWith(i,":")?i="htmx"+i:startsWith(i,"-")?i="htmx:"+i.slice(1):startsWith(i,"htmx-")&&(i="htmx:"+i.slice(5)),addHxOnEventHandler(e,i,r)}}}}function initNode(e){triggerEvent(e,"htmx:beforeProcessNode");let t=getInternalData(e),n=getTriggerSpecs(e);processVerbs(e,t,n)||(getClosestAttributeValue(e,"hx-boost")==="true"?boostElement(e,t,n):hasAttribute(e,"hx-trigger")&&n.forEach(function(o){addTriggerHandler(e,o,t,function(){})})),(e.tagName==="FORM"||getRawAttribute(e,"type")==="submit"&&hasAttribute(e,"form"))&&initButtonTracking(e),t.firstInitCompleted=!0,triggerEvent(e,"htmx:afterProcessNode")}function maybeDeInitAndHash(e){if(!(e instanceof Element))return!1;let t=getInternalData(e),n=attributeHash(e);return t.initHash!==n?(deInitNode(e),t.initHash=n,!0):!1}function processNode(e){if(e=resolveTarget(e),eltIsDisabled(e)){cleanUpElement(e);return}let t=[];maybeDeInitAndHash(e)&&t.push(e),forEach(findElementsToProcess(e),function(n){if(eltIsDisabled(n)){cleanUpElement(n);return}maybeDeInitAndHash(n)&&t.push(n)}),forEach(findHxOnWildcardElements(e),processHxOnWildcard),forEach(t,initNode)}function kebabEventName(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function makeEvent(e,t){return new CustomEvent(e,{bubbles:!0,cancelable:!0,composed:!0,detail:t})}function triggerErrorEvent(e,t,n){triggerEvent(e,t,mergeObjects({error:t},n))}function ignoreEventForLogging(e){return e==="htmx:afterProcessNode"}function withExtensions(e,t,n){forEach(getExtensions(e,[],n),function(r){try{t(r)}catch(o){logError(o)}})}function logError(e){console.error(e)}function triggerEvent(e,t,n){e=resolveTarget(e),n==null&&(n={}),n.elt=e;let r=makeEvent(t,n);htmx.logger&&!ignoreEventForLogging(t)&&htmx.logger(e,t,n),n.error&&(logError(n.error),triggerEvent(e,"htmx:error",{errorInfo:n}));let o=e.dispatchEvent(r),s=kebabEventName(t);if(o&&s!==t){let i=makeEvent(s,r.detail);o=o&&e.dispatchEvent(i)}return withExtensions(asElement(e),function(i){o=o&&i.onEvent(t,r)!==!1&&!r.defaultPrevented}),o}let currentPathForHistory;function setCurrentPathForHistory(e){currentPathForHistory=e,canAccessLocalStorage()&&sessionStorage.setItem("htmx-current-path-for-history",e)}setCurrentPathForHistory(location.pathname+location.search);function getHistoryElement(){return getDocument().querySelector("[hx-history-elt],[data-hx-history-elt]")||getDocument().body}function saveToHistoryCache(e,t){if(!canAccessLocalStorage())return;let n=cleanInnerHtmlForHistory(t),r=getDocument().title,o=window.scrollY;if(htmx.config.historyCacheSize<=0){sessionStorage.removeItem("htmx-history-cache");return}e=normalizePath(e);let s=parseJSON(sessionStorage.getItem("htmx-history-cache"))||[];for(let l=0;l<s.length;l++)if(s[l].url===e){s.splice(l,1);break}let i={url:e,content:n,title:r,scroll:o};for(triggerEvent(getDocument().body,"htmx:historyItemCreated",{item:i,cache:s}),s.push(i);s.length>htmx.config.historyCacheSize;)s.shift();for(;s.length>0;)try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(s));break}catch(l){triggerErrorEvent(getDocument().body,"htmx:historyCacheError",{cause:l,cache:s}),s.shift()}}function getCachedHistory(e){if(!canAccessLocalStorage())return null;e=normalizePath(e);let t=parseJSON(sessionStorage.getItem("htmx-history-cache"))||[];for(let n=0;n<t.length;n++)if(t[n].url===e)return t[n];return null}function cleanInnerHtmlForHistory(e){let t=htmx.config.requestClass,n=e.cloneNode(!0);return forEach(findAll(n,"."+t),function(r){removeClassFromElement(r,t)}),forEach(findAll(n,"[data-disabled-by-htmx]"),function(r){r.removeAttribute("disabled")}),n.innerHTML}function saveCurrentPageToHistory(){let e=getHistoryElement(),t=currentPathForHistory;canAccessLocalStorage()&&(t=sessionStorage.getItem("htmx-current-path-for-history")),t=t||location.pathname+location.search,getDocument().querySelector('[hx-history="false" i],[data-hx-history="false" i]')||(triggerEvent(getDocument().body,"htmx:beforeHistorySave",{path:t,historyElt:e}),saveToHistoryCache(t,e)),htmx.config.historyEnabled&&history.replaceState({htmx:!0},getDocument().title,location.href)}function pushUrlIntoHistory(e){htmx.config.getCacheBusterParam&&(e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,""),(endsWith(e,"&")||endsWith(e,"?"))&&(e=e.slice(0,-1))),htmx.config.historyEnabled&&history.pushState({htmx:!0},"",e),setCurrentPathForHistory(e)}function replaceUrlInHistory(e){htmx.config.historyEnabled&&history.replaceState({htmx:!0},"",e),setCurrentPathForHistory(e)}function settleImmediately(e){forEach(e,function(t){t.call(void 0)})}function loadHistoryFromServer(e){let t=new XMLHttpRequest,n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0},r={path:e,xhr:t,historyElt:getHistoryElement(),swapSpec:n};t.open("GET",e,!0),htmx.config.historyRestoreAsHxRequest&&t.setRequestHeader("HX-Request","true"),t.setRequestHeader("HX-History-Restore-Request","true"),t.setRequestHeader("HX-Current-URL",location.href),t.onload=function(){this.status>=200&&this.status<400?(r.response=this.response,triggerEvent(getDocument().body,"htmx:historyCacheMissLoad",r),swap(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:!0}),setCurrentPathForHistory(r.path),triggerEvent(getDocument().body,"htmx:historyRestore",{path:e,cacheMiss:!0,serverResponse:r.response})):triggerErrorEvent(getDocument().body,"htmx:historyCacheMissLoadError",r)},triggerEvent(getDocument().body,"htmx:historyCacheMiss",r)&&t.send()}function restoreHistory(e){saveCurrentPageToHistory(),e=e||location.pathname+location.search;let t=getCachedHistory(e);if(t){let n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll},r={path:e,item:t,historyElt:getHistoryElement(),swapSpec:n};triggerEvent(getDocument().body,"htmx:historyCacheHit",r)&&(swap(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title}),setCurrentPathForHistory(r.path),triggerEvent(getDocument().body,"htmx:historyRestore",r))}else htmx.config.refreshOnHistoryMiss?htmx.location.reload(!0):loadHistoryFromServer(e)}function addRequestIndicatorClasses(e){let t=findAttributeTargets(e,"hx-indicator");return t==null&&(t=[e]),forEach(t,function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||0)+1,n.classList.add.call(n.classList,htmx.config.requestClass)}),t}function disableElements(e){let t=findAttributeTargets(e,"hx-disabled-elt");return t==null&&(t=[]),forEach(t,function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||0)+1,n.setAttribute("disabled",""),n.setAttribute("data-disabled-by-htmx","")}),t}function removeRequestIndicators(e,t){forEach(e.concat(t),function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||1)-1}),forEach(e,function(n){getInternalData(n).requestCount===0&&n.classList.remove.call(n.classList,htmx.config.requestClass)}),forEach(t,function(n){getInternalData(n).requestCount===0&&(n.removeAttribute("disabled"),n.removeAttribute("data-disabled-by-htmx"))})}function haveSeenNode(e,t){for(let n=0;n<e.length;n++)if(e[n].isSameNode(t))return!0;return!1}function shouldInclude(e){let t=e;return t.name===""||t.name==null||t.disabled||closest(t,"fieldset[disabled]")||t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"?!1:t.type==="checkbox"||t.type==="radio"?t.checked:!0}function addValueToFormData(e,t,n){e!=null&&t!=null&&(Array.isArray(t)?t.forEach(function(r){n.append(e,r)}):n.append(e,t))}function removeValueFromFormData(e,t,n){if(e!=null&&t!=null){let r=n.getAll(e);Array.isArray(t)?r=r.filter(o=>t.indexOf(o)<0):r=r.filter(o=>o!==t),n.delete(e),forEach(r,o=>n.append(e,o))}}function getValueFromInput(e){return e instanceof HTMLSelectElement&&e.multiple?toArray(e.querySelectorAll("option:checked")).map(function(t){return t.value}):e instanceof HTMLInputElement&&e.files?toArray(e.files):e.value}function processInputValue(e,t,n,r,o){if(!(r==null||haveSeenNode(e,r))){if(e.push(r),shouldInclude(r)){let s=getRawAttribute(r,"name");addValueToFormData(s,getValueFromInput(r),t),o&&validateElement(r,n)}r instanceof HTMLFormElement&&(forEach(r.elements,function(s){e.indexOf(s)>=0?removeValueFromFormData(s.name,getValueFromInput(s),t):e.push(s),o&&validateElement(s,n)}),new FormData(r).forEach(function(s,i){s instanceof File&&s.name===""||addValueToFormData(i,s,t)}))}}function validateElement(e,t){let n=e;n.willValidate&&(triggerEvent(n,"htmx:validation:validate"),n.checkValidity()||(triggerEvent(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&htmx.config.reportValidityOfForms&&n.reportValidity(),t.push({elt:n,message:n.validationMessage,validity:n.validity})))}function overrideFormData(e,t){for(let n of t.keys())e.delete(n);return t.forEach(function(n,r){e.append(r,n)}),e}function getInputValues(e,t){let n=[],r=new FormData,o=new FormData,s=[],i=getInternalData(e);i.lastButtonClicked&&!bodyContains(i.lastButtonClicked)&&(i.lastButtonClicked=null);let l=e instanceof HTMLFormElement&&e.noValidate!==!0||getAttributeValue(e,"hx-validate")==="true";if(i.lastButtonClicked&&(l=l&&i.lastButtonClicked.formNoValidate!==!0),t!=="get"&&processInputValue(n,o,s,getRelatedForm(e),l),processInputValue(n,r,s,e,l),i.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&getRawAttribute(e,"type")==="submit"){let c=i.lastButtonClicked||e,d=getRawAttribute(c,"name");addValueToFormData(d,c.value,o)}let a=findAttributeTargets(e,"hx-include");return forEach(a,function(c){processInputValue(n,r,s,asElement(c),l),matches(c,"form")||forEach(asParentNode(c).querySelectorAll(INPUT_SELECTOR),function(d){processInputValue(n,r,s,d,l)})}),overrideFormData(r,o),{errors:s,formData:r,values:formDataProxy(r)}}function appendParam(e,t,n){e!==""&&(e+="&"),String(n)==="[object Object]"&&(n=JSON.stringify(n));let r=encodeURIComponent(n);return e+=encodeURIComponent(t)+"="+r,e}function urlEncode(e){e=formDataFromObject(e);let t="";return e.forEach(function(n,r){t=appendParam(t,r,n)}),t}function getHeaders(e,t,n){let r={"HX-Request":"true","HX-Trigger":getRawAttribute(e,"id"),"HX-Trigger-Name":getRawAttribute(e,"name"),"HX-Target":getAttributeValue(t,"id"),"HX-Current-URL":location.href};return getValuesForElement(e,"hx-headers",!1,r),n!==void 0&&(r["HX-Prompt"]=n),getInternalData(e).boosted&&(r["HX-Boosted"]="true"),r}function filterValues(e,t){let n=getClosestAttributeValue(t,"hx-params");if(n){if(n==="none")return new FormData;if(n==="*")return e;if(n.indexOf("not ")===0)return forEach(n.slice(4).split(","),function(r){r=r.trim(),e.delete(r)}),e;{let r=new FormData;return forEach(n.split(","),function(o){o=o.trim(),e.has(o)&&e.getAll(o).forEach(function(s){r.append(o,s)})}),r}}else return e}function isAnchorLink(e){return!!getRawAttribute(e,"href")&&getRawAttribute(e,"href").indexOf("#")>=0}function getSwapSpecification(e,t){let n=t||getClosestAttributeValue(e,"hx-swap"),r={swapStyle:getInternalData(e).boosted?"innerHTML":htmx.config.defaultSwapStyle,swapDelay:htmx.config.defaultSwapDelay,settleDelay:htmx.config.defaultSettleDelay};if(htmx.config.scrollIntoViewOnBoost&&getInternalData(e).boosted&&!isAnchorLink(e)&&(r.show="top"),n){let i=splitOnWhitespace(n);if(i.length>0)for(let l=0;l<i.length;l++){let a=i[l];if(a.indexOf("swap:")===0)r.swapDelay=parseInterval(a.slice(5));else if(a.indexOf("settle:")===0)r.settleDelay=parseInterval(a.slice(7));else if(a.indexOf("transition:")===0)r.transition=a.slice(11)==="true";else if(a.indexOf("ignoreTitle:")===0)r.ignoreTitle=a.slice(12)==="true";else if(a.indexOf("scroll:")===0){var o=a.slice(7).split(":");let d=o.pop();var s=o.length>0?o.join(":"):null;r.scroll=d,r.scrollTarget=s}else if(a.indexOf("show:")===0){var o=a.slice(5).split(":");let u=o.pop();var s=o.length>0?o.join(":"):null;r.show=u,r.showTarget=s}else if(a.indexOf("focus-scroll:")===0){let c=a.slice(13);r.focusScroll=c=="true"}else l==0?r.swapStyle=a:logError("Unknown modifier in hx-swap: "+a)}}return r}function usesFormData(e){return getClosestAttributeValue(e,"hx-encoding")==="multipart/form-data"||matches(e,"form")&&getRawAttribute(e,"enctype")==="multipart/form-data"}function encodeParamsForBody(e,t,n){let r=null;return withExtensions(t,function(o){r==null&&(r=o.encodeParameters(e,n,t))}),r??(usesFormData(t)?overrideFormData(new FormData,formDataFromObject(n)):urlEncode(n))}function makeSettleInfo(e){return{tasks:[],elts:[e]}}function updateScrollState(e,t){let n=e[0],r=e[e.length-1];if(t.scroll){var o=null;t.scrollTarget&&(o=asElement(querySelectorExt(n,t.scrollTarget))),t.scroll==="top"&&(n||o)&&(o=o||n,o.scrollTop=0),t.scroll==="bottom"&&(r||o)&&(o=o||r,o.scrollTop=o.scrollHeight),typeof t.scroll=="number"&&getWindow().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}if(t.show){var o=null;if(t.showTarget){let i=t.showTarget;t.showTarget==="window"&&(i="body"),o=asElement(querySelectorExt(n,i))}t.show==="top"&&(n||o)&&(o=o||n,o.scrollIntoView({block:"start",behavior:htmx.config.scrollBehavior})),t.show==="bottom"&&(r||o)&&(o=o||r,o.scrollIntoView({block:"end",behavior:htmx.config.scrollBehavior}))}}function getValuesForElement(e,t,n,r,o){if(r==null&&(r={}),e==null)return r;let s=getAttributeValue(e,t);if(s){let i=s.trim(),l=n;if(i==="unset")return null;i.indexOf("javascript:")===0?(i=i.slice(11),l=!0):i.indexOf("js:")===0&&(i=i.slice(3),l=!0),i.indexOf("{")!==0&&(i="{"+i+"}");let a;l?a=maybeEval(e,function(){return o?Function("event","return ("+i+")").call(e,o):Function("return ("+i+")").call(e)},{}):a=parseJSON(i);for(let c in a)a.hasOwnProperty(c)&&r[c]==null&&(r[c]=a[c])}return getValuesForElement(asElement(parentElt(e)),t,n,r,o)}function maybeEval(e,t,n){return htmx.config.allowEval?t():(triggerErrorEvent(e,"htmx:evalDisallowedError"),n)}function getHXVarsForElement(e,t,n){return getValuesForElement(e,"hx-vars",!0,n,t)}function getHXValsForElement(e,t,n){return getValuesForElement(e,"hx-vals",!1,n,t)}function getExpressionVars(e,t){return mergeObjects(getHXVarsForElement(e,t),getHXValsForElement(e,t))}function safelySetHeaderValue(e,t,n){if(n!==null)try{e.setRequestHeader(t,n)}catch{e.setRequestHeader(t,encodeURIComponent(n)),e.setRequestHeader(t+"-URI-AutoEncoded","true")}}function getPathFromResponse(e){if(e.responseURL)try{let t=new URL(e.responseURL);return t.pathname+t.search}catch{triggerErrorEvent(getDocument().body,"htmx:badResponseUrl",{url:e.responseURL})}}function hasHeader(e,t){return t.test(e.getAllResponseHeaders())}function ajaxHelper(e,t,n){if(e=e.toLowerCase(),n){if(n instanceof Element||typeof n=="string")return issueAjaxRequest(e,t,null,null,{targetOverride:resolveTarget(n)||DUMMY_ELT,returnPromise:!0});{let r=resolveTarget(n.target);return(n.target&&!r||n.source&&!r&&!resolveTarget(n.source))&&(r=DUMMY_ELT),issueAjaxRequest(e,t,resolveTarget(n.source),n.event,{handler:n.handler,headers:n.headers,values:n.values,targetOverride:r,swapOverride:n.swap,select:n.select,returnPromise:!0,push:n.push,replace:n.replace,selectOOB:n.selectOOB})}}else return issueAjaxRequest(e,t,null,null,{returnPromise:!0})}function hierarchyForElt(e){let t=[];for(;e;)t.push(e),e=e.parentElement;return t}function verifyPath(e,t,n){let r=new URL(t,location.protocol!=="about:"?location.href:window.origin),s=(location.protocol!=="about:"?location.origin:window.origin)===r.origin;return htmx.config.selfRequestsOnly&&!s?!1:triggerEvent(e,"htmx:validateUrl",mergeObjects({url:r,sameHost:s},n))}function formDataFromObject(e){if(e instanceof FormData)return e;let t=new FormData;for(let n in e)e.hasOwnProperty(n)&&(e[n]&&typeof e[n].forEach=="function"?e[n].forEach(function(r){t.append(n,r)}):typeof e[n]=="object"&&!(e[n]instanceof Blob)?t.append(n,JSON.stringify(e[n])):t.append(n,e[n]));return t}function formDataArrayProxy(e,t,n){return new Proxy(n,{get:function(r,o){return typeof o=="number"?r[o]:o==="length"?r.length:o==="push"?function(s){r.push(s),e.append(t,s)}:typeof r[o]=="function"?function(){r[o].apply(r,arguments),e.delete(t),r.forEach(function(s){e.append(t,s)})}:r[o]&&r[o].length===1?r[o][0]:r[o]},set:function(r,o,s){return r[o]=s,e.delete(t),r.forEach(function(i){e.append(t,i)}),!0}})}function formDataProxy(e){return new Proxy(e,{get:function(t,n){if(typeof n=="symbol"){let o=Reflect.get(t,n);return typeof o=="function"?function(){return o.apply(e,arguments)}:o}if(n==="toJSON")return()=>Object.fromEntries(e);if(n in t&&typeof t[n]=="function")return function(){return e[n].apply(e,arguments)};let r=e.getAll(n);if(r.length!==0)return r.length===1?r[0]:formDataArrayProxy(t,n,r)},set:function(t,n,r){return typeof n!="string"?!1:(t.delete(n),r&&typeof r.forEach=="function"?r.forEach(function(o){t.append(n,o)}):typeof r=="object"&&!(r instanceof Blob)?t.append(n,JSON.stringify(r)):t.append(n,r),!0)},deleteProperty:function(t,n){return typeof n=="string"&&t.delete(n),!0},ownKeys:function(t){return Reflect.ownKeys(Object.fromEntries(t))},getOwnPropertyDescriptor:function(t,n){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(t),n)}})}function issueAjaxRequest(e,t,n,r,o,s){let i=null,l=null;if(o=o??{},o.returnPromise&&typeof Promise<"u")var a=new Promise(function(g,b){i=g,l=b});n==null&&(n=getDocument().body);let c=o.handler||handleAjaxResponse,d=o.select||null;if(!bodyContains(n))return maybeCall(i),a;let u=o.targetOverride||asElement(getTarget(n));if(u==null||u==DUMMY_ELT)return triggerErrorEvent(n,"htmx:targetError",{target:getClosestAttributeValue(n,"hx-target")}),maybeCall(l),a;let f=getInternalData(n),m=f.lastButtonClicked;if(m){let g=getRawAttribute(m,"formaction");g!=null&&(t=g);let b=getRawAttribute(m,"formmethod");if(b!=null)if(VERBS.includes(b.toLowerCase()))e=b;else return maybeCall(i),a}let h=getClosestAttributeValue(n,"hx-confirm");if(s===void 0&&triggerEvent(n,"htmx:confirm",{target:u,elt:n,path:t,verb:e,triggeringEvent:r,etc:o,issueRequest:function(L){return issueAjaxRequest(e,t,n,r,o,!!L)},question:h})===!1)return maybeCall(i),a;let y=n,p=getClosestAttributeValue(n,"hx-sync"),w=null,T=!1;if(p){let g=p.split(":"),b=g[0].trim();if(b==="this"?y=findThisElement(n,"hx-sync"):y=asElement(querySelectorExt(n,b)),p=(g[1]||"drop").trim(),f=getInternalData(y),p==="drop"&&f.xhr&&f.abortable!==!0)return maybeCall(i),a;if(p==="abort"){if(f.xhr)return maybeCall(i),a;T=!0}else p==="replace"?triggerEvent(y,"htmx:abort"):p.indexOf("queue")===0&&(w=(p.split(" ")[1]||"last").trim())}if(f.xhr)if(f.abortable)triggerEvent(y,"htmx:abort");else{if(w==null){if(r){let g=getInternalData(r);g&&g.triggerSpec&&g.triggerSpec.queue&&(w=g.triggerSpec.queue)}w==null&&(w="last")}return f.queuedRequests==null&&(f.queuedRequests=[]),w==="first"&&f.queuedRequests.length===0?f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):w==="all"?f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):w==="last"&&(f.queuedRequests=[],f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)})),maybeCall(i),a}let v=new XMLHttpRequest;f.xhr=v,f.abortable=T;let E=function(){f.xhr=null,f.abortable=!1,f.queuedRequests!=null&&f.queuedRequests.length>0&&f.queuedRequests.shift()()},Q=getClosestAttributeValue(n,"hx-prompt");if(Q){var U=prompt(Q);if(U===null||!triggerEvent(n,"htmx:prompt",{prompt:U,target:u}))return maybeCall(i),E(),a}if(h&&!s&&!confirm(h))return maybeCall(i),E(),a;let I=getHeaders(n,u,U);e!=="get"&&!usesFormData(n)&&(I["Content-Type"]="application/x-www-form-urlencoded"),o.headers&&(I=mergeObjects(I,o.headers));let Z=getInputValues(n,e),q=Z.errors,ee=Z.formData;o.values&&overrideFormData(ee,formDataFromObject(o.values));let ve=formDataFromObject(getExpressionVars(n,r)),V=overrideFormData(ee,ve),H=filterValues(V,n);htmx.config.getCacheBusterParam&&e==="get"&&H.set("org.htmx.cache-buster",getRawAttribute(u,"id")||"true"),(t==null||t==="")&&(t=location.href);let j=getValuesForElement(n,"hx-request"),te=getInternalData(n).boosted,k=htmx.config.methodsThatUseUrlParams.indexOf(e)>=0,S={boosted:te,useUrlParams:k,formData:H,parameters:formDataProxy(H),unfilteredFormData:V,unfilteredParameters:formDataProxy(V),headers:I,elt:n,target:u,verb:e,errors:q,withCredentials:o.credentials||j.credentials||htmx.config.withCredentials,timeout:o.timeout||j.timeout||htmx.config.timeout,path:t,triggeringEvent:r};if(!triggerEvent(n,"htmx:configRequest",S))return maybeCall(i),E(),a;if(t=S.path,e=S.verb,I=S.headers,H=formDataFromObject(S.parameters),q=S.errors,k=S.useUrlParams,q&&q.length>0)return triggerEvent(n,"htmx:validation:halted",S),maybeCall(i),E(),a;let ne=t.split("#"),be=ne[0],W=ne[1],A=t;if(k&&(A=be,!H.keys().next().done&&(A.indexOf("?")<0?A+="?":A+="&",A+=urlEncode(H),W&&(A+="#"+W))),!verifyPath(n,A,S))return triggerErrorEvent(n,"htmx:invalidPath",S),maybeCall(l),E(),a;if(v.open(e.toUpperCase(),A,!0),v.overrideMimeType("text/html"),v.withCredentials=S.withCredentials,v.timeout=S.timeout,!j.noHeaders){for(let g in I)if(I.hasOwnProperty(g)){let b=I[g];safelySetHeaderValue(v,g,b)}}let x={xhr:v,target:u,requestConfig:S,etc:o,boosted:te,select:d,pathInfo:{requestPath:t,finalRequestPath:A,responsePath:null,anchor:W}};if(v.onload=function(){try{let g=hierarchyForElt(n);if(x.pathInfo.responsePath=getPathFromResponse(v),c(n,x),x.keepIndicators!==!0&&removeRequestIndicators(N,P),triggerEvent(n,"htmx:afterRequest",x),triggerEvent(n,"htmx:afterOnLoad",x),!bodyContains(n)){let b=null;for(;g.length>0&&b==null;){let L=g.shift();bodyContains(L)&&(b=L)}b&&(triggerEvent(b,"htmx:afterRequest",x),triggerEvent(b,"htmx:afterOnLoad",x))}maybeCall(i)}catch(g){throw triggerErrorEvent(n,"htmx:onLoadError",mergeObjects({error:g},x)),g}finally{E()}},v.onerror=function(){removeRequestIndicators(N,P),triggerErrorEvent(n,"htmx:afterRequest",x),triggerErrorEvent(n,"htmx:sendError",x),maybeCall(l),E()},v.onabort=function(){removeRequestIndicators(N,P),triggerErrorEvent(n,"htmx:afterRequest",x),triggerErrorEvent(n,"htmx:sendAbort",x),maybeCall(l),E()},v.ontimeout=function(){removeRequestIndicators(N,P),triggerErrorEvent(n,"htmx:afterRequest",x),triggerErrorEvent(n,"htmx:timeout",x),maybeCall(l),E()},!triggerEvent(n,"htmx:beforeRequest",x))return maybeCall(i),E(),a;var N=addRequestIndicatorClasses(n),P=disableElements(n);forEach(["loadstart","loadend","progress","abort"],function(g){forEach([v,v.upload],function(b){b.addEventListener(g,function(L){triggerEvent(n,"htmx:xhr:"+g,{lengthComputable:L.lengthComputable,loaded:L.loaded,total:L.total})})})}),triggerEvent(n,"htmx:beforeSend",x);let we=k?null:encodeParamsForBody(v,n,H);return v.send(we),a}function determineHistoryUpdates(e,t){let n=t.xhr,r=null,o=null;if(hasHeader(n,/HX-Push:/i)?(r=n.getResponseHeader("HX-Push"),o="push"):hasHeader(n,/HX-Push-Url:/i)?(r=n.getResponseHeader("HX-Push-Url"),o="push"):hasHeader(n,/HX-Replace-Url:/i)&&(r=n.getResponseHeader("HX-Replace-Url"),o="replace"),r)return r==="false"?{}:{type:o,path:r};let s=t.pathInfo.finalRequestPath,i=t.pathInfo.responsePath,l=t.etc.push||getClosestAttributeValue(e,"hx-push-url"),a=t.etc.replace||getClosestAttributeValue(e,"hx-replace-url"),c=getInternalData(e).boosted,d=null,u=null;return l?(d="push",u=l):a?(d="replace",u=a):c&&(d="push",u=i||s),u?u==="false"?{}:(u==="true"&&(u=i||s),t.pathInfo.anchor&&u.indexOf("#")===-1&&(u=u+"#"+t.pathInfo.anchor),{type:d,path:u}):{}}function codeMatches(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function resolveResponseHandling(e){for(var t=0;t<htmx.config.responseHandling.length;t++){var n=htmx.config.responseHandling[t];if(codeMatches(n,e.status))return n}return{swap:!1}}function handleTitle(e){if(e){let t=find("title");t?t.textContent=e:window.document.title=e}}function resolveRetarget(e,t){if(t==="this")return e;let n=asElement(querySelectorExt(e,t));if(n==null)throw triggerErrorEvent(e,"htmx:targetError",{target:t}),new Error(`Invalid re-target ${t}`);return n}function handleAjaxResponse(e,t){let n=t.xhr,r=t.target,o=t.etc,s=t.select;if(!triggerEvent(e,"htmx:beforeOnLoad",t))return;if(hasHeader(n,/HX-Trigger:/i)&&handleTriggerHeader(n,"HX-Trigger",e),hasHeader(n,/HX-Location:/i)){let T=n.getResponseHeader("HX-Location");var i={};T.indexOf("{")===0&&(i=parseJSON(T),T=i.path,delete i.path),i.push=i.push||"true",ajaxHelper("get",T,i);return}let l=hasHeader(n,/HX-Refresh:/i)&&n.getResponseHeader("HX-Refresh")==="true";if(hasHeader(n,/HX-Redirect:/i)){t.keepIndicators=!0,htmx.location.href=n.getResponseHeader("HX-Redirect"),l&&htmx.location.reload();return}if(l){t.keepIndicators=!0,htmx.location.reload();return}let a=determineHistoryUpdates(e,t),c=resolveResponseHandling(n),d=c.swap,u=!!c.error,f=htmx.config.ignoreTitle||c.ignoreTitle,m=c.select;c.target&&(t.target=resolveRetarget(e,c.target));var h=o.swapOverride;h==null&&c.swapOverride&&(h=c.swapOverride),hasHeader(n,/HX-Retarget:/i)&&(t.target=resolveRetarget(e,n.getResponseHeader("HX-Retarget"))),hasHeader(n,/HX-Reswap:/i)&&(h=n.getResponseHeader("HX-Reswap"));var y=n.response,p=mergeObjects({shouldSwap:d,serverResponse:y,isError:u,ignoreTitle:f,selectOverride:m,swapOverride:h},t);if(!(c.event&&!triggerEvent(r,c.event,p))&&triggerEvent(r,"htmx:beforeSwap",p)){if(r=p.target,y=p.serverResponse,u=p.isError,f=p.ignoreTitle,m=p.selectOverride,h=p.swapOverride,t.target=r,t.failed=u,t.successful=!u,p.shouldSwap){n.status===286&&cancelPolling(e),withExtensions(e,function(E){y=E.transformResponse(y,n,e)}),a.type&&saveCurrentPageToHistory();var w=getSwapSpecification(e,h);w.hasOwnProperty("ignoreTitle")||(w.ignoreTitle=f),r.classList.add(htmx.config.swappingClass),s&&(m=s),hasHeader(n,/HX-Reselect:/i)&&(m=n.getResponseHeader("HX-Reselect"));let T=o.selectOOB||getClosestAttributeValue(e,"hx-select-oob"),v=getClosestAttributeValue(e,"hx-select");swap(r,y,w,{select:m==="unset"?null:m||v,selectOOB:T,eventInfo:t,anchor:t.pathInfo.anchor,contextElement:e,afterSwapCallback:function(){if(hasHeader(n,/HX-Trigger-After-Swap:/i)){let E=e;bodyContains(e)||(E=getDocument().body),handleTriggerHeader(n,"HX-Trigger-After-Swap",E)}},afterSettleCallback:function(){if(hasHeader(n,/HX-Trigger-After-Settle:/i)){let E=e;bodyContains(e)||(E=getDocument().body),handleTriggerHeader(n,"HX-Trigger-After-Settle",E)}},beforeSwapCallback:function(){a.type&&(triggerEvent(getDocument().body,"htmx:beforeHistoryUpdate",mergeObjects({history:a},t)),a.type==="push"?(pushUrlIntoHistory(a.path),triggerEvent(getDocument().body,"htmx:pushedIntoHistory",{path:a.path})):(replaceUrlInHistory(a.path),triggerEvent(getDocument().body,"htmx:replacedInHistory",{path:a.path})))}})}u&&triggerErrorEvent(e,"htmx:responseError",mergeObjects({error:"Response Status Error Code "+n.status+" from "+t.pathInfo.requestPath},t))}}let extensions={};function extensionBase(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return!0},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return!1},handleSwap:function(e,t,n,r){return!1},encodeParameters:function(e,t,n){return null}}}function defineExtension(e,t){t.init&&t.init(internalAPI),extensions[e]=mergeObjects(extensionBase(),t)}function removeExtension(e){delete extensions[e]}function getExtensions(e,t,n){if(t==null&&(t=[]),e==null)return t;n==null&&(n=[]);let r=getAttributeValue(e,"hx-ext");return r&&forEach(r.split(","),function(o){if(o=o.replace(/ /g,""),o.slice(0,7)=="ignore:"){n.push(o.slice(7));return}if(n.indexOf(o)<0){let s=extensions[o];s&&t.indexOf(s)<0&&t.push(s)}}),getExtensions(asElement(parentElt(e)),t,n)}var isReady=!1;getDocument().addEventListener("DOMContentLoaded",function(){isReady=!0});function ready(e){isReady||getDocument().readyState==="complete"?e():getDocument().addEventListener("DOMContentLoaded",e)}function insertIndicatorStyles(){if(htmx.config.includeIndicatorStyles!==!1){let e=htmx.config.inlineStyleNonce?` nonce="${htmx.config.inlineStyleNonce}"`:"",t=htmx.config.indicatorClass,n=htmx.config.requestClass;getDocument().head.insertAdjacentHTML("beforeend",`<style${e}>.${t}{opacity:0;visibility: hidden} .${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}</style>`)}}function getMetaConfig(){let e=getDocument().querySelector('meta[name="htmx-config"]');return e?parseJSON(e.content):null}function mergeMetaConfig(){let e=getMetaConfig();e&&(htmx.config=mergeObjects(htmx.config,e))}return ready(function(){mergeMetaConfig(),insertIndicatorStyles();let e=getDocument().body;processNode(e);let t=getDocument().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(r){let o=r.detail.elt||r.target,s=getInternalData(o);s&&s.xhr&&s.xhr.abort()});let n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(r){r.state&&r.state.htmx?(restoreHistory(),forEach(t,function(o){triggerEvent(o,"htmx:restored",{document:getDocument(),triggerEvent})})):n&&n(r)},getWindow().setTimeout(function(){triggerEvent(e,"htmx:load",{}),e=null},0)}),htmx})(),D=xe;(function(){let e;D.defineExtension("json-enc",{init:function(t){e=t},onEvent:function(t,n){t==="htmx:configRequest"&&(n.detail.headers["Content-Type"]="application/json")},encodeParameters:function(t,n,r){t.overrideMimeType("text/json");let o={};n.forEach(function(i,l){Object.hasOwn(o,l)?(Array.isArray(o[l])||(o[l]=[o[l]]),o[l].push(i)):o[l]=i});let s=e.getExpressionVars(r);return Object.keys(o).forEach(function(i){o[i]=Object.hasOwn(s,i)?s[i]:o[i]}),JSON.stringify(o)}})})();var re="https://typeahead.waow.tech",se="https://public.api.bsky.app",Te="/xrpc/app.bsky.actor.searchActorsTypeahead",Se="/xrpc/app.bsky.actor.getProfiles";var Ce="atcr_recent_handles",ie="atcr_recent_profile_cache";var z=class{constructor(t){this.input=t,this.container=t.closest(".sailor-typeahead")||t.parentElement,this.dropdown=null,this.selectedCard=null,this.actors=[],this.currentItems=[],this.mode="hidden",this.focusIndex=-1,this.debounceTimer=null,this.requestSeq=0,this.primaryUnhealthyUntil=0,this.lastPrefetchPrefix="",this.lastPrefetchAt=0,this.createDropdown(),this.bindEvents(),this.input.value.trim().length===0&&this.showRecent()}createDropdown(){this.dropdown=document.createElement("div"),this.dropdown.className="sailor-typeahead-dropdown",this.dropdown.setAttribute("role","listbox"),this.dropdown.style.display="none",this.input.insertAdjacentElement("afterend",this.dropdown)}bindEvents(){this.input.addEventListener("focus",()=>this.handleFocus()),this.input.addEventListener("input",()=>this.handleInput()),this.input.addEventListener("keydown",t=>this.handleKeydown(t)),document.addEventListener("click",t=>{!this.input.contains(t.target)&&!this.dropdown.contains(t.target)&&this.hide()}),document.addEventListener("keydown",t=>{t.key==="Escape"&&this.selectedCard&&this.clearSelection()})}handleFocus(){this.input.value.trim().length===0&&this.showRecent()}handleInput(){let t=this.input.value.trim();if(t.length===0){this.showRecent();return}if(t.length>=2&&t.length<4){this.hide(),this.schedulePrefetch(t);return}if(t.length>=4){this.scheduleSearch(t);return}this.hide()}schedulePrefetch(t){clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>this.runPrefetch(t),150)}scheduleSearch(t){clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>this.runSearch(t),150)}async runPrefetch(t){let n=Date.now();if(!(t===this.lastPrefetchPrefix&&n-this.lastPrefetchAt<1e4)&&!(n<this.primaryUnhealthyUntil)){this.lastPrefetchPrefix=t,this.lastPrefetchAt=n;try{await X(re,t,400)}catch{this.primaryUnhealthyUntil=Date.now()+6e4}}}async runSearch(t){let n=++this.requestSeq,r=null;if(Date.now()>=this.primaryUnhealthyUntil)try{r=await X(re,t,1500)}catch{this.primaryUnhealthyUntil=Date.now()+6e4}if(r===null)try{r=await X(se,t,1500)}catch{r=[]}n===this.requestSeq&&(this.actors=r||[],this.focusIndex=-1,this.renderResults())}renderResults(){if(this.mode="results",this.dropdown.innerHTML="",this.currentItems=[],this.actors.length===0){this.hide();return}this.actors.forEach((t,n)=>{this.currentItems.push(t),this.dropdown.appendChild(this.buildActorRow(t,n))}),this.dropdown.style.display="block"}buildActorRow(t,n){let r=document.createElement("div");r.className="sailor-typeahead-item",r.setAttribute("role","option"),r.dataset.index=String(n),r.dataset.handle=t.handle;let o=document.createElement("div");if(o.className="sailor-typeahead-avatar",t.avatar){let a=document.createElement("img");a.src=t.avatar,a.alt="",a.loading="lazy",o.appendChild(a)}let s=document.createElement("div");s.className="sailor-typeahead-text";let i=t.displayName&&t.displayName!==t.handle;if(i){let a=document.createElement("div");a.className="sailor-typeahead-name",a.textContent=t.displayName,s.appendChild(a)}let l=document.createElement("div");return l.className=i?"sailor-typeahead-handle":"sailor-typeahead-name",l.textContent="@"+t.handle,s.appendChild(l),r.append(o,s),r.addEventListener("mousedown",a=>{a.preventDefault(),this.select(t)}),r}showRecent(){let t=Le();if(t.length===0){this.hide();return}this.mode="recent",this.focusIndex=-1,this.renderRecent(t),this.enrichRecent(t)}renderRecent(t){let n=F();this.dropdown.innerHTML="",this.currentItems=[];let r=document.createElement("div");r.className="sailor-typeahead-header",r.textContent="Recent accounts",this.dropdown.appendChild(r),t.forEach((o,s)=>{let i=n[o]?.profile||{handle:o};this.currentItems.push(i),this.dropdown.appendChild(this.buildActorRow(i,s))}),this.dropdown.style.display="block"}async enrichRecent(t){let n=F(),r=Date.now(),o=t.filter(l=>{let a=n[l];return!a||r-a.ts>864e5});if(o.length===0)return;let s=await Ae(o);if(s.length===0)return;let i=F();s.forEach(l=>{i[l.handle]={ts:r,profile:{handle:l.handle,displayName:l.displayName,avatar:l.avatar}}}),oe(i),this.mode==="recent"&&this.renderRecent(t)}hide(){this.mode="hidden",this.focusIndex=-1,this.dropdown.style.display="none"}select(t){if(typeof t=="string"&&(t={handle:t}),this.input.value=t.handle,this.hide(),this.showSelectedCard(t),t.handle){let n=F();n[t.handle]={ts:Date.now(),profile:{handle:t.handle,displayName:t.displayName,avatar:t.avatar}},oe(n)}}showSelectedCard(t){this.clearSelectedCard();let n=document.createElement("div");n.className="sailor-typeahead-selected";let r=document.createElement("div");if(r.className="sailor-typeahead-avatar",t.avatar){let a=document.createElement("img");a.src=t.avatar,a.alt="",r.appendChild(a)}let o=document.createElement("div");o.className="sailor-typeahead-text";let s=t.displayName&&t.displayName!==t.handle;if(s){let a=document.createElement("div");a.className="sailor-typeahead-name",a.textContent=t.displayName,o.appendChild(a)}let i=document.createElement("div");i.className=s?"sailor-typeahead-handle":"sailor-typeahead-name",i.textContent="@"+t.handle,o.appendChild(i);let l=document.createElement("button");l.type="button",l.className="sailor-typeahead-clear",l.tabIndex=-1,l.setAttribute("aria-label","Change account"),l.innerHTML="&times;",l.addEventListener("click",()=>this.clearSelection()),n.append(r,o,l),this.input.style.display="none",this.input.insertAdjacentElement("beforebegin",n),this.selectedCard=n}clearSelectedCard(){this.selectedCard&&(this.selectedCard.remove(),this.selectedCard=null)}clearSelection(){this.clearSelectedCard(),this.input.style.display="",this.input.value="",this.input.focus(),this.showRecent()}handleKeydown(t){if(this.mode==="hidden")return;let n=this.dropdown.querySelectorAll(".sailor-typeahead-item");n.length!==0&&(t.key==="ArrowDown"?(t.preventDefault(),this.focusIndex=(this.focusIndex+1)%n.length,this.updateFocus(n)):t.key==="ArrowUp"?(t.preventDefault(),this.focusIndex=this.focusIndex<=0?n.length-1:this.focusIndex-1,this.updateFocus(n)):t.key==="Enter"?this.focusIndex>=0&&this.currentItems[this.focusIndex]&&(t.preventDefault(),this.select(this.currentItems[this.focusIndex])):t.key==="Escape"?this.hide():t.key==="Tab"&&this.focusIndex===-1&&n.length>0&&(t.preventDefault(),this.focusIndex=0,this.updateFocus(n)))}updateFocus(t){t.forEach((n,r)=>{n.classList.toggle("focused",r===this.focusIndex),r===this.focusIndex&&n.scrollIntoView({block:"nearest"})})}};async function X(e,t,n){let r=new URL(Te,e);r.searchParams.set("q",t),r.searchParams.set("limit",String(8));let o=new AbortController,s=setTimeout(()=>o.abort(),n);try{let i=await fetch(r,{signal:o.signal});if(!i.ok)throw new Error("HTTP "+i.status);let l=await i.json();return Array.isArray(l.actors)?l.actors:[]}finally{clearTimeout(s)}}async function Ae(e){if(e.length===0)return[];let t=new URL(Se,se);e.forEach(o=>t.searchParams.append("actors",o));let n=new AbortController,r=setTimeout(()=>n.abort(),3e3);try{let o=await fetch(t,{signal:n.signal});if(!o.ok)return[];let s=await o.json();return Array.isArray(s.profiles)?s.profiles:[]}catch{return[]}finally{clearTimeout(r)}}function F(){try{return JSON.parse(localStorage.getItem(ie)||"{}")}catch{return{}}}function oe(e){try{localStorage.setItem(ie,JSON.stringify(e))}catch{}}function Le(){try{let e=localStorage.getItem(Ce);return e?JSON.parse(e):[]}catch{return[]}}document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("handle");e&&new z(e)});function ue(){return localStorage.getItem("theme")||"system"}function Ie(e){return e==="dark"||e==="light"?e:window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function J(){let e=ue(),n=Ie(e)==="dark";document.documentElement.classList.toggle("dark",n),document.documentElement.setAttribute("data-theme",n?"dark":"light"),He(e)}function de(e){localStorage.setItem("theme",e),J(),De()}function He(e){let t={system:"sun-moon",light:"sun",dark:"moon"};document.querySelectorAll("[data-theme-icon] use").forEach(n=>{n.setAttribute("href",`/icons.svg#${t[e]||"sun-moon"}`)}),document.querySelectorAll(".theme-option").forEach(n=>{let r=n.dataset.value===e;n.setAttribute("aria-checked",r?"true":"false");let o=n.querySelector(".theme-check");o&&(o.style.visibility=r?"visible":"hidden")})}function De(){document.querySelectorAll("[data-theme-toggle]").forEach(e=>{let t=e.closest("details");t&&t.removeAttribute("open")})}window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{ue()==="system"&&J()});function fe(e,t){if(!e)return;let n=e.querySelector(".nav-search-form"),r=e.querySelector('button[aria-controls="nav-search-form"]');e.classList.toggle("expanded",t),n&&(t?n.removeAttribute("inert"):n.setAttribute("inert","")),r&&r.setAttribute("aria-expanded",t?"true":"false")}function Re(){let e=document.querySelector(".nav-search-wrapper");if(!e)return;let t=!e.classList.contains("expanded");if(fe(e,t),t){let n=document.getElementById("nav-search-input");n&&n.focus()}}function ae(){fe(document.querySelector(".nav-search-wrapper"),!1)}document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelector(".nav-search-wrapper"),t=document.getElementById("nav-search-input");!e||!t||(document.addEventListener("keydown",n=>{if(n.key==="Escape"&&e.classList.contains("expanded")&&ae(),n.key==="/"&&!e.classList.contains("expanded")){let r=n.target.tagName;if(r==="INPUT"||r==="TEXTAREA"||n.target.isContentEditable)return;n.preventDefault(),e.classList.add("expanded"),t.focus()}}),document.addEventListener("click",n=>{e.classList.contains("expanded")&&!e.contains(n.target)&&ae()}))});function $(e,t){let n=()=>{if(!t)return;let r=t.innerHTML;t.innerHTML='<svg class="icon size-4" aria-hidden="true"><use href="/icons.svg#check"></use></svg> Copied!',setTimeout(()=>{t.innerHTML=r},2e3)};if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(e).then(n).catch(r=>{console.error("Clipboard API failed, falling back:",r),le(e)?n():C("Copy failed \u2014 check browser permissions","error")});return}le(e)?n():C("Copy failed \u2014 select the text and copy manually","error")}function le(e){let t=document.createElement("textarea");t.value=e,t.setAttribute("readonly",""),t.setAttribute("aria-hidden","true"),t.style.position="fixed",t.style.top="0",t.style.left="0",t.style.width="1px",t.style.height="1px",t.style.opacity="0",t.style.pointerEvents="none",document.body.appendChild(t);let n=!1;try{t.focus(),t.select(),t.setSelectionRange(0,e.length),n=document.execCommand&&document.execCommand("copy")}catch{n=!1}return document.body.removeChild(t),!!n}function Oe(e){let t=s=>{let i=(s==null?"":String(s)).replace(/\s+/g," ").trim();return/[",\n\r]/.test(i)?'"'+i.replace(/"/g,'""')+'"':i},n=s=>Array.from(s).map(i=>t(i.textContent)).join(","),r=[],o=e.querySelector("thead tr");return o&&r.push(n(o.querySelectorAll("th,td"))),e.querySelectorAll("tbody tr").forEach(s=>{r.push(n(s.querySelectorAll("td,th")))}),r.join(` 2 - `)}document.addEventListener("DOMContentLoaded",()=>{document.addEventListener("click",e=>{let t=e.target.closest("button[data-copy-csv]");if(t){let r=t.closest("[data-csv-section]"),o=r&&r.querySelector("table");o&&$(Oe(o),t);return}let n=e.target.closest("button[data-cmd]");if(n){$(n.getAttribute("data-cmd"),n);return}})});function Me(e){let t=Math.floor((new Date-new Date(e))/1e3),n={year:31536e3,month:2592e3,week:604800,day:86400,hour:3600,minute:60,second:1};for(let[r,o]of Object.entries(n)){let s=Math.floor(t/o);if(s>=1)return s===1?`1 ${r} ago`:`${s} ${r}s ago`}return"just now"}function B(){document.querySelectorAll("time[datetime]").forEach(e=>{let t=e.getAttribute("datetime");if(t&&!e.dataset.noUpdate){let n=Me(t);e.textContent!==n&&(e.textContent=n)}})}document.addEventListener("DOMContentLoaded",()=>{B(),J(),document.querySelectorAll("[data-theme-menu]").forEach(e=>{e.querySelectorAll(".theme-option").forEach(t=>{t.addEventListener("click",()=>{de(t.dataset.value)})})}),document.addEventListener("click",e=>{let t=e.target.closest("details.dropdown");document.querySelectorAll("details.dropdown[open]").forEach(n=>{n!==t&&n.removeAttribute("open")})})});document.addEventListener("htmx:afterSwap",B);var R=null;function he(){R===null&&(R=setInterval(B,6e4))}function qe(){R!==null&&(clearInterval(R),R=null)}document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?qe():(B(),he())});he();async function ke(e,t,n){try{let r=await fetch("/api/manifests",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e,digest:t,confirm:!1})});if(r.status===409){let o=await r.json();Ne(e,t,n,o.tags)}else if(r.ok)me(n);else{let o=await r.text();C(`Failed to delete manifest: ${o||r.status}`,"error")}}catch(r){console.error("Error deleting manifest:",r),C(`Error deleting manifest: ${r.message}`,"error")}}function Ne(e,t,n,r){let o=document.getElementById("manifest-delete-modal"),s=document.getElementById("manifest-delete-tags"),i=document.getElementById("confirm-manifest-delete-btn");s.innerHTML="",r.forEach(l=>{let a=document.createElement("li");a.textContent=l,s.appendChild(a)}),i.onclick=()=>Pe(e,t,n),K(o)}function Y(){O(document.getElementById("manifest-delete-modal"))}async function Pe(e,t,n){let r=document.getElementById("confirm-manifest-delete-btn"),o=r.textContent;try{r.disabled=!0,r.textContent="Deleting...";let s=await fetch("/api/manifests",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e,digest:t,confirm:!0})});if(s.ok)Y(),me(n),location.reload();else{let i=await s.text();C(`Failed to delete manifest: ${i||s.status}`,"error"),r.disabled=!1,r.textContent=o}}catch(s){console.error("Error deleting manifest:",s),C(`Error deleting manifest: ${s.message}`,"error"),r.disabled=!1,r.textContent=o}}async function Fe(e){let t=document.getElementById("confirm-untagged-delete-btn"),n=t.textContent;try{t.disabled=!0,t.textContent="Deleting...";let r=await fetch("/api/manifests/untagged",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e})}),o=await r.json();r.ok?(O(document.getElementById("untagged-delete-modal")),C(`Deleted ${o.deleted} untagged manifest(s)`,"success"),o.deleted>0&&location.reload(),t.disabled=!1,t.textContent=n):(C(`Failed to delete untagged manifests: ${o.error||"Unknown error"}`,"error"),t.disabled=!1,t.textContent=n)}catch(r){console.error("Error deleting untagged manifests:",r),C(`Error: ${r.message}`,"error"),t.disabled=!1,t.textContent=n}}function me(e){let t=document.getElementById(`manifest-${e}`);t&&t.remove()}document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("manifest-delete-modal");e&&e.addEventListener("click",t=>{t.target===e&&Y()})});var M=new WeakMap;function K(e,t){if(e&&(M.set(e,t||document.activeElement),typeof e.showModal=="function")){e.open&&(e.open=!1);try{e.showModal()}catch{}}}function O(e,{remove:t=!1}={}){if(!e)return;let n=M.get(e);if(M.delete(e),typeof e.close=="function"&&e.open)try{e.close()}catch{}t&&e.remove(),ge(n)}function ge(e){e&&typeof e.focus=="function"&&document.contains(e)&&e.focus()}document.addEventListener("close",e=>{let t=e.target;if(!(t instanceof HTMLDialogElement))return;let n=M.get(t);M.delete(t),ge(n)},!0);document.body.addEventListener("htmx:afterSettle",()=>{document.querySelectorAll("dialog.modal-open:not([data-modal-promoted]), dialog[open]:not([data-modal-promoted])").forEach(t=>{t.dataset.modalPromoted="1",K(t)})});document.addEventListener("change",e=>{let t=e.target.closest("select[data-diff-url]");if(!t)return;let n=t.dataset.diffUrl;n&&(window.location.href=n.replace("__VALUE__",encodeURIComponent(t.value)))});document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("pull-cmd-container");if(!e)return;let t=e.dataset.registryUrl,n=e.dataset.ownerHandle,r=e.dataset.repoName,o=e.dataset.tag||"latest",s=e.dataset.isLoggedIn==="true";function i(a){let d=(a==="none"?"":a+" pull ")+t+"/"+n+"/"+r+":"+o,u=document.getElementById("pull-cmd-display");if(!u)return;let f=u.querySelector("code");f&&(f.textContent=d);let m=u.querySelector("[data-cmd]");m&&(m.dataset.cmd=d),s&&window.htmx?window.htmx.ajax("POST","/api/profile/oci-client",{values:{oci_client:a},swap:"none"}):s||localStorage.setItem("oci-client",a)}if(!s){let a=localStorage.getItem("oci-client");if(a){let c=document.getElementById("oci-client-switcher");c&&(c.value=a,i(a))}}let l=document.getElementById("oci-client-switcher");l&&l.addEventListener("change",()=>i(l.value))});document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelectorAll(".platform-tab[data-platform]");e.length&&e.forEach(t=>{t.addEventListener("click",()=>{e.forEach(r=>{let o=r===t;r.classList.toggle("btn-primary",o),r.classList.toggle("btn-ghost",!o)}),document.querySelectorAll(".platform-content").forEach(r=>r.classList.add("hidden"));let n=document.getElementById(t.dataset.platform+"-content");n&&n.classList.remove("hidden")})})});document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("login-form");e&&e.addEventListener("submit",()=>{let t=e.querySelector('button[type="submit"]');!t||t.disabled||(t.disabled=!0,t.innerHTML='<span class="loading loading-spinner loading-sm align-middle"></span> Navigating&hellip;')})});document.addEventListener("DOMContentLoaded",()=>{let e=document.cookie.split("; ").find(n=>n.startsWith("atcr_login_handle="));if(!e)return;let t=decodeURIComponent(e.split("=")[1]);if(t){try{let n="atcr_recent_handles",r=JSON.parse(localStorage.getItem(n)||"[]");r=r.filter(o=>o!==t),r.unshift(t),r=r.slice(0,5),localStorage.setItem(n,JSON.stringify(r))}catch(n){console.error("Failed to save recent account:",n)}document.cookie="atcr_login_handle=; path=/; max-age=0"}});function ce(){let e=document.getElementById("featured-carousel"),t=document.getElementById("carousel-prev"),n=document.getElementById("carousel-next");if(!e)return;let r=e.querySelectorAll(".carousel-item");if(r.length===0)return;let o=null,s=5e3,i=0,l=0;function a(){let m=parseFloat(getComputedStyle(e).gap)||24;i=r[0].offsetWidth+m}a(),window.addEventListener("resize",()=>{cancelAnimationFrame(l),l=requestAnimationFrame(a)}),document.body.addEventListener("htmx:afterSettle",m=>{m.target&&m.target.contains&&m.target.contains(e)&&a()});function c(){let m=e.scrollWidth-e.clientWidth;e.scrollLeft>=m-10?e.scrollTo({left:0,behavior:"smooth"}):e.scrollBy({left:i,behavior:"smooth"})}function d(){e.scrollLeft<=10?e.scrollTo({left:e.scrollWidth,behavior:"smooth"}):e.scrollBy({left:-i,behavior:"smooth"})}function u(){o||document.visibilityState!=="hidden"&&(e.scrollWidth<=e.clientWidth+10||(o=setInterval(c,s)))}function f(){o&&(clearInterval(o),o=null)}t&&t.addEventListener("click",()=>{f(),d(),u()}),n&&n.addEventListener("click",()=>{f(),c(),u()}),e.addEventListener("mouseenter",f),e.addEventListener("mouseleave",u),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?f():u()}),u()}document.addEventListener("DOMContentLoaded",()=>{"requestIdleCallback"in window?requestIdleCallback(ce,{timeout:2e3}):setTimeout(ce,100)});var Be=4,_e=1500;function C(e,t){let n=document.getElementById("toast-container");n||(n=document.createElement("div"),n.id="toast-container",n.className="toast toast-end toast-bottom z-50",n.setAttribute("aria-live","polite"),n.setAttribute("aria-atomic","false"),document.body.appendChild(n));let r=(t||"info")+"|"+e,o=Date.now(),s=n.querySelector(`[data-toast-key="${Ve(r)}"]`);if(s&&o-Number(s.dataset.toastAt)<_e){Ue(s);return}let i=t==="error",l=i?"alert-error":"alert-success",a=document.createElement("div");a.className=`alert ${l} shadow-lg transition-opacity duration-300`,a.style.willChange="opacity",a.setAttribute("role",i?"alert":"status"),a.dataset.toastKey=r,a.dataset.toastAt=String(o);let c=document.createElement("span");for(c.textContent=e,a.appendChild(c),n.appendChild(a);n.children.length>Be;)n.firstElementChild.remove();pe(a)}function pe(e){e._dismissTimer=setTimeout(()=>{e.style.opacity="0",e._removeTimer=setTimeout(()=>e.remove(),300)},3e3)}function Ue(e){clearTimeout(e._dismissTimer),clearTimeout(e._removeTimer),e.style.opacity="",e.dataset.toastAt=String(Date.now()),pe(e)}function Ve(e){return window.CSS&&CSS.escape?CSS.escape(e):String(e).replace(/[^a-zA-Z0-9_-]/g,t=>"\\"+t)}async function je(e){try{let t=await fetch(`/api/webhooks/${e}/test`,{method:"POST",credentials:"include"}),n=await t.text();n.includes('class="success"')||t.ok&&!n.includes('class="error"')?C("Test webhook delivered successfully!","success"):C("Test delivery failed \u2014 check the webhook URL","error")}catch{C("Failed to reach server","error")}}(function(){let t={"switch-repo-tab":s=>window.switchRepoTab&&window.switchRepoTab(s.dataset.tab),"switch-editor-tab":s=>window.switchEditorTab&&window.switchEditorTab(s.dataset.tab),"insert-md":s=>window.insertMd&&window.insertMd(s.dataset.mdType),"toggle-editor":s=>window.toggleOverviewEditor&&window.toggleOverviewEditor(s.dataset.show==="true"),"show-modal":s=>K(document.getElementById(s.dataset.modalId),s),"close-dialog":s=>O(s.closest("dialog")),"remove-closest-dialog":s=>O(s.closest("dialog"),{remove:!0}),"close-manifest-delete-modal":()=>window.closeManifestDeleteModal&&window.closeManifestDeleteModal(),"save-overview":()=>window.saveOverview&&window.saveOverview(),"delete-manifest":s=>window.deleteManifest&&window.deleteManifest(s.dataset.repo,s.dataset.digest,s.dataset.manifestId||""),"delete-untagged":s=>window.deleteUntaggedManifests&&window.deleteUntaggedManifests(s.dataset.repo),copy:s=>window.copyToClipboard&&window.copyToClipboard(s.dataset.copy,s),"toggle-search":()=>window.toggleSearch&&window.toggleSearch(),"switch-settings-tab":s=>window.switchSettingsTab&&window.switchSettingsTab(s.dataset.tab),"test-webhook":s=>window.testWebhook&&window.testWebhook(s.dataset.webhookId),"diff-to":(s,i)=>window.diffToTag&&window.diffToTag(i,s),"modal-backdrop-close":(s,i)=>{i.target===s&&O(s,{remove:!0})}},n={"sort-tags":s=>window.sortTags&&window.sortTags(s.value),"submit-form":s=>s.form&&s.form.requestSubmit()},r={"filter-tags":s=>window.filterTags&&window.filterTags(s.value)};function o(s,i){let l=i.target.closest("[data-action]");if(!l)return;let a=s[l.dataset.action];a&&a(l,i)}document.addEventListener("click",s=>o(t,s)),document.addEventListener("change",s=>o(n,s)),document.addEventListener("input",s=>o(r,s))})();window.setTheme=de;window.toggleSearch=Re;window.copyToClipboard=$;window.deleteManifest=ke;window.deleteUntaggedManifests=Fe;window.closeManifestDeleteModal=Y;window.showToast=C;window.testWebhook=je;function We(){let e=document.getElementById("md-editor");if(!e)return;let t=e.dataset.ownerDid,n=e.dataset.repository;window.toggleOverviewEditor=function(r){document.getElementById("overview-view").classList.toggle("hidden",r),document.getElementById("overview-edit").classList.toggle("hidden",!r),r&&e.focus()},window.switchEditorTab=function(r){if(document.querySelectorAll(".editor-panel").forEach(o=>o.classList.add("hidden")),document.getElementById(r==="write"?"editor-write":"editor-preview").classList.remove("hidden"),document.querySelectorAll(".editor-tab").forEach(o=>{let s=o.dataset.tab===r;o.classList.toggle("border-primary",s),o.classList.toggle("text-primary",s),o.classList.toggle("border-transparent",!s),o.classList.toggle("text-base-content/60",!s)}),r==="preview"){let o=e.value,s=document.getElementById("preview-content");if(!o.trim()){s.innerHTML='<p class="text-base-content/60">Nothing to preview</p>';return}s.innerHTML='<p class="text-base-content/60"><span class="loading loading-spinner loading-xs align-middle"></span> Rendering preview&hellip;</p>';let i=new FormData;i.append("markdown",o),fetch("/api/repo-page/preview",{method:"POST",body:i}).then(l=>{if(!l.ok)throw new Error("HTTP "+l.status);return l.text()}).then(l=>{s.innerHTML=l}).catch(()=>{s.innerHTML='<p class="text-error">Preview failed. Check your connection and try again.</p>'})}},window.insertMd=function(r){let o=e.selectionStart,s=e.selectionEnd,i=e.value.substring(o,s),l=e.value.substring(0,o),a=e.value.substring(s),c,d,u;switch(r){case"heading":c="## "+(i||"Heading"),d=o+3,u=o+c.length;break;case"bold":c="**"+(i||"bold text")+"**",d=o+2,u=o+c.length-2;break;case"italic":c="_"+(i||"italic text")+"_",d=o+1,u=o+c.length-1;break;case"link":c="["+(i||"link text")+"](url)",d=o+c.length-4,u=o+c.length-1;break;case"image":c="!["+(i||"alt text")+"](url)",d=o+c.length-4,u=o+c.length-1;break;case"ul":c="- "+(i||"list item"),d=o+2,u=o+c.length;break;case"ol":c="1. "+(i||"list item"),d=o+3,u=o+c.length;break;case"code":i&&i.indexOf(` 3 - `)!==-1?(c="```\n"+i+"\n```",d=o+4,u=o+4+i.length):(c="`"+(i||"code")+"`",d=o+1,u=o+c.length-1);break;default:return}e.value=l+c+a,e.focus(),e.selectionStart=d,e.selectionEnd=u},window.saveOverview=function(){let r=document.getElementById("save-overview-btn");r.classList.add("btn-disabled"),r.innerHTML='<span class="loading loading-spinner loading-xs"></span> Saving...';let o=new FormData;o.append("did",t),o.append("repository",n),o.append("description",e.value),fetch("/api/repo-page",{method:"POST",body:o,headers:{"HX-Request":"true"}}).then(s=>s.ok?s.text():s.text().then(i=>{throw new Error(i)})).then(s=>{document.getElementById("overview-rendered").innerHTML=s,window.toggleOverviewEditor(!1),typeof window.showToast=="function"&&window.showToast("Overview saved","success")}).catch(s=>{typeof window.showToast=="function"&&window.showToast(s.message||"Failed to save","error")}).finally(()=>{r.classList.remove("btn-disabled"),r.innerHTML="Save"})},e.addEventListener("keydown",r=>{(r.ctrlKey||r.metaKey)&&r.key==="s"&&(r.preventDefault(),window.saveOverview())})}window.sortTags=function(e){let t=document.getElementById("tags-list");if(!t)return;let n=Array.from(t.querySelectorAll(".artifact-entry"));n.sort((r,o)=>{switch(e){case"oldest":return parseInt(r.dataset.created)-parseInt(o.dataset.created);case"az":return r.dataset.tag.localeCompare(o.dataset.tag);case"za":return o.dataset.tag.localeCompare(r.dataset.tag);default:return parseInt(o.dataset.created)-parseInt(r.dataset.created)}}),n.forEach(r=>t.appendChild(r))};var _=0;window.filterTags=function(e){_&&cancelAnimationFrame(_),_=requestAnimationFrame(()=>{_=0;let t=e.toLowerCase();document.querySelectorAll("#tags-list .artifact-entry").forEach(n=>{n.style.display=!t||n.dataset.tag.toLowerCase().includes(t)?"":"none"})})};function Xe(){if(!document.getElementById("tag-content"))return;let e=["overview","layers","vulns","sbom","artifacts"],t={};function n(i,l){if(t[i])return;t[i]=!0;let a=document.getElementById(i);if(!a)return;let c=new AbortController,d=setTimeout(()=>c.abort(),1e4);fetch(l,{signal:c.signal}).then(u=>{if(!u.ok)throw new Error("HTTP "+u.status);return u.text()}).then(u=>{a.innerHTML=u,a.querySelectorAll("script").forEach(f=>{let m=document.createElement("script");m.textContent=f.textContent,f.parentNode.replaceChild(m,f)}),typeof window.htmx<"u"&&window.htmx.process(a)}).catch(u=>{t[i]=!1;let m=u&&u.name==="AbortError"?"This section took too long to load.":"Couldn't load this section.";a.innerHTML='<div class="py-6 text-sm text-base-content/70"><p>'+m+'</p><button type="button" class="btn btn-sm btn-ghost mt-2" data-retry-section="'+i+'">Try again</button></div>'}).finally(()=>clearTimeout(d))}document.body.addEventListener("click",i=>{let l=i.target.closest("[data-retry-section]");if(!l)return;let a=l.getAttribute("data-retry-section"),d={"artifacts-content":o,"layers-content":()=>r("layers"),"vulns-content":()=>r("vulns"),"sbom-content":()=>r("sbom")}[a];if(d){let u=d();u&&n(a,u)}});function r(i){let l=document.getElementById("tag-content");if(!l)return null;let a=l.dataset.digest;return a?"/api/digest-content/"+l.dataset.owner+"/"+l.dataset.repo+"?digest="+encodeURIComponent(a)+"&section="+i:null}function o(){let i=document.getElementById("tag-content");return i?"/api/repo-tags/"+i.dataset.owner+"/"+i.dataset.repo:null}window.diffToTag=function(i,l){i.preventDefault();let a=l.dataset.diffTo,c=document.getElementById("tag-content"),d=document.getElementById("tag-selector");if(!c||!d||!a)return;let u=c.dataset.digest,f=d.value;!u||a===f||(window.location.href="/diff/"+c.dataset.owner+"/"+c.dataset.repo+"?from="+encodeURIComponent(u)+"&to="+encodeURIComponent(a))},window.switchRepoTab=function(i){window._activeRepoTab=i;let l=document.getElementById("tag-content");if(!l)return;l.querySelectorAll(".repo-panel").forEach(d=>d.classList.add("hidden"));let a=document.getElementById("tab-"+i);a&&a.classList.remove("hidden"),l.querySelectorAll(".repo-tab").forEach(d=>{let u=d.dataset.tab===i;d.classList.toggle("border-primary",u),d.classList.toggle("text-primary",u),d.classList.toggle("border-transparent",!u),d.classList.toggle("text-base-content/60",!u),d.setAttribute("aria-selected",u?"true":"false"),d.setAttribute("tabindex",u?"0":"-1")});let c=new URL(window.location);if(c.hash=i,history.replaceState(null,"",c.toString()),i==="artifacts"){let d=o();d&&n("artifacts-content",d)}if(i==="layers"){let d=r("layers");d&&n("layers-content",d)}if(i==="vulns"){let d=r("vulns");d&&n("vulns-content",d)}if(i==="sbom"){let d=r("sbom");d&&n("sbom-content",d)}};function s(){t={},[["artifacts-tab-btn","artifacts-content",o],["layers-tab-btn","layers-content",()=>r("layers")],["vulns-tab-btn","vulns-content",()=>r("vulns")],["sbom-tab-btn","sbom-content",()=>r("sbom")]].forEach(([c,d,u])=>{let f=document.getElementById(c);f&&f.addEventListener("mouseenter",()=>{let m=u();m&&n(d,m)},{once:!0})});let l=document.querySelector('[role="tablist"][aria-label="Repository sections"]');l&&!l.dataset.keyboardBound&&(l.dataset.keyboardBound="1",l.addEventListener("keydown",c=>{let d=Array.from(l.querySelectorAll(".repo-tab")),u=d.indexOf(document.activeElement);if(u===-1)return;let f=-1;switch(c.key){case"ArrowRight":f=(u+1)%d.length;break;case"ArrowLeft":f=(u-1+d.length)%d.length;break;case"Home":f=0;break;case"End":f=d.length-1;break;case"Enter":case" ":c.preventDefault(),window.switchRepoTab(d[u].dataset.tab);return;default:return}c.preventDefault(),d[f].focus()}));let a=window._activeRepoTab||window.location.hash.replace("#","")||"overview";e.indexOf(a)===-1&&(a="overview"),window.switchRepoTab(a)}s(),document.addEventListener("keydown",i=>{if(i.target.tagName==="INPUT"||i.target.tagName==="TEXTAREA"||i.target.tagName==="SELECT"||i.target.isContentEditable||i.ctrlKey||i.metaKey||i.altKey)return;let a={o:"overview",l:"layers",v:"vulns",s:"sbom",a:"artifacts"}[i.key.toLowerCase()];a&&e.indexOf(a)!==-1&&window.switchRepoTab(a)}),document.body.addEventListener("htmx:afterSettle",i=>{i.detail.target&&i.detail.target.id==="tag-content"&&s()})}document.addEventListener("DOMContentLoaded",()=>{We(),Xe()});function ze(){let e=["user","billing","storage","devices","webhooks","advanced"];if(!document.querySelector(".settings-tab-mobile, .menu li[data-tab]"))return;function t(a){document.querySelectorAll(".settings-panel").forEach(d=>d.classList.add("hidden"));let c=document.getElementById("tab-"+a);c&&c.classList.remove("hidden"),document.querySelectorAll(".menu li[data-tab]").forEach(d=>{let u=d.dataset.tab===a;d.classList.toggle("menu-active",u);let f=d.querySelector('a[role="tab"]');f&&(f.setAttribute("aria-selected",u?"true":"false"),f.setAttribute("tabindex",u?"0":"-1"))}),document.querySelectorAll(".settings-tab-mobile").forEach(d=>{let u=d.dataset.tab===a;d.classList.toggle("btn-ghost",!u),d.classList.toggle("btn-secondary",u),d.setAttribute("aria-selected",u?"true":"false"),d.setAttribute("tabindex",u?"0":"-1")}),history.replaceState(null,"","#"+a),document.body.dispatchEvent(new CustomEvent("tab:"+a))}window.isTabActive=function(a){let c=document.getElementById("tab-"+a);return c&&!c.classList.contains("hidden")},window.switchSettingsTab=t;function n(a,c){let d=c==="vertical"?"ArrowUp":"ArrowLeft",u=c==="vertical"?"ArrowDown":"ArrowRight";return function(f){let m=a.indexOf(f.currentTarget);if(m===-1)return;let h=null;f.key===d?h=a[(m-1+a.length)%a.length]:f.key===u?h=a[(m+1)%a.length]:f.key==="Home"?h=a[0]:f.key==="End"&&(h=a[a.length-1]),h&&(f.preventDefault(),t(h.dataset.tab||h.parentElement.dataset.tab),h.focus())}}let r=Array.from(document.querySelectorAll(".settings-tab-mobile")),o=n(r,"horizontal");r.forEach(a=>{a.addEventListener("click",c=>{c.preventDefault(),t(a.dataset.tab)}),a.addEventListener("keydown",o)});let s=Array.from(document.querySelectorAll('.menu li[data-tab] a[role="tab"]')),i=n(s,"vertical");s.forEach(a=>{a.addEventListener("click",c=>{c.preventDefault(),t(a.parentElement.dataset.tab)}),a.addEventListener("keydown",i)});let l=window.location.hash.replace("#","")||"user";e.indexOf(l)===-1&&(l="user"),t(l),window.addEventListener("hashchange",()=>{let a=window.location.hash.replace("#","")||"user";e.indexOf(a)!==-1&&t(a)})}function $e(){let e=document.getElementById("delete-account-btn");if(!e)return;let t=e.dataset.clientShortName||"this account",r="DELETE "+(e.dataset.profileHandle||"");function o(i){let l=document.createElement("div");return l.textContent=i,l.innerHTML}e.addEventListener("click",s);function s(){let i=document.getElementById("delete-pds-records").checked,l=document.createElement("div");l.className="modal modal-open",l.innerHTML=` 1 + var Le=(function(){"use strict";let htmx={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){return getInputValues(e,t||"post").values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:!0,historyCacheSize:10,refreshOnHistoryMiss:!1,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:!0,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:!0,allowScriptTags:!0,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:!1,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:!1,getCacheBusterParam:!1,globalViewTransitions:!1,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:!0,ignoreTitle:!1,scrollIntoViewOnBoost:!0,triggerSpecsCache:null,disableInheritance:!1,responseHandling:[{code:"204",swap:!1},{code:"[23]..",swap:!0},{code:"[45]..",swap:!1,error:!0}],allowNestedOobSwaps:!0,historyRestoreAsHxRequest:!0,reportValidityOfForms:!1},parseInterval:null,location,_:null,version:"2.0.8"};htmx.onLoad=onLoadHelper,htmx.process=processNode,htmx.on=addEventListenerImpl,htmx.off=removeEventListenerImpl,htmx.trigger=triggerEvent,htmx.ajax=ajaxHelper,htmx.find=find,htmx.findAll=findAll,htmx.closest=closest,htmx.remove=removeElement,htmx.addClass=addClassToElement,htmx.removeClass=removeClassFromElement,htmx.toggleClass=toggleClassOnElement,htmx.takeClass=takeClassForElement,htmx.swap=swap,htmx.defineExtension=defineExtension,htmx.removeExtension=removeExtension,htmx.logAll=logAll,htmx.logNone=logNone,htmx.parseInterval=parseInterval,htmx._=internalEval;let internalAPI={addTriggerHandler,bodyContains,canAccessLocalStorage,findThisElement,filterValues,swap,hasAttribute,getAttributeValue,getClosestAttributeValue,getClosestMatch,getExpressionVars,getHeaders,getInputValues,getInternalData,getSwapSpecification,getTriggerSpecs,getTarget,makeFragment,mergeObjects,makeSettleInfo,oobSwap,querySelectorExt,settleImmediately,shouldCancel,triggerEvent,triggerErrorEvent,withExtensions},VERBS=["get","post","put","delete","patch"],VERB_SELECTOR=VERBS.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function parseInterval(e){if(e==null)return;let t=NaN;return e.slice(-2)=="ms"?t=parseFloat(e.slice(0,-2)):e.slice(-1)=="s"?t=parseFloat(e.slice(0,-1))*1e3:e.slice(-1)=="m"?t=parseFloat(e.slice(0,-1))*1e3*60:t=parseFloat(e),isNaN(t)?void 0:t}function getRawAttribute(e,t){return e instanceof Element&&e.getAttribute(t)}function hasAttribute(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function getAttributeValue(e,t){return getRawAttribute(e,t)||getRawAttribute(e,"data-"+t)}function parentElt(e){let t=e.parentElement;return!t&&e.parentNode instanceof ShadowRoot?e.parentNode:t}function getDocument(){return document}function getRootNode(e,t){return e.getRootNode?e.getRootNode({composed:t}):getDocument()}function getClosestMatch(e,t){for(;e&&!t(e);)e=parentElt(e);return e||null}function getAttributeValueWithDisinheritance(e,t,n){let r=getAttributeValue(t,n),o=getAttributeValue(t,"hx-disinherit");var s=getAttributeValue(t,"hx-inherit");if(e!==t){if(htmx.config.disableInheritance)return s&&(s==="*"||s.split(" ").indexOf(n)>=0)?r:null;if(o&&(o==="*"||o.split(" ").indexOf(n)>=0))return"unset"}return r}function getClosestAttributeValue(e,t){let n=null;if(getClosestMatch(e,function(r){return!!(n=getAttributeValueWithDisinheritance(e,asElement(r),t))}),n!=="unset")return n}function matches(e,t){return e instanceof Element&&e.matches(t)}function getStartTag(e){let n=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i.exec(e);return n?n[1].toLowerCase():""}function parseHTML(e){return"parseHTMLUnsafe"in Document?Document.parseHTMLUnsafe(e):new DOMParser().parseFromString(e,"text/html")}function takeChildrenFor(e,t){for(;t.childNodes.length>0;)e.append(t.childNodes[0])}function duplicateScript(e){let t=getDocument().createElement("script");return forEach(e.attributes,function(n){t.setAttribute(n.name,n.value)}),t.textContent=e.textContent,t.async=!1,htmx.config.inlineScriptNonce&&(t.nonce=htmx.config.inlineScriptNonce),t}function isJavaScriptScriptNode(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function normalizeScriptTags(e){Array.from(e.querySelectorAll("script")).forEach(t=>{if(isJavaScriptScriptNode(t)){let n=duplicateScript(t),r=t.parentNode;try{r.insertBefore(n,t)}catch(o){logError(o)}finally{t.remove()}}})}function makeFragment(e){let t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,""),n=getStartTag(t),r;if(n==="html"){r=new DocumentFragment;let s=parseHTML(e);takeChildrenFor(r,s.body),r.title=s.title}else if(n==="body"){r=new DocumentFragment;let s=parseHTML(t);takeChildrenFor(r,s.body),r.title=s.title}else{let s=parseHTML('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=s.querySelector("template").content,r.title=s.title;var o=r.querySelector("title");o&&o.parentNode===r&&(o.remove(),r.title=o.innerText)}return r&&(htmx.config.allowScriptTags?normalizeScriptTags(r):r.querySelectorAll("script").forEach(s=>s.remove())),r}function maybeCall(e){e&&e()}function isType(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function isFunction(e){return typeof e=="function"}function isRawObject(e){return isType(e,"Object")}function getInternalData(e){let t="htmx-internal-data",n=e[t];return n||(n=e[t]={}),n}function toArray(e){let t=[];if(e)for(let n=0;n<e.length;n++)t.push(e[n]);return t}function forEach(e,t){if(e)for(let n=0;n<e.length;n++)t(e[n])}function isScrolledIntoView(e){let t=e.getBoundingClientRect(),n=t.top,r=t.bottom;return n<window.innerHeight&&r>=0}function bodyContains(e){return e.getRootNode({composed:!0})===document}function splitOnWhitespace(e){return e.trim().split(/\s+/)}function mergeObjects(e,t){for(let n in t)t.hasOwnProperty(n)&&(e[n]=t[n]);return e}function parseJSON(e){try{return JSON.parse(e)}catch(t){return logError(t),null}}function canAccessLocalStorage(){let e="htmx:sessionStorageTest";try{return sessionStorage.setItem(e,e),sessionStorage.removeItem(e),!0}catch{return!1}}function normalizePath(e){let t=new URL(e,"http://x");return t&&(e=t.pathname+t.search),e!="/"&&(e=e.replace(/\/+$/,"")),e}function internalEval(str){return maybeEval(getDocument().body,function(){return eval(str)})}function onLoadHelper(e){return htmx.on("htmx:load",function(n){e(n.detail.elt)})}function logAll(){htmx.logger=function(e,t,n){console&&console.log(t,e,n)}}function logNone(){htmx.logger=null}function find(e,t){return typeof e!="string"?e.querySelector(t):find(getDocument(),e)}function findAll(e,t){return typeof e!="string"?e.querySelectorAll(t):findAll(getDocument(),e)}function getWindow(){return window}function removeElement(e,t){e=resolveTarget(e),t?getWindow().setTimeout(function(){removeElement(e),e=null},t):parentElt(e).removeChild(e)}function asElement(e){return e instanceof Element?e:null}function asHtmlElement(e){return e instanceof HTMLElement?e:null}function asString(e){return typeof e=="string"?e:null}function asParentNode(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function addClassToElement(e,t,n){e=asElement(resolveTarget(e)),e&&(n?getWindow().setTimeout(function(){addClassToElement(e,t),e=null},n):e.classList&&e.classList.add(t))}function removeClassFromElement(e,t,n){let r=asElement(resolveTarget(e));r&&(n?getWindow().setTimeout(function(){removeClassFromElement(r,t),r=null},n):r.classList&&(r.classList.remove(t),r.classList.length===0&&r.removeAttribute("class")))}function toggleClassOnElement(e,t){e=resolveTarget(e),e.classList.toggle(t)}function takeClassForElement(e,t){e=resolveTarget(e),forEach(e.parentElement.children,function(n){removeClassFromElement(n,t)}),addClassToElement(asElement(e),t)}function closest(e,t){return e=asElement(resolveTarget(e)),e?e.closest(t):null}function startsWith(e,t){return e.substring(0,t.length)===t}function endsWith(e,t){return e.substring(e.length-t.length)===t}function normalizeSelector(e){let t=e.trim();return startsWith(t,"<")&&endsWith(t,"/>")?t.substring(1,t.length-2):t}function querySelectorAllExt(e,t,n){if(t.indexOf("global ")===0)return querySelectorAllExt(e,t.slice(7),!0);e=resolveTarget(e);let r=[];{let i=0,a=0;for(let l=0;l<t.length;l++){let c=t[l];if(c===","&&i===0){r.push(t.substring(a,l)),a=l+1;continue}c==="<"?i++:c==="/"&&l<t.length-1&&t[l+1]===">"&&i--}a<t.length&&r.push(t.substring(a))}let o=[],s=[];for(;r.length>0;){let i=normalizeSelector(r.shift()),a;i.indexOf("closest ")===0?a=closest(asElement(e),normalizeSelector(i.slice(8))):i.indexOf("find ")===0?a=find(asParentNode(e),normalizeSelector(i.slice(5))):i==="next"||i==="nextElementSibling"?a=asElement(e).nextElementSibling:i.indexOf("next ")===0?a=scanForwardQuery(e,normalizeSelector(i.slice(5)),!!n):i==="previous"||i==="previousElementSibling"?a=asElement(e).previousElementSibling:i.indexOf("previous ")===0?a=scanBackwardsQuery(e,normalizeSelector(i.slice(9)),!!n):i==="document"?a=document:i==="window"?a=window:i==="body"?a=document.body:i==="root"?a=getRootNode(e,!!n):i==="host"?a=e.getRootNode().host:s.push(i),a&&o.push(a)}if(s.length>0){let i=s.join(","),a=asParentNode(getRootNode(e,!!n));o.push(...toArray(a.querySelectorAll(i)))}return o}var scanForwardQuery=function(e,t,n){let r=asParentNode(getRootNode(e,n)).querySelectorAll(t);for(let o=0;o<r.length;o++){let s=r[o];if(s.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_PRECEDING)return s}},scanBackwardsQuery=function(e,t,n){let r=asParentNode(getRootNode(e,n)).querySelectorAll(t);for(let o=r.length-1;o>=0;o--){let s=r[o];if(s.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING)return s}};function querySelectorExt(e,t){return typeof e!="string"?querySelectorAllExt(e,t)[0]:querySelectorAllExt(getDocument().body,e)[0]}function resolveTarget(e,t){return typeof e=="string"?find(asParentNode(t)||document,e):e}function processEventArgs(e,t,n,r){return isFunction(t)?{target:getDocument().body,event:asString(e),listener:t,options:n}:{target:resolveTarget(e),event:asString(t),listener:n,options:r}}function addEventListenerImpl(e,t,n,r){return ready(function(){let s=processEventArgs(e,t,n,r);s.target.addEventListener(s.event,s.listener,s.options)}),isFunction(t)?t:n}function removeEventListenerImpl(e,t,n){return ready(function(){let r=processEventArgs(e,t,n);r.target.removeEventListener(r.event,r.listener)}),isFunction(t)?t:n}let DUMMY_ELT=getDocument().createElement("output");function findAttributeTargets(e,t){let n=getClosestAttributeValue(e,t);if(n){if(n==="this")return[findThisElement(e,t)];{let r=querySelectorAllExt(e,n);if(/(^|,)(\s*)inherit(\s*)($|,)/.test(n)){let s=asElement(getClosestMatch(e,function(i){return i!==e&&hasAttribute(asElement(i),t)}));s&&r.push(...findAttributeTargets(s,t))}return r.length===0?(logError('The selector "'+n+'" on '+t+" returned no matches!"),[DUMMY_ELT]):r}}}function findThisElement(e,t){return asElement(getClosestMatch(e,function(n){return getAttributeValue(asElement(n),t)!=null}))}function getTarget(e){let t=getClosestAttributeValue(e,"hx-target");return t?t==="this"?findThisElement(e,"hx-target"):querySelectorExt(e,t):getInternalData(e).boosted?getDocument().body:e}function shouldSettleAttribute(e){return htmx.config.attributesToSettle.includes(e)}function cloneAttributes(e,t){forEach(Array.from(e.attributes),function(n){!t.hasAttribute(n.name)&&shouldSettleAttribute(n.name)&&e.removeAttribute(n.name)}),forEach(t.attributes,function(n){shouldSettleAttribute(n.name)&&e.setAttribute(n.name,n.value)})}function isInlineSwap(e,t){let n=getExtensions(t);for(let r=0;r<n.length;r++){let o=n[r];try{if(o.isInlineSwap(e))return!0}catch(s){logError(s)}}return e==="outerHTML"}function oobSwap(e,t,n,r){r=r||getDocument();let o="#"+CSS.escape(getRawAttribute(t,"id")),s="outerHTML";e==="true"||(e.indexOf(":")>0?(s=e.substring(0,e.indexOf(":")),o=e.substring(e.indexOf(":")+1)):s=e),t.removeAttribute("hx-swap-oob"),t.removeAttribute("data-hx-swap-oob");let i=querySelectorAllExt(r,o,!1);return i.length?(forEach(i,function(a){let l,c=t.cloneNode(!0);l=getDocument().createDocumentFragment(),l.appendChild(c),isInlineSwap(s,a)||(l=asParentNode(c));let d={shouldSwap:!0,target:a,fragment:l};triggerEvent(a,"htmx:oobBeforeSwap",d)&&(a=d.target,d.shouldSwap&&(handlePreservedElements(l),swapWithStyle(s,a,a,l,n),restorePreservedElements()),forEach(n.elts,function(u){triggerEvent(u,"htmx:oobAfterSwap",d)}))}),t.parentNode.removeChild(t)):(t.parentNode.removeChild(t),triggerErrorEvent(getDocument().body,"htmx:oobErrorNoTarget",{content:t})),e}function restorePreservedElements(){let e=find("#--htmx-preserve-pantry--");if(e){for(let t of[...e.children]){let n=find("#"+t.id);n.parentNode.moveBefore(t,n),n.remove()}e.remove()}}function handlePreservedElements(e){forEach(findAll(e,"[hx-preserve], [data-hx-preserve]"),function(t){let n=getAttributeValue(t,"id"),r=getDocument().getElementById(n);if(r!=null)if(t.moveBefore){let o=find("#--htmx-preserve-pantry--");o==null&&(getDocument().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>"),o=find("#--htmx-preserve-pantry--")),o.moveBefore(r,null)}else t.parentNode.replaceChild(r,t)})}function handleAttributes(e,t,n){forEach(t.querySelectorAll("[id]"),function(r){let o=getRawAttribute(r,"id");if(o&&o.length>0){let s=o.replace("'","\\'"),i=r.tagName.replace(":","\\:"),a=asParentNode(e),l=a&&a.querySelector(i+"[id='"+s+"']");if(l&&l!==a){let c=r.cloneNode();cloneAttributes(r,l),n.tasks.push(function(){cloneAttributes(r,c)})}}})}function makeAjaxLoadTask(e){return function(){removeClassFromElement(e,htmx.config.addedClass),processNode(asElement(e)),processFocus(asParentNode(e)),triggerEvent(e,"htmx:load")}}function processFocus(e){let t="[autofocus]",n=asHtmlElement(matches(e,t)?e:e.querySelector(t));n?.focus()}function insertNodesBefore(e,t,n,r){for(handleAttributes(e,n,r);n.childNodes.length>0;){let o=n.firstChild;addClassToElement(asElement(o),htmx.config.addedClass),e.insertBefore(o,t),o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE&&r.tasks.push(makeAjaxLoadTask(o))}}function stringHash(e,t){let n=0;for(;n<e.length;)t=(t<<5)-t+e.charCodeAt(n++)|0;return t}function attributeHash(e){let t=0;for(let n=0;n<e.attributes.length;n++){let r=e.attributes[n];r.value&&(t=stringHash(r.name,t),t=stringHash(r.value,t))}return t}function deInitOnHandlers(e){let t=getInternalData(e);if(t.onHandlers){for(let n=0;n<t.onHandlers.length;n++){let r=t.onHandlers[n];removeEventListenerImpl(e,r.event,r.listener)}delete t.onHandlers}}function deInitNode(e){let t=getInternalData(e);t.timeout&&clearTimeout(t.timeout),t.listenerInfos&&forEach(t.listenerInfos,function(n){n.on&&removeEventListenerImpl(n.on,n.trigger,n.listener)}),deInitOnHandlers(e),forEach(Object.keys(t),function(n){n!=="firstInitCompleted"&&delete t[n]})}function cleanUpElement(e){triggerEvent(e,"htmx:beforeCleanupElement"),deInitNode(e),forEach(e.children,function(t){cleanUpElement(t)})}function swapOuterHTML(e,t,n){if(e.tagName==="BODY")return swapInnerHTML(e,t,n);let r,o=e.previousSibling,s=parentElt(e);if(s){for(insertNodesBefore(s,e,t,n),o==null?r=s.firstChild:r=o.nextSibling,n.elts=n.elts.filter(function(i){return i!==e});r&&r!==e;)r instanceof Element&&n.elts.push(r),r=r.nextSibling;cleanUpElement(e),e.remove()}}function swapAfterBegin(e,t,n){return insertNodesBefore(e,e.firstChild,t,n)}function swapBeforeBegin(e,t,n){return insertNodesBefore(parentElt(e),e,t,n)}function swapBeforeEnd(e,t,n){return insertNodesBefore(e,null,t,n)}function swapAfterEnd(e,t,n){return insertNodesBefore(parentElt(e),e.nextSibling,t,n)}function swapDelete(e){cleanUpElement(e);let t=parentElt(e);if(t)return t.removeChild(e)}function swapInnerHTML(e,t,n){let r=e.firstChild;if(insertNodesBefore(e,r,t,n),r){for(;r.nextSibling;)cleanUpElement(r.nextSibling),e.removeChild(r.nextSibling);cleanUpElement(r),e.removeChild(r)}}function swapWithStyle(e,t,n,r,o){switch(e){case"none":return;case"outerHTML":swapOuterHTML(n,r,o);return;case"afterbegin":swapAfterBegin(n,r,o);return;case"beforebegin":swapBeforeBegin(n,r,o);return;case"beforeend":swapBeforeEnd(n,r,o);return;case"afterend":swapAfterEnd(n,r,o);return;case"delete":swapDelete(n);return;default:var s=getExtensions(t);for(let i=0;i<s.length;i++){let a=s[i];try{let l=a.handleSwap(e,n,r,o);if(l){if(Array.isArray(l))for(let c=0;c<l.length;c++){let d=l[c];d.nodeType!==Node.TEXT_NODE&&d.nodeType!==Node.COMMENT_NODE&&o.tasks.push(makeAjaxLoadTask(d))}return}}catch(l){logError(l)}}e==="innerHTML"?swapInnerHTML(n,r,o):swapWithStyle(htmx.config.defaultSwapStyle,t,n,r,o)}}function findAndSwapOobElements(e,t,n){var r=findAll(e,"[hx-swap-oob], [data-hx-swap-oob]");return forEach(r,function(o){if(htmx.config.allowNestedOobSwaps||o.parentElement===null){let s=getAttributeValue(o,"hx-swap-oob");s!=null&&oobSwap(s,o,t,n)}else o.removeAttribute("hx-swap-oob"),o.removeAttribute("data-hx-swap-oob")}),r.length>0}function swap(e,t,n,r){r||(r={});let o=null,s=null,i=function(){maybeCall(r.beforeSwapCallback),e=resolveTarget(e);let c=r.contextElement?getRootNode(r.contextElement,!1):getDocument(),d=document.activeElement,u={};u={elt:d,start:d?d.selectionStart:null,end:d?d.selectionEnd:null};let f=makeSettleInfo(e);if(n.swapStyle==="textContent")e.textContent=t;else{let h=makeFragment(t);if(f.title=r.title||h.title,r.historyRequest&&(h=h.querySelector("[hx-history-elt],[data-hx-history-elt]")||h),r.selectOOB){let g=r.selectOOB.split(",");for(let p=0;p<g.length;p++){let y=g[p].split(":",2),w=y[0].trim();w.indexOf("#")===0&&(w=w.substring(1));let b=y[1]||"true",v=h.querySelector("#"+w);v&&oobSwap(b,v,f,c)}}if(findAndSwapOobElements(h,f,c),forEach(findAll(h,"template"),function(g){g.content&&findAndSwapOobElements(g.content,f,c)&&g.remove()}),r.select){let g=getDocument().createDocumentFragment();forEach(h.querySelectorAll(r.select),function(p){g.appendChild(p)}),h=g}handlePreservedElements(h),swapWithStyle(n.swapStyle,r.contextElement,e,h,f),restorePreservedElements()}if(u.elt&&!bodyContains(u.elt)&&getRawAttribute(u.elt,"id")){let h=document.getElementById(getRawAttribute(u.elt,"id")),g={preventScroll:n.focusScroll!==void 0?!n.focusScroll:!htmx.config.defaultFocusScroll};if(h){if(u.start&&h.setSelectionRange)try{h.setSelectionRange(u.start,u.end)}catch{}h.focus(g)}}e.classList.remove(htmx.config.swappingClass),forEach(f.elts,function(h){h.classList&&h.classList.add(htmx.config.settlingClass),triggerEvent(h,"htmx:afterSwap",r.eventInfo)}),maybeCall(r.afterSwapCallback),n.ignoreTitle||handleTitle(f.title);let m=function(){if(forEach(f.tasks,function(h){h.call()}),forEach(f.elts,function(h){h.classList&&h.classList.remove(htmx.config.settlingClass),triggerEvent(h,"htmx:afterSettle",r.eventInfo)}),r.anchor){let h=asElement(resolveTarget("#"+r.anchor));h&&h.scrollIntoView({block:"start",behavior:"auto"})}updateScrollState(f.elts,n),maybeCall(r.afterSettleCallback),maybeCall(o)};n.settleDelay>0?getWindow().setTimeout(m,n.settleDelay):m()},a=htmx.config.globalViewTransitions;n.hasOwnProperty("transition")&&(a=n.transition);let l=r.contextElement||getDocument();if(a&&triggerEvent(l,"htmx:beforeTransition",r.eventInfo)&&typeof Promise<"u"&&document.startViewTransition){let c=new Promise(function(u,f){o=u,s=f}),d=i;i=function(){document.startViewTransition(function(){return d(),c})}}try{n?.swapDelay&&n.swapDelay>0?getWindow().setTimeout(i,n.swapDelay):i()}catch(c){throw triggerErrorEvent(l,"htmx:swapError",r.eventInfo),maybeCall(s),c}}function handleTriggerHeader(e,t,n){let r=e.getResponseHeader(t);if(r.indexOf("{")===0){let o=parseJSON(r);for(let s in o)if(o.hasOwnProperty(s)){let i=o[s];isRawObject(i)?n=i.target!==void 0?i.target:n:i={value:i},triggerEvent(n,s,i)}}else{let o=r.split(",");for(let s=0;s<o.length;s++)triggerEvent(n,o[s].trim(),[])}}let WHITESPACE=/\s/,WHITESPACE_OR_COMMA=/[\s,]/,SYMBOL_START=/[_$a-zA-Z]/,SYMBOL_CONT=/[_$a-zA-Z0-9]/,STRINGISH_START=['"',"'","/"],NOT_WHITESPACE=/[^\s]/,COMBINED_SELECTOR_START=/[{(]/,COMBINED_SELECTOR_END=/[})]/;function tokenizeString(e){let t=[],n=0;for(;n<e.length;){if(SYMBOL_START.exec(e.charAt(n))){for(var r=n;SYMBOL_CONT.exec(e.charAt(n+1));)n++;t.push(e.substring(r,n+1))}else if(STRINGISH_START.indexOf(e.charAt(n))!==-1){let o=e.charAt(n);var r=n;for(n++;n<e.length&&e.charAt(n)!==o;)e.charAt(n)==="\\"&&n++,n++;t.push(e.substring(r,n+1))}else{let o=e.charAt(n);t.push(o)}n++}return t}function isPossibleRelativeReference(e,t,n){return SYMBOL_START.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function maybeGenerateConditional(e,t,n){if(t[0]==="["){t.shift();let r=1,o=" return (function("+n+"){ return (",s=null;for(;t.length>0;){let i=t[0];if(i==="]"){if(r--,r===0){s===null&&(o=o+"true"),t.shift(),o+=")})";try{let a=maybeEval(e,function(){return Function(o)()},function(){return!0});return a.source=o,a}catch(a){return triggerErrorEvent(getDocument().body,"htmx:syntax:error",{error:a,source:o}),null}}}else i==="["&&r++;isPossibleRelativeReference(i,s,n)?o+="(("+n+"."+i+") ? ("+n+"."+i+") : (window."+i+"))":o=o+i,s=t.shift()}}}function consumeUntil(e,t){let n="";for(;e.length>0&&!t.test(e[0]);)n+=e.shift();return n}function consumeCSSSelector(e){let t;return e.length>0&&COMBINED_SELECTOR_START.test(e[0])?(e.shift(),t=consumeUntil(e,COMBINED_SELECTOR_END).trim(),e.shift()):t=consumeUntil(e,WHITESPACE_OR_COMMA),t}let INPUT_SELECTOR="input, textarea, select";function parseAndCacheTrigger(e,t,n){let r=[],o=tokenizeString(t);do{consumeUntil(o,NOT_WHITESPACE);let a=o.length,l=consumeUntil(o,/[,\[\s]/);if(l!=="")if(l==="every"){let c={trigger:"every"};consumeUntil(o,NOT_WHITESPACE),c.pollInterval=parseInterval(consumeUntil(o,/[,\[\s]/)),consumeUntil(o,NOT_WHITESPACE);var s=maybeGenerateConditional(e,o,"event");s&&(c.eventFilter=s),r.push(c)}else{let c={trigger:l};var s=maybeGenerateConditional(e,o,"event");for(s&&(c.eventFilter=s),consumeUntil(o,NOT_WHITESPACE);o.length>0&&o[0]!==",";){let u=o.shift();if(u==="changed")c.changed=!0;else if(u==="once")c.once=!0;else if(u==="consume")c.consume=!0;else if(u==="delay"&&o[0]===":")o.shift(),c.delay=parseInterval(consumeUntil(o,WHITESPACE_OR_COMMA));else if(u==="from"&&o[0]===":"){if(o.shift(),COMBINED_SELECTOR_START.test(o[0]))var i=consumeCSSSelector(o);else{var i=consumeUntil(o,WHITESPACE_OR_COMMA);if(i==="closest"||i==="find"||i==="next"||i==="previous"){o.shift();let m=consumeCSSSelector(o);m.length>0&&(i+=" "+m)}}c.from=i}else u==="target"&&o[0]===":"?(o.shift(),c.target=consumeCSSSelector(o)):u==="throttle"&&o[0]===":"?(o.shift(),c.throttle=parseInterval(consumeUntil(o,WHITESPACE_OR_COMMA))):u==="queue"&&o[0]===":"?(o.shift(),c.queue=consumeUntil(o,WHITESPACE_OR_COMMA)):u==="root"&&o[0]===":"?(o.shift(),c[u]=consumeCSSSelector(o)):u==="threshold"&&o[0]===":"?(o.shift(),c[u]=consumeUntil(o,WHITESPACE_OR_COMMA)):triggerErrorEvent(e,"htmx:syntax:error",{token:o.shift()});consumeUntil(o,NOT_WHITESPACE)}r.push(c)}o.length===a&&triggerErrorEvent(e,"htmx:syntax:error",{token:o.shift()}),consumeUntil(o,NOT_WHITESPACE)}while(o[0]===","&&o.shift());return n&&(n[t]=r),r}function getTriggerSpecs(e){let t=getAttributeValue(e,"hx-trigger"),n=[];if(t){let r=htmx.config.triggerSpecsCache;n=r&&r[t]||parseAndCacheTrigger(e,t,r)}return n.length>0?n:matches(e,"form")?[{trigger:"submit"}]:matches(e,'input[type="button"], input[type="submit"]')?[{trigger:"click"}]:matches(e,INPUT_SELECTOR)?[{trigger:"change"}]:[{trigger:"click"}]}function cancelPolling(e){getInternalData(e).cancelled=!0}function processPolling(e,t,n){let r=getInternalData(e);r.timeout=getWindow().setTimeout(function(){bodyContains(e)&&r.cancelled!==!0&&(maybeFilterEvent(n,e,makeEvent("hx:poll:trigger",{triggerSpec:n,target:e}))||t(e),processPolling(e,t,n))},n.pollInterval)}function isLocalLink(e){return location.hostname===e.hostname&&getRawAttribute(e,"href")&&getRawAttribute(e,"href").indexOf("#")!==0}function eltIsDisabled(e){return closest(e,htmx.config.disableSelector)}function boostElement(e,t,n){if(e instanceof HTMLAnchorElement&&isLocalLink(e)&&(e.target===""||e.target==="_self")||e.tagName==="FORM"&&String(getRawAttribute(e,"method")).toLowerCase()!=="dialog"){t.boosted=!0;let r,o;if(e.tagName==="A")r="get",o=getRawAttribute(e,"href");else{let s=getRawAttribute(e,"method");r=s?s.toLowerCase():"get",o=getRawAttribute(e,"action"),(o==null||o==="")&&(o=location.href),r==="get"&&o.includes("?")&&(o=o.replace(/\?[^#]+/,""))}n.forEach(function(s){addEventListener(e,function(i,a){let l=asElement(i);if(eltIsDisabled(l)){cleanUpElement(l);return}issueAjaxRequest(r,o,l,a)},t,s,!0)})}}function shouldCancel(e,t){if(e.type==="submit"&&t.tagName==="FORM")return!0;if(e.type==="click"){let n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit")return!0;let r=t.closest("a"),o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href")))return!0}return!1}function ignoreBoostedAnchorCtrlClick(e,t){return getInternalData(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function maybeFilterEvent(e,t,n){let r=e.eventFilter;if(r)try{return r.call(t,n)!==!0}catch(o){let s=r.source;return triggerErrorEvent(getDocument().body,"htmx:eventFilter:error",{error:o,source:s}),!0}return!1}function addEventListener(e,t,n,r,o){let s=getInternalData(e),i;r.from?i=querySelectorAllExt(e,r.from):i=[e],r.changed&&("lastValue"in s||(s.lastValue=new WeakMap),i.forEach(function(a){s.lastValue.has(r)||s.lastValue.set(r,new WeakMap),s.lastValue.get(r).set(a,a.value)})),forEach(i,function(a){let l=function(c){if(!bodyContains(e)){a.removeEventListener(r.trigger,l);return}if(ignoreBoostedAnchorCtrlClick(e,c)||((o||shouldCancel(c,a))&&c.preventDefault(),maybeFilterEvent(r,e,c)))return;let d=getInternalData(c);if(d.triggerSpec=r,d.handledFor==null&&(d.handledFor=[]),d.handledFor.indexOf(e)<0){if(d.handledFor.push(e),r.consume&&c.stopPropagation(),r.target&&c.target&&!matches(asElement(c.target),r.target))return;if(r.once){if(s.triggeredOnce)return;s.triggeredOnce=!0}if(r.changed){let u=c.target,f=u.value,m=s.lastValue.get(r);if(m.has(u)&&m.get(u)===f)return;m.set(u,f)}if(s.delayed&&clearTimeout(s.delayed),s.throttle)return;r.throttle>0?s.throttle||(triggerEvent(e,"htmx:trigger"),t(e,c),s.throttle=getWindow().setTimeout(function(){s.throttle=null},r.throttle)):r.delay>0?s.delayed=getWindow().setTimeout(function(){triggerEvent(e,"htmx:trigger"),t(e,c)},r.delay):(triggerEvent(e,"htmx:trigger"),t(e,c))}};n.listenerInfos==null&&(n.listenerInfos=[]),n.listenerInfos.push({trigger:r.trigger,listener:l,on:a}),a.addEventListener(r.trigger,l)})}let windowIsScrolling=!1,scrollHandler=null;function initScrollHandler(){scrollHandler||(scrollHandler=function(){windowIsScrolling=!0},window.addEventListener("scroll",scrollHandler),window.addEventListener("resize",scrollHandler),setInterval(function(){windowIsScrolling&&(windowIsScrolling=!1,forEach(getDocument().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){maybeReveal(e)}))},200))}function maybeReveal(e){!hasAttribute(e,"data-hx-revealed")&&isScrolledIntoView(e)&&(e.setAttribute("data-hx-revealed","true"),getInternalData(e).initHash?triggerEvent(e,"revealed"):e.addEventListener("htmx:afterProcessNode",function(){triggerEvent(e,"revealed")},{once:!0}))}function loadImmediately(e,t,n,r){let o=function(){n.loaded||(n.loaded=!0,triggerEvent(e,"htmx:trigger"),t(e))};r>0?getWindow().setTimeout(o,r):o()}function processVerbs(e,t,n){let r=!1;return forEach(VERBS,function(o){if(hasAttribute(e,"hx-"+o)){let s=getAttributeValue(e,"hx-"+o);r=!0,t.path=s,t.verb=o,n.forEach(function(i){addTriggerHandler(e,i,t,function(a,l){let c=asElement(a);if(eltIsDisabled(c)){cleanUpElement(c);return}issueAjaxRequest(o,s,c,l)})})}}),r}function addTriggerHandler(e,t,n,r){if(t.trigger==="revealed")initScrollHandler(),addEventListener(e,r,n,t),maybeReveal(asElement(e));else if(t.trigger==="intersect"){let o={};t.root&&(o.root=querySelectorExt(e,t.root)),t.threshold&&(o.threshold=parseFloat(t.threshold)),new IntersectionObserver(function(i){for(let a=0;a<i.length;a++)if(i[a].isIntersecting){triggerEvent(e,"intersect");break}},o).observe(asElement(e)),addEventListener(asElement(e),r,n,t)}else!n.firstInitCompleted&&t.trigger==="load"?maybeFilterEvent(t,e,makeEvent("load",{elt:e}))||loadImmediately(asElement(e),r,n,t.delay):t.pollInterval>0?(n.polling=!0,processPolling(asElement(e),r,t)):addEventListener(e,r,n,t)}function shouldProcessHxOn(e){let t=asElement(e);if(!t)return!1;let n=t.attributes;for(let r=0;r<n.length;r++){let o=n[r].name;if(startsWith(o,"hx-on:")||startsWith(o,"data-hx-on:")||startsWith(o,"hx-on-")||startsWith(o,"data-hx-on-"))return!0}return!1}let HX_ON_QUERY=new XPathEvaluator().createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function processHXOnRoot(e,t){shouldProcessHxOn(e)&&t.push(asElement(e));let n=HX_ON_QUERY.evaluate(e),r=null;for(;r=n.iterateNext();)t.push(asElement(r))}function findHxOnWildcardElements(e){let t=[];if(e instanceof DocumentFragment)for(let n of e.childNodes)processHXOnRoot(n,t);else processHXOnRoot(e,t);return t}function findElementsToProcess(e){if(e.querySelectorAll){let n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]",r=[];for(let s in extensions){let i=extensions[s];if(i.getSelectors){var t=i.getSelectors();t&&r.push(t)}}return e.querySelectorAll(VERB_SELECTOR+n+", form, [type='submit'], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(s=>", "+s).join(""))}else return[]}function maybeSetLastButtonClicked(e){let t=getTargetButton(e.target),n=getRelatedFormData(e);n&&(n.lastButtonClicked=t)}function maybeUnsetLastButtonClicked(e){let t=getRelatedFormData(e);t&&(t.lastButtonClicked=null)}function getTargetButton(e){return closest(asElement(e),"button, input[type='submit']")}function getRelatedForm(e){return e.form||closest(e,"form")}function getRelatedFormData(e){let t=getTargetButton(e.target);if(!t)return;let n=getRelatedForm(t);if(n)return getInternalData(n)}function initButtonTracking(e){e.addEventListener("click",maybeSetLastButtonClicked),e.addEventListener("focusin",maybeSetLastButtonClicked),e.addEventListener("focusout",maybeUnsetLastButtonClicked)}function addHxOnEventHandler(e,t,n){let r=getInternalData(e);Array.isArray(r.onHandlers)||(r.onHandlers=[]);let o,s=function(i){maybeEval(e,function(){eltIsDisabled(e)||(o||(o=new Function("event",n)),o.call(e,i))})};e.addEventListener(t,s),r.onHandlers.push({event:t,listener:s})}function processHxOnWildcard(e){deInitOnHandlers(e);for(let t=0;t<e.attributes.length;t++){let n=e.attributes[t].name,r=e.attributes[t].value;if(startsWith(n,"hx-on")||startsWith(n,"data-hx-on")){let o=n.indexOf("-on")+3,s=n.slice(o,o+1);if(s==="-"||s===":"){let i=n.slice(o+1);startsWith(i,":")?i="htmx"+i:startsWith(i,"-")?i="htmx:"+i.slice(1):startsWith(i,"htmx-")&&(i="htmx:"+i.slice(5)),addHxOnEventHandler(e,i,r)}}}}function initNode(e){triggerEvent(e,"htmx:beforeProcessNode");let t=getInternalData(e),n=getTriggerSpecs(e);processVerbs(e,t,n)||(getClosestAttributeValue(e,"hx-boost")==="true"?boostElement(e,t,n):hasAttribute(e,"hx-trigger")&&n.forEach(function(o){addTriggerHandler(e,o,t,function(){})})),(e.tagName==="FORM"||getRawAttribute(e,"type")==="submit"&&hasAttribute(e,"form"))&&initButtonTracking(e),t.firstInitCompleted=!0,triggerEvent(e,"htmx:afterProcessNode")}function maybeDeInitAndHash(e){if(!(e instanceof Element))return!1;let t=getInternalData(e),n=attributeHash(e);return t.initHash!==n?(deInitNode(e),t.initHash=n,!0):!1}function processNode(e){if(e=resolveTarget(e),eltIsDisabled(e)){cleanUpElement(e);return}let t=[];maybeDeInitAndHash(e)&&t.push(e),forEach(findElementsToProcess(e),function(n){if(eltIsDisabled(n)){cleanUpElement(n);return}maybeDeInitAndHash(n)&&t.push(n)}),forEach(findHxOnWildcardElements(e),processHxOnWildcard),forEach(t,initNode)}function kebabEventName(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function makeEvent(e,t){return new CustomEvent(e,{bubbles:!0,cancelable:!0,composed:!0,detail:t})}function triggerErrorEvent(e,t,n){triggerEvent(e,t,mergeObjects({error:t},n))}function ignoreEventForLogging(e){return e==="htmx:afterProcessNode"}function withExtensions(e,t,n){forEach(getExtensions(e,[],n),function(r){try{t(r)}catch(o){logError(o)}})}function logError(e){console.error(e)}function triggerEvent(e,t,n){e=resolveTarget(e),n==null&&(n={}),n.elt=e;let r=makeEvent(t,n);htmx.logger&&!ignoreEventForLogging(t)&&htmx.logger(e,t,n),n.error&&(logError(n.error),triggerEvent(e,"htmx:error",{errorInfo:n}));let o=e.dispatchEvent(r),s=kebabEventName(t);if(o&&s!==t){let i=makeEvent(s,r.detail);o=o&&e.dispatchEvent(i)}return withExtensions(asElement(e),function(i){o=o&&i.onEvent(t,r)!==!1&&!r.defaultPrevented}),o}let currentPathForHistory;function setCurrentPathForHistory(e){currentPathForHistory=e,canAccessLocalStorage()&&sessionStorage.setItem("htmx-current-path-for-history",e)}setCurrentPathForHistory(location.pathname+location.search);function getHistoryElement(){return getDocument().querySelector("[hx-history-elt],[data-hx-history-elt]")||getDocument().body}function saveToHistoryCache(e,t){if(!canAccessLocalStorage())return;let n=cleanInnerHtmlForHistory(t),r=getDocument().title,o=window.scrollY;if(htmx.config.historyCacheSize<=0){sessionStorage.removeItem("htmx-history-cache");return}e=normalizePath(e);let s=parseJSON(sessionStorage.getItem("htmx-history-cache"))||[];for(let a=0;a<s.length;a++)if(s[a].url===e){s.splice(a,1);break}let i={url:e,content:n,title:r,scroll:o};for(triggerEvent(getDocument().body,"htmx:historyItemCreated",{item:i,cache:s}),s.push(i);s.length>htmx.config.historyCacheSize;)s.shift();for(;s.length>0;)try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(s));break}catch(a){triggerErrorEvent(getDocument().body,"htmx:historyCacheError",{cause:a,cache:s}),s.shift()}}function getCachedHistory(e){if(!canAccessLocalStorage())return null;e=normalizePath(e);let t=parseJSON(sessionStorage.getItem("htmx-history-cache"))||[];for(let n=0;n<t.length;n++)if(t[n].url===e)return t[n];return null}function cleanInnerHtmlForHistory(e){let t=htmx.config.requestClass,n=e.cloneNode(!0);return forEach(findAll(n,"."+t),function(r){removeClassFromElement(r,t)}),forEach(findAll(n,"[data-disabled-by-htmx]"),function(r){r.removeAttribute("disabled")}),n.innerHTML}function saveCurrentPageToHistory(){let e=getHistoryElement(),t=currentPathForHistory;canAccessLocalStorage()&&(t=sessionStorage.getItem("htmx-current-path-for-history")),t=t||location.pathname+location.search,getDocument().querySelector('[hx-history="false" i],[data-hx-history="false" i]')||(triggerEvent(getDocument().body,"htmx:beforeHistorySave",{path:t,historyElt:e}),saveToHistoryCache(t,e)),htmx.config.historyEnabled&&history.replaceState({htmx:!0},getDocument().title,location.href)}function pushUrlIntoHistory(e){htmx.config.getCacheBusterParam&&(e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,""),(endsWith(e,"&")||endsWith(e,"?"))&&(e=e.slice(0,-1))),htmx.config.historyEnabled&&history.pushState({htmx:!0},"",e),setCurrentPathForHistory(e)}function replaceUrlInHistory(e){htmx.config.historyEnabled&&history.replaceState({htmx:!0},"",e),setCurrentPathForHistory(e)}function settleImmediately(e){forEach(e,function(t){t.call(void 0)})}function loadHistoryFromServer(e){let t=new XMLHttpRequest,n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0},r={path:e,xhr:t,historyElt:getHistoryElement(),swapSpec:n};t.open("GET",e,!0),htmx.config.historyRestoreAsHxRequest&&t.setRequestHeader("HX-Request","true"),t.setRequestHeader("HX-History-Restore-Request","true"),t.setRequestHeader("HX-Current-URL",location.href),t.onload=function(){this.status>=200&&this.status<400?(r.response=this.response,triggerEvent(getDocument().body,"htmx:historyCacheMissLoad",r),swap(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:!0}),setCurrentPathForHistory(r.path),triggerEvent(getDocument().body,"htmx:historyRestore",{path:e,cacheMiss:!0,serverResponse:r.response})):triggerErrorEvent(getDocument().body,"htmx:historyCacheMissLoadError",r)},triggerEvent(getDocument().body,"htmx:historyCacheMiss",r)&&t.send()}function restoreHistory(e){saveCurrentPageToHistory(),e=e||location.pathname+location.search;let t=getCachedHistory(e);if(t){let n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll},r={path:e,item:t,historyElt:getHistoryElement(),swapSpec:n};triggerEvent(getDocument().body,"htmx:historyCacheHit",r)&&(swap(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title}),setCurrentPathForHistory(r.path),triggerEvent(getDocument().body,"htmx:historyRestore",r))}else htmx.config.refreshOnHistoryMiss?htmx.location.reload(!0):loadHistoryFromServer(e)}function addRequestIndicatorClasses(e){let t=findAttributeTargets(e,"hx-indicator");return t==null&&(t=[e]),forEach(t,function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||0)+1,n.classList.add.call(n.classList,htmx.config.requestClass)}),t}function disableElements(e){let t=findAttributeTargets(e,"hx-disabled-elt");return t==null&&(t=[]),forEach(t,function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||0)+1,n.setAttribute("disabled",""),n.setAttribute("data-disabled-by-htmx","")}),t}function removeRequestIndicators(e,t){forEach(e.concat(t),function(n){let r=getInternalData(n);r.requestCount=(r.requestCount||1)-1}),forEach(e,function(n){getInternalData(n).requestCount===0&&n.classList.remove.call(n.classList,htmx.config.requestClass)}),forEach(t,function(n){getInternalData(n).requestCount===0&&(n.removeAttribute("disabled"),n.removeAttribute("data-disabled-by-htmx"))})}function haveSeenNode(e,t){for(let n=0;n<e.length;n++)if(e[n].isSameNode(t))return!0;return!1}function shouldInclude(e){let t=e;return t.name===""||t.name==null||t.disabled||closest(t,"fieldset[disabled]")||t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"?!1:t.type==="checkbox"||t.type==="radio"?t.checked:!0}function addValueToFormData(e,t,n){e!=null&&t!=null&&(Array.isArray(t)?t.forEach(function(r){n.append(e,r)}):n.append(e,t))}function removeValueFromFormData(e,t,n){if(e!=null&&t!=null){let r=n.getAll(e);Array.isArray(t)?r=r.filter(o=>t.indexOf(o)<0):r=r.filter(o=>o!==t),n.delete(e),forEach(r,o=>n.append(e,o))}}function getValueFromInput(e){return e instanceof HTMLSelectElement&&e.multiple?toArray(e.querySelectorAll("option:checked")).map(function(t){return t.value}):e instanceof HTMLInputElement&&e.files?toArray(e.files):e.value}function processInputValue(e,t,n,r,o){if(!(r==null||haveSeenNode(e,r))){if(e.push(r),shouldInclude(r)){let s=getRawAttribute(r,"name");addValueToFormData(s,getValueFromInput(r),t),o&&validateElement(r,n)}r instanceof HTMLFormElement&&(forEach(r.elements,function(s){e.indexOf(s)>=0?removeValueFromFormData(s.name,getValueFromInput(s),t):e.push(s),o&&validateElement(s,n)}),new FormData(r).forEach(function(s,i){s instanceof File&&s.name===""||addValueToFormData(i,s,t)}))}}function validateElement(e,t){let n=e;n.willValidate&&(triggerEvent(n,"htmx:validation:validate"),n.checkValidity()||(triggerEvent(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&htmx.config.reportValidityOfForms&&n.reportValidity(),t.push({elt:n,message:n.validationMessage,validity:n.validity})))}function overrideFormData(e,t){for(let n of t.keys())e.delete(n);return t.forEach(function(n,r){e.append(r,n)}),e}function getInputValues(e,t){let n=[],r=new FormData,o=new FormData,s=[],i=getInternalData(e);i.lastButtonClicked&&!bodyContains(i.lastButtonClicked)&&(i.lastButtonClicked=null);let a=e instanceof HTMLFormElement&&e.noValidate!==!0||getAttributeValue(e,"hx-validate")==="true";if(i.lastButtonClicked&&(a=a&&i.lastButtonClicked.formNoValidate!==!0),t!=="get"&&processInputValue(n,o,s,getRelatedForm(e),a),processInputValue(n,r,s,e,a),i.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&getRawAttribute(e,"type")==="submit"){let c=i.lastButtonClicked||e,d=getRawAttribute(c,"name");addValueToFormData(d,c.value,o)}let l=findAttributeTargets(e,"hx-include");return forEach(l,function(c){processInputValue(n,r,s,asElement(c),a),matches(c,"form")||forEach(asParentNode(c).querySelectorAll(INPUT_SELECTOR),function(d){processInputValue(n,r,s,d,a)})}),overrideFormData(r,o),{errors:s,formData:r,values:formDataProxy(r)}}function appendParam(e,t,n){e!==""&&(e+="&"),String(n)==="[object Object]"&&(n=JSON.stringify(n));let r=encodeURIComponent(n);return e+=encodeURIComponent(t)+"="+r,e}function urlEncode(e){e=formDataFromObject(e);let t="";return e.forEach(function(n,r){t=appendParam(t,r,n)}),t}function getHeaders(e,t,n){let r={"HX-Request":"true","HX-Trigger":getRawAttribute(e,"id"),"HX-Trigger-Name":getRawAttribute(e,"name"),"HX-Target":getAttributeValue(t,"id"),"HX-Current-URL":location.href};return getValuesForElement(e,"hx-headers",!1,r),n!==void 0&&(r["HX-Prompt"]=n),getInternalData(e).boosted&&(r["HX-Boosted"]="true"),r}function filterValues(e,t){let n=getClosestAttributeValue(t,"hx-params");if(n){if(n==="none")return new FormData;if(n==="*")return e;if(n.indexOf("not ")===0)return forEach(n.slice(4).split(","),function(r){r=r.trim(),e.delete(r)}),e;{let r=new FormData;return forEach(n.split(","),function(o){o=o.trim(),e.has(o)&&e.getAll(o).forEach(function(s){r.append(o,s)})}),r}}else return e}function isAnchorLink(e){return!!getRawAttribute(e,"href")&&getRawAttribute(e,"href").indexOf("#")>=0}function getSwapSpecification(e,t){let n=t||getClosestAttributeValue(e,"hx-swap"),r={swapStyle:getInternalData(e).boosted?"innerHTML":htmx.config.defaultSwapStyle,swapDelay:htmx.config.defaultSwapDelay,settleDelay:htmx.config.defaultSettleDelay};if(htmx.config.scrollIntoViewOnBoost&&getInternalData(e).boosted&&!isAnchorLink(e)&&(r.show="top"),n){let i=splitOnWhitespace(n);if(i.length>0)for(let a=0;a<i.length;a++){let l=i[a];if(l.indexOf("swap:")===0)r.swapDelay=parseInterval(l.slice(5));else if(l.indexOf("settle:")===0)r.settleDelay=parseInterval(l.slice(7));else if(l.indexOf("transition:")===0)r.transition=l.slice(11)==="true";else if(l.indexOf("ignoreTitle:")===0)r.ignoreTitle=l.slice(12)==="true";else if(l.indexOf("scroll:")===0){var o=l.slice(7).split(":");let d=o.pop();var s=o.length>0?o.join(":"):null;r.scroll=d,r.scrollTarget=s}else if(l.indexOf("show:")===0){var o=l.slice(5).split(":");let u=o.pop();var s=o.length>0?o.join(":"):null;r.show=u,r.showTarget=s}else if(l.indexOf("focus-scroll:")===0){let c=l.slice(13);r.focusScroll=c=="true"}else a==0?r.swapStyle=l:logError("Unknown modifier in hx-swap: "+l)}}return r}function usesFormData(e){return getClosestAttributeValue(e,"hx-encoding")==="multipart/form-data"||matches(e,"form")&&getRawAttribute(e,"enctype")==="multipart/form-data"}function encodeParamsForBody(e,t,n){let r=null;return withExtensions(t,function(o){r==null&&(r=o.encodeParameters(e,n,t))}),r??(usesFormData(t)?overrideFormData(new FormData,formDataFromObject(n)):urlEncode(n))}function makeSettleInfo(e){return{tasks:[],elts:[e]}}function updateScrollState(e,t){let n=e[0],r=e[e.length-1];if(t.scroll){var o=null;t.scrollTarget&&(o=asElement(querySelectorExt(n,t.scrollTarget))),t.scroll==="top"&&(n||o)&&(o=o||n,o.scrollTop=0),t.scroll==="bottom"&&(r||o)&&(o=o||r,o.scrollTop=o.scrollHeight),typeof t.scroll=="number"&&getWindow().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}if(t.show){var o=null;if(t.showTarget){let i=t.showTarget;t.showTarget==="window"&&(i="body"),o=asElement(querySelectorExt(n,i))}t.show==="top"&&(n||o)&&(o=o||n,o.scrollIntoView({block:"start",behavior:htmx.config.scrollBehavior})),t.show==="bottom"&&(r||o)&&(o=o||r,o.scrollIntoView({block:"end",behavior:htmx.config.scrollBehavior}))}}function getValuesForElement(e,t,n,r,o){if(r==null&&(r={}),e==null)return r;let s=getAttributeValue(e,t);if(s){let i=s.trim(),a=n;if(i==="unset")return null;i.indexOf("javascript:")===0?(i=i.slice(11),a=!0):i.indexOf("js:")===0&&(i=i.slice(3),a=!0),i.indexOf("{")!==0&&(i="{"+i+"}");let l;a?l=maybeEval(e,function(){return o?Function("event","return ("+i+")").call(e,o):Function("return ("+i+")").call(e)},{}):l=parseJSON(i);for(let c in l)l.hasOwnProperty(c)&&r[c]==null&&(r[c]=l[c])}return getValuesForElement(asElement(parentElt(e)),t,n,r,o)}function maybeEval(e,t,n){return htmx.config.allowEval?t():(triggerErrorEvent(e,"htmx:evalDisallowedError"),n)}function getHXVarsForElement(e,t,n){return getValuesForElement(e,"hx-vars",!0,n,t)}function getHXValsForElement(e,t,n){return getValuesForElement(e,"hx-vals",!1,n,t)}function getExpressionVars(e,t){return mergeObjects(getHXVarsForElement(e,t),getHXValsForElement(e,t))}function safelySetHeaderValue(e,t,n){if(n!==null)try{e.setRequestHeader(t,n)}catch{e.setRequestHeader(t,encodeURIComponent(n)),e.setRequestHeader(t+"-URI-AutoEncoded","true")}}function getPathFromResponse(e){if(e.responseURL)try{let t=new URL(e.responseURL);return t.pathname+t.search}catch{triggerErrorEvent(getDocument().body,"htmx:badResponseUrl",{url:e.responseURL})}}function hasHeader(e,t){return t.test(e.getAllResponseHeaders())}function ajaxHelper(e,t,n){if(e=e.toLowerCase(),n){if(n instanceof Element||typeof n=="string")return issueAjaxRequest(e,t,null,null,{targetOverride:resolveTarget(n)||DUMMY_ELT,returnPromise:!0});{let r=resolveTarget(n.target);return(n.target&&!r||n.source&&!r&&!resolveTarget(n.source))&&(r=DUMMY_ELT),issueAjaxRequest(e,t,resolveTarget(n.source),n.event,{handler:n.handler,headers:n.headers,values:n.values,targetOverride:r,swapOverride:n.swap,select:n.select,returnPromise:!0,push:n.push,replace:n.replace,selectOOB:n.selectOOB})}}else return issueAjaxRequest(e,t,null,null,{returnPromise:!0})}function hierarchyForElt(e){let t=[];for(;e;)t.push(e),e=e.parentElement;return t}function verifyPath(e,t,n){let r=new URL(t,location.protocol!=="about:"?location.href:window.origin),s=(location.protocol!=="about:"?location.origin:window.origin)===r.origin;return htmx.config.selfRequestsOnly&&!s?!1:triggerEvent(e,"htmx:validateUrl",mergeObjects({url:r,sameHost:s},n))}function formDataFromObject(e){if(e instanceof FormData)return e;let t=new FormData;for(let n in e)e.hasOwnProperty(n)&&(e[n]&&typeof e[n].forEach=="function"?e[n].forEach(function(r){t.append(n,r)}):typeof e[n]=="object"&&!(e[n]instanceof Blob)?t.append(n,JSON.stringify(e[n])):t.append(n,e[n]));return t}function formDataArrayProxy(e,t,n){return new Proxy(n,{get:function(r,o){return typeof o=="number"?r[o]:o==="length"?r.length:o==="push"?function(s){r.push(s),e.append(t,s)}:typeof r[o]=="function"?function(){r[o].apply(r,arguments),e.delete(t),r.forEach(function(s){e.append(t,s)})}:r[o]&&r[o].length===1?r[o][0]:r[o]},set:function(r,o,s){return r[o]=s,e.delete(t),r.forEach(function(i){e.append(t,i)}),!0}})}function formDataProxy(e){return new Proxy(e,{get:function(t,n){if(typeof n=="symbol"){let o=Reflect.get(t,n);return typeof o=="function"?function(){return o.apply(e,arguments)}:o}if(n==="toJSON")return()=>Object.fromEntries(e);if(n in t&&typeof t[n]=="function")return function(){return e[n].apply(e,arguments)};let r=e.getAll(n);if(r.length!==0)return r.length===1?r[0]:formDataArrayProxy(t,n,r)},set:function(t,n,r){return typeof n!="string"?!1:(t.delete(n),r&&typeof r.forEach=="function"?r.forEach(function(o){t.append(n,o)}):typeof r=="object"&&!(r instanceof Blob)?t.append(n,JSON.stringify(r)):t.append(n,r),!0)},deleteProperty:function(t,n){return typeof n=="string"&&t.delete(n),!0},ownKeys:function(t){return Reflect.ownKeys(Object.fromEntries(t))},getOwnPropertyDescriptor:function(t,n){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(t),n)}})}function issueAjaxRequest(e,t,n,r,o,s){let i=null,a=null;if(o=o??{},o.returnPromise&&typeof Promise<"u")var l=new Promise(function(E,x){i=E,a=x});n==null&&(n=getDocument().body);let c=o.handler||handleAjaxResponse,d=o.select||null;if(!bodyContains(n))return maybeCall(i),l;let u=o.targetOverride||asElement(getTarget(n));if(u==null||u==DUMMY_ELT)return triggerErrorEvent(n,"htmx:targetError",{target:getClosestAttributeValue(n,"hx-target")}),maybeCall(a),l;let f=getInternalData(n),m=f.lastButtonClicked;if(m){let E=getRawAttribute(m,"formaction");E!=null&&(t=E);let x=getRawAttribute(m,"formmethod");if(x!=null)if(VERBS.includes(x.toLowerCase()))e=x;else return maybeCall(i),l}let h=getClosestAttributeValue(n,"hx-confirm");if(s===void 0&&triggerEvent(n,"htmx:confirm",{target:u,elt:n,path:t,verb:e,triggeringEvent:r,etc:o,issueRequest:function(L){return issueAjaxRequest(e,t,n,r,o,!!L)},question:h})===!1)return maybeCall(i),l;let g=n,p=getClosestAttributeValue(n,"hx-sync"),y=null,w=!1;if(p){let E=p.split(":"),x=E[0].trim();if(x==="this"?g=findThisElement(n,"hx-sync"):g=asElement(querySelectorExt(n,x)),p=(E[1]||"drop").trim(),f=getInternalData(g),p==="drop"&&f.xhr&&f.abortable!==!0)return maybeCall(i),l;if(p==="abort"){if(f.xhr)return maybeCall(i),l;w=!0}else p==="replace"?triggerEvent(g,"htmx:abort"):p.indexOf("queue")===0&&(y=(p.split(" ")[1]||"last").trim())}if(f.xhr)if(f.abortable)triggerEvent(g,"htmx:abort");else{if(y==null){if(r){let E=getInternalData(r);E&&E.triggerSpec&&E.triggerSpec.queue&&(y=E.triggerSpec.queue)}y==null&&(y="last")}return f.queuedRequests==null&&(f.queuedRequests=[]),y==="first"&&f.queuedRequests.length===0?f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):y==="all"?f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)}):y==="last"&&(f.queuedRequests=[],f.queuedRequests.push(function(){issueAjaxRequest(e,t,n,r,o)})),maybeCall(i),l}let b=new XMLHttpRequest;f.xhr=b,f.abortable=w;let v=function(){f.xhr=null,f.abortable=!1,f.queuedRequests!=null&&f.queuedRequests.length>0&&f.queuedRequests.shift()()},te=getClosestAttributeValue(n,"hx-prompt");if(te){var V=prompt(te);if(V===null||!triggerEvent(n,"htmx:prompt",{prompt:V,target:u}))return maybeCall(i),v(),l}if(h&&!s&&!confirm(h))return maybeCall(i),v(),l;let H=getHeaders(n,u,V);e!=="get"&&!usesFormData(n)&&(H["Content-Type"]="application/x-www-form-urlencoded"),o.headers&&(H=mergeObjects(H,o.headers));let ne=getInputValues(n,e),N=ne.errors,re=ne.formData;o.values&&overrideFormData(re,formDataFromObject(o.values));let Se=formDataFromObject(getExpressionVars(n,r)),j=overrideFormData(re,Se),R=filterValues(j,n);htmx.config.getCacheBusterParam&&e==="get"&&R.set("org.htmx.cache-buster",getRawAttribute(u,"id")||"true"),(t==null||t==="")&&(t=location.href);let W=getValuesForElement(n,"hx-request"),oe=getInternalData(n).boosted,P=htmx.config.methodsThatUseUrlParams.indexOf(e)>=0,C={boosted:oe,useUrlParams:P,formData:R,parameters:formDataProxy(R),unfilteredFormData:j,unfilteredParameters:formDataProxy(j),headers:H,elt:n,target:u,verb:e,errors:N,withCredentials:o.credentials||W.credentials||htmx.config.withCredentials,timeout:o.timeout||W.timeout||htmx.config.timeout,path:t,triggeringEvent:r};if(!triggerEvent(n,"htmx:configRequest",C))return maybeCall(i),v(),l;if(t=C.path,e=C.verb,H=C.headers,R=formDataFromObject(C.parameters),N=C.errors,P=C.useUrlParams,N&&N.length>0)return triggerEvent(n,"htmx:validation:halted",C),maybeCall(i),v(),l;let se=t.split("#"),Ce=se[0],X=se[1],A=t;if(P&&(A=Ce,!R.keys().next().done&&(A.indexOf("?")<0?A+="?":A+="&",A+=urlEncode(R),X&&(A+="#"+X))),!verifyPath(n,A,C))return triggerErrorEvent(n,"htmx:invalidPath",C),maybeCall(a),v(),l;if(b.open(e.toUpperCase(),A,!0),b.overrideMimeType("text/html"),b.withCredentials=C.withCredentials,b.timeout=C.timeout,!W.noHeaders){for(let E in H)if(H.hasOwnProperty(E)){let x=H[E];safelySetHeaderValue(b,E,x)}}let T={xhr:b,target:u,requestConfig:C,etc:o,boosted:oe,select:d,pathInfo:{requestPath:t,finalRequestPath:A,responsePath:null,anchor:X}};if(b.onload=function(){try{let E=hierarchyForElt(n);if(T.pathInfo.responsePath=getPathFromResponse(b),c(n,T),T.keepIndicators!==!0&&removeRequestIndicators(F,B),triggerEvent(n,"htmx:afterRequest",T),triggerEvent(n,"htmx:afterOnLoad",T),!bodyContains(n)){let x=null;for(;E.length>0&&x==null;){let L=E.shift();bodyContains(L)&&(x=L)}x&&(triggerEvent(x,"htmx:afterRequest",T),triggerEvent(x,"htmx:afterOnLoad",T))}maybeCall(i)}catch(E){throw triggerErrorEvent(n,"htmx:onLoadError",mergeObjects({error:E},T)),E}finally{v()}},b.onerror=function(){removeRequestIndicators(F,B),triggerErrorEvent(n,"htmx:afterRequest",T),triggerErrorEvent(n,"htmx:sendError",T),maybeCall(a),v()},b.onabort=function(){removeRequestIndicators(F,B),triggerErrorEvent(n,"htmx:afterRequest",T),triggerErrorEvent(n,"htmx:sendAbort",T),maybeCall(a),v()},b.ontimeout=function(){removeRequestIndicators(F,B),triggerErrorEvent(n,"htmx:afterRequest",T),triggerErrorEvent(n,"htmx:timeout",T),maybeCall(a),v()},!triggerEvent(n,"htmx:beforeRequest",T))return maybeCall(i),v(),l;var F=addRequestIndicatorClasses(n),B=disableElements(n);forEach(["loadstart","loadend","progress","abort"],function(E){forEach([b,b.upload],function(x){x.addEventListener(E,function(L){triggerEvent(n,"htmx:xhr:"+E,{lengthComputable:L.lengthComputable,loaded:L.loaded,total:L.total})})})}),triggerEvent(n,"htmx:beforeSend",T);let Ae=P?null:encodeParamsForBody(b,n,R);return b.send(Ae),l}function determineHistoryUpdates(e,t){let n=t.xhr,r=null,o=null;if(hasHeader(n,/HX-Push:/i)?(r=n.getResponseHeader("HX-Push"),o="push"):hasHeader(n,/HX-Push-Url:/i)?(r=n.getResponseHeader("HX-Push-Url"),o="push"):hasHeader(n,/HX-Replace-Url:/i)&&(r=n.getResponseHeader("HX-Replace-Url"),o="replace"),r)return r==="false"?{}:{type:o,path:r};let s=t.pathInfo.finalRequestPath,i=t.pathInfo.responsePath,a=t.etc.push||getClosestAttributeValue(e,"hx-push-url"),l=t.etc.replace||getClosestAttributeValue(e,"hx-replace-url"),c=getInternalData(e).boosted,d=null,u=null;return a?(d="push",u=a):l?(d="replace",u=l):c&&(d="push",u=i||s),u?u==="false"?{}:(u==="true"&&(u=i||s),t.pathInfo.anchor&&u.indexOf("#")===-1&&(u=u+"#"+t.pathInfo.anchor),{type:d,path:u}):{}}function codeMatches(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function resolveResponseHandling(e){for(var t=0;t<htmx.config.responseHandling.length;t++){var n=htmx.config.responseHandling[t];if(codeMatches(n,e.status))return n}return{swap:!1}}function handleTitle(e){if(e){let t=find("title");t?t.textContent=e:window.document.title=e}}function resolveRetarget(e,t){if(t==="this")return e;let n=asElement(querySelectorExt(e,t));if(n==null)throw triggerErrorEvent(e,"htmx:targetError",{target:t}),new Error(`Invalid re-target ${t}`);return n}function handleAjaxResponse(e,t){let n=t.xhr,r=t.target,o=t.etc,s=t.select;if(!triggerEvent(e,"htmx:beforeOnLoad",t))return;if(hasHeader(n,/HX-Trigger:/i)&&handleTriggerHeader(n,"HX-Trigger",e),hasHeader(n,/HX-Location:/i)){let w=n.getResponseHeader("HX-Location");var i={};w.indexOf("{")===0&&(i=parseJSON(w),w=i.path,delete i.path),i.push=i.push||"true",ajaxHelper("get",w,i);return}let a=hasHeader(n,/HX-Refresh:/i)&&n.getResponseHeader("HX-Refresh")==="true";if(hasHeader(n,/HX-Redirect:/i)){t.keepIndicators=!0,htmx.location.href=n.getResponseHeader("HX-Redirect"),a&&htmx.location.reload();return}if(a){t.keepIndicators=!0,htmx.location.reload();return}let l=determineHistoryUpdates(e,t),c=resolveResponseHandling(n),d=c.swap,u=!!c.error,f=htmx.config.ignoreTitle||c.ignoreTitle,m=c.select;c.target&&(t.target=resolveRetarget(e,c.target));var h=o.swapOverride;h==null&&c.swapOverride&&(h=c.swapOverride),hasHeader(n,/HX-Retarget:/i)&&(t.target=resolveRetarget(e,n.getResponseHeader("HX-Retarget"))),hasHeader(n,/HX-Reswap:/i)&&(h=n.getResponseHeader("HX-Reswap"));var g=n.response,p=mergeObjects({shouldSwap:d,serverResponse:g,isError:u,ignoreTitle:f,selectOverride:m,swapOverride:h},t);if(!(c.event&&!triggerEvent(r,c.event,p))&&triggerEvent(r,"htmx:beforeSwap",p)){if(r=p.target,g=p.serverResponse,u=p.isError,f=p.ignoreTitle,m=p.selectOverride,h=p.swapOverride,t.target=r,t.failed=u,t.successful=!u,p.shouldSwap){n.status===286&&cancelPolling(e),withExtensions(e,function(v){g=v.transformResponse(g,n,e)}),l.type&&saveCurrentPageToHistory();var y=getSwapSpecification(e,h);y.hasOwnProperty("ignoreTitle")||(y.ignoreTitle=f),r.classList.add(htmx.config.swappingClass),s&&(m=s),hasHeader(n,/HX-Reselect:/i)&&(m=n.getResponseHeader("HX-Reselect"));let w=o.selectOOB||getClosestAttributeValue(e,"hx-select-oob"),b=getClosestAttributeValue(e,"hx-select");swap(r,g,y,{select:m==="unset"?null:m||b,selectOOB:w,eventInfo:t,anchor:t.pathInfo.anchor,contextElement:e,afterSwapCallback:function(){if(hasHeader(n,/HX-Trigger-After-Swap:/i)){let v=e;bodyContains(e)||(v=getDocument().body),handleTriggerHeader(n,"HX-Trigger-After-Swap",v)}},afterSettleCallback:function(){if(hasHeader(n,/HX-Trigger-After-Settle:/i)){let v=e;bodyContains(e)||(v=getDocument().body),handleTriggerHeader(n,"HX-Trigger-After-Settle",v)}},beforeSwapCallback:function(){l.type&&(triggerEvent(getDocument().body,"htmx:beforeHistoryUpdate",mergeObjects({history:l},t)),l.type==="push"?(pushUrlIntoHistory(l.path),triggerEvent(getDocument().body,"htmx:pushedIntoHistory",{path:l.path})):(replaceUrlInHistory(l.path),triggerEvent(getDocument().body,"htmx:replacedInHistory",{path:l.path})))}})}u&&triggerErrorEvent(e,"htmx:responseError",mergeObjects({error:"Response Status Error Code "+n.status+" from "+t.pathInfo.requestPath},t))}}let extensions={};function extensionBase(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return!0},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return!1},handleSwap:function(e,t,n,r){return!1},encodeParameters:function(e,t,n){return null}}}function defineExtension(e,t){t.init&&t.init(internalAPI),extensions[e]=mergeObjects(extensionBase(),t)}function removeExtension(e){delete extensions[e]}function getExtensions(e,t,n){if(t==null&&(t=[]),e==null)return t;n==null&&(n=[]);let r=getAttributeValue(e,"hx-ext");return r&&forEach(r.split(","),function(o){if(o=o.replace(/ /g,""),o.slice(0,7)=="ignore:"){n.push(o.slice(7));return}if(n.indexOf(o)<0){let s=extensions[o];s&&t.indexOf(s)<0&&t.push(s)}}),getExtensions(asElement(parentElt(e)),t,n)}var isReady=!1;getDocument().addEventListener("DOMContentLoaded",function(){isReady=!0});function ready(e){isReady||getDocument().readyState==="complete"?e():getDocument().addEventListener("DOMContentLoaded",e)}function insertIndicatorStyles(){if(htmx.config.includeIndicatorStyles!==!1){let e=htmx.config.inlineStyleNonce?` nonce="${htmx.config.inlineStyleNonce}"`:"",t=htmx.config.indicatorClass,n=htmx.config.requestClass;getDocument().head.insertAdjacentHTML("beforeend",`<style${e}>.${t}{opacity:0;visibility: hidden} .${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}</style>`)}}function getMetaConfig(){let e=getDocument().querySelector('meta[name="htmx-config"]');return e?parseJSON(e.content):null}function mergeMetaConfig(){let e=getMetaConfig();e&&(htmx.config=mergeObjects(htmx.config,e))}return ready(function(){mergeMetaConfig(),insertIndicatorStyles();let e=getDocument().body;processNode(e);let t=getDocument().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(r){let o=r.detail.elt||r.target,s=getInternalData(o);s&&s.xhr&&s.xhr.abort()});let n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(r){r.state&&r.state.htmx?(restoreHistory(),forEach(t,function(o){triggerEvent(o,"htmx:restored",{document:getDocument(),triggerEvent})})):n&&n(r)},getWindow().setTimeout(function(){triggerEvent(e,"htmx:load",{}),e=null},0)}),htmx})(),O=Le;(function(){let e;O.defineExtension("json-enc",{init:function(t){e=t},onEvent:function(t,n){t==="htmx:configRequest"&&(n.detail.headers["Content-Type"]="application/json")},encodeParameters:function(t,n,r){t.overrideMimeType("text/json");let o={};n.forEach(function(i,a){Object.hasOwn(o,a)?(Array.isArray(o[a])||(o[a]=[o[a]]),o[a].push(i)):o[a]=i});let s=e.getExpressionVars(r);return Object.keys(o).forEach(function(i){o[i]=Object.hasOwn(s,i)?s[i]:o[i]}),JSON.stringify(o)}})})();var ie="https://typeahead.waow.tech",le="https://public.api.bsky.app",Ie="/xrpc/app.bsky.actor.searchActorsTypeahead",He="/xrpc/app.bsky.actor.getProfiles";var Re="atcr_recent_handles",ce="atcr_recent_profile_cache";var $=class{constructor(t){this.input=t,this.container=t.closest(".sailor-typeahead")||t.parentElement,this.dropdown=null,this.selectedCard=null,this.actors=[],this.currentItems=[],this.mode="hidden",this.focusIndex=-1,this.debounceTimer=null,this.requestSeq=0,this.primaryUnhealthyUntil=0,this.lastPrefetchPrefix="",this.lastPrefetchAt=0,this.createDropdown(),this.bindEvents(),this.input.value.trim().length===0&&this.showRecent()}createDropdown(){this.dropdown=document.createElement("div"),this.dropdown.className="sailor-typeahead-dropdown",this.dropdown.setAttribute("role","listbox"),this.dropdown.style.display="none",this.input.insertAdjacentElement("afterend",this.dropdown)}bindEvents(){this.input.addEventListener("focus",()=>this.handleFocus()),this.input.addEventListener("input",()=>this.handleInput()),this.input.addEventListener("keydown",t=>this.handleKeydown(t)),document.addEventListener("click",t=>{!this.input.contains(t.target)&&!this.dropdown.contains(t.target)&&this.hide()}),document.addEventListener("keydown",t=>{t.key==="Escape"&&this.selectedCard&&this.clearSelection()})}handleFocus(){this.input.value.trim().length===0&&this.showRecent()}handleInput(){let t=this.input.value.trim();if(t.length===0){this.showRecent();return}if(t.length>=2&&t.length<4){this.hide(),this.schedulePrefetch(t);return}if(t.length>=4){this.scheduleSearch(t);return}this.hide()}schedulePrefetch(t){clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>this.runPrefetch(t),150)}scheduleSearch(t){clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>this.runSearch(t),150)}async runPrefetch(t){let n=Date.now();if(!(t===this.lastPrefetchPrefix&&n-this.lastPrefetchAt<1e4)&&!(n<this.primaryUnhealthyUntil)){this.lastPrefetchPrefix=t,this.lastPrefetchAt=n;try{await z(ie,t,400)}catch{this.primaryUnhealthyUntil=Date.now()+6e4}}}async runSearch(t){let n=++this.requestSeq,r=null;if(Date.now()>=this.primaryUnhealthyUntil)try{r=await z(ie,t,1500)}catch{this.primaryUnhealthyUntil=Date.now()+6e4}if(r===null)try{r=await z(le,t,1500)}catch{r=[]}n===this.requestSeq&&(this.actors=r||[],this.focusIndex=-1,this.renderResults())}renderResults(){if(this.mode="results",this.dropdown.innerHTML="",this.currentItems=[],this.actors.length===0){this.hide();return}this.actors.forEach((t,n)=>{this.currentItems.push(t),this.dropdown.appendChild(this.buildActorRow(t,n))}),this.dropdown.style.display="block"}buildActorRow(t,n){let r=document.createElement("div");r.className="sailor-typeahead-item",r.setAttribute("role","option"),r.setAttribute("aria-selected","false"),r.dataset.index=String(n),r.dataset.handle=t.handle;let o=document.createElement("div");if(o.className="sailor-typeahead-avatar",t.avatar){let l=document.createElement("img");l.src=t.avatar,l.alt="",l.loading="lazy",o.appendChild(l)}let s=document.createElement("div");s.className="sailor-typeahead-text";let i=t.displayName&&t.displayName!==t.handle;if(i){let l=document.createElement("div");l.className="sailor-typeahead-name",l.textContent=t.displayName,s.appendChild(l)}let a=document.createElement("div");return a.className=i?"sailor-typeahead-handle":"sailor-typeahead-name",a.textContent="@"+t.handle,s.appendChild(a),r.append(o,s),r.addEventListener("mousedown",l=>{l.preventDefault(),this.select(t)}),r}showRecent(){let t=Oe();if(t.length===0){this.hide();return}this.mode="recent",this.focusIndex=-1,this.renderRecent(t),this.enrichRecent(t)}renderRecent(t){let n=_();this.dropdown.innerHTML="",this.currentItems=[];let r=document.createElement("div");r.className="sailor-typeahead-header",r.textContent="Recent accounts",this.dropdown.appendChild(r),t.forEach((o,s)=>{let i=n[o]?.profile||{handle:o};this.currentItems.push(i),this.dropdown.appendChild(this.buildActorRow(i,s))}),this.dropdown.style.display="block"}async enrichRecent(t){let n=_(),r=Date.now(),o=t.filter(a=>{let l=n[a];return!l||r-l.ts>864e5});if(o.length===0)return;let s=await De(o);if(s.length===0)return;let i=_();s.forEach(a=>{i[a.handle]={ts:r,profile:{handle:a.handle,displayName:a.displayName,avatar:a.avatar}}}),ae(i),this.mode==="recent"&&this.renderRecent(t)}hide(){this.mode="hidden",this.focusIndex=-1,this.dropdown.style.display="none"}select(t){if(typeof t=="string"&&(t={handle:t}),this.input.value=t.handle,this.hide(),this.showSelectedCard(t),t.handle){let n=_();n[t.handle]={ts:Date.now(),profile:{handle:t.handle,displayName:t.displayName,avatar:t.avatar}},ae(n)}}showSelectedCard(t){this.clearSelectedCard();let n=document.createElement("div");n.className="sailor-typeahead-selected";let r=document.createElement("div");if(r.className="sailor-typeahead-avatar",t.avatar){let l=document.createElement("img");l.src=t.avatar,l.alt="",r.appendChild(l)}let o=document.createElement("div");o.className="sailor-typeahead-text";let s=t.displayName&&t.displayName!==t.handle;if(s){let l=document.createElement("div");l.className="sailor-typeahead-name",l.textContent=t.displayName,o.appendChild(l)}let i=document.createElement("div");i.className=s?"sailor-typeahead-handle":"sailor-typeahead-name",i.textContent="@"+t.handle,o.appendChild(i);let a=document.createElement("button");a.type="button",a.className="sailor-typeahead-clear",a.setAttribute("aria-label","Change account"),a.innerHTML="&times;",a.addEventListener("click",()=>this.clearSelection()),n.append(r,o,a),this.input.style.display="none",this.input.insertAdjacentElement("beforebegin",n),this.selectedCard=n}clearSelectedCard(){this.selectedCard&&(this.selectedCard.remove(),this.selectedCard=null)}clearSelection(){this.clearSelectedCard(),this.input.style.display="",this.input.value="",this.input.focus(),this.showRecent()}handleKeydown(t){if(this.mode==="hidden")return;let n=this.dropdown.querySelectorAll(".sailor-typeahead-item");n.length!==0&&(t.key==="ArrowDown"?(t.preventDefault(),this.focusIndex=(this.focusIndex+1)%n.length,this.updateFocus(n)):t.key==="ArrowUp"?(t.preventDefault(),this.focusIndex=this.focusIndex<=0?n.length-1:this.focusIndex-1,this.updateFocus(n)):t.key==="Enter"?this.focusIndex>=0&&this.currentItems[this.focusIndex]&&(t.preventDefault(),this.select(this.currentItems[this.focusIndex])):t.key==="Escape"?this.hide():t.key==="Tab"&&this.focusIndex===-1&&n.length>0&&(t.preventDefault(),this.focusIndex=0,this.updateFocus(n)))}updateFocus(t){t.forEach((n,r)=>{let o=r===this.focusIndex;n.classList.toggle("focused",o),n.setAttribute("aria-selected",o?"true":"false"),o&&n.scrollIntoView({block:"nearest"})})}destroy(){this.debounceTimer&&(clearTimeout(this.debounceTimer),this.debounceTimer=null)}};async function z(e,t,n){let r=new URL(Ie,e);r.searchParams.set("q",t),r.searchParams.set("limit",String(8));let o=new AbortController,s=setTimeout(()=>o.abort(),n);try{let i=await fetch(r,{signal:o.signal});if(!i.ok)throw new Error("HTTP "+i.status);let a=await i.json();return Array.isArray(a.actors)?a.actors:[]}finally{clearTimeout(s)}}async function De(e){if(e.length===0)return[];let t=new URL(He,le);e.forEach(o=>t.searchParams.append("actors",o));let n=new AbortController,r=setTimeout(()=>n.abort(),3e3);try{let o=await fetch(t,{signal:n.signal});if(!o.ok)return[];let s=await o.json();return Array.isArray(s.profiles)?s.profiles:[]}catch{return[]}finally{clearTimeout(r)}}function _(){try{return JSON.parse(localStorage.getItem(ce)||"{}")}catch{return{}}}function ae(e){try{localStorage.setItem(ce,JSON.stringify(e))}catch{}}function Oe(){try{let e=localStorage.getItem(Re);return e?JSON.parse(e):[]}catch{return[]}}var I=null;function ue(){let e=document.getElementById("handle");e&&(I&&I.input===e||(I&&I.destroy(),I=new $(e)))}document.addEventListener("DOMContentLoaded",ue);document.body.addEventListener("htmx:afterSettle",ue);document.body.addEventListener("htmx:beforeSwap",()=>{I&&!document.contains(I.input)&&(I.destroy(),I=null)});function Y(e){try{return localStorage.getItem(e)}catch{return null}}function K(e,t){try{localStorage.setItem(e,t)}catch{}}function me(){return Y("theme")||"system"}function Me(e){return e==="dark"||e==="light"?e:window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function G(){let e=me(),n=Me(e)==="dark";document.documentElement.classList.toggle("dark",n),document.documentElement.setAttribute("data-theme",n?"dark":"light"),ke(e)}function ge(e){K("theme",e),G(),qe()}function ke(e){let t={system:"sun-moon",light:"sun",dark:"moon"};document.querySelectorAll("[data-theme-icon] use").forEach(n=>{n.setAttribute("href",`/icons.svg#${t[e]||"sun-moon"}`)}),document.querySelectorAll(".theme-option").forEach(n=>{let r=n.dataset.value===e;n.setAttribute("aria-checked",r?"true":"false");let o=n.querySelector(".theme-check");o&&(o.style.visibility=r?"visible":"hidden")})}function qe(){document.querySelectorAll("[data-theme-toggle]").forEach(e=>{let t=e.closest("details");t&&t.removeAttribute("open")})}document.addEventListener("DOMContentLoaded",()=>{document.querySelectorAll("[data-theme-toggle]").forEach(e=>{let t=e.closest("details");if(!t)return;let n=()=>e.setAttribute("aria-expanded",t.open?"true":"false");n(),t.addEventListener("toggle",n)})});window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{me()==="system"&&G()});function pe(e,t){if(!e)return;let n=e.querySelector(".nav-search-form"),r=e.querySelector('button[aria-controls="nav-search-form"]');e.classList.toggle("expanded",t),n&&(t?n.removeAttribute("inert"):n.setAttribute("inert","")),r&&r.setAttribute("aria-expanded",t?"true":"false")}function Ne(){let e=document.querySelector(".nav-search-wrapper");if(!e)return;let t=!e.classList.contains("expanded");if(pe(e,t),t){let n=document.getElementById("nav-search-input");n&&n.focus()}}function de(){let e=document.querySelector(".nav-search-wrapper");if(pe(e,!1),e){let t=e.querySelector('[aria-controls="nav-search-form"]');t&&t.focus()}}document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelector(".nav-search-wrapper"),t=document.getElementById("nav-search-input");!e||!t||(document.addEventListener("keydown",n=>{if(n.key==="Escape"&&e.classList.contains("expanded")&&de(),n.key==="/"&&!e.classList.contains("expanded")){let r=n.target.tagName;if(r==="INPUT"||r==="TEXTAREA"||n.target.isContentEditable)return;n.preventDefault(),e.classList.add("expanded"),t.focus()}}),document.addEventListener("click",n=>{e.classList.contains("expanded")&&!e.contains(n.target)&&de()}))});function J(e,t){let n=()=>{if(!t||!document.contains(t))return;let r=t.innerHTML;t.innerHTML='<svg class="icon size-4" aria-hidden="true"><use href="/icons.svg#check"></use></svg> Copied!',setTimeout(()=>{document.contains(t)&&(t.innerHTML=r)},2e3)};if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(e).then(n).catch(r=>{console.error("Clipboard API failed, falling back:",r),fe(e)?n():S("Copy failed \u2014 check browser permissions","error")});return}fe(e)?n():S("Copy failed \u2014 select the text and copy manually","error")}function fe(e){let t=document.createElement("textarea");t.value=e,t.setAttribute("readonly",""),t.setAttribute("aria-hidden","true"),t.style.position="fixed",t.style.top="0",t.style.left="0",t.style.width="1px",t.style.height="1px",t.style.opacity="0",t.style.pointerEvents="none",document.body.appendChild(t);let n=!1;try{t.focus(),t.select(),t.setSelectionRange(0,e.length),n=document.execCommand&&document.execCommand("copy")}catch{n=!1}return document.body.removeChild(t),!!n}function Pe(e){let t=s=>{let i=(s==null?"":String(s)).trim();return/[",\n\r]/.test(i)?'"'+i.replace(/"/g,'""')+'"':i},n=s=>Array.from(s).map(i=>t(i.textContent)).join(","),r=[],o=e.querySelector("thead tr");return o&&r.push(n(o.querySelectorAll("th,td"))),e.querySelectorAll("tbody tr").forEach(s=>{r.push(n(s.querySelectorAll("td,th")))}),r.join(` 2 + `)}document.addEventListener("DOMContentLoaded",()=>{document.addEventListener("click",e=>{let t=e.target.closest("button[data-copy-csv]");if(t){let r=t.closest("[data-csv-section]"),o=r&&r.querySelector("table");o&&J(Pe(o),t);return}let n=e.target.closest("button[data-cmd]");if(n){J(n.getAttribute("data-cmd"),n);return}})});function Fe(e){let t=Math.floor((new Date-new Date(e))/1e3),n={year:31536e3,month:2592e3,week:604800,day:86400,hour:3600,minute:60,second:1};for(let[r,o]of Object.entries(n)){let s=Math.floor(t/o);if(s>=1)return s===1?`1 ${r} ago`:`${s} ${r}s ago`}return"just now"}function U(){document.querySelectorAll("time[datetime]").forEach(e=>{let t=e.getAttribute("datetime");if(t&&!e.dataset.noUpdate){let n=Fe(t);e.textContent!==n&&(e.textContent=n)}})}document.addEventListener("DOMContentLoaded",()=>{U(),G(),document.querySelectorAll("[data-theme-menu]").forEach(e=>{e.querySelectorAll(".theme-option").forEach(t=>{t.addEventListener("click",()=>{ge(t.dataset.value)})})}),document.addEventListener("click",e=>{let t=e.target.closest("details.dropdown");document.querySelectorAll("details.dropdown[open]").forEach(n=>{n!==t&&n.removeAttribute("open")})})});document.addEventListener("htmx:afterSwap",U);var M=null;function ye(){M===null&&(M=setInterval(U,6e4))}function Be(){M!==null&&(clearInterval(M),M=null)}document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?Be():(U(),ye())});ye();async function _e(e,t,n){try{let r=await fetch("/api/manifests",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e,digest:t,confirm:!1})});if(r.status===409){let o=await r.json();Ue(e,t,n,o.tags)}else if(r.ok)Ee(n);else{let o=await r.text();S(`Failed to delete manifest: ${o||r.status}`,"error")}}catch(r){console.error("Error deleting manifest:",r),S(`Error deleting manifest: ${r.message}`,"error")}}function Ue(e,t,n,r){let o=document.getElementById("manifest-delete-modal"),s=document.getElementById("manifest-delete-tags"),i=document.getElementById("confirm-manifest-delete-btn");s.innerHTML="",r.forEach(a=>{let l=document.createElement("li");l.textContent=a,s.appendChild(l)}),i.onclick=()=>Ve(e,t,n),Z(o)}function Q(){k(document.getElementById("manifest-delete-modal"))}async function Ve(e,t,n){let r=document.getElementById("confirm-manifest-delete-btn"),o=r.textContent;try{r.disabled=!0,r.textContent="Deleting...";let s=await fetch("/api/manifests",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e,digest:t,confirm:!0})});if(s.ok)Q(),Ee(n),location.reload();else{let i=await s.text();S(`Failed to delete manifest: ${i||s.status}`,"error"),r.disabled=!1,r.textContent=o}}catch(s){console.error("Error deleting manifest:",s),S(`Error deleting manifest: ${s.message}`,"error"),r.disabled=!1,r.textContent=o}}async function je(e){let t=document.getElementById("confirm-untagged-delete-btn"),n=t.textContent;try{t.disabled=!0,t.textContent="Deleting...";let r=await fetch("/api/manifests/untagged",{method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo:e})}),o=await r.json();r.ok?(k(document.getElementById("untagged-delete-modal")),S(`Deleted ${o.deleted} untagged manifest(s)`,"success"),o.deleted>0&&location.reload(),t.disabled=!1,t.textContent=n):(S(`Failed to delete untagged manifests: ${o.error||"Unknown error"}`,"error"),t.disabled=!1,t.textContent=n)}catch(r){console.error("Error deleting untagged manifests:",r),S(`Error: ${r.message}`,"error"),t.disabled=!1,t.textContent=n}}function Ee(e){let t=document.getElementById(`manifest-${e}`);t&&t.remove()}document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("manifest-delete-modal");e&&e.addEventListener("click",t=>{t.target===e&&Q()})});var q=new WeakMap;function Z(e,t){if(e&&(q.set(e,t||document.activeElement),typeof e.showModal=="function")){e.open&&(e.open=!1);try{e.showModal()}catch{}}}function k(e,{remove:t=!1}={}){if(!e)return;let n=q.get(e);if(q.delete(e),typeof e.close=="function"&&e.open)try{e.close()}catch{}t&&e.remove(),ve(n)}function ve(e){e&&typeof e.focus=="function"&&document.contains(e)&&e.focus()}document.addEventListener("close",e=>{let t=e.target;if(!(t instanceof HTMLDialogElement))return;let n=q.get(t);q.delete(t),ve(n)},!0);document.body.addEventListener("htmx:afterSettle",()=>{document.querySelectorAll("dialog.modal-open:not([data-modal-promoted]), dialog[open]:not([data-modal-promoted])").forEach(t=>{t.dataset.modalPromoted="1",Z(t)})});document.addEventListener("change",e=>{let t=e.target.closest("select[data-diff-url]");if(!t)return;let n=t.dataset.diffUrl;n&&(window.location.href=n.replace("__VALUE__",encodeURIComponent(t.value)))});document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("pull-cmd-container");if(!e)return;let t=e.dataset.registryUrl,n=e.dataset.ownerHandle,r=e.dataset.repoName,o=e.dataset.tag||"latest",s=e.dataset.isLoggedIn==="true";function i(l){let d=(l==="none"?"":l+" pull ")+t+"/"+n+"/"+r+":"+o,u=document.getElementById("pull-cmd-display");if(!u)return;let f=u.querySelector("code");f&&(f.textContent=d);let m=u.querySelector("[data-cmd]");m&&(m.dataset.cmd=d),s&&window.htmx?window.htmx.ajax("POST","/api/profile/oci-client",{values:{oci_client:l},swap:"none"}):s||K("oci-client",l)}if(!s){let l=Y("oci-client");if(l){let c=document.getElementById("oci-client-switcher");c&&(c.value=l,i(l))}}let a=document.getElementById("oci-client-switcher");a&&a.addEventListener("change",()=>i(a.value))});document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelectorAll(".platform-tab[data-platform]");e.length&&e.forEach(t=>{t.addEventListener("click",()=>{e.forEach(r=>{let o=r===t;r.classList.toggle("btn-primary",o),r.classList.toggle("btn-ghost",!o),r.setAttribute("aria-selected",o?"true":"false"),r.setAttribute("tabindex",o?"0":"-1")}),document.querySelectorAll(".platform-content").forEach(r=>{r.classList.add("hidden"),r.setAttribute("hidden","")});let n=document.getElementById(t.dataset.platform+"-content");n&&(n.classList.remove("hidden"),n.removeAttribute("hidden"),t.focus())}),t.addEventListener("keydown",n=>{if(n.key!=="ArrowLeft"&&n.key!=="ArrowRight")return;n.preventDefault();let r=Array.from(e),o=r.indexOf(t);(n.key==="ArrowRight"?r[(o+1)%r.length]:r[(o-1+r.length)%r.length]).click()})})});document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("login-form");e&&e.addEventListener("submit",()=>{let t=e.querySelector('button[type="submit"]');!t||t.disabled||(t.disabled=!0,t.innerHTML='<span class="loading loading-spinner loading-sm align-middle"></span> Navigating&hellip;')})});document.addEventListener("DOMContentLoaded",()=>{let e=document.cookie.split("; ").find(n=>n.startsWith("atcr_login_handle="));if(!e)return;let t=decodeURIComponent(e.split("=")[1]);if(t&&typeof t=="string"&&t.length>0){try{let n="atcr_recent_handles",r=Y(n),o=[];try{o=JSON.parse(r||"[]")}catch{o=[]}Array.isArray(o)||(o=[]),o=o.filter(s=>s!==t),o.unshift(t),o=o.slice(0,5),K(n,JSON.stringify(o))}catch(n){console.error("Failed to save recent account:",n)}document.cookie="atcr_login_handle=; path=/; max-age=0"}});function he(){let e=document.getElementById("featured-carousel"),t=document.getElementById("carousel-prev"),n=document.getElementById("carousel-next");if(!e)return;let r=e.querySelectorAll(".carousel-item");if(r.length===0||!r[0])return;let o=null,s=5e3,i=window.matchMedia("(prefers-reduced-motion: reduce)"),a=()=>i.matches?"auto":"smooth",l=0,c=0;function d(){if(!r[0])return;let y=parseFloat(getComputedStyle(e).gap)||24;l=r[0].offsetWidth+y}d(),window.addEventListener("resize",()=>{cancelAnimationFrame(c),c=requestAnimationFrame(d)}),document.body.addEventListener("htmx:afterSettle",y=>{y.target&&y.target.contains&&y.target.contains(e)&&d()});function u(){let y=e.scrollWidth-e.clientWidth;e.scrollLeft>=y-10?e.scrollTo({left:0,behavior:a()}):e.scrollBy({left:l,behavior:a()})}function f(){e.scrollLeft<=10?e.scrollTo({left:e.scrollWidth,behavior:a()}):e.scrollBy({left:-l,behavior:a()})}function m(){o||document.visibilityState!=="hidden"&&(e.scrollWidth<=e.clientWidth+10||i.matches||(o=setInterval(u,s)))}function h(){o&&(clearInterval(o),o=null)}t&&t.addEventListener("click",()=>{h(),f(),m()}),n&&n.addEventListener("click",()=>{h(),u(),m()});let g=document.getElementById("carousel-pause"),p=!1;if(g){let y=g.querySelector(".carousel-pause-icon"),w=g.querySelector(".carousel-play-icon");g.setAttribute("aria-pressed","false"),g.setAttribute("aria-label","Pause carousel auto-advance"),g.addEventListener("click",()=>{p=!p,p?(h(),g.setAttribute("aria-pressed","true"),g.setAttribute("aria-label","Resume carousel auto-advance"),y&&y.classList.add("hidden"),w&&w.classList.remove("hidden")):(g.setAttribute("aria-pressed","false"),g.setAttribute("aria-label","Pause carousel auto-advance"),y&&y.classList.remove("hidden"),w&&w.classList.add("hidden"),m())})}e.addEventListener("mouseenter",h),e.addEventListener("mouseleave",()=>{p||m()}),document.addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"?h():p||m()}),m()}document.addEventListener("DOMContentLoaded",()=>{"requestIdleCallback"in window?requestIdleCallback(he,{timeout:2e3}):setTimeout(he,100)});document.body.addEventListener("htmx:responseError",e=>{let t=e.detail&&e.detail.elt;if(t&&t.closest&&t.closest("[data-suppress-htmx-toast]"))return;let n=e.detail&&e.detail.xhr,r=n&&n.getResponseHeader&&n.getResponseHeader("HX-Trigger");if(r&&r.indexOf("toast")!==-1)return;let o=n?n.status:0,s=o===401?"Session expired \u2014 please sign in again":o===403?"Not authorized":o===404?"Not found":o===429?"Too many requests \u2014 please slow down":o>=500?"Server error \u2014 please try again":"Something went wrong";S(s,"error")});document.body.addEventListener("htmx:sendError",e=>{let t=e.detail&&e.detail.elt;t&&t.closest&&t.closest("[data-suppress-htmx-toast]")||S("Network error \u2014 check your connection","error")});document.body.addEventListener("toast",e=>{let t=e&&e.detail||{},n=t.message||t.msg||"";if(!n)return;let r=t.type||"info";S(n,r)});var We=4,Xe=1500;function be(){let e=document.getElementById("toast-container");return e||(e=document.createElement("div"),e.id="toast-container",e.className="toast toast-end toast-bottom z-50",e.setAttribute("aria-live","polite"),e.setAttribute("aria-atomic","false"),document.body&&document.body.appendChild(e),e)}document.addEventListener("DOMContentLoaded",be);function S(e,t){let n=be(),r=(t||"info")+"|"+e,o=Date.now(),s=n.querySelector(`[data-toast-key="${$e(r)}"]`);if(s&&o-Number(s.dataset.toastAt)<Xe){ze(s);return}let i=t==="error",a=i?"alert-error":"alert-success",l=document.createElement("div");l.className=`alert ${a} shadow-lg transition-opacity duration-300`,l.style.willChange="opacity",l.setAttribute("role",i?"alert":"status"),l.dataset.toastKey=r,l.dataset.toastAt=String(o);let c=document.createElement("span");for(c.textContent=e,l.appendChild(c),n.appendChild(l);n.children.length>We;)n.firstElementChild.remove();we(l)}function we(e){e._dismissTimer=setTimeout(()=>{e.style.opacity="0",e._removeTimer=setTimeout(()=>e.remove(),300)},3e3)}function ze(e){clearTimeout(e._dismissTimer),clearTimeout(e._removeTimer),e.style.opacity="",e.dataset.toastAt=String(Date.now()),we(e)}function $e(e){return window.CSS&&CSS.escape?CSS.escape(e):String(e).replace(/[^a-zA-Z0-9_-]/g,t=>"\\"+t)}async function Je(e){try{let t=await fetch(`/api/webhooks/${e}/test`,{method:"POST",credentials:"include"}),n=await t.text();n.includes('class="success"')||t.ok&&!n.includes('class="error"')?S("Test webhook delivered successfully!","success"):S("Test delivery failed \u2014 check the webhook URL","error")}catch{S("Failed to reach server","error")}}(function(){let t={"switch-repo-tab":s=>window.switchRepoTab&&window.switchRepoTab(s.dataset.tab),"switch-editor-tab":s=>window.switchEditorTab&&window.switchEditorTab(s.dataset.tab),"insert-md":s=>window.insertMd&&window.insertMd(s.dataset.mdType),"toggle-editor":s=>window.toggleOverviewEditor&&window.toggleOverviewEditor(s.dataset.show==="true"),"show-modal":s=>Z(document.getElementById(s.dataset.modalId),s),"close-dialog":s=>k(s.closest("dialog")),"remove-closest-dialog":s=>k(s.closest("dialog"),{remove:!0}),"close-manifest-delete-modal":()=>window.closeManifestDeleteModal&&window.closeManifestDeleteModal(),"save-overview":()=>window.saveOverview&&window.saveOverview(),"delete-manifest":s=>window.deleteManifest&&window.deleteManifest(s.dataset.repo,s.dataset.digest,s.dataset.manifestId||""),"delete-untagged":s=>window.deleteUntaggedManifests&&window.deleteUntaggedManifests(s.dataset.repo),copy:s=>window.copyToClipboard&&window.copyToClipboard(s.dataset.copy,s),"toggle-search":()=>window.toggleSearch&&window.toggleSearch(),"switch-settings-tab":s=>window.switchSettingsTab&&window.switchSettingsTab(s.dataset.tab),"test-webhook":s=>window.testWebhook&&window.testWebhook(s.dataset.webhookId),"diff-to":(s,i)=>window.diffToTag&&window.diffToTag(i,s),"modal-backdrop-close":(s,i)=>{i.target===s&&k(s,{remove:!0})}},n={"sort-tags":s=>window.sortTags&&window.sortTags(s.value),"submit-form":s=>s.form&&s.form.requestSubmit()},r={"filter-tags":s=>window.filterTags&&window.filterTags(s.value)};function o(s,i){let a=i.target.closest("[data-action]");if(!a)return;let l=s[a.dataset.action];l&&l(a,i)}document.addEventListener("click",s=>o(t,s)),document.addEventListener("change",s=>o(n,s)),document.addEventListener("input",s=>o(r,s))})();window.setTheme=ge;window.toggleSearch=Ne;window.copyToClipboard=J;window.deleteManifest=_e;window.deleteUntaggedManifests=je;window.closeManifestDeleteModal=Q;window.showToast=S;window.testWebhook=Je;function Ye(){let e=document.getElementById("md-editor");if(!e)return;let t=e.dataset.ownerDid,n=e.dataset.repository;window.toggleOverviewEditor=function(r){document.getElementById("overview-view").classList.toggle("hidden",r),document.getElementById("overview-edit").classList.toggle("hidden",!r),r&&e.focus()},window.switchEditorTab=function(r){if(document.querySelectorAll(".editor-panel").forEach(o=>o.classList.add("hidden")),document.getElementById(r==="write"?"editor-write":"editor-preview").classList.remove("hidden"),document.querySelectorAll(".editor-tab").forEach(o=>{let s=o.dataset.tab===r;o.classList.toggle("border-primary",s),o.classList.toggle("text-primary",s),o.classList.toggle("border-transparent",!s),o.classList.toggle("text-base-content/60",!s)}),r==="preview"){let o=e.value,s=document.getElementById("preview-content");if(!o.trim()){s.innerHTML='<p class="text-base-content/60">Nothing to preview</p>';return}s.innerHTML='<p class="text-base-content/60"><span class="loading loading-spinner loading-xs align-middle"></span> Rendering preview&hellip;</p>';let i=new FormData;i.append("markdown",o),fetch("/api/repo-page/preview",{method:"POST",body:i}).then(a=>{if(!a.ok)throw new Error("HTTP "+a.status);return a.text()}).then(a=>{s.innerHTML=a}).catch(()=>{s.innerHTML='<p class="text-error">Preview failed. Check your connection and try again.</p>'})}},window.insertMd=function(r){let o=e.selectionStart,s=e.selectionEnd,i=e.value.substring(o,s),a=e.value.substring(0,o),l=e.value.substring(s),c,d,u;switch(r){case"heading":c="## "+(i||"Heading"),d=o+3,u=o+c.length;break;case"bold":c="**"+(i||"bold text")+"**",d=o+2,u=o+c.length-2;break;case"italic":c="_"+(i||"italic text")+"_",d=o+1,u=o+c.length-1;break;case"link":c="["+(i||"link text")+"](url)",d=o+c.length-4,u=o+c.length-1;break;case"image":c="!["+(i||"alt text")+"](url)",d=o+c.length-4,u=o+c.length-1;break;case"ul":c="- "+(i||"list item"),d=o+2,u=o+c.length;break;case"ol":c="1. "+(i||"list item"),d=o+3,u=o+c.length;break;case"code":i&&i.indexOf(` 3 + `)!==-1?(c="```\n"+i+"\n```",d=o+4,u=o+4+i.length):(c="`"+(i||"code")+"`",d=o+1,u=o+c.length-1);break;default:return}e.value=a+c+l,e.focus(),e.selectionStart=d,e.selectionEnd=u},window.saveOverview=function(){let r=document.getElementById("save-overview-btn");r.classList.add("btn-disabled"),r.innerHTML='<span class="loading loading-spinner loading-xs"></span> Saving...';let o=new FormData;o.append("did",t),o.append("repository",n),o.append("description",e.value),fetch("/api/repo-page",{method:"POST",body:o,headers:{"HX-Request":"true"}}).then(s=>s.ok?s.text():s.text().then(i=>{throw new Error(i)})).then(s=>{document.getElementById("overview-rendered").innerHTML=s,window.toggleOverviewEditor(!1),typeof window.showToast=="function"&&window.showToast("Overview saved","success")}).catch(s=>{typeof window.showToast=="function"&&window.showToast(s.message||"Failed to save","error")}).finally(()=>{r.classList.remove("btn-disabled"),r.innerHTML="Save"})},e.addEventListener("keydown",r=>{(r.ctrlKey||r.metaKey)&&r.key==="s"&&(r.preventDefault(),window.saveOverview())})}window.sortTags=function(e){let t=document.getElementById("tags-list");if(!t)return;let n=Array.from(t.querySelectorAll(".artifact-entry"));n.sort((r,o)=>{switch(e){case"oldest":return parseInt(r.dataset.created)-parseInt(o.dataset.created);case"az":return r.dataset.tag.localeCompare(o.dataset.tag);case"za":return o.dataset.tag.localeCompare(r.dataset.tag);default:return parseInt(o.dataset.created)-parseInt(r.dataset.created)}}),n.forEach(r=>t.appendChild(r))};var D=0;window.filterTags=function(e){D&&cancelAnimationFrame(D),D=requestAnimationFrame(()=>{D=0;let t=e.toLowerCase();document.querySelectorAll("#tags-list .artifact-entry").forEach(n=>{n.style.display=!t||n.dataset.tag.toLowerCase().includes(t)?"":"none"})})};document.body.addEventListener("htmx:beforeSwap",()=>{D&&(cancelAnimationFrame(D),D=0)});function Ke(){if(!document.getElementById("tag-content"))return;let e=["overview","layers","vulns","sbom","artifacts"],t={};function n(i,a){if(t[i]==="loading"||t[i]==="loaded")return;t[i]="loading";let l=document.getElementById(i);if(!l){delete t[i];return}let c=new AbortController,d=setTimeout(()=>c.abort(),1e4);fetch(a,{signal:c.signal}).then(u=>{if(!u.ok)throw new Error("HTTP "+u.status);return u.text()}).then(u=>{t[i]="loaded",document.contains(l)&&(l.innerHTML=u,l.querySelectorAll("script").forEach(f=>{let m=document.createElement("script");m.textContent=f.textContent,f.parentNode.replaceChild(m,f)}),typeof window.htmx<"u"&&window.htmx.process(l))}).catch(u=>{if(delete t[i],!document.contains(l))return;let m=u&&u.name==="AbortError"?"This section took too long to load.":"Couldn't load this section.";l.innerHTML='<div class="py-6 text-sm text-base-content/70"><p>'+m+'</p><button type="button" class="btn btn-sm btn-ghost mt-2" data-retry-section="'+i+'">Try again</button></div>'}).finally(()=>clearTimeout(d))}document.body.addEventListener("click",i=>{let a=i.target.closest("[data-retry-section]");if(!a)return;let l=a.getAttribute("data-retry-section"),d={"artifacts-content":o,"layers-content":()=>r("layers"),"vulns-content":()=>r("vulns"),"sbom-content":()=>r("sbom")}[l];if(d){let u=d();u&&n(l,u)}});function r(i){let a=document.getElementById("tag-content");if(!a||!a.dataset)return null;let l=a.dataset.digest,c=a.dataset.owner,d=a.dataset.repo;return!l||!c||!d?null:"/api/digest-content/"+c+"/"+d+"?digest="+encodeURIComponent(l)+"&section="+i}function o(){let i=document.getElementById("tag-content");if(!i||!i.dataset)return null;let a=i.dataset.owner,l=i.dataset.repo;return!a||!l?null:"/api/repo-tags/"+a+"/"+l}window.diffToTag=function(i,a){i.preventDefault();let l=a.dataset.diffTo,c=document.getElementById("tag-content"),d=document.getElementById("tag-selector");if(!c||!d||!l)return;let u=c.dataset.digest,f=d.value;!u||l===f||(window.location.href="/diff/"+c.dataset.owner+"/"+c.dataset.repo+"?from="+encodeURIComponent(u)+"&to="+encodeURIComponent(l))},window.switchRepoTab=function(i){window._activeRepoTab=i;let a=document.getElementById("tag-content");if(!a)return;a.querySelectorAll(".repo-panel").forEach(d=>d.classList.add("hidden"));let l=document.getElementById("tab-"+i);l&&l.classList.remove("hidden"),a.querySelectorAll(".repo-tab").forEach(d=>{let u=d.dataset.tab===i;d.classList.toggle("border-primary",u),d.classList.toggle("text-primary",u),d.classList.toggle("border-transparent",!u),d.classList.toggle("text-base-content/60",!u),d.setAttribute("aria-selected",u?"true":"false"),d.setAttribute("tabindex",u?"0":"-1")});let c=new URL(window.location);if(c.hash=i,history.replaceState(null,"",c.toString()),i==="artifacts"){let d=o();d&&n("artifacts-content",d)}if(i==="layers"){let d=r("layers");d&&n("layers-content",d)}if(i==="vulns"){let d=r("vulns");d&&n("vulns-content",d)}if(i==="sbom"){let d=r("sbom");d&&n("sbom-content",d)}};function s(){t={},[["artifacts-tab-btn","artifacts-content",o],["layers-tab-btn","layers-content",()=>r("layers")],["vulns-tab-btn","vulns-content",()=>r("vulns")],["sbom-tab-btn","sbom-content",()=>r("sbom")]].forEach(([c,d,u])=>{let f=document.getElementById(c);f&&f.addEventListener("mouseenter",()=>{let m=u();m&&n(d,m)},{once:!0})});let a=document.querySelector('[role="tablist"][aria-label="Repository sections"]');a&&!a.dataset.keyboardBound&&(a.dataset.keyboardBound="1",a.addEventListener("keydown",c=>{let d=Array.from(a.querySelectorAll(".repo-tab")),u=d.indexOf(document.activeElement);if(u===-1)return;let f=-1;switch(c.key){case"ArrowRight":f=(u+1)%d.length;break;case"ArrowLeft":f=(u-1+d.length)%d.length;break;case"Home":f=0;break;case"End":f=d.length-1;break;case"Enter":case" ":c.preventDefault(),window.switchRepoTab(d[u].dataset.tab);return;default:return}c.preventDefault(),d[f].focus()}));let l=window._activeRepoTab||window.location.hash.replace("#","")||"overview";e.indexOf(l)===-1&&(l="overview"),window.switchRepoTab(l)}s(),document.addEventListener("keydown",i=>{if(i.target.tagName==="INPUT"||i.target.tagName==="TEXTAREA"||i.target.tagName==="SELECT"||i.target.isContentEditable||i.ctrlKey||i.metaKey||i.altKey)return;let l={o:"overview",l:"layers",v:"vulns",s:"sbom",a:"artifacts"}[i.key.toLowerCase()];l&&e.indexOf(l)!==-1&&window.switchRepoTab(l)}),document.body.addEventListener("htmx:afterSettle",i=>{i.detail.target&&i.detail.target.id==="tag-content"&&s()})}document.addEventListener("DOMContentLoaded",()=>{Ye(),Ke()});function Ge(){let e=Array.from(document.querySelectorAll('.menu li[data-tab] a[role="tab"]')),t=Array.from(document.querySelectorAll(".settings-tab-mobile"));if(!e.length&&!t.length)return;function n(s,i){let a=i==="vertical"?"ArrowUp":"ArrowLeft",l=i==="vertical"?"ArrowDown":"ArrowRight";s.forEach(c=>{c.addEventListener("keydown",d=>{let u=s.indexOf(d.currentTarget);if(u===-1)return;let f=null;d.key===a?f=s[(u-1+s.length)%s.length]:d.key===l?f=s[(u+1)%s.length]:d.key==="Home"?f=s[0]:d.key==="End"&&(f=s[s.length-1]),f&&(d.preventDefault(),f.focus(),f.click())})})}n(e,"vertical"),n(t,"horizontal");function r(){let s=t.find(i=>i.getAttribute("aria-selected")==="true");s&&s.scrollIntoView({inline:"center",block:"nearest"})}r();function o(s){e.forEach(i=>{let a=i.parentElement.dataset.tab===s;i.setAttribute("aria-selected",a?"true":"false"),i.setAttribute("tabindex",a?"0":"-1"),i.parentElement.classList.toggle("menu-active",a)}),t.forEach(i=>{let a=i.dataset.tab===s;i.setAttribute("aria-selected",a?"true":"false"),i.setAttribute("tabindex",a?"0":"-1"),i.classList.toggle("btn-secondary",a),i.classList.toggle("btn-ghost",!a)}),r()}[...e,...t].forEach(s=>{s.addEventListener("click",()=>o(s.dataset.tab||s.parentElement.dataset.tab))}),document.body.addEventListener("htmx:historyRestore",()=>{let s=location.pathname.match(/^\/settings\/(user|storage|billing|devices|webhooks|advanced)/);s&&o(s[1])})}function Qe(){document.addEventListener("click",function(n){let r=n.target.closest("#delete-account-btn");r&&t(r)});function e(n){let r=document.createElement("div");return r.textContent=n,r.innerHTML}function t(n){let r=n.dataset.clientShortName||"this account",s="DELETE "+(n.dataset.profileHandle||""),i=document.getElementById("delete-pds-records").checked,a=document.createElement("div");a.className="modal modal-open",a.innerHTML=` 4 4 <div class="modal-box bg-base-200 max-w-lg"> 5 5 <h2 class="text-xl font-bold flex items-center gap-2 text-error"> 6 6 <svg class="icon size-6" aria-hidden="true"><use href="/icons.svg#alert-triangle"></use></svg> 7 - Delete ${o(t)} Data 7 + Delete ${e(r)} Data 8 8 </h2> 9 9 10 10 <div class="py-4 space-y-4"> ··· 18 18 </p> 19 19 20 20 <ul class="list-disc list-inside text-sm space-y-1 text-base-content/70"> 21 - <li>Your ${o(t)} account and all settings</li> 21 + <li>Your ${e(r)} account and all settings</li> 22 22 <li>All authorized devices</li> 23 23 <li>Your data from all holds you're a member of</li> 24 24 ${i?"<li>All io.atcr.* records from your ATProto PDS</li>":""} 25 25 </ul> 26 26 27 27 <div class="space-y-2"> 28 - <label for="confirm-delete-input" class="text-sm">Type <strong class="font-mono">${o(r)}</strong> to confirm:</label> 29 - <input type="text" id="confirm-delete-input" class="input input-bordered w-full font-mono" placeholder="${o(r)}" autocomplete="off"> 28 + <label for="confirm-delete-input" class="text-sm">Type <strong class="font-mono">${e(s)}</strong> to confirm:</label> 29 + <input type="text" id="confirm-delete-input" class="input input-bordered w-full font-mono" placeholder="${e(s)}" autocomplete="off"> 30 30 </div> 31 31 </div> 32 32 ··· 34 34 <button type="button" class="btn" id="cancel-delete">Cancel</button> 35 35 <button type="button" class="btn btn-error gap-2" id="confirm-delete" disabled> 36 36 <svg class="icon size-4" aria-hidden="true"><use href="/icons.svg#trash-2"></use></svg> 37 - Delete My ${o(t)} Data 37 + Delete My ${e(r)} Data 38 38 </button> 39 39 </div> 40 40 </div> 41 41 <div class="modal-backdrop bg-black/50" id="modal-backdrop"></div> 42 - `,document.body.appendChild(l);let a=document.getElementById("confirm-delete-input"),c=document.getElementById("confirm-delete"),d=document.getElementById("cancel-delete");setTimeout(()=>a.focus(),100),a.addEventListener("input",function(){c.disabled=this.value!==r}),a.addEventListener("keydown",function(m){m.key==="Enter"&&this.value===r&&f()}),d.addEventListener("click",()=>l.remove()),document.getElementById("modal-backdrop").addEventListener("click",()=>l.remove());function u(m){m.key==="Escape"&&(l.remove(),document.removeEventListener("keydown",u))}document.addEventListener("keydown",u),c.addEventListener("click",f);async function f(){let m=document.getElementById("delete-pds-records").checked;c.disabled=!0,c.innerHTML='<svg class="icon size-4 animate-spin" aria-hidden="true"><use href="/icons.svg#loader-2"></use></svg> Deleting...',d.disabled=!0;try{let h=await fetch("/api/account",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({delete_pds_records:m,confirmation:r})}),y=await h.json();if(h.ok&&y.success)l.querySelector(".modal-box").innerHTML=` 42 + `,document.body.appendChild(a);let l=document.getElementById("confirm-delete-input"),c=document.getElementById("confirm-delete"),d=document.getElementById("cancel-delete");setTimeout(()=>l.focus(),100),l.addEventListener("input",function(){c.disabled=this.value!==s}),l.addEventListener("keydown",function(m){m.key==="Enter"&&this.value===s&&f()}),d.addEventListener("click",()=>a.remove()),document.getElementById("modal-backdrop").addEventListener("click",()=>a.remove());function u(m){m.key==="Escape"&&(a.remove(),document.removeEventListener("keydown",u))}document.addEventListener("keydown",u),c.addEventListener("click",f);async function f(){let m=document.getElementById("delete-pds-records").checked;c.disabled=!0,c.innerHTML='<svg class="icon size-4 animate-spin" aria-hidden="true"><use href="/icons.svg#loader-2"></use></svg> Deleting...',d.disabled=!0;try{let h=await fetch("/api/account",{method:"DELETE",headers:{"Content-Type":"application/json"},body:JSON.stringify({delete_pds_records:m,confirmation:s})}),g=await h.json();if(h.ok&&g.success)a.querySelector(".modal-box").innerHTML=` 43 43 <h2 class="text-xl font-bold flex items-center gap-2 text-success"> 44 44 <svg class="icon size-6" aria-hidden="true"><use href="/icons.svg#check-circle"></use></svg> 45 45 Account Deleted ··· 48 48 <p>Your account has been successfully deleted.</p> 49 49 <p class="text-base-content/70">Redirecting to home page...</p> 50 50 </div> 51 - `,setTimeout(()=>{window.location.href="/?deleted=true"},2e3);else{let p=y.errors||["An unknown error occurred"];l.querySelector(".modal-box").innerHTML=` 51 + `,setTimeout(()=>{window.location.href="/?deleted=true"},2e3);else{let p=g.errors||["An unknown error occurred"];a.querySelector(".modal-box").innerHTML=` 52 52 <h2 class="text-xl font-bold flex items-center gap-2 text-error"> 53 53 <svg class="icon size-6" aria-hidden="true"><use href="/icons.svg#x-circle"></use></svg> 54 54 Deletion Failed ··· 56 56 <div class="py-4 space-y-4"> 57 57 <p>There were errors during account deletion:</p> 58 58 <ul class="list-disc list-inside text-sm space-y-1 text-error"> 59 - ${p.map(w=>"<li>"+o(w)+"</li>").join("")} 59 + ${p.map(y=>"<li>"+e(y)+"</li>").join("")} 60 60 </ul> 61 61 </div> 62 62 <div class="modal-action"> 63 63 <button type="button" class="btn" data-dismiss-modal>Close</button> 64 64 </div> 65 - `,l.querySelector("[data-dismiss-modal]").addEventListener("click",()=>l.remove())}}catch(h){console.error("Delete account error:",h),l.querySelector(".modal-box").innerHTML=` 65 + `,a.querySelector("[data-dismiss-modal]").addEventListener("click",()=>a.remove())}}catch(h){console.error("Delete account error:",h),a.querySelector(".modal-box").innerHTML=` 66 66 <h2 class="text-xl font-bold flex items-center gap-2 text-error"> 67 67 <svg class="icon size-6" aria-hidden="true"><use href="/icons.svg#x-circle"></use></svg> 68 68 Error 69 69 </h2> 70 70 <div class="py-4"> 71 - <p>Failed to delete account: ${o(h.message)}</p> 71 + <p>Failed to delete account: ${e(h.message)}</p> 72 72 </div> 73 73 <div class="modal-action"> 74 74 <button type="button" class="btn" data-dismiss-modal>Close</button> 75 75 </div> 76 - `,l.querySelector("[data-dismiss-modal]").addEventListener("click",()=>l.remove())}}}}document.addEventListener("DOMContentLoaded",()=>{ze(),$e()});var G="showEmptyLayers";function Je(e,t,n){let r=0;for(let o=t;o<t+n;o++){let s=e[o].querySelector("td[data-bytes]");if(!s)continue;let i=Number(s.dataset.bytes);Number.isFinite(i)&&(r+=i)}return r}function Ye(e){return e<1024?e+" B":e<1048576?(e/1024).toFixed(1)+" KB":e<1073741824?(e/1048576).toFixed(1)+" MB":(e/1073741824).toFixed(1)+" GB"}function Ke(e){let t=e.querySelector("tbody");if(!t)return;let n=Array.from(t.querySelectorAll("tr")),r=0;for(;r<n.length;){if(n[r].dataset.noCommand!=="true"){r++;continue}let o=r;for(;r<n.length&&n[r].dataset.noCommand==="true";)n[r].classList.add("no-history-row","hidden"),r++;let s=r-o;if(s<=1){n[o].classList.remove("hidden");continue}let i=n[o].querySelector("td").textContent.trim(),l=n[r-1].querySelector("td").textContent.trim(),a=Ye(Je(n,o,s)),c=document.createElement("tr");c.className="no-history-summary cursor-pointer hover:bg-base-300",c.innerHTML='<td colspan="2" class="text-sm py-2">Layers '+i+"-"+l+' contain no history <span class="text-xs ml-2">('+s+' layers, click to expand)</span></td><td class="text-right text-sm whitespace-nowrap">'+a+"</td>",c.addEventListener("click",()=>{c.remove();for(let d=o;d<o+s;d++)n[d].classList.remove("hidden")}),t.insertBefore(c,n[o])}}function ye(e){let t=localStorage.getItem(G)==="true";e.querySelectorAll('tr[data-empty="true"]').forEach(n=>{n.style.display=t?"":"none"})}function Ee(e){let t=e||document;(t.querySelectorAll?t.querySelectorAll(".layers-table:not([data-layers-processed])"):[]).forEach(r=>{r.setAttribute("data-layers-processed","1"),Ke(r),ye(r)})}function Ge(e){localStorage.setItem(G,e),document.querySelectorAll(".show-empty-layers-cb").forEach(t=>{t.checked=e}),document.querySelectorAll(".layers-table").forEach(ye)}document.addEventListener("DOMContentLoaded",()=>{let e=localStorage.getItem(G)==="true";document.querySelectorAll(".show-empty-layers-cb").forEach(t=>{t.checked=e}),Ee()});document.addEventListener("change",e=>{e.target.matches("[data-toggle-empty-layers]")&&Ge(e.target.checked)});document.body.addEventListener("htmx:afterSettle",e=>{e.target&&e.target.querySelectorAll&&Ee(e.target)});window.htmx=D;D.config.methodsThatUseUrlParams=["get"]; 76 + `,a.querySelector("[data-dismiss-modal]").addEventListener("click",()=>a.remove())}}}}document.addEventListener("DOMContentLoaded",()=>{Ge(),Qe()});var ee="showEmptyLayers";function Ze(e,t,n){let r=0;for(let o=t;o<t+n;o++){let s=e[o].querySelector("td[data-bytes]");if(!s)continue;let i=Number(s.dataset.bytes);Number.isFinite(i)&&(r+=i)}return r}function et(e){return e<1024?e+" B":e<1048576?(e/1024).toFixed(1)+" KB":e<1073741824?(e/1048576).toFixed(1)+" MB":(e/1073741824).toFixed(1)+" GB"}function tt(e){let t=e.querySelector("tbody");if(!t)return;let n=Array.from(t.querySelectorAll("tr")),r=0;for(;r<n.length;){if(n[r].dataset.noCommand!=="true"){r++;continue}let o=r;for(;r<n.length&&n[r].dataset.noCommand==="true";)n[r].classList.add("no-history-row","hidden"),r++;let s=r-o;if(s<=1){n[o].classList.remove("hidden");continue}let i=n[o].querySelector("td").textContent.trim(),a=n[r-1].querySelector("td").textContent.trim(),l=et(Ze(n,o,s)),c=document.createElement("tr");c.className="no-history-summary cursor-pointer hover:bg-base-300",c.innerHTML='<td colspan="2" class="text-sm py-2">Layers '+i+"-"+a+' contain no history <span class="text-xs ml-2">('+s+' layers, click to expand)</span></td><td class="text-right text-sm whitespace-nowrap">'+l+"</td>",c.addEventListener("click",()=>{c.remove();for(let d=o;d<o+s;d++)n[d].classList.remove("hidden")}),t.insertBefore(c,n[o])}}function xe(e){let t=localStorage.getItem(ee)==="true";e.querySelectorAll('tr[data-empty="true"]').forEach(n=>{n.style.display=t?"":"none"})}function Te(e){let t=e||document;(t.querySelectorAll?t.querySelectorAll(".layers-table:not([data-layers-processed])"):[]).forEach(r=>{r.setAttribute("data-layers-processed","1"),tt(r),xe(r)})}function nt(e){localStorage.setItem(ee,e),document.querySelectorAll(".show-empty-layers-cb").forEach(t=>{t.checked=e}),document.querySelectorAll(".layers-table").forEach(xe)}document.addEventListener("DOMContentLoaded",()=>{let e=localStorage.getItem(ee)==="true";document.querySelectorAll(".show-empty-layers-cb").forEach(t=>{t.checked=e}),Te()});document.addEventListener("change",e=>{e.target.matches("[data-toggle-empty-layers]")&&nt(e.target.checked)});document.body.addEventListener("htmx:afterSettle",e=>{e.target&&e.target.querySelectorAll&&Te(e.target)});window.htmx=O;O.config.methodsThatUseUrlParams=["get"];
+5
pkg/appview/public/sitemap-static.xml
··· 2 2 <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> 3 3 <url> 4 4 <loc>https://atcr.io/</loc> 5 + <lastmod>2026-04-21</lastmod> 5 6 <changefreq>daily</changefreq> 6 7 <priority>1.0</priority> 7 8 </url> 8 9 <url> 9 10 <loc>https://atcr.io/search</loc> 11 + <lastmod>2026-04-21</lastmod> 10 12 <changefreq>daily</changefreq> 11 13 <priority>0.8</priority> 12 14 </url> 13 15 <url> 14 16 <loc>https://atcr.io/install</loc> 17 + <lastmod>2026-04-21</lastmod> 15 18 <changefreq>monthly</changefreq> 16 19 <priority>0.9</priority> 17 20 </url> 18 21 <url> 19 22 <loc>https://atcr.io/privacy</loc> 23 + <lastmod>2026-04-21</lastmod> 20 24 <changefreq>yearly</changefreq> 21 25 <priority>0.3</priority> 22 26 </url> 23 27 <url> 24 28 <loc>https://atcr.io/terms</loc> 29 + <lastmod>2026-04-21</lastmod> 25 30 <changefreq>yearly</changefreq> 26 31 <priority>0.3</priority> 27 32 </url>
+104
pkg/appview/readme/fetcher_test.go
··· 296 296 } 297 297 } 298 298 299 + // TestRenderMarkdown_XSSRegression verifies that XSS payloads cannot survive 300 + // the goldmark→bluemonday pipeline. Goldmark (without WithUnsafe) replaces raw 301 + // HTML with "<!-- raw HTML omitted -->"; bluemonday then strips that comment and 302 + // any event-handler attributes or dangerous protocols. 303 + func TestRenderMarkdown_XSSRegression(t *testing.T) { 304 + fetcher := NewFetcher() 305 + 306 + tests := []struct { 307 + name string 308 + input string 309 + wantAbsent []string // must NOT appear in output 310 + wantPresent []string // MUST appear in output (safe rendered form) 311 + }{ 312 + { 313 + name: "inline script tag", 314 + input: "<script>alert('xss')</script>", 315 + wantAbsent: []string{"<script>", "alert(", "</script>"}, 316 + }, 317 + { 318 + name: "script tag in fenced code block is escaped, not executed", 319 + input: "```\n<script>alert('xss')</script>\n```", 320 + // goldmark HTML-escapes content inside code blocks 321 + wantAbsent: []string{"<script>alert("}, 322 + wantPresent: []string{"&lt;script&gt;"}, 323 + }, 324 + { 325 + name: "javascript: protocol in markdown link", 326 + input: "[click me](javascript:alert('xss'))", 327 + wantAbsent: []string{"javascript:"}, 328 + }, 329 + { 330 + name: "javascript: protocol in inline HTML anchor", 331 + input: `<a href="javascript:alert('xss')">click</a>`, 332 + wantAbsent: []string{"javascript:"}, 333 + }, 334 + { 335 + name: "img onerror via inline HTML", 336 + input: `<img src="x" onerror="alert('xss')">`, 337 + wantAbsent: []string{"onerror", "alert("}, 338 + }, 339 + { 340 + name: "img with injected attribute via markdown image syntax", 341 + input: `![alt](x" onerror="alert('xss'))`, 342 + // Malformed URL — goldmark rejects the image and renders it as literal escaped text. 343 + // The actual XSS vector (an <img> with an onerror attribute) cannot form. 344 + wantAbsent: []string{"<img"}, 345 + }, 346 + { 347 + name: "svg onload", 348 + input: `<svg onload="alert('xss')"><circle r="10"/></svg>`, 349 + wantAbsent: []string{"onload", "alert("}, 350 + }, 351 + { 352 + name: "iframe element", 353 + input: `<iframe src="https://evil.com"></iframe>`, 354 + wantAbsent: []string{"<iframe"}, 355 + }, 356 + { 357 + name: "style tag", 358 + input: `<style>body { background: red; }</style>`, 359 + wantAbsent: []string{"<style>"}, 360 + }, 361 + { 362 + name: "meta refresh redirect", 363 + input: `<meta http-equiv="refresh" content="0;url=https://evil.com">`, 364 + wantAbsent: []string{"<meta"}, 365 + }, 366 + { 367 + name: "data URI in img src", 368 + input: `<img src="data:text/html,<script>alert(1)</script>">`, 369 + wantAbsent: []string{"data:text/html", "alert("}, 370 + }, 371 + { 372 + name: "form action exfiltration", 373 + input: `<form action="https://evil.com"><button>Submit</button></form>`, 374 + wantAbsent: []string{"<form", "action="}, 375 + }, 376 + { 377 + name: "onclick on arbitrary element", 378 + input: `<p onclick="alert('xss')">click me</p>`, 379 + wantAbsent: []string{"onclick", "alert("}, 380 + }, 381 + } 382 + 383 + for _, tt := range tests { 384 + t.Run(tt.name, func(t *testing.T) { 385 + result, err := fetcher.RenderMarkdown([]byte(tt.input)) 386 + if err != nil { 387 + t.Fatalf("RenderMarkdown() unexpected error: %v", err) 388 + } 389 + for _, bad := range tt.wantAbsent { 390 + if strings.Contains(result, bad) { 391 + t.Errorf("output contains dangerous string %q\nfull output: %s", bad, result) 392 + } 393 + } 394 + for _, good := range tt.wantPresent { 395 + if !strings.Contains(result, good) { 396 + t.Errorf("output missing expected string %q\nfull output: %s", good, result) 397 + } 398 + } 399 + }) 400 + } 401 + } 402 + 299 403 func containsSubstring(s, substr string) bool { 300 404 return len(substr) == 0 || (len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstringHelper(s, substr))) 301 405 }
+10 -1
pkg/appview/routes/routes.go
··· 45 45 BillingManager *billing.Manager // Stripe billing manager (nil if not configured) 46 46 WebhookDispatcher *webhooks.Dispatcher // Webhook dispatcher (nil if not configured) 47 47 ClaudeAPIKey string // Anthropic API key for AI advisor (empty = disabled) 48 + SourceURL string // Source code URL for the footer "Source" link 48 49 } 49 50 50 51 // RegisterUIRoutes registers all web UI and API routes on the provided router ··· 80 81 ClientName: deps.ClientName, 81 82 ClientShortName: deps.ClientShortName, 82 83 AIAdvisorEnabled: deps.ClaudeAPIKey != "", 84 + SourceURL: deps.SourceURL, 83 85 } 84 86 85 87 // OAuth login routes (public) ··· 178 180 router.Group(func(r chi.Router) { 179 181 r.Use(middleware.RequireAuth(deps.SessionStore, deps.Database)) 180 182 181 - r.Get("/settings", (&uihandlers.SettingsHandler{BaseUIHandler: base}).ServeHTTP) 183 + settings := &uihandlers.SettingsHandler{BaseUIHandler: base} 184 + r.Get("/settings", settings.ServeHTTP) 185 + r.Get("/settings/user", settings.ServeTab("user")) 186 + r.Get("/settings/storage", settings.ServeTab("storage")) 187 + r.Get("/settings/billing", settings.ServeTab("billing")) 188 + r.Get("/settings/devices", settings.ServeTab("devices")) 189 + r.Get("/settings/webhooks", settings.ServeTab("webhooks")) 190 + r.Get("/settings/advanced", settings.ServeTab("advanced")) 182 191 r.Get("/api/storage", (&uihandlers.StorageHandler{BaseUIHandler: base}).ServeHTTP) 183 192 r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{BaseUIHandler: base}).ServeHTTP) 184 193 r.Post("/api/profile/auto-remove-untagged", (&uihandlers.UpdateAutoRemoveUntaggedHandler{BaseUIHandler: base}).ServeHTTP)
+1
pkg/appview/server.go
··· 334 334 BillingManager: s.BillingManager, 335 335 WebhookDispatcher: s.WebhookDispatcher, 336 336 ClaudeAPIKey: cfg.AI.APIKey, 337 + SourceURL: cfg.UI.SourceURL, 337 338 LegalConfig: routes.LegalConfig{ 338 339 CompanyName: cfg.Legal.CompanyName, 339 340 Jurisdiction: cfg.Legal.Jurisdiction,
+42 -9
pkg/appview/src/css/main.css
··· 175 175 even if values currently coincide. Used by .text-star/.fill-star/etc. */ 176 176 --color-star: oklch(82% 0.189 84.429); 177 177 178 + /* Helm brand color (official Helm blue #0F1689). Two variants so the 179 + light-mode value stays legible on a near-white surface and the 180 + dark-mode value stays legible on Deep Ocean. */ 181 + --color-helm-light: oklch(31% 0.181 267.5); 182 + --color-helm-dark: oklch(64.6% 0.19 273.2); 183 + 178 184 /* Vulnerability severity scale. Held constant across themes on purpose: 179 185 CVE severity is a product-semantic signal that needs to read the same 180 186 way regardless of surface. Content-pair colors come from the same hue ··· 391 397 TOUCH TARGET SIZING 392 398 Small buttons and compact form controls meet the keyboard minimum on 393 399 desktop but fall below the 44×44 recommended touch target on touch 394 - devices (WCAG 2.5.5). Grow them only on coarse-pointer devices so 395 - pointer-primary layouts stay dense. 400 + devices (WCAG 2.5.5). Grow them on any device that can't reliably 401 + produce hover — covers pure touch as well as hybrid touchscreen 402 + laptops where `pointer: coarse` alone misses. 396 403 ======================================== */ 397 - @media (pointer: coarse) { 404 + @media (pointer: coarse), (hover: none) { 398 405 /* Icon-only buttons grow both axes — daisyUI's circle/square variants 399 406 are the marker for these. */ 400 407 :is(.btn-circle, .btn-square):is(.btn-xs, .btn-sm) { ··· 508 515 509 516 /* `min-w-0` + `flex-1` let the code shrink below its intrinsic width so 510 517 `truncate` can actually produce an ellipsis inside a flex container. 511 - Without them, long commands overflow silently. */ 518 + Without them, long commands overflow silently. `pr-10` reserves room 519 + for the absolutely-positioned copy button so the ellipsis doesn't 520 + sit under it. */ 512 521 .cmd code { 513 - @apply font-mono text-sm truncate min-w-0 flex-1; 522 + @apply font-mono text-sm truncate min-w-0 flex-1 pr-10; 523 + } 524 + 525 + /* Copy button visibility: 526 + - Touch / coarse-pointer devices (tap can't produce :hover and 527 + rarely produces :focus): always visible at sm+ widths so users 528 + can find the control. 529 + - Hover-capable devices (desktop): hidden until the .cmd group is 530 + hovered or the button itself focused, keeping the command line 531 + visually tidy while power users still get the affordance. 532 + Mobile (<sm) always shows the button regardless. */ 533 + .cmd .cmd-copy { 534 + @apply opacity-100; 535 + } 536 + @media (hover: hover) and (pointer: fine) { 537 + .cmd .cmd-copy { 538 + @apply sm:opacity-0 transition-opacity; 539 + } 540 + .cmd:hover .cmd-copy, 541 + .cmd .cmd-copy:focus, 542 + .cmd .cmd-copy:focus-visible { 543 + @apply opacity-100; 544 + } 514 545 } 515 546 516 547 /* ---------------------------------------- ··· 536 567 537 568 /* ---------------------------------------- 538 569 HELM BRAND COLOR (official Helm blue #0F1689) 570 + Tokens live on :root (--color-helm-{light,dark}) so the value is 571 + declared once and any future brand shift updates every consumer. 539 572 ---------------------------------------- */ 540 573 .text-helm { 541 - @apply text-[oklch(31%_0.181_267.5)]; 574 + color: var(--color-helm-light); 542 575 } 543 576 544 577 [data-theme="dark"] .text-helm { 545 - @apply text-[oklch(64.6%_0.19_273.2)]; 578 + color: var(--color-helm-dark); 546 579 } 547 580 548 581 .badge-helm { 549 - --badge-color: oklch(31% 0.181 267.5); 582 + --badge-color: var(--color-helm-light); 550 583 } 551 584 552 585 [data-theme="dark"] .badge-helm { 553 - --badge-color: oklch(64.6% 0.19 273.2); 586 + --badge-color: var(--color-helm-dark); 554 587 } 555 588 556 589 /* ----------------------------------------
+182 -30
pkg/appview/src/js/app.js
··· 1 + // Safe localStorage wrappers. Safari private mode, disabled storage, and 2 + // quota-exceeded all throw from getItem/setItem — absorb those failures so 3 + // individual features degrade silently instead of crashing the page. 4 + function lsGet(key) { 5 + try { return localStorage.getItem(key); } catch (_) { return null; } 6 + } 7 + function lsSet(key, value) { 8 + try { localStorage.setItem(key, value); } catch (_) { /* quota/disabled */ } 9 + } 10 + 1 11 // Theme management (system / light / dark) 2 12 function getThemePreference() { 3 - return localStorage.getItem('theme') || 'system'; 13 + return lsGet('theme') || 'system'; 4 14 } 5 15 6 16 function getEffectiveTheme(pref) { ··· 20 30 } 21 31 22 32 function setTheme(theme) { 23 - localStorage.setItem('theme', theme); 33 + lsSet('theme', theme); 24 34 applyTheme(); 25 35 closeThemeDropdown(); 26 36 } ··· 51 61 }); 52 62 } 53 63 64 + // Sync aria-expanded on theme-toggle summaries with the native <details> 65 + // open state. Without this, SR announcements lag behind actual disclosure. 66 + document.addEventListener('DOMContentLoaded', () => { 67 + document.querySelectorAll('[data-theme-toggle]').forEach(btn => { 68 + const details = btn.closest('details'); 69 + if (!details) return; 70 + const sync = () => btn.setAttribute('aria-expanded', details.open ? 'true' : 'false'); 71 + sync(); 72 + details.addEventListener('toggle', sync); 73 + }); 74 + }); 75 + 54 76 // Listen for system theme changes 55 77 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { 56 78 if (getThemePreference() === 'system') { ··· 88 110 } 89 111 90 112 function closeSearch() { 91 - setSearchExpanded(document.querySelector('.nav-search-wrapper'), false); 113 + const wrapper = document.querySelector('.nav-search-wrapper'); 114 + setSearchExpanded(wrapper, false); 115 + // Return focus to the toggle button so keyboard users don't get dropped 116 + // back at the top of the page when the search form collapses. 117 + if (wrapper) { 118 + const toggle = wrapper.querySelector('[aria-controls="nav-search-form"]'); 119 + if (toggle) toggle.focus(); 120 + } 92 121 } 93 122 94 123 // Close search on Escape key and click outside ··· 127 156 // dispatcher or direct callers — no implicit global `event` fallback. 128 157 function copyToClipboard(text, btn) { 129 158 const onSuccess = () => { 130 - if (!btn) return; 159 + if (!btn || !document.contains(btn)) return; 131 160 const originalHTML = btn.innerHTML; 132 161 btn.innerHTML = '<svg class="icon size-4" aria-hidden="true"><use href="/icons.svg#check"></use></svg> Copied!'; 133 - setTimeout(() => { btn.innerHTML = originalHTML; }, 2000); 162 + setTimeout(() => { 163 + if (document.contains(btn)) btn.innerHTML = originalHTML; 164 + }, 2000); 134 165 }; 135 166 136 167 if (navigator.clipboard && window.isSecureContext) { ··· 184 215 return !!ok; 185 216 } 186 217 187 - // Serialize a <table> (thead + tbody) as CSV (RFC 4180 quoting) 218 + // Serialize a <table> (thead + tbody) as CSV (RFC 4180 quoting). 219 + // Preserves embedded newlines — RFC 4180 allows them inside quoted fields, 220 + // and Excel/Sheets decode them back into line breaks. Collapsing them to 221 + // spaces would silently lose structure in multi-line SBOM/vuln cells. 188 222 function tableToCSV(table) { 189 223 const escape = (s) => { 190 - const v = (s == null ? '' : String(s)).replace(/\s+/g, ' ').trim(); 224 + const v = (s == null ? '' : String(s)).trim(); 191 225 return /[",\n\r]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; 192 226 }; 193 227 const rowToCsv = (cells) => Array.from(cells).map((c) => escape(c.textContent)).join(','); ··· 559 593 if (isLoggedIn && window.htmx) { 560 594 window.htmx.ajax('POST', '/api/profile/oci-client', { values: { oci_client: client }, swap: 'none' }); 561 595 } else if (!isLoggedIn) { 562 - localStorage.setItem('oci-client', client); 596 + lsSet('oci-client', client); 563 597 } 564 598 } 565 599 566 600 // Restore preference for anonymous users. 567 601 if (!isLoggedIn) { 568 - const saved = localStorage.getItem('oci-client'); 602 + const saved = lsGet('oci-client'); 569 603 if (saved) { 570 604 const sel = document.getElementById('oci-client-switcher'); 571 605 if (sel) { ··· 591 625 const active = t === tab; 592 626 t.classList.toggle('btn-primary', active); 593 627 t.classList.toggle('btn-ghost', !active); 628 + t.setAttribute('aria-selected', active ? 'true' : 'false'); 629 + t.setAttribute('tabindex', active ? '0' : '-1'); 594 630 }); 595 - document.querySelectorAll('.platform-content').forEach(p => p.classList.add('hidden')); 631 + document.querySelectorAll('.platform-content').forEach(p => { 632 + p.classList.add('hidden'); 633 + p.setAttribute('hidden', ''); 634 + }); 596 635 const panel = document.getElementById(tab.dataset.platform + '-content'); 597 - if (panel) panel.classList.remove('hidden'); 636 + if (panel) { 637 + panel.classList.remove('hidden'); 638 + panel.removeAttribute('hidden'); 639 + tab.focus(); 640 + } 641 + }); 642 + // Arrow-key navigation across tabs within the tablist. 643 + tab.addEventListener('keydown', (e) => { 644 + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; 645 + e.preventDefault(); 646 + const tabArr = Array.from(tabs); 647 + const i = tabArr.indexOf(tab); 648 + const next = e.key === 'ArrowRight' 649 + ? tabArr[(i + 1) % tabArr.length] 650 + : tabArr[(i - 1 + tabArr.length) % tabArr.length]; 651 + next.click(); 598 652 }); 599 653 }); 600 654 }); ··· 619 673 if (!cookie) return; 620 674 621 675 const handle = decodeURIComponent(cookie.split('=')[1]); 622 - if (handle) { 676 + if (handle && typeof handle === 'string' && handle.length > 0) { 623 677 // Save to recent accounts 624 678 try { 625 679 const key = 'atcr_recent_handles'; 626 - let recent = JSON.parse(localStorage.getItem(key) || '[]'); 680 + const raw = lsGet(key); 681 + let recent = []; 682 + try { recent = JSON.parse(raw || '[]'); } catch (_) { recent = []; } 683 + if (!Array.isArray(recent)) recent = []; 627 684 recent = recent.filter(h => h !== handle); 628 685 recent.unshift(handle); 629 686 recent = recent.slice(0, 5); 630 - localStorage.setItem(key, JSON.stringify(recent)); 687 + lsSet(key, JSON.stringify(recent)); 631 688 } catch (err) { 632 689 console.error('Failed to save recent account:', err); 633 690 } ··· 648 705 if (!carousel) return; 649 706 650 707 const items = carousel.querySelectorAll('.carousel-item'); 651 - if (items.length === 0) return; 708 + if (items.length === 0 || !items[0]) return; 652 709 653 710 let intervalId = null; 654 711 const intervalMs = 5000; 655 712 713 + // Respect prefers-reduced-motion — users who opt out of animation 714 + // shouldn't have a carousel auto-advancing every 5 seconds, and the 715 + // smooth-scroll itself is distracting to them. Use instant scroll 716 + // for manual nav and skip auto-advance entirely. 717 + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); 718 + const scrollBehavior = () => reduceMotion.matches ? 'auto' : 'smooth'; 719 + 656 720 // Cache the per-step scroll distance; offsetWidth forces layout, so 657 721 // measuring once per resize beats once per autoplay tick. rAF-coalesces 658 722 // bursty resize events. 659 723 let stepPx = 0; 660 724 let resizeRaf = 0; 661 725 function measureStep() { 726 + if (!items[0]) return; 662 727 const gap = parseFloat(getComputedStyle(carousel).gap) || 24; 663 728 stepPx = items[0].offsetWidth + gap; 664 729 } ··· 674 739 function advance() { 675 740 const max = carousel.scrollWidth - carousel.clientWidth; 676 741 if (carousel.scrollLeft >= max - 10) { 677 - carousel.scrollTo({ left: 0, behavior: 'smooth' }); 742 + carousel.scrollTo({ left: 0, behavior: scrollBehavior() }); 678 743 } else { 679 - carousel.scrollBy({ left: stepPx, behavior: 'smooth' }); 744 + carousel.scrollBy({ left: stepPx, behavior: scrollBehavior() }); 680 745 } 681 746 } 682 747 683 748 function retreat() { 684 749 if (carousel.scrollLeft <= 10) { 685 - carousel.scrollTo({ left: carousel.scrollWidth, behavior: 'smooth' }); 750 + carousel.scrollTo({ left: carousel.scrollWidth, behavior: scrollBehavior() }); 686 751 } else { 687 - carousel.scrollBy({ left: -stepPx, behavior: 'smooth' }); 752 + carousel.scrollBy({ left: -stepPx, behavior: scrollBehavior() }); 688 753 } 689 754 } 690 755 ··· 692 757 if (intervalId) return; 693 758 if (document.visibilityState === 'hidden') return; 694 759 if (carousel.scrollWidth <= carousel.clientWidth + 10) return; 760 + if (reduceMotion.matches) return; 695 761 intervalId = setInterval(advance, intervalMs); 696 762 } 697 763 ··· 702 768 if (prevBtn) prevBtn.addEventListener('click', () => { stopInterval(); retreat(); startInterval(); }); 703 769 if (nextBtn) nextBtn.addEventListener('click', () => { stopInterval(); advance(); startInterval(); }); 704 770 771 + // User-controlled pause button for WCAG 2.2.2 compliance — auto-advancing 772 + // content must be pausable without relying on hover, which touch users 773 + // can't produce. 774 + const pauseBtn = document.getElementById('carousel-pause'); 775 + let userPaused = false; 776 + if (pauseBtn) { 777 + const pauseIcon = pauseBtn.querySelector('.carousel-pause-icon'); 778 + const playIcon = pauseBtn.querySelector('.carousel-play-icon'); 779 + // Seed aria-label/aria-pressed on load so SRs announce the correct 780 + // state before any click; without this the button reads with no 781 + // label until the user interacts. 782 + pauseBtn.setAttribute('aria-pressed', 'false'); 783 + pauseBtn.setAttribute('aria-label', 'Pause carousel auto-advance'); 784 + pauseBtn.addEventListener('click', () => { 785 + userPaused = !userPaused; 786 + if (userPaused) { 787 + stopInterval(); 788 + pauseBtn.setAttribute('aria-pressed', 'true'); 789 + pauseBtn.setAttribute('aria-label', 'Resume carousel auto-advance'); 790 + if (pauseIcon) pauseIcon.classList.add('hidden'); 791 + if (playIcon) playIcon.classList.remove('hidden'); 792 + } else { 793 + pauseBtn.setAttribute('aria-pressed', 'false'); 794 + pauseBtn.setAttribute('aria-label', 'Pause carousel auto-advance'); 795 + if (pauseIcon) pauseIcon.classList.remove('hidden'); 796 + if (playIcon) playIcon.classList.add('hidden'); 797 + startInterval(); 798 + } 799 + }); 800 + } 801 + 802 + // Gate mouse-based pause and visibility resume on the user's explicit 803 + // pause state so hovering doesn't un-pause against their wish. 705 804 carousel.addEventListener('mouseenter', stopInterval); 706 - carousel.addEventListener('mouseleave', startInterval); 805 + carousel.addEventListener('mouseleave', () => { if (!userPaused) startInterval(); }); 707 806 708 807 // Pause autoplay while the tab is hidden — a scroll-snap animation on an 709 808 // invisible carousel still eats compositor time on the other tab. 710 809 document.addEventListener('visibilitychange', () => { 711 810 if (document.visibilityState === 'hidden') stopInterval(); 712 - else startInterval(); 811 + else if (!userPaused) startInterval(); 713 812 }); 714 813 715 814 startInterval(); ··· 724 823 } 725 824 }); 726 825 826 + // htmx error handling — fires toast on failed requests across the app. 827 + // Servers can also emit HX-Trigger: {"toast":{"message":"...","type":"error"}} 828 + // which htmx turns into a 'toast' CustomEvent handled below — this listener 829 + // is the fallback for handlers that didn't set the header. 830 + // Opt-out: any ancestor with data-suppress-htmx-toast skips the toast (use 831 + // for components that render their own inline error state). 832 + document.body.addEventListener('htmx:responseError', (evt) => { 833 + const elt = evt.detail && evt.detail.elt; 834 + if (elt && elt.closest && elt.closest('[data-suppress-htmx-toast]')) return; 835 + const xhr = evt.detail && evt.detail.xhr; 836 + // If server already triggered a toast via HX-Trigger, don't double up. 837 + const trigger = xhr && xhr.getResponseHeader && xhr.getResponseHeader('HX-Trigger'); 838 + if (trigger && trigger.indexOf('toast') !== -1) return; 839 + const status = xhr ? xhr.status : 0; 840 + const msg = status === 401 ? 'Session expired \u2014 please sign in again' 841 + : status === 403 ? 'Not authorized' 842 + : status === 404 ? 'Not found' 843 + : status === 429 ? 'Too many requests \u2014 please slow down' 844 + : status >= 500 ? 'Server error \u2014 please try again' 845 + : 'Something went wrong'; 846 + showToast(msg, 'error'); 847 + }); 848 + 849 + document.body.addEventListener('htmx:sendError', (evt) => { 850 + const elt = evt.detail && evt.detail.elt; 851 + if (elt && elt.closest && elt.closest('[data-suppress-htmx-toast]')) return; 852 + showToast('Network error \u2014 check your connection', 'error'); 853 + }); 854 + 855 + // Server-triggered toast via HX-Trigger JSON header. 856 + // Accepts both { "toast": { "message": "...", "type": "success" } } (a custom 857 + // 'toast' event named in the header) and CustomEvent fired through the same 858 + // body element. Success/error/info/warning types map to showToast's internal 859 + // types (info and warning fall through to success styling until showToast 860 + // gains more variants). 861 + document.body.addEventListener('toast', (evt) => { 862 + const d = (evt && evt.detail) || {}; 863 + const message = d.message || d.msg || ''; 864 + if (!message) return; 865 + const type = d.type || 'info'; 866 + showToast(message, type); 867 + }); 868 + 727 869 // Toast notifications (auto-dismiss after 3s). 728 870 // - Uses textContent, never innerHTML — error text sometimes relays server 729 871 // response bodies that could contain markup. ··· 734 876 const TOAST_MAX = 4; 735 877 const TOAST_DEDUPE_MS = 1500; 736 878 737 - function showToast(message, type) { 879 + // Pre-create the toast container so the aria-live region exists before the 880 + // first announcement. If the very first toast fires before DOMContentLoaded 881 + // (e.g. an htmx:responseError during initial boot), we still construct the 882 + // container lazily in showToast() — but under normal flow the pre-created 883 + // one is used. 884 + function ensureToastContainer() { 738 885 let container = document.getElementById('toast-container'); 739 - if (!container) { 740 - container = document.createElement('div'); 741 - container.id = 'toast-container'; 742 - container.className = 'toast toast-end toast-bottom z-50'; 743 - container.setAttribute('aria-live', 'polite'); 744 - container.setAttribute('aria-atomic', 'false'); 745 - document.body.appendChild(container); 746 - } 886 + if (container) return container; 887 + container = document.createElement('div'); 888 + container.id = 'toast-container'; 889 + container.className = 'toast toast-end toast-bottom z-50'; 890 + container.setAttribute('aria-live', 'polite'); 891 + container.setAttribute('aria-atomic', 'false'); 892 + if (document.body) document.body.appendChild(container); 893 + return container; 894 + } 895 + document.addEventListener('DOMContentLoaded', ensureToastContainer); 896 + 897 + function showToast(message, type) { 898 + const container = ensureToastContainer(); 747 899 748 900 // Dedupe: if an identical toast is already on screen and was added 749 901 // within the dedupe window, reset its dismiss timer instead of adding
+29 -9
pkg/appview/src/js/repository.js
··· 190 190 }); 191 191 }; 192 192 193 + // Cancel any pending filter rAF before htmx swaps the tag list; a stale 194 + // frame would walk a detached DOM and dirty layout for nothing. 195 + document.body.addEventListener('htmx:beforeSwap', () => { 196 + if (filterTagsHandle) { cancelAnimationFrame(filterTagsHandle); filterTagsHandle = 0; } 197 + }); 198 + 193 199 // ---------------------------------------- 194 200 // Tag-scoped tab controller (reads config from #tag-content data attributes) 195 201 // ---------------------------------------- ··· 197 203 if (!document.getElementById('tag-content')) return; 198 204 199 205 const validTabs = ['overview', 'layers', 'vulns', 'sbom', 'artifacts']; 206 + // State per target id: 'loading' while a request is in-flight, 'loaded' 207 + // on success. On error we clear the entry so the retry button can 208 + // trigger a fresh fetch; without a separate 'loading' marker, a failing 209 + // request would leave loaded[id]=true and block all retries. 200 210 let loaded = {}; 201 211 202 212 function lazyLoad(id, url) { 203 - if (loaded[id]) return; 204 - loaded[id] = true; 213 + if (loaded[id] === 'loading' || loaded[id] === 'loaded') return; 214 + loaded[id] = 'loading'; 205 215 const target = document.getElementById(id); 206 - if (!target) return; 216 + if (!target) { delete loaded[id]; return; } 207 217 208 218 // Abort if the request hangs. SBOM/vuln endpoints can stall when a 209 219 // hold is overloaded; without a timeout the spinner spins forever. ··· 216 226 return r.text(); 217 227 }) 218 228 .then(html => { 229 + loaded[id] = 'loaded'; 230 + if (!document.contains(target)) return; // swapped out while fetching 219 231 target.innerHTML = html; 220 232 // innerHTML doesn't execute <script> tags — re-create them 221 233 target.querySelectorAll('script').forEach(old => { ··· 226 238 if (typeof window.htmx !== 'undefined') window.htmx.process(target); 227 239 }) 228 240 .catch(err => { 229 - loaded[id] = false; 241 + // Clear state immediately so the retry button (or another 242 + // tab switch) can fire a fresh request. 243 + delete loaded[id]; 244 + if (!document.contains(target)) return; 230 245 const timedOut = err && err.name === 'AbortError'; 231 246 const msg = timedOut 232 247 ? 'This section took too long to load.' ··· 254 269 255 270 function contentUrl(section) { 256 271 const el = document.getElementById('tag-content'); 257 - if (!el) return null; 272 + if (!el || !el.dataset) return null; 258 273 const digest = el.dataset.digest; 259 - if (!digest) return null; 260 - return '/api/digest-content/' + el.dataset.owner + '/' + el.dataset.repo + 274 + const owner = el.dataset.owner; 275 + const repo = el.dataset.repo; 276 + if (!digest || !owner || !repo) return null; 277 + return '/api/digest-content/' + owner + '/' + repo + 261 278 '?digest=' + encodeURIComponent(digest) + '&section=' + section; 262 279 } 263 280 264 281 function tagsUrl() { 265 282 const el = document.getElementById('tag-content'); 266 - if (!el) return null; 267 - return '/api/repo-tags/' + el.dataset.owner + '/' + el.dataset.repo; 283 + if (!el || !el.dataset) return null; 284 + const owner = el.dataset.owner; 285 + const repo = el.dataset.repo; 286 + if (!owner || !repo) return null; 287 + return '/api/repo-tags/' + owner + '/' + repo; 268 288 } 269 289 270 290 window.diffToTag = function(e, link) {
+30 -6
pkg/appview/src/js/sailor-typeahead.js
··· 183 183 const row = document.createElement('div'); 184 184 row.className = 'sailor-typeahead-item'; 185 185 row.setAttribute('role', 'option'); 186 + row.setAttribute('aria-selected', 'false'); 186 187 row.dataset.index = String(index); 187 188 row.dataset.handle = actor.handle; 188 189 ··· 344 345 const clearBtn = document.createElement('button'); 345 346 clearBtn.type = 'button'; 346 347 clearBtn.className = 'sailor-typeahead-clear'; 347 - clearBtn.tabIndex = -1; // keep out of tab order; Navigate button should come next 348 348 clearBtn.setAttribute('aria-label', 'Change account'); 349 349 clearBtn.innerHTML = '&times;'; 350 350 clearBtn.addEventListener('click', () => this.clearSelection()); ··· 401 401 402 402 updateFocus(items) { 403 403 items.forEach((item, i) => { 404 - item.classList.toggle('focused', i === this.focusIndex); 405 - if (i === this.focusIndex) { 404 + const focused = i === this.focusIndex; 405 + item.classList.toggle('focused', focused); 406 + item.setAttribute('aria-selected', focused ? 'true' : 'false'); 407 + if (focused) { 406 408 item.scrollIntoView({ block: 'nearest' }); 407 409 } 408 410 }); 411 + } 412 + 413 + // Clear any pending debounce when the typeahead is torn down (htmx swap 414 + // that removes the input from the DOM). Without this the timer callback 415 + // would still fire and keep the class instance alive on a detached node. 416 + destroy() { 417 + if (this.debounceTimer) { 418 + clearTimeout(this.debounceTimer); 419 + this.debounceTimer = null; 420 + } 409 421 } 410 422 } 411 423 ··· 480 492 } 481 493 } 482 494 483 - document.addEventListener('DOMContentLoaded', () => { 495 + let currentTypeahead = null; 496 + function attachTypeahead() { 484 497 const input = document.getElementById('handle'); 485 - if (input) { 486 - new SailorTypeahead(input); 498 + if (!input) return; 499 + if (currentTypeahead && currentTypeahead.input === input) return; // already attached to this node 500 + if (currentTypeahead) currentTypeahead.destroy(); 501 + currentTypeahead = new SailorTypeahead(input); 502 + } 503 + document.addEventListener('DOMContentLoaded', attachTypeahead); 504 + // Re-attach after htmx swaps — if #handle was inside a swapped region, the 505 + // old instance's debounce timer still references the detached node. 506 + document.body.addEventListener('htmx:afterSettle', attachTypeahead); 507 + document.body.addEventListener('htmx:beforeSwap', () => { 508 + if (currentTypeahead && !document.contains(currentTypeahead.input)) { 509 + currentTypeahead.destroy(); 510 + currentTypeahead = null; 487 511 } 488 512 });
+62 -91
pkg/appview/src/js/settings.js
··· 1 - // Settings page: tab controller + account deletion modal. 2 - // Both initializers are no-ops when their targets aren't on the page. 1 + // Settings page: tablist arrow-key nav + mobile active-tab scroll + account deletion modal. 2 + // Tab switching itself is handled server-side via per-tab URLs; htmx swaps the 3 + // panel without a full page reload. JS here only adds keyboard ergonomics and 4 + // ensures the active tab is visible in the mobile scroll strip on load. 3 5 4 - // ---------------------------------------- 5 - // Tab controller 6 - // Mobile horizontal tablist (.settings-tab-mobile) and desktop vertical 7 - // sidebar menu (.menu li[data-tab]) stay in sync via a single switch fn. 8 - // Uses roving tabindex + arrow-key nav per WAI-ARIA tabs pattern. 9 - // ---------------------------------------- 10 6 function initSettingsTabs() { 11 - const validTabs = ['user', 'billing', 'storage', 'devices', 'webhooks', 'advanced']; 12 - if (!document.querySelector('.settings-tab-mobile, .menu li[data-tab]')) return; 7 + const sidebarTabs = Array.from(document.querySelectorAll('.menu li[data-tab] a[role="tab"]')); 8 + const mobileTabs = Array.from(document.querySelectorAll('.settings-tab-mobile')); 9 + if (!sidebarTabs.length && !mobileTabs.length) return; 13 10 14 - function switchSettingsTab(tabId) { 15 - document.querySelectorAll('.settings-panel').forEach(p => p.classList.add('hidden')); 16 - const panel = document.getElementById('tab-' + tabId); 17 - if (panel) panel.classList.remove('hidden'); 18 - 19 - document.querySelectorAll('.menu li[data-tab]').forEach(li => { 20 - const active = li.dataset.tab === tabId; 21 - li.classList.toggle('menu-active', active); 22 - const a = li.querySelector('a[role="tab"]'); 23 - if (a) { 24 - a.setAttribute('aria-selected', active ? 'true' : 'false'); 25 - a.setAttribute('tabindex', active ? '0' : '-1'); 26 - } 11 + function bindArrowNav(tabs, orientation) { 12 + const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft'; 13 + const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight'; 14 + tabs.forEach(tab => { 15 + tab.addEventListener('keydown', e => { 16 + const idx = tabs.indexOf(e.currentTarget); 17 + if (idx === -1) return; 18 + let target = null; 19 + if (e.key === prevKey) target = tabs[(idx - 1 + tabs.length) % tabs.length]; 20 + else if (e.key === nextKey) target = tabs[(idx + 1) % tabs.length]; 21 + else if (e.key === 'Home') target = tabs[0]; 22 + else if (e.key === 'End') target = tabs[tabs.length - 1]; 23 + if (!target) return; 24 + e.preventDefault(); 25 + target.focus(); 26 + target.click(); 27 + }); 27 28 }); 28 - 29 - document.querySelectorAll('.settings-tab-mobile').forEach(btn => { 30 - const active = btn.dataset.tab === tabId; 31 - btn.classList.toggle('btn-ghost', !active); 32 - btn.classList.toggle('btn-secondary', active); 33 - btn.setAttribute('aria-selected', active ? 'true' : 'false'); 34 - btn.setAttribute('tabindex', active ? '0' : '-1'); 35 - }); 36 - 37 - history.replaceState(null, '', '#' + tabId); 38 - document.body.dispatchEvent(new CustomEvent('tab:' + tabId)); 39 29 } 30 + bindArrowNav(sidebarTabs, 'vertical'); 31 + bindArrowNav(mobileTabs, 'horizontal'); 40 32 41 - // Exposed so HTMX hx-trigger="every 30s[isTabActive('devices')]" can poll. 42 - window.isTabActive = function(tabId) { 43 - const panel = document.getElementById('tab-' + tabId); 44 - return panel && !panel.classList.contains('hidden'); 45 - }; 46 - 47 - // Exposed so inline <a href="#billing"> onclick can still hop tabs. 48 - window.switchSettingsTab = switchSettingsTab; 49 - 50 - function handleTabKeydown(tabs, orientation) { 51 - const prevKey = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft'; 52 - const nextKey = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight'; 53 - return function(e) { 54 - const idx = tabs.indexOf(e.currentTarget); 55 - if (idx === -1) return; 56 - let target = null; 57 - if (e.key === prevKey) target = tabs[(idx - 1 + tabs.length) % tabs.length]; 58 - else if (e.key === nextKey) target = tabs[(idx + 1) % tabs.length]; 59 - else if (e.key === 'Home') target = tabs[0]; 60 - else if (e.key === 'End') target = tabs[tabs.length - 1]; 61 - if (!target) return; 62 - e.preventDefault(); 63 - switchSettingsTab(target.dataset.tab || target.parentElement.dataset.tab); 64 - target.focus(); 65 - }; 33 + function scrollActiveMobileIntoView() { 34 + const activeMobile = mobileTabs.find(t => t.getAttribute('aria-selected') === 'true'); 35 + if (activeMobile) activeMobile.scrollIntoView({ inline: 'center', block: 'nearest' }); 66 36 } 37 + scrollActiveMobileIntoView(); 67 38 68 - const mobileTabs = Array.from(document.querySelectorAll('.settings-tab-mobile')); 69 - const mobileKeydown = handleTabKeydown(mobileTabs, 'horizontal'); 70 - mobileTabs.forEach(btn => { 71 - btn.addEventListener('click', e => { 72 - e.preventDefault(); 73 - switchSettingsTab(btn.dataset.tab); 39 + // Sidebar + mobile tablist live outside #tab-content, so htmx swaps don't 40 + // touch their aria-selected / menu-active state. Sync on click. 41 + function setActiveTab(slug) { 42 + sidebarTabs.forEach(a => { 43 + const active = a.parentElement.dataset.tab === slug; 44 + a.setAttribute('aria-selected', active ? 'true' : 'false'); 45 + a.setAttribute('tabindex', active ? '0' : '-1'); 46 + a.parentElement.classList.toggle('menu-active', active); 74 47 }); 75 - btn.addEventListener('keydown', mobileKeydown); 76 - }); 77 - 78 - const sidebarTabs = Array.from(document.querySelectorAll('.menu li[data-tab] a[role="tab"]')); 79 - const sidebarKeydown = handleTabKeydown(sidebarTabs, 'vertical'); 80 - sidebarTabs.forEach(link => { 81 - link.addEventListener('click', e => { 82 - e.preventDefault(); 83 - switchSettingsTab(link.parentElement.dataset.tab); 48 + mobileTabs.forEach(btn => { 49 + const active = btn.dataset.tab === slug; 50 + btn.setAttribute('aria-selected', active ? 'true' : 'false'); 51 + btn.setAttribute('tabindex', active ? '0' : '-1'); 52 + btn.classList.toggle('btn-secondary', active); 53 + btn.classList.toggle('btn-ghost', !active); 84 54 }); 85 - link.addEventListener('keydown', sidebarKeydown); 55 + scrollActiveMobileIntoView(); 56 + } 57 + [...sidebarTabs, ...mobileTabs].forEach(link => { 58 + link.addEventListener('click', () => setActiveTab(link.dataset.tab || link.parentElement.dataset.tab)); 86 59 }); 87 60 88 - let hash = window.location.hash.replace('#', '') || 'user'; 89 - if (validTabs.indexOf(hash) === -1) hash = 'user'; 90 - switchSettingsTab(hash); 91 - 92 - window.addEventListener('hashchange', () => { 93 - let h = window.location.hash.replace('#', '') || 'user'; 94 - if (validTabs.indexOf(h) !== -1) switchSettingsTab(h); 61 + // Back/forward: htmx's history restore swaps #tab-content; sync tablist from URL. 62 + document.body.addEventListener('htmx:historyRestore', () => { 63 + const m = location.pathname.match(/^\/settings\/(user|storage|billing|devices|webhooks|advanced)/); 64 + if (m) setActiveTab(m[1]); 95 65 }); 96 66 } 97 67 ··· 101 71 // module stays pure JS with no server-side string interpolation. 102 72 // ---------------------------------------- 103 73 function initAccountDeletion() { 104 - const deleteBtn = document.getElementById('delete-account-btn'); 105 - if (!deleteBtn) return; 106 - 107 - const clientShortName = deleteBtn.dataset.clientShortName || 'this account'; 108 - const profileHandle = deleteBtn.dataset.profileHandle || ''; 109 - const expectedConfirmation = 'DELETE ' + profileHandle; 74 + // Delegated: #delete-account-btn may be swapped in via htmx (advanced tab). 75 + document.addEventListener('click', function(e) { 76 + const deleteBtn = e.target.closest('#delete-account-btn'); 77 + if (!deleteBtn) return; 78 + showDeleteConfirmationModal(deleteBtn); 79 + }); 110 80 111 81 function escapeHtml(text) { 112 82 const div = document.createElement('div'); ··· 114 84 return div.innerHTML; 115 85 } 116 86 117 - deleteBtn.addEventListener('click', showDeleteConfirmationModal); 118 - 119 - function showDeleteConfirmationModal() { 87 + function showDeleteConfirmationModal(deleteBtn) { 88 + const clientShortName = deleteBtn.dataset.clientShortName || 'this account'; 89 + const profileHandle = deleteBtn.dataset.profileHandle || ''; 90 + const expectedConfirmation = 'DELETE ' + profileHandle; 120 91 const deletePDSNow = document.getElementById('delete-pds-records').checked; 121 92 122 93 const modal = document.createElement('div');
+43 -9
pkg/appview/templates/components/card-grid.html
··· 15 15 - .HasMore: bool - whether to show Load More button 16 16 */}} 17 17 {{ if .Repositories }} 18 - <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"> 18 + <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"> 19 19 {{ range .Repositories }} 20 20 {{ template "repo-card" . }} 21 21 {{ end }} 22 22 </div> 23 - {{ if and .HasMore .LoadMoreURL }} 24 - <div class="mt-6 text-center"> 23 + {{ if and .HasMore .LoadMoreURL .TargetID }} 24 + <div id="{{ .TargetID }}-lm" class="mt-6 text-center"> 25 25 <button 26 26 class="btn btn-outline" 27 27 hx-get="{{ .LoadMoreURL }}" 28 28 hx-trigger="click" 29 - hx-target="#{{ .TargetID }}" 30 - hx-swap="beforeend" 29 + hx-target="#{{ .TargetID }}-lm" 30 + hx-swap="outerHTML" 31 + hx-indicator="#{{ .TargetID }}-lm-spinner" 31 32 > 33 + <span id="{{ .TargetID }}-lm-spinner" class="htmx-indicator loading loading-spinner loading-sm"></span> 32 34 Load More 33 35 </button> 34 36 </div> ··· 36 38 {{ else }} 37 39 <div class="py-12 text-center"> 38 40 {{ if .EmptyIcon }} 39 - <div class="text-base-content/60 mb-4"> 41 + <div class="text-base-content/60"> 40 42 {{ icon .EmptyIcon "size-12 mx-auto mb-4" }} 41 43 <p class="text-lg">{{ or .EmptyMessage "No repositories found." }}</p> 42 44 </div> 43 - {{ if .EmptySubtext }} 44 - <p class="text-base-content/70 text-sm">{{ .EmptySubtext }}</p> 45 - {{ end }} 46 45 {{ else }} 47 46 <p class="text-base-content/60">{{ or .EmptyMessage "No repositories found." }}</p> 47 + {{ end }} 48 + {{ if .EmptySubtext }} 49 + <p class="text-base-content/70 text-sm mt-2">{{ .EmptySubtext }}</p> 48 50 {{ end }} 49 51 </div> 50 52 {{ end }} 51 53 {{ end }} 54 + 55 + {{/* 56 + card-grid-append — response fragment for Load More pagination. 57 + Emits the new page's cards OOB-swapped into #{{ .TargetID }} and 58 + replaces the existing #{{ .TargetID }}-lm button wrapper via the 59 + primary outerHTML swap. When .HasMore is false, the button wrapper 60 + is replaced with an empty div, removing the Load More control. 61 + */}} 62 + {{ define "card-grid-append" }} 63 + <div hx-swap-oob="beforeend:#{{ .TargetID }}"> 64 + {{ range .Repositories }} 65 + {{ template "repo-card" . }} 66 + {{ end }} 67 + </div> 68 + {{ if and .HasMore .LoadMoreURL }} 69 + <div id="{{ .TargetID }}-lm" class="mt-6 text-center"> 70 + <button 71 + class="btn btn-outline" 72 + hx-get="{{ .LoadMoreURL }}" 73 + hx-trigger="click" 74 + hx-target="#{{ .TargetID }}-lm" 75 + hx-swap="outerHTML" 76 + hx-indicator="#{{ .TargetID }}-lm-spinner" 77 + > 78 + <span id="{{ .TargetID }}-lm-spinner" class="htmx-indicator loading loading-spinner loading-sm"></span> 79 + Load More 80 + </button> 81 + </div> 82 + {{ else }} 83 + <div id="{{ .TargetID }}-lm"></div> 84 + {{ end }} 85 + {{ end }}
+3 -3
pkg/appview/templates/components/docker-command.html
··· 8 8 <div class="cmd group"> 9 9 {{ icon "terminal" "size-4 shrink-0 text-base-content/60" }} 10 10 <code>{{ . }}</code> 11 - <button class="btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity" data-cmd="{{ . }}" aria-label="Copy command to clipboard"> 11 + <button class="cmd-copy btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2" data-cmd="{{ . }}" aria-label="Copy command to clipboard"> 12 12 {{ icon "copy" "size-4" }} 13 13 </button> 14 14 </div> ··· 24 24 - Display: string - short form shown in the UI (e.g. "alice.bsky.social/myapp:v1.2.3") 25 25 - Copy: string - full command copied to clipboard (e.g. "docker pull atcr.io/alice.bsky.social/myapp:v1.2.3") 26 26 */}} 27 - <div class="cmd group !w-full"> 27 + <div class="cmd group w-full!"> 28 28 {{ icon "terminal" "size-4 shrink-0 text-base-content/60" }} 29 29 <code>{{ .Display }}</code> 30 - <button class="btn btn-ghost btn-xs absolute right-1 top-1/2 -translate-y-1/2 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity" data-cmd="{{ .Copy }}" aria-label="Copy pull command to clipboard"> 30 + <button class="cmd-copy btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2" data-cmd="{{ or .Copy .Display }}" aria-label="Copy pull command to clipboard"> 31 31 {{ icon "copy" "size-4" }} 32 32 </button> 33 33 </div>
+11 -9
pkg/appview/templates/components/footer.html
··· 1 1 {{ define "footer" }} 2 2 <footer class="footer footer-center bg-base-200 text-base-content p-6 pt-20 mt-auto relative"> 3 - <img src="/static/wave-pattern.svg" alt="" width="1440" height="64" loading="lazy" decoding="async" class="absolute top-0 left-0 w-full h-16 pointer-events-none rotate-180" aria-hidden="true"> 3 + <img src="/static/wave-pattern.svg" alt="" width="1440" height="64" decoding="async" class="absolute top-0 left-0 w-full h-16 pointer-events-none rotate-180" aria-hidden="true"> 4 4 <nav class="flex flex-wrap justify-center items-center gap-x-2 gap-y-1 text-sm"> 5 5 <a href="/privacy" class="link link-hover">Privacy</a> 6 - <span class="text-base-content/30">·</span> 6 + <span aria-hidden="true" class="text-base-content/30">·</span> 7 7 <a href="/terms" class="link link-hover">Terms</a> 8 - <span class="text-base-content/30">·</span> 9 - <a href="https://bsky.app/profile/atcr.io" target="_blank" rel="noopener" class="link link-hover inline-flex items-center gap-1"> 10 - <svg class="size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 568 501"><path fill="currentColor" d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/></svg> 8 + <span aria-hidden="true" class="text-base-content/30">·</span> 9 + <a href="https://bsky.app/profile/atcr.io" target="_blank" rel="noopener" aria-label="{{ .ClientShortName }} on Bluesky" class="link link-hover inline-flex items-center gap-1"> 10 + <svg aria-hidden="true" class="size-3.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 568 501"><path fill="currentColor" d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z"/></svg> 11 11 Bluesky 12 12 </a> 13 - <span class="text-base-content/30">·</span> 14 - <a href="https://tangled.org/evan.jarrett.net/at-container-registry" target="_blank" rel="noopener" class="link link-hover inline-flex items-center gap-1"> 15 - <img src="/static/tangled-black.svg" alt="" width="14" height="14" loading="lazy" decoding="async" class="size-3.5 icon-light"> 16 - <img src="/static/tangled-white.svg" alt="" width="14" height="14" loading="lazy" decoding="async" class="size-3.5 icon-dark"> 13 + {{ with .SourceURL }} 14 + <span aria-hidden="true" class="text-base-content/30">·</span> 15 + <a href="{{ . }}" target="_blank" rel="noopener" class="link link-hover inline-flex items-center gap-1"> 16 + <img src="/static/tangled-black.svg" alt="" width="14" height="14" loading="lazy" decoding="async" class="size-3.5 icon-light" aria-hidden="true"> 17 + <img src="/static/tangled-white.svg" alt="" width="14" height="14" loading="lazy" decoding="async" class="size-3.5 icon-dark" aria-hidden="true"> 17 18 Source 18 19 </a> 20 + {{ end }} 19 21 </nav> 20 22 </footer> 21 23 {{ end }}
+28 -13
pkg/appview/templates/components/head.html
··· 1 1 {{ define "head" }} 2 2 <meta charset="UTF-8"> 3 3 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 4 + <meta name="color-scheme" content="dark light"> 5 + <meta name="referrer" content="strict-origin-when-cross-origin"> 4 6 <meta name="theme-color" id="theme-color"> 5 7 6 8 <!-- Favicons --> ··· 14 16 <link rel="preconnect" href="https://imgs.blue" crossorigin> 15 17 <link rel="dns-prefetch" href="https://imgs.blue"> 16 18 17 - <!-- Preload critical assets. Onest is the display face used on h1/hero 18 - headings and is the LCP candidate on landing/discovery pages. --> 19 + <!-- Preload critical assets. Font list is discovered from the public FS 20 + at startup so renaming a .woff2 doesn't silently 404. --> 19 21 <link rel="preload" href="/icons.svg" as="image" type="image/svg+xml"> 20 - <link rel="preload" href="/fonts/onest-latin.woff2" as="font" type="font/woff2" crossorigin> 21 - <link rel="preload" href="/fonts/figtree-latin.woff2" as="font" type="font/woff2" crossorigin> 22 - <link rel="preload" href="/fonts/commit-mono-400.woff2" as="font" type="font/woff2" crossorigin> 22 + {{ range fontPreloads }} 23 + <link rel="preload" href="{{ . }}" as="font" type="font/woff2" crossorigin> 24 + {{ end }} 23 25 24 26 <!-- Theme: apply early to prevent flash --> 25 27 <script> ··· 32 34 33 35 function updateThemeColor() { 34 36 var meta = document.getElementById('theme-color'); 35 - if (meta) { 36 - var bg = getComputedStyle(document.documentElement).getPropertyValue('--color-base-100').trim(); 37 - if (bg) meta.setAttribute('content', bg); 37 + if (!meta) return; 38 + var bg = getComputedStyle(document.documentElement).getPropertyValue('--color-base-100').trim(); 39 + if (bg) { 40 + meta.setAttribute('content', bg); 41 + return true; 38 42 } 43 + return false; 44 + } 45 + 46 + // On cold load the stylesheet may not be parsed when this script 47 + // runs, so --color-base-100 resolves empty. Retry on rAF until we 48 + // get a real value, and again on the load event as a final safety 49 + // net. Also re-run on system preference changes. 50 + function scheduleThemeColorUpdate() { 51 + if (updateThemeColor()) return; 52 + requestAnimationFrame(function() { 53 + if (updateThemeColor()) return; 54 + window.addEventListener('load', updateThemeColor, { once: true }); 55 + }); 39 56 } 40 57 41 58 var pref = localStorage.getItem('theme') || 'system'; ··· 43 60 document.documentElement.classList.toggle('dark', effective === 'dark'); 44 61 document.documentElement.setAttribute('data-theme', effective); 45 62 46 - // Update theme-color after styles are applied 47 63 if (document.readyState === 'loading') { 48 - document.addEventListener('DOMContentLoaded', updateThemeColor); 64 + document.addEventListener('DOMContentLoaded', scheduleThemeColorUpdate); 49 65 } else { 50 - updateThemeColor(); 66 + scheduleThemeColorUpdate(); 51 67 } 52 68 53 - // Also update when system preference changes 54 - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateThemeColor); 69 + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', scheduleThemeColorUpdate); 55 70 })(); 56 71 </script> 57 72
+6 -6
pkg/appview/templates/components/hero.html
··· 4 4 */}} 5 5 <section class="hero bg-base-200 min-h-[60vh] py-16 pb-24 relative overflow-hidden"> 6 6 <div class="hero-content text-center flex-col relative z-10 w-full"> 7 - <h1 class="text-4xl md:text-5xl font-display font-bold tracking-tight">your registry <span class="text-primary">at</span> sea.</h1> 8 - <p class="text-lg text-base-content/70 max-w-lg mt-4"> 9 - Push and pull Docker images on the AT Protocol.<br> 7 + <h1 class="text-4xl md:text-5xl font-display font-bold tracking-tight text-balance">your registry <span class="text-primary">at</span> sea.</h1> 8 + <p class="text-lg text-base-content/70 max-w-lg mt-4 text-balance"> 9 + Push and pull Docker images on the AT Protocol. 10 10 Browse public registries or control your data. 11 11 </p> 12 12 ··· 23 23 24 24 <!-- Benefit Cards --> 25 25 <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12 w-full max-w-4xl"> 26 - <div class="card bg-base-200 shadow-sm p-6 text-center"> 26 + <div class="card bg-base-100 border border-base-300 p-6 text-center"> 27 27 <div class="text-primary mb-4 flex justify-center"> 28 28 {{ icon "ship" "size-8" }} 29 29 </div> 30 30 <h2 class="font-semibold text-lg">Works with Docker</h2> 31 31 <p class="text-base-content/70 mt-2">Use docker push &amp; pull. No new tools to learn.</p> 32 32 </div> 33 - <div class="card bg-base-200 shadow-sm p-6 text-center"> 33 + <div class="card bg-base-100 border border-base-300 p-6 text-center"> 34 34 <div class="text-primary mb-4 flex justify-center"> 35 35 {{ icon "anchor" "size-8" }} 36 36 </div> 37 37 <h2 class="font-semibold text-lg">Your Data</h2> 38 38 <p class="text-base-content/70 mt-2">Join shared holds or captain your own storage.</p> 39 39 </div> 40 - <div class="card bg-base-200 shadow-sm p-6 text-center"> 40 + <div class="card bg-base-100 border border-base-300 p-6 text-center"> 41 41 <div class="text-primary mb-4 flex justify-center"> 42 42 {{ icon "compass" "size-8" }} 43 43 </div>
+13 -9
pkg/appview/templates/components/meta.html
··· 1 1 {{ define "meta" }} 2 - {{/* Title */}} 3 - <title>{{ .Title }}</title> 2 + {{/* Title falls back to SiteName if empty — better than rendering a blank 3 + <title> that screen readers and tab chrome handle poorly. */}} 4 + <title>{{ or .Title .SiteName "ATCR" }}</title> 4 5 5 - {{/* Basic meta */}} 6 - <meta name="description" content="{{ .Description }}"> 6 + {{/* Description: omit the tag entirely when empty rather than emitting 7 + <meta name="description" content="">, which some SEO tools flag. */}} 8 + {{ if .Description }}<meta name="description" content="{{ .Description }}">{{ end }} 7 9 {{ if .Canonical }}<link rel="canonical" href="{{ .Canonical }}">{{ end }} 8 10 {{ if .Robots }}<meta name="robots" content="{{ .Robots }}">{{ end }} 9 11 10 12 {{/* OpenGraph */}} 11 - <meta property="og:locale" content="en_US"> 12 - <meta property="og:title" content="{{ .Title }}"> 13 - <meta property="og:description" content="{{ .Description }}"> 13 + <meta property="og:locale" content="{{ or .OGLocale "en_US" }}"> 14 + <meta property="og:title" content="{{ or .Title .SiteName "ATCR" }}"> 15 + {{ if .Description }}<meta property="og:description" content="{{ .Description }}">{{ end }} 14 16 <meta property="og:type" content="{{ or .OGType "website" }}"> 15 17 {{ if .Canonical }}<meta property="og:url" content="{{ .Canonical }}">{{ end }} 16 18 {{ if .OGImage }} 17 19 <meta property="og:image" content="{{ .OGImage }}"> 18 20 <meta property="og:image:width" content="1200"> 19 21 <meta property="og:image:height" content="630"> 22 + {{ if .OGImageAlt }}<meta property="og:image:alt" content="{{ .OGImageAlt }}">{{ end }} 20 23 {{ end }} 21 24 <meta property="og:site_name" content="{{ or .SiteName "ATCR" }}"> 22 25 23 26 {{/* Twitter Card */}} 24 27 <meta name="twitter:card" content="{{ or .TwitterCard "summary_large_image" }}"> 25 - <meta name="twitter:title" content="{{ .Title }}"> 26 - <meta name="twitter:description" content="{{ .Description }}"> 28 + <meta name="twitter:title" content="{{ or .Title .SiteName "ATCR" }}"> 29 + {{ if .Description }}<meta name="twitter:description" content="{{ .Description }}">{{ end }} 27 30 {{ if .OGImage }}<meta name="twitter:image" content="{{ .OGImage }}">{{ end }} 31 + {{ if and .OGImage .OGImageAlt }}<meta name="twitter:image:alt" content="{{ .OGImageAlt }}">{{ end }} 28 32 29 33 {{/* JSON-LD */}} 30 34 {{ range .JSONLD }}
-30
pkg/appview/templates/components/modal.html
··· 1 - {{ define "manifest-modal" }} 2 - <dialog class="modal modal-open" data-action="modal-backdrop-close"> 3 - <div class="modal-box bg-base-200"> 4 - <button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" data-action="remove-closest-dialog" aria-label="Close manifest details">✕</button> 5 - 6 - <h2 class="text-xl font-semibold mb-4">Manifest Details</h2> 7 - 8 - <dl class="grid grid-cols-[max-content_1fr] gap-x-6 gap-y-3 text-sm"> 9 - <dt class="text-base-content/70 font-medium">Digest</dt> 10 - <dd class="font-mono break-all">{{ .Digest }}</dd> 11 - 12 - <dt class="text-base-content/70 font-medium">Media Type</dt> 13 - <dd class="break-all">{{ .MediaType }}</dd> 14 - 15 - <dt class="text-base-content/70 font-medium">Hold Endpoint</dt> 16 - <dd class="break-all">{{ .HoldEndpoint }}</dd> 17 - 18 - <dt class="text-base-content/70 font-medium">Created</dt> 19 - <dd> 20 - <time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}"> 21 - {{ .CreatedAt.Format "2006-01-02 15:04:05 MST" }} 22 - </time> 23 - </dd> 24 - </dl> 25 - </div> 26 - <form method="dialog" class="modal-backdrop"> 27 - <button data-action="remove-closest-dialog">close</button> 28 - </form> 29 - </dialog> 30 - {{ end }}
+3 -3
pkg/appview/templates/components/nav-brand.html
··· 1 1 {{ define "nav-brand" }} 2 - <a href="/" class="flex items-center gap-2 text-2xl font-display font-bold text-secondary no-underline tracking-tight"> 3 - <img src="/favicon-96x96.png" width="48" height="48" fetchpriority="high" class="h-12 w-auto" alt="{{ .ClientName }} logo"> 4 - {{ .ClientName }} 2 + <a href="/" class="flex items-center gap-2 text-2xl font-display font-bold text-secondary no-underline tracking-tight focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 rounded-sm"> 3 + <img src="/favicon-96x96.png" width="48" height="48" fetchpriority="high" class="h-12 w-auto" alt="" aria-hidden="true"> 4 + <span class="min-w-0 max-w-56 truncate sm:max-w-none">{{ .ClientName }}</span> 5 5 </a> 6 6 {{ end }}
+21 -4
pkg/appview/templates/components/nav-theme-toggle.html
··· 3 3 <summary data-theme-toggle class="btn btn-ghost btn-circle list-none" aria-label="Theme settings" aria-haspopup="menu"> 4 4 <svg class="icon size-5" data-theme-icon aria-hidden="true"><use href="/icons.svg#sun"></use></svg> 5 5 </summary> 6 - <ul data-theme-menu role="menu" class="dropdown-content menu bg-base-200 text-base-content rounded-box z-50 w-40 p-2 shadow-lg"> 6 + <ul data-theme-menu role="group" aria-label="Select theme" class="dropdown-content menu bg-base-200 text-base-content rounded-box z-50 w-40 p-2 shadow-lg"> 7 7 <li role="none"> 8 - <button type="button" role="menuitemradio" aria-checked="false" class="theme-option" data-value="system"> 8 + <button type="button" role="radio" aria-checked="false" class="theme-option" data-value="system"> 9 9 {{ icon "sun-moon" "size-4" }} 10 10 <span>System</span> 11 11 {{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }} 12 12 </button> 13 13 </li> 14 14 <li role="none"> 15 - <button type="button" role="menuitemradio" aria-checked="false" class="theme-option" data-value="light"> 15 + <button type="button" role="radio" aria-checked="false" class="theme-option" data-value="light"> 16 16 {{ icon "sun" "size-4" }} 17 17 <span>Light</span> 18 18 {{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }} 19 19 </button> 20 20 </li> 21 21 <li role="none"> 22 - <button type="button" role="menuitemradio" aria-checked="false" class="theme-option" data-value="dark"> 22 + <button type="button" role="radio" aria-checked="false" class="theme-option" data-value="dark"> 23 23 {{ icon "moon" "size-4" }} 24 24 <span>Dark</span> 25 25 {{ icon "check" "size-4 ml-auto text-secondary theme-check invisible" }} ··· 27 27 </li> 28 28 </ul> 29 29 </details> 30 + <script> 31 + // Sync aria-checked on the theme radios before JS has a chance to hydrate 32 + // the full menu. Without this, screen readers pre-hydration announce all 33 + // three options as "not selected." Runs inline so it executes right after 34 + // the menu renders. 35 + (function() { 36 + try { 37 + var pref = localStorage.getItem('theme') || 'system'; 38 + document.querySelectorAll('.theme-option').forEach(function(btn) { 39 + var on = btn.dataset.value === pref; 40 + btn.setAttribute('aria-checked', on ? 'true' : 'false'); 41 + var check = btn.querySelector('.theme-check'); 42 + if (check) check.classList.toggle('invisible', !on); 43 + }); 44 + } catch (e) { /* localStorage unavailable; leave defaults */ } 45 + })(); 46 + </script> 30 47 {{ end }}
+9 -6
pkg/appview/templates/components/nav-user.html
··· 1 1 {{ define "nav-user" }} 2 2 {{ if .User }} 3 3 <details class="dropdown dropdown-end"> 4 - <summary class="btn btn-ghost gap-2 list-none" aria-label="User menu"> 4 + <summary class="btn btn-ghost gap-2 list-none focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2" aria-label="User menu" aria-haspopup="menu"> 5 5 <div class="avatar{{ if not .User.Avatar }} avatar-placeholder{{ end }}"> 6 6 {{ if .User.Avatar }} 7 - <div class="w-7 rounded-full"> 8 - <img src="{{ resizeImage .User.Avatar 96 }}" alt="{{ .User.Handle }}" width="28" height="28" /> 7 + <div class="w-7 rounded-full bg-secondary text-secondary-content flex items-center justify-center relative"> 8 + <span aria-hidden="true" class="text-xs">{{ firstChar .User.Handle }}</span> 9 + <img src="{{ resizeImage .User.Avatar 96 }}" alt="" aria-hidden="true" width="28" height="28" 10 + class="absolute inset-0 w-full h-full rounded-full object-cover" 11 + onerror="this.remove()" /> 9 12 </div> 10 13 {{ else }} 11 14 <div class="bg-secondary text-secondary-content w-7 rounded-full"> 12 - <span class="text-xs">{{ firstChar .User.Handle }}</span> 15 + <span aria-hidden="true" class="text-xs">{{ firstChar .User.Handle }}</span> 13 16 </div> 14 17 {{ end }} 15 18 </div> 16 - <span class="hidden sm:inline">@{{ .User.Handle }}</span> 19 + <span class="hidden sm:inline truncate max-w-48">@{{ .User.Handle }}</span> 17 20 {{ icon "chevron-down" "size-3.5" }} 18 21 </summary> 19 22 <ul class="dropdown-content menu bg-base-200 text-base-content rounded-box z-50 w-52 p-2 shadow-lg"> ··· 26 29 <form id="logout-form" action="/auth/logout" method="POST" hidden></form> 27 30 </details> 28 31 {{ else }} 29 - <a href="/auth/oauth/login?return_to=/" class="btn btn-secondary btn-sm">Login</a> 32 + <a href="/auth/oauth/login?return_to={{ urlquery (or .CurrentPath "/") }}" class="btn btn-secondary btn-sm">Login</a> 30 33 {{ end }} 31 34 {{ end }}
+2 -2
pkg/appview/templates/components/nav.html
··· 4 4 5 5 {{ define "nav" }} 6 6 {{ template "skip-link" }} 7 - <nav class="navbar bg-base-200 text-secondary px-4"> 7 + <nav aria-label="Primary" class="navbar bg-base-200 px-4"> 8 8 <div class="navbar-start"> 9 9 {{ template "nav-brand" . }} 10 10 </div> ··· 18 18 19 19 {{ define "nav-simple" }} 20 20 {{ template "skip-link" }} 21 - <nav class="navbar bg-base-200 text-secondary px-4"> 21 + <nav aria-label="Primary" class="navbar bg-base-200 px-4"> 22 22 <div class="navbar-start"> 23 23 {{ template "nav-brand" . }} 24 24 </div>
+1 -1
pkg/appview/templates/components/pull-command-switcher.html
··· 34 34 <option value="crane"{{ if eq .OciClient "crane" }} selected{{ end }}>crane</option> 35 35 <option value="none"{{ if eq .OciClient "none" }} selected{{ end }}>image ref only</option> 36 36 </select> 37 - <div id="pull-cmd-display" class="flex-1 min-w-0"> 37 + <div id="pull-cmd-display" class="flex-1 min-w-0" aria-live="polite"> 38 38 {{ if .Tag }} 39 39 {{ template "docker-command" (print (pullPrefix .OciClient) .RegistryURL "/" .OwnerHandle "/" .RepoName ":" .Tag) }} 40 40 {{ else }}
+3 -2
pkg/appview/templates/components/pull-count.html
··· 3 3 Pull count component - displays download icon with count 4 4 Required: .PullCount (int) 5 5 */}} 6 - <span class="flex items-center gap-2 text-base-content/60"> 6 + <span class="flex items-center gap-2 text-base-content/60" title="{{ .PullCount }} pulls"> 7 7 {{ icon "arrow-down-to-line" "size-[1.1rem] text-primary" }} 8 - <span class="font-semibold text-base-content">{{ .PullCount }}</span> 8 + <span class="font-semibold text-base-content">{{ humanizeCount .PullCount }}</span> 9 + <span class="sr-only">pulls</span> 9 10 </span> 10 11 {{ end }}
+4 -3
pkg/appview/templates/components/repo-avatar.html
··· 8 8 {{ if .IconURL }} 9 9 <img src="{{ resizeImage .IconURL 160 }}" alt="{{ .RepositoryName }}" width="80" height="80" fetchpriority="high" class="w-20 rounded-lg object-cover"> 10 10 {{ else }} 11 - <div class="avatar avatar-placeholder"> 11 + <div class="avatar avatar-placeholder" role="img" aria-label="Avatar for {{ .RepositoryName }}"> 12 12 <div class="bg-neutral text-neutral-content w-20 rounded-lg shadow-sm uppercase"> 13 - <span class="text-4xl">{{ firstChar .RepositoryName }}</span> 13 + <span aria-hidden="true" class="text-4xl">{{ firstChar .RepositoryName }}</span> 14 14 </div> 15 15 </div> 16 16 {{ end }} 17 17 {{ if .IsOwner }} 18 - <label class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 hover:opacity-100 transition-opacity cursor-pointer rounded-lg" for="avatar-upload" aria-label="Upload repository icon"> 18 + <label class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 hover:opacity-100 focus-within:opacity-100 transition-opacity cursor-pointer rounded-lg" for="avatar-upload" aria-label="Upload repository icon"> 19 19 {{ icon "plus" "size-8 text-neutral-content" }} 20 20 </label> 21 21 <input type="hidden" id="avatar-repo" name="repo" value="{{ .RepositoryName }}"> ··· 26 26 hx-encoding="multipart/form-data" 27 27 hx-swap="outerHTML" 28 28 hx-target="#repo-avatar" 29 + hx-on::before-swap="if(!event.detail.successful) event.detail.shouldSwap=false" 29 30 hx-on::after-request="if(event.detail.xhr.status===401) window.location='/auth/oauth/login'" 30 31 class="hidden"> 31 32 {{ end }}
+9 -6
pkg/appview/templates/components/repo-card.html
··· 32 32 </div> 33 33 {{ end }} 34 34 <div class="flex-1 min-w-0"> 35 - <div class="font-semibold text-sm flex items-baseline gap-1 min-w-0"> 36 - <a href="/u/{{ .OwnerHandle }}" class="link link-primary truncate min-w-0">{{ .OwnerHandle }}</a> 37 - <span class="text-base-content/60 shrink-0">/</span> 38 - <span class="text-base-content truncate min-w-0">{{ .Repository }}</span> 35 + <div class="font-semibold text-sm flex items-baseline gap-1 min-w-0" aria-label="{{ .Repository }} by {{ .OwnerHandle }}"> 36 + <a href="/u/{{ .OwnerHandle }}" tabindex="-1" class="link link-primary truncate min-w-0 max-w-[50%]">{{ .OwnerHandle }}</a> 37 + <span class="text-base-content/60 shrink-0" aria-hidden="true">/</span> 38 + <span class="text-base-content truncate min-w-0 flex-1">{{ .Repository }}</span> 39 39 </div> 40 40 {{ if .Tag }} 41 41 <span class="block text-base-content/60 text-sm truncate">Tag: {{ .Tag }}</span> ··· 73 73 {{ template "star" (dict "IsStarred" .IsStarred "StarCount" .StarCount) }} 74 74 {{ template "pull-count" (dict "PullCount" .PullCount) }} 75 75 {{ if eq .ArtifactType "helm-chart" }} 76 - {{ icon "helm" "size-5 text-helm" }} 76 + <span class="inline-flex items-center"> 77 + {{ icon "helm" "size-5 text-helm" }} 78 + <span class="sr-only">Helm chart</span> 79 + </span> 77 80 {{ end }} 78 81 </div> 79 82 {{ if not .LastUpdated.IsZero }} 80 - <span class="text-base-content/60 text-sm flex items-center gap-1">{{ icon "history" "size-4" }}{{ timeAgoShort .LastUpdated }}</span> 83 + <span class="text-base-content/60 text-sm flex items-center gap-1" title="{{ humanizeTime .LastUpdated }}">{{ icon "history" "size-4" }}{{ timeAgoShort .LastUpdated }}</span> 81 84 {{ end }} 82 85 </div> 83 86 </article>
+18 -6
pkg/appview/templates/components/star.html
··· 8 8 Display mode: renders as span (default) 9 9 */}} 10 10 {{ if .Interactive }} 11 + {{ if .IsAuthenticated }} 11 12 <button class="btn btn-sm gap-2 btn-ghost group border border-transparent hover:border-primary{{ if .IsStarred }} border-star!{{ end }}" 12 - id="star-btn" 13 13 hx-ext="json-enc" 14 14 {{ if .IsStarred }} 15 15 hx-delete="/api/stars" 16 16 {{ else }} 17 17 hx-post="/api/stars" 18 18 {{ end }} 19 - hx-vals='{"handle": "{{ .Handle }}", "repo": "{{ .Repository }}"}' 19 + hx-vals='{{ dict "handle" .Handle "repo" .Repository | toJSON }}' 20 + hx-target="this" 20 21 hx-swap="outerHTML" 21 22 hx-on::before-request="this.disabled=true" 22 - hx-on::after-request="if(event.detail.xhr.status===401) window.location='/auth/oauth/login'" 23 + hx-on::after-request="if(event.detail.xhr.status===401){window.location='/auth/oauth/login'}else if(!event.detail.successful){this.disabled=false}" 23 24 aria-label="{{ if .IsStarred }}Unstar{{ else }}Star{{ end }} {{ .Handle }}/{{ .Repository }}"> 24 - <svg class="icon size-4 text-star stroke-star transition-transform group-hover:scale-110{{ if .IsStarred }} fill-star!{{ end }}" id="star-icon" aria-hidden="true"><use href="/icons.svg#star"></use></svg> 25 - <span id="star-count">{{ .StarCount }}</span> 25 + <svg class="icon size-4 text-star stroke-star transition-transform group-hover:scale-110{{ if .IsStarred }} fill-star!{{ end }}" aria-hidden="true"><use href="/icons.svg#star"></use></svg> 26 + <span>{{ .StarCount }}</span> 26 27 </button> 27 28 {{ else }} 28 - <span class="flex items-center gap-2 text-base-content/60"> 29 + {{/* Anon viewer: link to login so the click isn't an abrupt 401 redirect. */}} 30 + <a href="/auth/oauth/login?return_to=/r/{{ .Handle }}/{{ .Repository }}" 31 + class="btn btn-sm gap-2 btn-ghost border border-transparent hover:border-primary" 32 + title="Sign in to star this repository" 33 + aria-label="Sign in to star {{ .Handle }}/{{ .Repository }}"> 34 + <svg class="icon size-4 text-star stroke-star" aria-hidden="true"><use href="/icons.svg#star"></use></svg> 35 + <span>{{ .StarCount }}</span> 36 + </a> 37 + {{ end }} 38 + {{ else }} 39 + <span class="flex items-center gap-2 text-base-content/60" title="{{ .StarCount }} stars"> 29 40 <svg class="icon size-[1.1rem] text-star stroke-star{{ if .IsStarred }} fill-star!{{ end }}" aria-hidden="true"><use href="/icons.svg#star"></use></svg> 30 41 <span class="font-semibold text-base-content">{{ .StarCount }}</span> 42 + <span class="sr-only">stars</span> 31 43 </span> 32 44 {{ end }} 33 45 {{ end }}
+1 -1
pkg/appview/templates/pages/404.html
··· 6 6 {{ template "meta" .Meta }} 7 7 </head> 8 8 <body> 9 - {{ template "nav-simple" . }} 9 + {{ if .User }}{{ template "nav" . }}{{ else }}{{ template "nav-simple" . }}{{ end }} 10 10 <main id="main-content" class="hero min-h-[60vh]"> 11 11 <div class="hero-content text-center"> 12 12 <div class="flex flex-col items-center">
+24 -14
pkg/appview/templates/pages/diff.html
··· 19 19 </ul> 20 20 </div> 21 21 22 + {{ if or .FromFailed .ToFailed }} 23 + {{ template "alert" (dict "Type" "warning" "Message" (printf "We couldn't fetch details for %s%s%s — showing what we have." (or (and .FromFailed .FromTag) "") (or (and .FromFailed .ToFailed) " and ") (or (and .ToFailed .ToTag) ""))) }} 24 + {{ end }} 25 + 26 + {{ if and (not .FromFailed) (not .ToFailed) (eq .FromDigest .ToDigest) }} 27 + {{ template "alert" (dict "Type" "info" "Message" "These manifests are identical — no layers or vulnerabilities changed.") }} 28 + {{ end }} 29 + 22 30 <!-- Summary Card --> 23 31 <div class="card bg-base-200 shadow-sm border border-base-300 p-6"> 24 32 <div class="flex flex-wrap items-center gap-2 mb-4"> 25 - <h1 class="text-xl font-bold"> 26 - <span class="font-mono">{{ .FromTag }}</span> 27 - <span class="text-base-content/40 mx-1">→</span> 28 - <span class="font-mono">{{ .ToTag }}</span> 33 + <h1 class="text-xl font-bold wrap-break-word min-w-0"> 34 + <span class="whitespace-nowrap"> 35 + <span class="font-mono inline-block align-baseline max-w-[24ch] truncate" title="{{ .FromTag }}">{{ .FromTag }}</span> 36 + <span class="text-base-content/40 mx-1" aria-hidden="true">→</span> 37 + </span> 38 + <span class="font-mono inline-block align-baseline max-w-[24ch] truncate" title="{{ .ToTag }}">{{ .ToTag }}</span> 29 39 </h1> 30 40 </div> 31 41 ··· 47 57 {{ if gt .Summary.VulnFixedCount 0 }} 48 58 <div class="stat bg-success/10 rounded-lg p-3"> 49 59 <div class="stat-title text-xs">Fixed</div> 50 - <div class="stat-value text-sm text-success">-{{ .Summary.VulnFixedCount }} vuln{{ if gt .Summary.VulnFixedCount 1 }}s{{ end }}</div> 60 + <div class="stat-value text-sm text-success">-{{ .Summary.VulnFixedCount }} {{ pluralize .Summary.VulnFixedCount "vuln" "vulns" }}</div> 51 61 <div class="stat-desc text-xs"> 52 - {{ if gt .Summary.VulnFixedBySev.Critical 0 }}{{ .Summary.VulnFixedBySev.Critical }}C {{ end }} 53 - {{ if gt .Summary.VulnFixedBySev.High 0 }}{{ .Summary.VulnFixedBySev.High }}H {{ end }} 54 - {{ if gt .Summary.VulnFixedBySev.Medium 0 }}{{ .Summary.VulnFixedBySev.Medium }}M {{ end }} 55 - {{ if gt .Summary.VulnFixedBySev.Low 0 }}{{ .Summary.VulnFixedBySev.Low }}L{{ end }} 62 + {{ if gt .Summary.VulnFixedBySev.Critical 0 }}{{ .Summary.VulnFixedBySev.Critical }}<span class="sr-only"> Critical</span><span aria-hidden="true">C</span> {{ end }} 63 + {{ if gt .Summary.VulnFixedBySev.High 0 }}{{ .Summary.VulnFixedBySev.High }}<span class="sr-only"> High</span><span aria-hidden="true">H</span> {{ end }} 64 + {{ if gt .Summary.VulnFixedBySev.Medium 0 }}{{ .Summary.VulnFixedBySev.Medium }}<span class="sr-only"> Medium</span><span aria-hidden="true">M</span> {{ end }} 65 + {{ if gt .Summary.VulnFixedBySev.Low 0 }}{{ .Summary.VulnFixedBySev.Low }}<span class="sr-only"> Low</span><span aria-hidden="true">L</span>{{ end }} 56 66 </div> 57 67 </div> 58 68 {{ end }} ··· 61 71 {{ if gt .Summary.VulnNewCount 0 }} 62 72 <div class="stat bg-error/10 rounded-lg p-3"> 63 73 <div class="stat-title text-xs">New</div> 64 - <div class="stat-value text-sm text-error">+{{ .Summary.VulnNewCount }} vuln{{ if gt .Summary.VulnNewCount 1 }}s{{ end }}</div> 74 + <div class="stat-value text-sm text-error">+{{ .Summary.VulnNewCount }} {{ pluralize .Summary.VulnNewCount "vuln" "vulns" }}</div> 65 75 <div class="stat-desc text-xs"> 66 - {{ if gt .Summary.VulnNewBySev.Critical 0 }}{{ .Summary.VulnNewBySev.Critical }}C {{ end }} 67 - {{ if gt .Summary.VulnNewBySev.High 0 }}{{ .Summary.VulnNewBySev.High }}H {{ end }} 68 - {{ if gt .Summary.VulnNewBySev.Medium 0 }}{{ .Summary.VulnNewBySev.Medium }}M {{ end }} 69 - {{ if gt .Summary.VulnNewBySev.Low 0 }}{{ .Summary.VulnNewBySev.Low }}L{{ end }} 76 + {{ if gt .Summary.VulnNewBySev.Critical 0 }}{{ .Summary.VulnNewBySev.Critical }}<span class="sr-only"> Critical</span><span aria-hidden="true">C</span> {{ end }} 77 + {{ if gt .Summary.VulnNewBySev.High 0 }}{{ .Summary.VulnNewBySev.High }}<span class="sr-only"> High</span><span aria-hidden="true">H</span> {{ end }} 78 + {{ if gt .Summary.VulnNewBySev.Medium 0 }}{{ .Summary.VulnNewBySev.Medium }}<span class="sr-only"> Medium</span><span aria-hidden="true">M</span> {{ end }} 79 + {{ if gt .Summary.VulnNewBySev.Low 0 }}{{ .Summary.VulnNewBySev.Low }}<span class="sr-only"> Low</span><span aria-hidden="true">L</span>{{ end }} 70 80 </div> 71 81 </div> 72 82 {{ end }}
+12 -7
pkg/appview/templates/pages/digest.html
··· 26 26 <!-- Title: tags or truncated digest --> 27 27 <div class="flex flex-wrap items-center gap-2"> 28 28 {{ if .Manifest.Tags }} 29 - <h1 class="text-xl font-bold">{{ range $i, $tag := .Manifest.Tags }}{{ if $i }}{{ if lt $i 3 }}, {{ end }}{{ end }}{{ if lt $i 3 }}{{ $tag }}{{ end }}{{ end }}{{ if gt (len .Manifest.Tags) 3 }} <span class="text-sm font-normal text-base-content/60" title="{{ range $i, $tag := .Manifest.Tags }}{{ if $i }}, {{ end }}{{ $tag }}{{ end }}">+{{ sub (len .Manifest.Tags) 3 }} more</span>{{ end }}</h1> 29 + <h1 class="text-xl font-bold flex flex-wrap gap-x-1 items-center min-w-0">{{ range $i, $tag := .Manifest.Tags }}{{ if lt $i 3 }}{{ if $i }}<span aria-hidden="true">,</span>{{ end }}<span class="inline-block max-w-[24ch] truncate align-baseline" title="{{ $tag }}">{{ $tag }}</span>{{ end }}{{ end }}{{ if gt (len .Manifest.Tags) 3 }}<span class="text-sm font-normal text-base-content/60" title="{{ range $i, $tag := .Manifest.Tags }}{{ if $i }}, {{ end }}{{ $tag }}{{ end }}">+{{ sub (len .Manifest.Tags) 3 }} more</span>{{ end }}</h1> 30 30 {{ else }} 31 31 <h1 class="text-xl font-bold font-mono" title="{{ .Manifest.Digest }}">{{ truncateDigest (trimPrefix "sha256:" .Manifest.Digest) 16 }}</h1> 32 32 {{ end }} ··· 46 46 </div> 47 47 </div> 48 48 <div class="flex items-center gap-2 shrink-0"> 49 - <span class="text-base-content text-sm flex items-center gap-1" title="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">{{ icon "history" "size-4" }}{{ timeAgoShort .Manifest.CreatedAt }}</span> 49 + {{ if not .Manifest.CreatedAt.IsZero }} 50 + <span class="text-base-content text-sm flex items-center gap-1" title="{{ humanizeTime .Manifest.CreatedAt }}">{{ icon "history" "size-4" }}{{ timeAgoShort .Manifest.CreatedAt }}</span> 51 + {{ end }} 50 52 </div> 51 53 </div> 52 54 ··· 73 75 </div> 74 76 75 77 <!-- Upgrade Banner (HTMX lazy-loaded) --> 76 - <div id="upgrade-banner" 77 - hx-get="/api/upgrade-banner/{{ .Owner.Handle }}/{{ .Repository }}?digest={{ .Manifest.Digest }}{{ if .Manifest.HoldEndpoint }}&holdEndpoint={{ .Manifest.HoldEndpoint }}{{ end }}" 78 + <div id="upgrade-banner" role="status" aria-live="polite" 79 + hx-get="/api/upgrade-banner/{{ .Owner.Handle }}/{{ .Repository }}?digest={{ urlquery .Manifest.Digest }}{{ if .Manifest.HoldEndpoint }}&holdEndpoint={{ urlquery .Manifest.HoldEndpoint }}{{ end }}" 78 80 hx-trigger="load" 79 81 hx-swap="innerHTML"> 80 82 </div> 81 83 82 84 <!-- Content: Layers + Vulnerabilities --> 83 - <div id="digest-content"> 85 + <div id="digest-content" aria-live="polite" aria-busy="false"> 84 86 {{ if .Manifest.IsManifestList }} 87 + {{ if .Manifest.Platforms }} 85 88 <!-- Auto-load selected platform --> 86 - {{ if .Manifest.Platforms }} 87 - <div hx-get="/api/digest-content/{{ .Owner.Handle }}/{{ .Repository }}?digest={{ .SelectedPlatform }}" 89 + <div hx-get="/api/digest-content/{{ .Owner.Handle }}/{{ .Repository }}?digest={{ urlquery .SelectedPlatform }}" 88 90 hx-trigger="load" 89 91 hx-target="#digest-content" 90 92 hx-swap="innerHTML" 93 + hx-on::response-error="this.innerHTML='<div class=&quot;alert alert-error&quot;>We couldn&rsquo;t load this platform. Try refreshing.</div>'" 91 94 class="flex items-center justify-center py-12"> 92 95 {{ icon "loader" "size-6 animate-spin text-base-content/40" }} 93 96 <span class="ml-2 text-base-content/60">Loading layers and vulnerabilities...</span> 94 97 </div> 98 + {{ else }} 99 + <p class="py-12 text-center text-base-content/60">No platform manifests found for this image index.</p> 95 100 {{ end }} 96 101 {{ else }} 97 102 {{ template "digest-content" . }}
+32 -1
pkg/appview/templates/pages/home.html
··· 16 16 <div class="space-y-12"> 17 17 <!-- Featured Repositories Section --> 18 18 {{ if .FeaturedRepos }} 19 - <section> 19 + <section aria-roledescription="carousel" aria-label="Featured repositories"> 20 20 <div class="flex justify-between items-center mb-6"> 21 21 <h2 class="text-2xl font-bold">Featured</h2> 22 + {{ if gt (len .FeaturedRepos) 1 }} 22 23 <div class="flex gap-2"> 24 + <button id="carousel-pause" class="btn btn-circle btn-ghost btn-sm" aria-label="Pause carousel auto-advance" aria-pressed="false"> 25 + {{ icon "pause" "size-5 carousel-pause-icon" }} 26 + {{ icon "play" "size-5 carousel-play-icon hidden" }} 27 + </button> 23 28 <button id="carousel-prev" class="btn btn-circle btn-ghost btn-sm" aria-label="Previous featured repository"> 24 29 {{ icon "chevron-left" "size-5" }} 25 30 </button> ··· 27 32 {{ icon "chevron-right" "size-5" }} 28 33 </button> 29 34 </div> 35 + {{ end }} 30 36 </div> 31 37 <div id="featured-carousel" class="carousel w-full gap-3 sm:gap-6 scroll-smooth"> 32 38 {{ range $i, $repo := .FeaturedRepos }} ··· 44 50 <h2 class="text-2xl font-bold mb-6">What's New</h2> 45 51 {{ template "card-grid" (dict "Repositories" .RecentRepos) }} 46 52 </section> 53 + {{ end }} 54 + {{ if and (not .FeaturedRepos) (not .RecentRepos) }} 55 + {{ if .HasError }} 56 + {{ template "state-error" (dict 57 + "Title" "We couldn't load the home page" 58 + "Subtext" "Something went wrong fetching repositories. This is usually temporary." 59 + "RetryURL" "/" 60 + ) }} 61 + {{ else }} 62 + <div class="text-center py-16 text-base-content/60"> 63 + {{ if .User }} 64 + {{ icon "package" "size-12 mx-auto mb-4 text-base-content/30" }} 65 + <p class="text-lg font-medium text-base-content/70">Nothing here yet</p> 66 + <p class="mt-2 text-sm">Push your first image to get started.</p> 67 + {{ template "docker-command" (print "docker push atcr.io/" .User.Handle "/my-image:latest") }} 68 + {{ else }} 69 + {{ icon "package" "size-12 mx-auto mb-4 text-base-content/30" }} 70 + <p class="text-lg font-medium text-base-content/70">No public repositories yet</p> 71 + <p class="mt-2 text-sm">Be the first to push an image.</p> 72 + {{ end }} 73 + </div> 74 + {{ end }} 75 + {{ else if .HasError }} 76 + {{/* Partial failure: render a non-blocking warning above what did load. */}} 77 + {{ template "alert" (dict "Type" "warning" "Message" "Some sections couldn't be loaded right now. Try refreshing the page.") }} 47 78 {{ end }} 48 79 </div> 49 80 </main>
+11 -7
pkg/appview/templates/pages/install.html
··· 16 16 <section> 17 17 <h2 class="text-xl font-semibold mb-4">Quick Install</h2> 18 18 19 - <div class="flex gap-2 mb-4"> 20 - <button type="button" class="btn btn-sm btn-primary platform-tab" data-platform="linux">Linux / macOS</button> 21 - <button type="button" class="btn btn-sm btn-ghost platform-tab" data-platform="windows">Windows</button> 19 + <div class="flex gap-2 mb-4" role="tablist" aria-label="Platform"> 20 + <button type="button" role="tab" id="linux-tab" 21 + aria-selected="true" aria-controls="linux-content" 22 + class="btn btn-sm btn-primary platform-tab" data-platform="linux">Linux / macOS</button> 23 + <button type="button" role="tab" id="windows-tab" 24 + aria-selected="false" aria-controls="windows-content" tabindex="-1" 25 + class="btn btn-sm btn-ghost platform-tab" data-platform="windows">Windows</button> 22 26 </div> 23 27 24 - <div class="platform-content" id="linux-content"> 28 + <div class="platform-content" id="linux-content" role="tabpanel" aria-labelledby="linux-tab"> 25 29 <h3 class="text-lg font-medium mb-3">Using install script</h3> 26 30 <div class="mockup-code bg-base-300 text-base-content mb-6"> 27 31 <pre data-prefix="$"><code>curl -fsSL {{ .SiteURL }}/static/install.sh | bash</code></pre> ··· 35 39 </div> 36 40 </div> 37 41 38 - <div class="platform-content hidden" id="windows-content"> 42 + <div class="platform-content hidden" id="windows-content" role="tabpanel" aria-labelledby="windows-tab" hidden> 39 43 <h3 class="text-lg font-medium mb-3">Using PowerShell (Run as Administrator)</h3> 40 44 <div class="mockup-code bg-base-300 text-base-content mb-6"> 41 45 <pre data-prefix="PS"><code>iwr -useb {{ .SiteURL }}/static/install.ps1 | iex</code></pre> ··· 95 99 <p class="mb-4">You can also use <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">docker login</code> with your ATProto app password:</p> 96 100 97 101 <ol class="list-decimal list-inside space-y-2 ml-4 mb-6"> 98 - <li>Generate an app password at <a href="https://bsky.app/settings/app-passwords" target="_blank" class="link link-primary">bsky.app/settings/app-passwords</a></li> 102 + <li>Generate an app password at <a href="https://bsky.app/settings/app-passwords" target="_blank" rel="noopener noreferrer" class="link link-primary">bsky.app/settings/app-passwords</a></li> 99 103 <li>Run: <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">docker login {{ .RegistryURL }}</code></li> 100 104 <li>Enter your handle as username</li> 101 105 <li>Enter your app password</li> ··· 103 107 104 108 <div class="alert alert-info"> 105 109 {{ icon "info" "size-5" }} 106 - <span>Create an app password at <a href="https://bsky.app/settings/app-passwords" target="_blank" class="underline font-medium hover:no-underline">bsky.app/settings/app-passwords</a>.</span> 110 + <span>Create an app password at <a href="https://bsky.app/settings/app-passwords" target="_blank" rel="noopener noreferrer" class="underline font-medium hover:no-underline">bsky.app/settings/app-passwords</a>.</span> 107 111 </div> 108 112 </section> 109 113
+5 -3
pkg/appview/templates/pages/learn-more.html
··· 164 164 </p> 165 165 <div class="flex flex-col sm:flex-row items-center justify-center gap-4"> 166 166 <a href="/install" class="btn btn-primary btn-lg">Get Started</a> 167 - <a href="https://tangled.org/evan.jarrett.net/at-container-registry" target="_blank" rel="noopener" class="btn btn-ghost btn-lg"> 168 - <img src="/static/tangled-black.svg" alt="" width="20" height="20" loading="lazy" decoding="async" class="size-5 mr-2 icon-light"> 169 - <img src="/static/tangled-white.svg" alt="" width="20" height="20" loading="lazy" decoding="async" class="size-5 mr-2 icon-dark"> 167 + {{ with .SourceURL }} 168 + <a href="{{ . }}" target="_blank" rel="noopener noreferrer" class="btn btn-ghost btn-lg"> 169 + <img src="/static/tangled-black.svg" alt="" aria-hidden="true" width="20" height="20" loading="lazy" decoding="async" class="size-5 mr-2 icon-light"> 170 + <img src="/static/tangled-white.svg" alt="" aria-hidden="true" width="20" height="20" loading="lazy" decoding="async" class="size-5 mr-2 icon-dark"> 170 171 View Source 171 172 </a> 173 + {{ end }} 172 174 </div> 173 175 </section> 174 176 </main>
+16 -3
pkg/appview/templates/pages/login.html
··· 17 17 {{ icon "circle-x" "size-5" }} 18 18 <span> 19 19 {{ if eq .Error "handle_required" }} 20 - Please enter your Atmosphere Account 20 + Please enter your Atmosphere Account. 21 + {{ else if eq .Error "invalid_handle" }} 22 + That handle doesn't look right. Check for typos and try again. 23 + {{ else if eq .Error "pds_unreachable" }} 24 + We couldn't reach your PDS. It may be offline — try again in a minute. 25 + {{ else if eq .Error "state_mismatch" }} 26 + Your sign-in session expired before we finished. Please start over. 27 + {{ else if eq .Error "access_denied" }} 28 + You declined to authorize {{ .ClientShortName }}. No changes were made. 29 + {{ else if eq .Error "invalid_scope" }} 30 + Your PDS refused the requested permissions. Try again or contact your PDS operator. 31 + {{ else if eq .Error "session_expired" }} 32 + Your session expired. Please sign in again. 21 33 {{ else if eq .Error "auth_failed" }} 22 34 Authentication failed. Please try again. 23 35 {{ else }} ··· 27 39 </div> 28 40 {{ end }} 29 41 30 - <form action="/auth/oauth/login" method="POST" id="login-form" class="max-w-md mx-auto flex flex-col"> 42 + <form action="/auth/oauth/login" method="POST" id="login-form" class="max-w-md mx-auto flex flex-col" 43 + onsubmit="var b=this.querySelector('button[type=submit]');if(b){b.disabled=true;b.textContent='Signing in\u2026';}"> 31 44 <input type="hidden" name="return_to" value="{{ .ReturnTo }}" /> 32 45 33 46 <div class="sailor-typeahead relative order-1"> ··· 46 59 {{ if .Error }}aria-invalid="true" aria-describedby="login-error"{{ end }} /> 47 60 </div> 48 61 49 - <button type="submit" class="btn btn-primary btn-lg w-full mt-6 order-3"> 62 + <button type="submit" aria-label="Sign in" class="btn btn-primary btn-lg w-full mt-6 order-3"> 50 63 Navigate 51 64 </button> 52 65
+6 -10
pkg/appview/templates/pages/privacy.html
··· 9 9 {{ template "nav" . }} 10 10 11 11 <main id="main-content" class="container mx-auto px-4 py-8 max-w-4xl"> 12 - <h1 class="text-3xl font-display font-bold tracking-tight mb-2">Privacy Policy - {{ .CompanyName }} ({{ .SiteURL }})</h1> 13 - <p class="text-base-content/60 mb-8"><em>Last updated: January 2025</em></p> 12 + <h1 class="text-3xl font-display font-bold tracking-tight mb-2 wrap-break-word">Privacy Policy &mdash; {{ .CompanyName }}{{ with .SiteURL }} ({{ . }}){{ end }}</h1> 13 + <p class="text-base-content/60 mb-8"><em>Last updated: {{ .LastUpdated }}</em></p> 14 14 15 15 <div class="prose prose-sm max-w-none space-y-8"> 16 16 <section> ··· 321 321 <p class="mt-2">Please include your AT Protocol DID or handle so we can verify your identity.</p> 322 322 323 323 <p class="mt-2">We will respond to requests within 30 days (GDPR) or 45 days (CCPA).</p> 324 - </section> 325 - 326 - <section> 327 - <h2 class="text-xl font-semibold text-primary">Contact</h2> 328 - 329 - <p>For questions about this privacy policy or to exercise your data rights, contact:</p> 330 324 331 - <p class="mt-4"><strong>Email:</strong> <a href="mailto:privacy@{{ .SiteURL }}" class="link link-primary">privacy@{{ .SiteURL }}</a></p> 332 - <p><strong>Website:</strong> <a href="https://{{ .SiteURL }}" class="link link-primary">https://{{ .SiteURL }}</a></p> 325 + {{ with .Jurisdiction }} 326 + <p class="mt-4 text-sm text-base-content/70">This policy is governed by the laws of {{ . }}.</p> 327 + {{ end }} 333 328 </section> 329 + 334 330 </div> 335 331 </main> 336 332
+48 -24
pkg/appview/templates/pages/repository.html
··· 15 15 <div class="flex gap-4 items-start"> 16 16 {{ template "repo-avatar" (dict "IconURL" .Repository.IconURL "RepositoryName" .Repository.Name "IsOwner" .IsOwner) }} 17 17 <div class="flex-1 min-w-0"> 18 - <h1 class="text-2xl md:text-3xl font-display font-bold tracking-tight"> 18 + <h1 class="text-2xl md:text-3xl font-display font-bold tracking-tight wrap-break-word min-w-0"> 19 19 <a href="/u/{{ .Owner.Handle }}" class="link link-primary">{{ .Owner.Handle }}</a> 20 - <span class="text-base-content/60">/</span> 20 + <span class="text-base-content/60" aria-hidden="true">/</span> 21 21 <span>{{ .Repository.Name }}</span> 22 22 </h1> 23 23 {{ if .Repository.Description }} 24 - <p class="text-base-content/70 mt-2">{{ .Repository.Description }}</p> 24 + <p class="text-base-content/70 mt-2 line-clamp-3 wrap-break-word">{{ .Repository.Description }}</p> 25 25 {{ end }} 26 26 </div> 27 27 </div> ··· 29 29 <!-- Metadata Row --> 30 30 <div class="flex flex-wrap items-center gap-3"> 31 31 <div class="flex items-center gap-3"> 32 - {{ template "star" (dict "IsStarred" .IsStarred "StarCount" .Stats.StarCount "Interactive" true "Handle" .Owner.Handle "Repository" .Repository.Name) }} 32 + {{ if .StatsAvailable }} 33 + {{ template "star" (dict "IsStarred" .IsStarred "StarCount" .Stats.StarCount "Interactive" true "Handle" .Owner.Handle "Repository" .Repository.Name "IsAuthenticated" (ne .User nil)) }} 33 34 {{ template "pull-count" (dict "PullCount" .Stats.PullCount) }} 35 + {{ else }} 36 + {{/* Stats query failed — show a subdued indicator rather 37 + than zeros that could be mistaken for real counts. */}} 38 + <span class="text-sm text-base-content/50" title="Star and pull counts are temporarily unavailable"> 39 + {{ icon "alert-circle" "size-4 inline" }} Stats unavailable 40 + </span> 41 + {{ end }} 34 42 {{ if .TagCount }} 35 - <span class="flex items-center gap-1 text-sm text-base-content/70" title="{{ .TagCount }} tags"> 43 + <span class="flex items-center gap-1 text-sm text-base-content/70" title="{{ .TagCount }} {{ pluralize .TagCount "tag" "tags" }}"> 36 44 {{ icon "tag" "size-4" }} {{ .TagCount }} 37 45 </span> 38 46 {{ end }} 39 - {{ if .Stats.LastPush }} 47 + {{ if and .StatsAvailable .Stats.LastPush }} 40 48 <span class="text-sm text-base-content/70" title="Last pushed {{ (derefTime .Stats.LastPush).Format "2006-01-02T15:04:05Z07:00" }}"> 41 49 Updated {{ timeAgoShort (derefTime .Stats.LastPush) }} 42 50 </span> ··· 57 65 {{ .SPDXID }} 58 66 </a> 59 67 {{ else }} 60 - <span class="badge badge-md badge-soft badge-secondary" title="Custom license: {{ .Name }}"> 68 + <span class="badge badge-md badge-soft badge-secondary max-w-48 truncate" title="Custom license: {{ .Name }}"> 61 69 {{ .Name }} 62 70 </span> 63 71 {{ end }} ··· 83 91 <div class="flex flex-wrap items-center gap-3"> 84 92 <div class="flex items-center gap-2"> 85 93 {{ icon "tag" "size-6 text-base-content/60" }} 94 + <label for="tag-selector" class="sr-only">Select image tag</label> 86 95 <select id="tag-selector" class="select select-sm select-bordered font-mono" 87 96 hx-get="/r/{{ .Owner.Handle }}/{{ .Repository.Name }}" 88 97 hx-target="#tag-content" 89 98 hx-swap="outerHTML" 90 99 hx-push-url="true" 91 100 hx-include="this" 101 + hx-indicator="#tag-swap-spinner" 92 102 name="tag"> 93 103 {{ range .AllTags }} 94 104 <option value="{{ . }}"{{ if eq . $.SelectedTag.Info.Tag.Tag }} selected{{ end }}>{{ . }}</option> 95 105 {{ end }} 96 106 </select> 107 + <span id="tag-swap-spinner" class="htmx-indicator" aria-hidden="true"> 108 + {{ icon "loader" "size-4 animate-spin" }} 109 + </span> 97 110 {{ if gt (len .AllTags) 1 }} 98 111 <div class="dropdown dropdown-end" id="diff-dropdown"> 99 - <label tabindex="0" class="btn btn-ghost btn-sm gap-1" title="Compare with another tag"> 112 + <button type="button" tabindex="0" class="btn btn-ghost btn-sm gap-1" title="Compare with another tag" aria-haspopup="menu" aria-expanded="false"> 100 113 {{ icon "git-compare" "size-4" }} Diff 101 - </label> 102 - <ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-10 w-56 p-2 shadow max-h-60 overflow-y-auto"> 114 + </button> 115 + <ul tabindex="0" role="menu" class="dropdown-content menu bg-base-200 rounded-box z-10 w-auto min-w-56 max-w-md p-2 shadow max-h-60 overflow-y-auto"> 103 116 {{ range .AllTags }} 104 - <li><a href="#" data-action="diff-to" data-diff-to="{{ . }}">{{ . }}</a></li> 117 + <li role="none"><button type="button" role="menuitem" data-action="diff-to" data-diff-to="{{ . }}" class="truncate text-left">{{ . }}</button></li> 105 118 {{ end }} 106 119 </ul> 107 120 </div> ··· 113 126 <span class="badge badge-sm badge-outline font-mono">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span> 114 127 {{ end }} 115 128 </div> 116 - {{ else }} 129 + {{ else if gt (len .SelectedTag.Info.Platforms) 0 }} 117 130 {{ $p := index .SelectedTag.Info.Platforms 0 }} 118 131 {{ if $p.OS }} 119 132 <div id="platform-badges"> ··· 141 154 <div id="overview-edit" class="card bg-base-200 shadow-sm p-6 hidden"> 142 155 <!-- Write/Preview tabs --> 143 156 <div class="border-b border-base-300 mb-4"> 144 - <nav class="flex gap-0" role="tablist"> 157 + <nav class="flex gap-0" role="tablist" aria-label="README editor mode"> 145 158 <button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-primary text-primary" 159 + id="tab-write" 160 + role="tab" 161 + aria-selected="true" 162 + aria-controls="editor-write" 146 163 data-tab="write" data-action="switch-editor-tab"> 147 164 Write 148 165 </button> 149 166 <button class="editor-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-base-content/60" 167 + id="tab-preview" 168 + role="tab" 169 + aria-selected="false" 170 + aria-controls="editor-preview" 171 + tabindex="-1" 150 172 data-tab="preview" data-action="switch-editor-tab"> 151 173 Preview 152 174 </button> ··· 154 176 </div> 155 177 156 178 <!-- Write panel --> 157 - <div id="editor-write" class="editor-panel"> 179 + <div id="editor-write" class="editor-panel" role="tabpanel" aria-labelledby="tab-write"> 158 180 <!-- Toolbar --> 159 181 <div class="flex flex-wrap gap-1 mb-2 p-1 bg-base-200 rounded-lg"> 160 - <button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="heading" title="Heading"> 182 + <button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="heading" title="Heading" aria-label="Heading"> 161 183 {{ icon "heading" "size-4" }} 162 184 </button> 163 - <button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="bold" title="Bold"> 185 + <button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="bold" title="Bold" aria-label="Bold"> 164 186 {{ icon "bold" "size-4" }} 165 187 </button> 166 - <button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="italic" title="Italic"> 188 + <button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="italic" title="Italic" aria-label="Italic"> 167 189 {{ icon "italic" "size-4" }} 168 190 </button> 169 191 <div class="divider divider-horizontal mx-0"></div> 170 - <button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="link" title="Link"> 192 + <button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="link" title="Link" aria-label="Link"> 171 193 {{ icon "link" "size-4" }} 172 194 </button> 173 - <button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="image" title="Image"> 195 + <button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="image" title="Image" aria-label="Image"> 174 196 {{ icon "image" "size-4" }} 175 197 </button> 176 198 <div class="divider divider-horizontal mx-0"></div> 177 - <button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="ul" title="Bulleted list"> 199 + <button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="ul" title="Bulleted list" aria-label="Bulleted list"> 178 200 {{ icon "list" "size-4" }} 179 201 </button> 180 - <button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="ol" title="Numbered list"> 202 + <button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="ol" title="Numbered list" aria-label="Numbered list"> 181 203 {{ icon "list-ordered" "size-4" }} 182 204 </button> 183 - <button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="code" title="Code"> 205 + <button type="button" class="btn btn-ghost btn-sm md:btn-xs" data-action="insert-md" data-md-type="code" title="Code" aria-label="Code"> 184 206 {{ icon "code" "size-4" }} 185 207 </button> 186 208 </div> ··· 194 216 </div> 195 217 196 218 <!-- Preview panel --> 197 - <div id="editor-preview" class="editor-panel hidden"> 198 - <div id="preview-content" class="prose prose-sm max-w-none min-h-[20rem] p-4 border border-base-300 rounded-lg"> 219 + <div id="editor-preview" class="editor-panel hidden" role="tabpanel" aria-labelledby="tab-preview"> 220 + <div id="preview-content" class="prose prose-sm max-w-none min-h-80 p-4 border border-base-300 rounded-lg"> 199 221 <p class="text-base-content/60">Nothing to preview</p> 200 222 </div> 201 223 </div> ··· 210 232 </div> 211 233 </main> 212 234 235 + {{ if .IsOwner }} 213 236 <!-- Manifest Delete Confirmation Modal --> 214 237 <dialog id="manifest-delete-modal" class="modal" aria-modal="true" aria-labelledby="manifest-delete-title"> 215 238 <div class="modal-box bg-base-200"> ··· 243 266 </div> 244 267 <form method="dialog" class="modal-backdrop"><button>close</button></form> 245 268 </dialog> 269 + {{ end }} 246 270 247 271 <!-- Attestation Details Modal --> 248 272 <dialog id="attestation-detail-modal" class="modal" aria-modal="true" aria-labelledby="attestation-detail-title">
+16 -7
pkg/appview/templates/pages/search.html
··· 10 10 11 11 <main id="main-content" class="container mx-auto px-4 py-8"> 12 12 {{ if .SearchQuery }} 13 - <h1 class="text-2xl font-bold mb-6">Search Results for "{{ .SearchQuery }}"</h1> 13 + <h1 class="text-2xl font-bold mb-6 wrap-break-word line-clamp-2">Search Results for "{{ .SearchQuery }}"</h1> 14 14 {{ else }} 15 15 <h1 class="text-2xl font-bold mb-2">Search</h1> 16 16 <p class="text-base-content/60 mb-6">Enter a search term to find images.</p> 17 17 {{ end }} 18 18 19 - <div id="search-results" hx-get="/api/search-results?q={{ .SearchQuery }}" hx-trigger="load" hx-swap="innerHTML"> 20 - <!-- Initial loading state --> 19 + {{/* Noscript fallback: a plain GET form so users without JavaScript 20 + can still search. The nav search box posts to /search too, but 21 + that's above the fold of the page result; this gives a stable 22 + on-page form at the top of results. */}} 23 + <noscript> 24 + <form action="/search" method="get" class="mb-6 flex gap-2"> 25 + <input type="text" name="q" value="{{ .SearchQuery }}" 26 + class="input input-bordered flex-1" 27 + placeholder="Search images"> 28 + <button type="submit" class="btn btn-primary">Search</button> 29 + </form> 30 + </noscript> 31 + 32 + <div id="search-results" role="region" aria-live="polite" aria-busy="false"> 21 33 {{ if .SearchQuery }} 22 - <div class="flex items-center gap-2 text-base-content/60"> 23 - <span class="loading loading-spinner loading-sm"></span> 24 - <span>Searching...</span> 25 - </div> 34 + {{ template "search-results" .Results }} 26 35 {{ end }} 27 36 </div> 28 37 </main>
+38 -273
pkg/appview/templates/pages/settings.html
··· 14 14 <!-- Mobile identity info (below lg) --> 15 15 <div class="lg:hidden mb-4 space-y-1 text-xs text-base-content/70"> 16 16 <div class="break-all"><code>{{ .Profile.DID }}</code></div> 17 - <div><a href="{{ .Profile.PDSEndpoint }}/account" target="_blank" class="link link-primary inline-flex items-center gap-1">{{ .Profile.PDSEndpoint }} {{ icon "external-link" "size-3" }}</a></div> 17 + <div>{{ with .Profile.PDSEndpoint }}<a href="{{ . }}/account" target="_blank" rel="noopener noreferrer" class="link link-primary inline-flex items-center gap-1 break-all max-w-full" title="{{ . }}">{{ . }} {{ icon "external-link" "size-3 shrink-0" }}</a>{{ end }}</div> 18 18 </div> 19 19 20 20 <!-- Mobile tab bar (below lg) --> 21 + {{ $active := .ActiveTab }} 21 22 <div class="flex gap-2 overflow-x-auto pb-2 lg:hidden mb-6" role="tablist" aria-label="Settings sections" aria-orientation="horizontal"> 22 - <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="user" role="tab" id="tab-mobile-user" aria-controls="tab-user" aria-selected="false" tabindex="-1"> 23 - {{ icon "user" "size-4" }} User 24 - </button> 25 - <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="billing" role="tab" id="tab-mobile-billing" aria-controls="tab-billing" aria-selected="false" tabindex="-1"> 26 - {{ icon "credit-card" "size-4" }} Billing 27 - </button> 28 - <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="storage" role="tab" id="tab-mobile-storage" aria-controls="tab-storage" aria-selected="false" tabindex="-1"> 29 - {{ icon "hard-drive" "size-4" }} Storage 30 - </button> 31 - <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="devices" role="tab" id="tab-mobile-devices" aria-controls="tab-devices" aria-selected="false" tabindex="-1"> 32 - {{ icon "terminal" "size-4" }} Devices 33 - </button> 34 - <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="webhooks" role="tab" id="tab-mobile-webhooks" aria-controls="tab-webhooks" aria-selected="false" tabindex="-1"> 35 - {{ icon "webhook" "size-4" }} Webhooks 36 - </button> 37 - <button class="btn btn-sm btn-ghost settings-tab-mobile" data-tab="advanced" role="tab" id="tab-mobile-advanced" aria-controls="tab-advanced" aria-selected="false" tabindex="-1"> 38 - {{ icon "shield-check" "size-4" }} Advanced 39 - </button> 23 + {{ range .Tabs }} 24 + <a href="/settings/{{ .Slug }}" 25 + class="btn btn-sm {{ if eq .Slug $active }}btn-secondary{{ else }}btn-ghost{{ end }} settings-tab-mobile shrink-0" 26 + data-tab="{{ .Slug }}" 27 + hx-get="/settings/{{ .Slug }}" 28 + hx-target="#tab-content" 29 + hx-swap="innerHTML show:top" 30 + hx-push-url="true" 31 + role="tab" 32 + id="tab-mobile-{{ .Slug }}" 33 + aria-controls="tab-content" 34 + aria-selected="{{ if eq .Slug $active }}true{{ else }}false{{ end }}" 35 + tabindex="{{ if eq .Slug $active }}0{{ else }}-1{{ end }}"> 36 + {{ icon .Icon "size-4" }} {{ .Label }} 37 + </a> 38 + {{ end }} 40 39 </div> 41 40 42 41 <div class="flex gap-8"> 43 42 <!-- Sidebar (lg and above) --> 44 43 <aside class="hidden lg:block w-56 shrink-0"> 45 44 <ul class="menu bg-base-200 rounded-box w-full" role="tablist" aria-label="Settings sections" aria-orientation="vertical"> 46 - <li data-tab="user" role="none"><a href="#user" role="tab" id="tab-desktop-user" aria-controls="tab-user" aria-selected="false" tabindex="-1">{{ icon "user" "size-4" }} User</a></li> 47 - <li data-tab="billing" role="none"><a href="#billing" role="tab" id="tab-desktop-billing" aria-controls="tab-billing" aria-selected="false" tabindex="-1">{{ icon "credit-card" "size-4" }} Billing</a></li> 48 - <li data-tab="storage" role="none"><a href="#storage" role="tab" id="tab-desktop-storage" aria-controls="tab-storage" aria-selected="false" tabindex="-1">{{ icon "hard-drive" "size-4" }} Storage</a></li> 49 - <li data-tab="devices" role="none"><a href="#devices" role="tab" id="tab-desktop-devices" aria-controls="tab-devices" aria-selected="false" tabindex="-1">{{ icon "terminal" "size-4" }} Devices</a></li> 50 - <li data-tab="webhooks" role="none"><a href="#webhooks" role="tab" id="tab-desktop-webhooks" aria-controls="tab-webhooks" aria-selected="false" tabindex="-1">{{ icon "webhook" "size-4" }} Webhooks</a></li> 51 - <li data-tab="advanced" role="none"><a href="#advanced" role="tab" id="tab-desktop-advanced" aria-controls="tab-advanced" aria-selected="false" tabindex="-1">{{ icon "shield-check" "size-4" }} Advanced</a></li> 45 + {{ range .Tabs }} 46 + <li data-tab="{{ .Slug }}" role="none" {{ if eq .Slug $active }}class="menu-active"{{ end }}> 47 + <a href="/settings/{{ .Slug }}" 48 + hx-get="/settings/{{ .Slug }}" 49 + hx-target="#tab-content" 50 + hx-swap="innerHTML show:top" 51 + hx-push-url="true" 52 + role="tab" 53 + id="tab-desktop-{{ .Slug }}" 54 + aria-controls="tab-content" 55 + aria-selected="{{ if eq .Slug $active }}true{{ else }}false{{ end }}" 56 + tabindex="{{ if eq .Slug $active }}0{{ else }}-1{{ end }}"> 57 + {{ icon .Icon "size-4" }} {{ .Label }} 58 + </a> 59 + </li> 60 + {{ end }} 52 61 </ul> 53 62 <div class="mt-4 px-2 space-y-1 text-xs text-base-content/70"> 54 63 <div class="break-all"><code>{{ .Profile.DID }}</code></div> 55 - <div><a href="{{ .Profile.PDSEndpoint }}/account" target="_blank" class="link link-primary inline-flex items-center gap-1">{{ .Profile.PDSEndpoint }} {{ icon "external-link" "size-3" }}</a></div> 64 + <div>{{ with .Profile.PDSEndpoint }}<a href="{{ . }}/account" target="_blank" rel="noopener noreferrer" class="link link-primary inline-flex items-center gap-1 break-all max-w-full" title="{{ . }}">{{ . }} {{ icon "external-link" "size-3 shrink-0" }}</a>{{ end }}</div> 56 65 </div> 57 66 </aside> 58 67 59 - <!-- Tab content --> 60 - <div class="flex-1 min-w-0"> 61 - 62 - <!-- USER TAB --> 63 - <div id="tab-user" class="settings-panel hidden space-y-6" role="tabpanel" aria-labelledby="tab-desktop-user" tabindex="0"> 64 - <section class="card bg-base-200 shadow-sm p-6 space-y-6"> 65 - <div> 66 - <h2 class="text-xl font-semibold">Preferences</h2> 67 - <p class="text-base-content/70 mt-1">Customize your experience across the site.</p> 68 - </div> 69 - 70 - <!-- Preferred Client Selector --> 71 - <div class="flex items-center gap-4"> 72 - <div> 73 - <label for="oci-client-select" class="text-sm font-medium">Preferred client</label> 74 - <p id="oci-client-hint" class="text-xs text-base-content/70">Sets the pull command shown on repository pages. Choose <em>Image reference only</em> to copy without a command prefix.</p> 75 - </div> 76 - {{ $oci := .Profile.OciClient }} 77 - <select id="oci-client-select" aria-describedby="oci-client-hint" class="select select-sm select-bordered min-w-40" 78 - name="oci_client" 79 - hx-post="/api/profile/oci-client" 80 - hx-trigger="change" 81 - hx-swap="none"> 82 - <option value="docker"{{ if or (eq $oci "") (eq $oci "docker") }} selected{{ end }}>Docker</option> 83 - <option value="podman"{{ if eq $oci "podman" }} selected{{ end }}>Podman</option> 84 - <option value="buildah"{{ if eq $oci "buildah" }} selected{{ end }}>Buildah</option> 85 - <option value="nerdctl"{{ if eq $oci "nerdctl" }} selected{{ end }}>nerdctl</option> 86 - <option value="crane"{{ if eq $oci "crane" }} selected{{ end }}>crane</option> 87 - <option value="none"{{ if eq $oci "none" }} selected{{ end }}>Image reference only</option> 88 - </select> 89 - </div> 90 - 91 - <!-- AI Image Advisor Toggle --> 92 - {{ if .AIAdvisorEnabled }} 93 - <div class="divider my-2"></div> 94 - <div class="flex items-start gap-3"> 95 - {{ if .Profile.HasAIAdvisorAccess }} 96 - <label class="flex items-start gap-3 cursor-pointer"> 97 - <input type="checkbox" class="toggle toggle-primary mt-0.5" 98 - hx-post="/api/profile/ai-advisor" 99 - hx-trigger="change" 100 - hx-swap="none" 101 - {{ if .Profile.AIAdvisorEnabled }}checked{{ end }}> 102 - <div> 103 - <span class="font-medium">AI Image Advisor</span> 104 - <p class="text-xs text-base-content/60">Analyze your container images for optimization suggestions using AI.</p> 105 - </div> 106 - </label> 107 - {{ else }} 108 - <div> 109 - <span class="font-medium text-base-content/50">AI Image Advisor</span> 110 - <p class="text-xs text-base-content/70">Analyze your container images for optimization suggestions using AI.</p> 111 - <p class="text-xs text-primary mt-1"> 112 - <a href="/settings#billing">Upgrade your plan</a> to enable this feature. 113 - </p> 114 - </div> 115 - {{ end }} 116 - </div> 117 - {{ end }} 118 - </section> 119 - </div> 120 - 121 - <!-- STORAGE TAB --> 122 - <div id="tab-storage" class="settings-panel hidden space-y-4" role="tabpanel" aria-labelledby="tab-desktop-storage" tabindex="0"> 123 - <!-- Holds --> 124 - {{ if .AllHolds }} 125 - <div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> 126 - <div class="space-y-4"> 127 - {{ template "hold_selector" . }} 128 - {{ if .ActiveHold }} 129 - {{ template "hold_card" .ActiveHold }} 130 - {{ else }} 131 - <div class="card bg-base-200 shadow-sm p-6 text-center text-base-content/60"> 132 - No active hold selected. Choose one above. 133 - </div> 134 - {{ end }} 135 - </div> 136 - <div> 137 - {{ if .OtherHolds }} 138 - {{ template "other_holds_table" .OtherHolds }} 139 - {{ end }} 140 - </div> 141 - </div> 142 - {{ else }} 143 - <div class="card bg-base-200 shadow-sm p-6 text-center text-base-content/60"> 144 - No holds configured. Push an image to get started. 145 - </div> 146 - {{ end }} 147 - 148 - <!-- Storage Preferences --> 149 - <section class="card bg-base-200 shadow-sm p-6 space-y-4"> 150 - <h2 class="text-xl font-semibold">Storage Preferences</h2> 151 - <label class="flex items-start gap-3 cursor-pointer"> 152 - <input type="checkbox" class="toggle toggle-primary mt-0.5" 153 - hx-post="/api/profile/auto-remove-untagged" 154 - hx-trigger="change" 155 - hx-swap="none" 156 - {{ if .Profile.AutoRemoveUntagged }}checked{{ end }}> 157 - <div> 158 - <span class="font-medium">Automatically remove untagged manifests</span> 159 - <p class="text-sm text-base-content/60 mt-1"> 160 - When a tag is overwritten, the old manifest and its layers are cleaned up. 161 - Multi-arch child manifests are preserved. 162 - </p> 163 - </div> 164 - </label> 165 - </section> 166 - </div> 167 - 168 - <!-- BILLING TAB --> 169 - <div id="tab-billing" class="settings-panel hidden space-y-4" role="tabpanel" aria-labelledby="tab-desktop-billing" tabindex="0"> 170 - {{ template "subscription_plans" .Subscription }} 171 - </div> 172 - 173 - <!-- DEVICES TAB --> 174 - <div id="tab-devices" class="settings-panel hidden space-y-6" role="tabpanel" aria-labelledby="tab-desktop-devices" tabindex="0"> 175 - <section class="card bg-base-200 shadow-sm p-6 space-y-6"> 176 - <div> 177 - <h2 class="text-xl font-semibold">Authorized Devices</h2> 178 - <p class="text-base-content/70 mt-1">Devices authorized via <code class="cmd">docker-credential-atcr</code> credential helper.</p> 179 - </div> 180 - 181 - <!-- Setup Instructions --> 182 - <div class="bg-base-200 rounded-lg p-4 space-y-4"> 183 - <h3 class="font-semibold">First Time Setup</h3> 184 - <ol class="list-decimal list-inside space-y-4 text-sm"> 185 - <li>Install credential helper: 186 - <pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>curl -fsSL {{ .SiteURL }}/static/install.sh | bash</code></pre> 187 - </li> 188 - <li>Configure Docker to use the helper. Add to <code class="cmd">~/.docker/config.json</code>: 189 - <pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>{ 190 - "credHelpers": { 191 - "{{ .RegistryURL }}": "atcr" 192 - } 193 - }</code></pre> 194 - </li> 195 - <li>Run any Docker command: 196 - <div class="mt-2">{{ template "docker-command" (print (pullPrefix .OciClient) .RegistryURL "/" .Profile.Handle "/myimage") }}</div> 197 - </li> 198 - <li>Browser will open for authorization - click Approve</li> 199 - <li>Done! Device is automatically authorized</li> 200 - </ol> 201 - 202 - <div class="pt-3 border-t border-base-300 text-sm"> 203 - <strong>Fallback:</strong> Use <a href="https://bsky.app/settings/app-passwords" target="_blank" class="link link-primary">app password</a> with <code class="cmd">docker login {{ .RegistryURL }}</code> for quick start (no device tracking) 204 - </div> 205 - </div> 206 - 207 - <!-- Devices List --> 208 - <div class="space-y-3"> 209 - <h3 class="font-semibold">Your Authorized Devices</h3> 210 - <div class="overflow-x-auto"> 211 - <table class="table table-zebra"> 212 - <thead> 213 - <tr> 214 - <th>Device Name</th> 215 - <th>IP Address</th> 216 - <th>Created</th> 217 - <th>Last Used</th> 218 - <th>Actions</th> 219 - </tr> 220 - </thead> 221 - <tbody id="devices-table" 222 - hx-get="/api/devices" 223 - hx-trigger="tab:devices from:body once, every 30s[isTabActive('devices')], devicesChanged from:body" 224 - hx-swap="innerHTML"> 225 - <tr><td colspan="5" class="text-center">{{ icon "loader-2" "size-4 animate-spin inline-block" }} Loading...</td></tr> 226 - </tbody> 227 - </table> 228 - </div> 229 - </div> 230 - </section> 231 - </div> 232 - 233 - <!-- WEBHOOKS TAB --> 234 - <div id="tab-webhooks" class="settings-panel hidden space-y-6" role="tabpanel" aria-labelledby="tab-desktop-webhooks" tabindex="0"> 235 - <section class="card bg-base-200 shadow-sm p-6 space-y-4"> 236 - <div> 237 - <h2 class="text-xl font-semibold">Webhooks</h2> 238 - <p class="text-base-content/70 mt-1">Get notified when images are pushed or vulnerability scans complete.</p> 239 - </div> 240 - <div id="webhooks-content"> 241 - {{ template "webhooks_list" .WebhooksData }} 242 - </div> 243 - </section> 244 - </div> 245 - 246 - <!-- ADVANCED TAB --> 247 - <div id="tab-advanced" class="settings-panel hidden space-y-6" role="tabpanel" aria-labelledby="tab-desktop-advanced" tabindex="0"> 248 - <!-- Data Privacy Section --> 249 - <section class="card bg-base-200 shadow-sm p-6 space-y-4"> 250 - <h2 class="text-xl font-semibold">Data Privacy</h2> 251 - <p class="text-base-content/70">Download a copy of all data we store about you.</p> 252 - 253 - <div> 254 - <a href="/api/export-data" class="btn btn-secondary gap-2" download> 255 - {{ icon "download" "size-4" }} 256 - Export All My Data 257 - </a> 258 - </div> 259 - 260 - <p class="text-sm text-base-content/60"> 261 - This includes your authorized devices, sessions, and hold memberships. 262 - Data stored on your PDS is already under your control. 263 - See our <a href="/privacy" class="link link-primary">Privacy Policy</a> for details. 264 - </p> 265 - </section> 266 - 267 - <!-- Danger Zone Section --> 268 - <section class="border-2 border-error rounded-lg p-6 space-y-4"> 269 - <h2 class="text-xl font-semibold text-error flex items-center gap-2"> 270 - {{ icon "alert-triangle" "size-5" }} 271 - Danger Zone 272 - </h2> 273 - 274 - <div class="space-y-4"> 275 - <div> 276 - <h3 class="font-semibold">Delete {{ .ClientShortName }} Data</h3> 277 - <p class="text-base-content/70 mt-1">Remove your data from {{ .ClientShortName }}. This action cannot be undone.</p> 278 - </div> 279 - 280 - <div class="alert bg-base-200"> 281 - {{ icon "info" "size-5 shrink-0" }} 282 - <span><strong>This does not delete your ATProto (Bluesky, Blacksky, Tangled) account.</strong><br>Only {{ .ClientShortName }}-specific data (authorized devices, hold memberships, settings) will be removed.</span> 283 - </div> 284 - 285 - <div class="space-y-2"> 286 - <label class="flex items-start gap-3 cursor-pointer"> 287 - <input type="checkbox" id="delete-pds-records" class="checkbox checkbox-sm mt-0.5"> 288 - <span class="text-sm">Also delete all <code class="cmd">io.atcr.*</code> records from my ATProto PDS</span> 289 - </label> 290 - <p class="text-xs text-base-content/60 ml-7"> 291 - This removes {{ .ClientShortName }} records (manifests, tags, stars, profile) stored in your PDS. 292 - Other records in your account are not impacted. 293 - </p> 294 - </div> 295 - 296 - <button type="button" id="delete-account-btn" class="btn btn-error btn-lg gap-2" 297 - data-client-short-name="{{ .ClientShortName }}" 298 - data-profile-handle="{{ .Profile.Handle }}"> 299 - {{ icon "trash-2" "size-5" }} 300 - Delete My {{ .ClientShortName }} Data 301 - </button> 302 - </div> 303 - </section> 304 - </div> 305 - 68 + <!-- Active tab content --> 69 + <div id="tab-content" class="flex-1 min-w-0 space-y-6" role="tabpanel" aria-labelledby="tab-desktop-{{ $active }}" tabindex="0"> 70 + {{ template "settings-panel" . }} 306 71 </div> 307 72 </div> 308 73 </main>
+2 -2
pkg/appview/templates/pages/terms.html
··· 9 9 {{ template "nav" . }} 10 10 11 11 <main id="main-content" class="container mx-auto px-4 py-8 max-w-4xl"> 12 - <h1 class="text-3xl font-display font-bold tracking-tight mb-2">Terms of Service - {{ .CompanyName }} ({{ .SiteURL }})</h1> 13 - <p class="text-base-content/60 mb-8"><em>Last updated: January 2025</em></p> 12 + <h1 class="text-3xl font-display font-bold tracking-tight mb-2 wrap-break-word">Terms of Service &mdash; {{ .CompanyName }}{{ with .SiteURL }} ({{ . }}){{ end }}</h1> 13 + <p class="text-base-content/60 mb-8"><em>Last updated: {{ .LastUpdated }}</em></p> 14 14 15 15 <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> 16 16
+13 -7
pkg/appview/templates/pages/user.html
··· 15 15 {{ if .ViewedUser.Avatar }} 16 16 <div class="avatar"> 17 17 <div class="w-20 rounded-full shadow"> 18 - <img src="{{ resizeImage .ViewedUser.Avatar 160 }}" alt="{{ .ViewedUser.Handle }}" width="80" height="80" fetchpriority="high" /> 18 + <img src="{{ resizeImage .ViewedUser.Avatar 160 }}" alt="" aria-hidden="true" width="80" height="80" fetchpriority="high" /> 19 19 </div> 20 20 </div> 21 21 {{ else if .HasProfile }} 22 - <div class="avatar avatar-placeholder"> 22 + <div class="avatar avatar-placeholder" role="img" aria-label="Avatar for {{ .ViewedUser.Handle }}"> 23 23 <div class="bg-neutral text-neutral-content w-20 rounded-full shadow"> 24 - <span class="text-3xl">{{ firstChar .ViewedUser.Handle }}</span> 24 + <span aria-hidden="true" class="text-3xl">{{ firstChar .ViewedUser.Handle }}</span> 25 25 </div> 26 26 </div> 27 27 {{ else }} 28 - <div class="avatar avatar-placeholder"> 28 + <div class="avatar avatar-placeholder" role="img" aria-label="Unknown user avatar"> 29 29 <div class="bg-neutral text-neutral-content/60 w-20 rounded-full shadow"> 30 - <span class="text-3xl">?</span> 30 + <span aria-hidden="true" class="text-3xl">?</span> 31 31 </div> 32 32 </div> 33 33 {{ end }} 34 - <div class="flex items-center gap-2"> 35 - <h1 class="text-2xl md:text-3xl font-display font-bold tracking-tight">{{ .ViewedUser.Handle }}</h1> 34 + <div class="flex flex-wrap items-center justify-center gap-2 min-w-0"> 35 + <h1 class="text-2xl md:text-3xl font-display font-bold tracking-tight break-all min-w-0">{{ .ViewedUser.Handle }}</h1> 36 36 {{ if or (eq .SupporterBadge "Captain") (eq .SupporterBadge "owner") }} 37 37 <span class="badge badge-sm supporter-badge-owner">{{ .SupporterBadge }}</span> 38 38 {{ else if .SupporterBadge }} ··· 46 46 <div class="text-center text-base-content/60 py-12"> 47 47 <p>This user hasn't set up their {{ .ClientShortName }} profile yet.</p> 48 48 </div> 49 + {{ else if .HasError }} 50 + {{ template "state-error" (dict 51 + "Title" "We couldn't load their images" 52 + "Subtext" "The database had trouble fetching this profile. Try refreshing in a moment." 53 + "RetryURL" (printf "/u/%s" .ViewedUser.Handle) 54 + ) }} 49 55 {{ else }} 50 56 <div class="w-full"> 51 57 {{ template "card-grid" (dict "Repositories" .Repositories "Columns" 4 "EmptyMessage" "No images yet.") }}
+7 -3
pkg/appview/templates/partials/alert.html
··· 1 1 {{ define "alert" }} 2 2 {{ if eq .Type "success" }} 3 - <div class="success">{{ icon "check" "size-5" }} {{ .Message }}</div> 3 + <div class="alert alert-success wrap-break-word" role="status" aria-live="polite">{{ icon "check-circle" "size-5 shrink-0" }} <span>{{ .Message }}</span></div> 4 4 {{ else if eq .Type "error" }} 5 - <div class="error">{{ icon "alert-circle" "size-5" }} {{ .Message }}</div> 5 + <div class="alert alert-error wrap-break-word" role="alert" aria-live="assertive">{{ icon "alert-circle" "size-5 shrink-0" }} <span>{{ .Message }}</span></div> 6 + {{ else if eq .Type "warning" }} 7 + <div class="alert alert-warning wrap-break-word" role="alert" aria-live="polite">{{ icon "alert-triangle" "size-5 shrink-0" }} <span>{{ .Message }}</span></div> 8 + {{ else if eq .Type "info" }} 9 + <div class="alert alert-info wrap-break-word" role="status" aria-live="polite">{{ icon "info" "size-5 shrink-0" }} <span>{{ .Message }}</span></div> 6 10 {{ else }} 7 - <div class="{{ .Class }}">{{ .Message }}</div> 11 + <div class="alert wrap-break-word" role="status" aria-live="polite"><span>{{ .Message }}</span></div> 8 12 {{ end }} 9 13 {{ end }}
+15 -4
pkg/appview/templates/partials/attestation-details.html
··· 1 1 {{ define "attestation-details" }} 2 2 {{ if .Error }} 3 - <p class="text-base-content/60">{{ .Error }}</p> 3 + <p class="text-base-content/60 wrap-break-word">{{ .Error }}</p> 4 4 {{ else }} 5 5 <div class="space-y-4"> 6 - <p class="font-semibold text-sm">{{ len .Attestations }} attestation{{ if gt (len .Attestations) 1 }}s{{ end }} attached</p> 6 + <p class="font-semibold text-sm">{{ len .Attestations }} {{ pluralize (len .Attestations) "attestation" "attestations" }} attached</p> 7 7 8 8 {{ range .Attestations }} 9 9 <div class="bg-base-200 rounded-lg p-4 space-y-3"> 10 10 <div class="flex flex-wrap items-center justify-between gap-2"> 11 + {{/* "Unknown" / "Binary" etc. predicates shouldn't read as success-green. */}} 12 + {{ if or (eq .PredicateType "Unknown") (eq .PredicateType "Binary") }} 13 + <span class="badge badge-md badge-ghost">{{ .PredicateType }}</span> 14 + {{ else }} 11 15 <span class="badge badge-md badge-soft badge-success">{{ .PredicateType }}</span> 16 + {{ end }} 12 17 <code class="font-mono text-xs text-base-content/60 truncate max-w-48" title="{{ .Digest }}">{{ .Digest }}</code> 13 18 </div> 14 19 {{ if .NeedsLogin }} 20 + {{ if $.LoginURL }} 15 21 <p class="text-sm text-base-content/70"><a href="{{ $.LoginURL }}" class="link link-primary">Log in</a> to view attestation content</p> 22 + {{ else }} 23 + <p class="text-sm text-base-content/70"><a href="/auth/oauth/login" class="link link-primary">Log in</a> to view attestation content</p> 24 + {{ end }} 16 25 {{ else if .FetchError }} 17 - <p class="text-sm text-base-content/70">{{ .FetchError }}</p> 26 + <p class="text-sm text-base-content/70 wrap-break-word">{{ .FetchError }}</p> 18 27 {{ else if .RawJSON }} 19 28 <details> 20 29 <summary class="cursor-pointer text-sm text-base-content/70 hover:text-base-content">View content</summary> ··· 23 32 </div> 24 33 </details> 25 34 {{ else if .Size }} 26 - <p class="text-sm text-base-content/70">Binary content ({{ .Size }} bytes) — cannot display inline</p> 35 + <p class="text-sm text-base-content/70">Binary content ({{ humanizeBytes .Size }}) — cannot display inline</p> 27 36 {{ end }} 28 37 </div> 38 + {{ else }} 39 + <p class="text-base-content/60">No attestations attached to this manifest.</p> 29 40 {{ end }} 30 41 </div> 31 42 {{ end }}
+4 -3
pkg/appview/templates/partials/devices-table.html
··· 1 1 {{ define "devices-table" }} 2 2 {{ range .Devices }} 3 3 <tr id="device-{{ .ID }}"> 4 - <td>{{ .Name }}</td> 5 - <td class="font-mono text-sm">{{ if .IPAddress }}{{ .IPAddress }}{{ else }}Unknown{{ end }}</td> 4 + <td><span class="truncate inline-block max-w-xs align-middle" title="{{ .Name }}">{{ .Name }}</span></td> 5 + <td class="font-mono text-sm"><span class="truncate inline-block max-w-48 align-middle" title="{{ .IPAddress }}">{{ if .IPAddress }}{{ .IPAddress }}{{ else }}Unknown{{ end }}</span></td> 6 6 <td>{{ formatDate .CreatedAt }}</td> 7 7 <td>{{ if isZeroTime .LastUsed }}Never{{ else }}{{ formatDate .LastUsed }}{{ end }}</td> 8 8 <td> ··· 10 10 hx-delete="/api/devices/{{ .ID }}" 11 11 hx-target="#device-{{ .ID }}" 12 12 hx-swap="delete" 13 - hx-confirm="Revoke access for {{ .Name }}?"> 13 + hx-confirm="Revoke access for {{ .Name }}?" 14 + aria-label="Revoke access for {{ .Name }}"> 14 15 {{ icon "trash-2" "size-4" }} 15 16 </button> 16 17 </td>
+50 -35
pkg/appview/templates/partials/diff-content.html
··· 7 7 {{ if .LayerDiff }} 8 8 <div class="overflow-x-auto"> 9 9 <table class="table table-xs w-full"> 10 + <caption class="sr-only">Layer differences</caption> 10 11 <thead> 11 12 <tr class="text-xs"> 12 - <th class="w-6"></th> 13 - <th class="w-8">#</th> 14 - <th>Command</th> 15 - <th class="text-right w-24">Size</th> 13 + <th scope="col" class="w-6"><span class="sr-only">Change</span></th> 14 + <th scope="col" class="w-8">#</th> 15 + <th scope="col">Command</th> 16 + <th scope="col" class="text-right w-24">Size</th> 16 17 </tr> 17 18 </thead> 18 19 <tbody> 19 20 {{ range .LayerDiff }} 20 21 <tr class="{{ if eq .Status "added" }}bg-success/10{{ else if eq .Status "removed" }}bg-error/10{{ else if eq .Status "rebuilt" }}bg-warning/10{{ else }}opacity-60{{ end }}"> 21 - <td class="font-mono text-xs text-center font-bold {{ if eq .Status "added" }}text-success{{ else if eq .Status "removed" }}text-error{{ else if eq .Status "rebuilt" }}text-warning{{ end }}">{{ if eq .Status "added" }}+{{ else if eq .Status "removed" }}-{{ else if eq .Status "rebuilt" }}~{{ end }}</td> 22 + <td class="font-mono text-xs text-center font-bold {{ if eq .Status "added" }}text-success{{ else if eq .Status "removed" }}text-error{{ else if eq .Status "rebuilt" }}text-warning{{ end }}">{{/* Glyph + sr-only label: color is redundant information. */}}{{ if eq .Status "added" }}<span aria-hidden="true">+</span><span class="sr-only">Added</span>{{ else if eq .Status "removed" }}<span aria-hidden="true">-</span><span class="sr-only">Removed</span>{{ else if eq .Status "rebuilt" }}<span aria-hidden="true">~</span><span class="sr-only">Rebuilt</span>{{ else }}<span class="sr-only">Unchanged</span>{{ end }}</td> 22 23 <td class="font-mono text-xs">{{ .Layer.Index }}</td> 23 24 <td> 24 25 {{ if .Layer.Command }} ··· 48 49 <h2 class="text-lg font-semibold">Vulnerabilities</h2> 49 50 50 51 {{ if not .HasVulnData }} 51 - <p class="text-base-content/60">Vulnerability scan data not available for both manifests</p> 52 + {{/* Branch on per-side scan status so users can tell "not scanned 53 + yet" from "hold offline" from transient errors. */}} 54 + {{ if or (eq .FromScanStatus "hold-unreachable") (eq .ToScanStatus "hold-unreachable") }} 55 + <div class="alert alert-warning" role="status"> 56 + {{ icon "wifi-off" "size-4 shrink-0" }} 57 + <span>We couldn't reach the hold to fetch scan data. Try again in a moment.</span> 58 + </div> 59 + {{ else if or (eq .FromScanStatus "no-data") (eq .ToScanStatus "no-data") }} 60 + <p class="text-base-content/60">Neither manifest has been scanned yet. Vulnerability comparison will appear after both scans complete.</p> 61 + {{ else }} 62 + <p class="text-base-content/60">Vulnerability scan data isn't available for both manifests.</p> 63 + {{ end }} 52 64 {{ else }} 53 65 54 66 <!-- Fixed Vulns --> ··· 62 74 <div class="collapse-content"> 63 75 <div class="overflow-x-auto"> 64 76 <table class="table table-xs w-full"> 77 + <caption class="sr-only">Vulnerabilities fixed in the newer manifest</caption> 65 78 <thead> 66 79 <tr class="text-xs"> 67 - <th>CVE</th> 68 - <th>Severity</th> 69 - <th>Package</th> 70 - <th>Was</th> 80 + <th scope="col">CVE</th> 81 + <th scope="col">Severity</th> 82 + <th scope="col">Package</th> 83 + <th scope="col">Was</th> 71 84 </tr> 72 85 </thead> 73 86 <tbody> 74 87 {{ range .FixedVulns }} 75 88 <tr> 76 89 <td> 77 - {{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener" class="link link-primary text-xs font-mono">{{ .CVEID }}</a> 78 - {{ else }}<span class="text-xs font-mono">{{ .CVEID }}</span>{{ end }} 90 + {{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-xs font-mono">{{ or .CVEID "—" }}</a> 91 + {{ else }}<span class="text-xs font-mono">{{ or .CVEID "—" }}</span>{{ end }} 79 92 </td> 80 93 <td> 81 - <span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}">{{ .Severity }}</span> 94 + <span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}" title="{{ severityLabel .Severity }}">{{ severityLabel .Severity }}</span> 82 95 </td> 83 - <td class="text-xs">{{ .Package }}</td> 84 - <td class="text-xs font-mono">{{ .Version }}</td> 96 + <td class="text-xs truncate max-w-xs" title="{{ .Package }}">{{ .Package }}</td> 97 + <td class="text-xs font-mono truncate max-w-40" title="{{ .Version }}">{{ .Version }}</td> 85 98 </tr> 86 99 {{ end }} 87 100 </tbody> ··· 102 115 <div class="collapse-content"> 103 116 <div class="overflow-x-auto"> 104 117 <table class="table table-xs w-full"> 118 + <caption class="sr-only">Vulnerabilities new to the newer manifest</caption> 105 119 <thead> 106 120 <tr class="text-xs"> 107 - <th>CVE</th> 108 - <th>Severity</th> 109 - <th>Package</th> 110 - <th>Version</th> 111 - <th>Fix</th> 121 + <th scope="col">CVE</th> 122 + <th scope="col">Severity</th> 123 + <th scope="col">Package</th> 124 + <th scope="col">Version</th> 125 + <th scope="col">Fix</th> 112 126 </tr> 113 127 </thead> 114 128 <tbody> 115 129 {{ range .NewVulns }} 116 130 <tr> 117 131 <td> 118 - {{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener" class="link link-primary text-xs font-mono">{{ .CVEID }}</a> 119 - {{ else }}<span class="text-xs font-mono">{{ .CVEID }}</span>{{ end }} 132 + {{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-xs font-mono">{{ or .CVEID "—" }}</a> 133 + {{ else }}<span class="text-xs font-mono">{{ or .CVEID "—" }}</span>{{ end }} 120 134 </td> 121 135 <td> 122 - <span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}">{{ .Severity }}</span> 136 + <span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}" title="{{ severityLabel .Severity }}">{{ severityLabel .Severity }}</span> 123 137 </td> 124 - <td class="text-xs">{{ .Package }}</td> 125 - <td class="text-xs font-mono">{{ .Version }}</td> 138 + <td class="text-xs truncate max-w-xs" title="{{ .Package }}">{{ .Package }}</td> 139 + <td class="text-xs font-mono truncate max-w-40" title="{{ .Version }}">{{ .Version }}</td> 126 140 <td class="text-xs font-mono">{{ .FixedIn }}</td> 127 141 </tr> 128 142 {{ end }} ··· 143 157 <div class="collapse-content"> 144 158 <div class="overflow-x-auto"> 145 159 <table class="table table-xs w-full"> 160 + <caption class="sr-only">Vulnerabilities present in both manifests</caption> 146 161 <thead> 147 162 <tr class="text-xs"> 148 - <th>CVE</th> 149 - <th>Severity</th> 150 - <th>Package</th> 151 - <th>Version</th> 152 - <th>Fix</th> 163 + <th scope="col">CVE</th> 164 + <th scope="col">Severity</th> 165 + <th scope="col">Package</th> 166 + <th scope="col">Version</th> 167 + <th scope="col">Fix</th> 153 168 </tr> 154 169 </thead> 155 170 <tbody> 156 171 {{ range .UnchangedVulns }} 157 172 <tr> 158 173 <td> 159 - {{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener" class="link link-primary text-xs font-mono">{{ .CVEID }}</a> 160 - {{ else }}<span class="text-xs font-mono">{{ .CVEID }}</span>{{ end }} 174 + {{ if .CVEURL }}<a href="{{ .CVEURL }}" target="_blank" rel="noopener noreferrer" class="link link-primary text-xs font-mono">{{ or .CVEID "—" }}</a> 175 + {{ else }}<span class="text-xs font-mono">{{ or .CVEID "—" }}</span>{{ end }} 161 176 </td> 162 177 <td> 163 - <span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}">{{ .Severity }}</span> 178 + <span class="badge badge-xs {{ if eq .Severity "Critical" }}badge-error{{ else if eq .Severity "High" }}badge-warning{{ else if eq .Severity "Medium" }}badge-info{{ else }}badge-ghost{{ end }}" title="{{ severityLabel .Severity }}">{{ severityLabel .Severity }}</span> 164 179 </td> 165 - <td class="text-xs">{{ .Package }}</td> 166 - <td class="text-xs font-mono">{{ .Version }}</td> 180 + <td class="text-xs truncate max-w-xs" title="{{ .Package }}">{{ .Package }}</td> 181 + <td class="text-xs font-mono truncate max-w-40" title="{{ .Version }}">{{ .Version }}</td> 167 182 <td class="text-xs font-mono">{{ .FixedIn }}</td> 168 183 </tr> 169 184 {{ end }}
+8 -7
pkg/appview/templates/partials/digest-content.html
··· 13 13 {{ if .Layers }} 14 14 <div class="overflow-x-auto"> 15 15 <table class="table table-xs w-full layers-table"> 16 + <caption class="sr-only">Image layers</caption> 16 17 <thead> 17 18 <tr class="text-xs"> 18 - <th class="w-8">#</th> 19 - <th>Command</th> 20 - <th class="text-right w-24">Size</th> 19 + <th scope="col" class="w-8">#</th> 20 + <th scope="col">Command</th> 21 + <th scope="col" class="text-right">Size</th> 21 22 </tr> 22 23 </thead> 23 24 <tbody> ··· 43 44 <!-- Vulnerabilities + SBOM (Right) --> 44 45 <div class="card bg-base-200 shadow-sm border border-base-300 p-6 space-y-4 min-w-0"> 45 46 <div role="tablist" class="tabs tabs-bordered"> 46 - <input type="radio" name="scan-tabs" role="tab" class="tab" aria-label="Vulnerabilities" checked="checked" /> 47 - <div role="tabpanel" class="tab-content pt-4"> 47 + <input type="radio" id="scan-tab-vulns" name="scan-tabs" role="tab" class="tab" aria-label="Vulnerabilities" aria-controls="scan-panel-vulns" checked="checked" /> 48 + <div id="scan-panel-vulns" role="tabpanel" aria-labelledby="scan-tab-vulns" class="tab-content pt-4"> 48 49 {{ if .VulnData }} 49 50 {{ template "vuln-details" .VulnData }} 50 51 {{ else }} ··· 52 53 {{ end }} 53 54 </div> 54 55 55 - <input type="radio" name="scan-tabs" role="tab" class="tab" aria-label="SBOM" /> 56 - <div role="tabpanel" class="tab-content pt-4"> 56 + <input type="radio" id="scan-tab-sbom" name="scan-tabs" role="tab" class="tab" aria-label="SBOM" aria-controls="scan-panel-sbom" /> 57 + <div id="scan-panel-sbom" role="tabpanel" aria-labelledby="scan-tab-sbom" class="tab-content pt-4"> 57 58 {{ if .SbomData }} 58 59 {{ template "sbom-details" .SbomData }} 59 60 {{ else }}
+17 -3
pkg/appview/templates/partials/health-badge.html
··· 1 1 {{ define "health-badge" }} 2 2 {{ if .Pending }} 3 - <span class="badge badge-sm badge-info" 3 + <span class="badge badge-sm badge-info" role="status" aria-live="polite" 4 4 hx-get="/api/manifest-health?endpoint={{ .RetryURL }}" 5 5 hx-trigger="load delay:3s" 6 - hx-swap="outerHTML">{{ icon "refresh-ccw" "size-3" }} Checking...</span> 6 + hx-swap="outerHTML">{{ icon "refresh-ccw" "size-3 animate-spin" }} Checking…</span> 7 7 {{ else if not .Reachable }} 8 - <span class="badge badge-sm badge-warning">{{ icon "triangle-alert" "size-3" }} Offline</span> 8 + {{/* Branch on classified reason so the tooltip tells operators what 9 + kind of failure they're seeing, not just "it's down". */}} 10 + {{ if eq .Reason "dns" }} 11 + <span class="badge badge-sm badge-warning" role="status" title="DNS lookup failed — the hostname can't be resolved">{{ icon "triangle-alert" "size-3" }} DNS failed</span> 12 + {{ else if eq .Reason "tls" }} 13 + <span class="badge badge-sm badge-warning" role="status" title="TLS handshake failed — certificate may be expired or invalid">{{ icon "triangle-alert" "size-3" }} TLS error</span> 14 + {{ else if eq .Reason "refused" }} 15 + <span class="badge badge-sm badge-warning" role="status" title="Connection refused — nothing is listening on the endpoint">{{ icon "triangle-alert" "size-3" }} Refused</span> 16 + {{ else if eq .Reason "timeout" }} 17 + <span class="badge badge-sm badge-warning" role="status" title="Request timed out — the hold is slow or unreachable">{{ icon "triangle-alert" "size-3" }} Timeout</span> 18 + {{ else if eq .Reason "http" }} 19 + <span class="badge badge-sm badge-warning" role="status" title="The hold responded with an error status">{{ icon "triangle-alert" "size-3" }} HTTP error</span> 20 + {{ else }} 21 + <span class="badge badge-sm badge-warning" role="status" title="We couldn't reach the hold">{{ icon "triangle-alert" "size-3" }} Offline</span> 22 + {{ end }} 9 23 {{ end }} 10 24 {{ end }}
+4 -3
pkg/appview/templates/partials/hold_card.html
··· 4 4 <div class="p-4 flex flex-wrap items-center gap-2"> 5 5 <div class="flex-1 min-w-0"> 6 6 <div class="flex items-center gap-2 flex-wrap"> 7 - <h3 class="font-semibold text-lg truncate">{{ .DisplayName }}</h3> 8 - <span class="badge badge-sm badge-soft badge-primary">Active</span> 7 + <h3 class="font-semibold text-lg truncate">{{ or .DisplayName .DID }}</h3> 9 8 {{ if eq .Membership "owner" }}<span class="badge badge-sm badge-primary">Owner</span> 10 9 {{ else }}<span class="badge badge-sm badge-secondary">Crew</span>{{ end }} 11 10 {{ if eq .Status "online" }}<span class="badge badge-sm badge-success gap-1.5"><span class="inline-block size-1.5 rounded-full bg-current" aria-hidden="true"></span>Online</span> 12 11 {{ else if eq .Status "offline" }}<span class="badge badge-sm badge-error gap-1.5"><span class="inline-block size-1.5 rounded-full bg-current" aria-hidden="true"></span>Offline</span> 12 + {{ else }}<span class="badge badge-sm badge-ghost gap-1.5"><span class="inline-block size-1.5 rounded-full bg-current" aria-hidden="true"></span>Unknown</span> 13 13 {{ end }} 14 14 </div> 15 15 <code class="text-xs text-base-content/70 break-all">{{ .DID }}</code> ··· 21 21 <div id="storage-stats-active" 22 22 hx-get="/api/storage?hold_did={{ .DID | urlquery }}" 23 23 hx-trigger="load, tab:storage from:body once" 24 - hx-swap="innerHTML"> 24 + hx-swap="innerHTML" 25 + hx-on::after-request="if(!event.detail.successful) this.innerHTML='<p class=\'text-sm text-base-content/70\'>Storage unavailable.</p>'"> 25 26 <p class="flex items-center gap-2 text-sm text-base-content/70">{{ icon "loader-2" "size-4 animate-spin" }} Loading storage...</p> 26 27 </div> 27 28 </div>
+6 -8
pkg/appview/templates/partials/hold_selector.html
··· 8 8 <option value="" selected>-- Select a hold --</option> 9 9 {{ end }} 10 10 11 + {{ if .MemberHolds }} 11 12 <optgroup label="Your Holds"> 12 - {{ range .AllHolds }} 13 - {{ if ne .Membership "eligible" }} 13 + {{ range .MemberHolds }} 14 14 <option value="{{ .DID }}" {{ if .IsActive }}selected{{ end }}> 15 15 {{ .DisplayName }}{{ if eq .Membership "owner" }} (Owner){{ else }} (Crew){{ end }}{{ if .Region }} &middot; {{ .Region }}{{ end }} 16 16 </option> 17 - {{ end }} 18 17 {{ end }} 19 18 </optgroup> 19 + {{ end }} 20 20 21 - {{ range .AllHolds }}{{ if eq .Membership "eligible" }} 21 + {{ if .EligibleHolds }} 22 22 <optgroup label="Available Holds"> 23 - {{ range $.AllHolds }} 24 - {{ if eq .Membership "eligible" }} 23 + {{ range .EligibleHolds }} 25 24 <option value="{{ .DID }}"> 26 25 {{ .DisplayName }}{{ if .Region }} &middot; {{ .Region }}{{ end }} (Join) 27 26 </option> 28 - {{ end }} 29 27 {{ end }} 30 28 </optgroup> 31 - {{ break }}{{ end }}{{ end }} 29 + {{ end }} 32 30 </select> 33 31 <noscript><button type="submit" class="btn btn-sm btn-primary">Switch</button></noscript> 34 32 </form>
+11 -10
pkg/appview/templates/partials/image-advisor-results.html
··· 2 2 {{ if eq .Error "upgrade_required" }} 3 3 <div class="alert alert-info text-sm"> 4 4 {{ icon "sparkles" "size-4" }} 5 - <span>AI Image Advisor is a paid feature. <a href="/settings#billing" class="link link-primary font-medium">Upgrade your plan</a> to unlock image analysis.</span> 5 + <span>AI Image Advisor is a paid feature. <a href="/settings/billing" class="link link-primary font-medium">Upgrade your plan</a> to unlock image analysis.</span> 6 6 </div> 7 7 {{ else if .Error }} 8 8 <div class="alert alert-warning text-sm"> ··· 18 18 </h3> 19 19 <div class="overflow-x-auto"> 20 20 <table class="table table-xs w-full"> 21 + <caption class="sr-only">AI optimization suggestions for this image</caption> 21 22 <thead> 22 23 <tr> 23 - <th>Action</th> 24 - <th>Category</th> 25 - <th>Impact</th> 26 - <th>Effort</th> 27 - <th class="w-1/2">Detail</th> 24 + <th scope="col">Action</th> 25 + <th scope="col">Category</th> 26 + <th scope="col">Impact</th> 27 + <th scope="col">Effort</th> 28 + <th scope="col" class="w-1/2">Detail</th> 28 29 </tr> 29 30 </thead> 30 31 <tbody> 31 32 {{ range .Suggestions }} 32 33 <tr> 33 - <td class="font-medium text-sm">{{ .Action }}</td> 34 + <td class="font-medium text-sm wrap-break-word">{{ .Action }}</td> 34 35 <td> 35 36 <span class="badge badge-sm badge-ghost whitespace-nowrap">{{ .Category }}</span> 36 37 </td> ··· 49 50 {{ else if eq .Effort "medium" }} 50 51 <span class="badge badge-sm badge-warning">medium</span> 51 52 {{ else }} 52 - <span class="badge badge-sm badge-ghost">high</span> 53 + <span class="badge badge-sm badge-outline">high</span> 53 54 {{ end }} 54 55 </td> 55 - <td class="text-xs max-w-xs"> 56 + <td class="text-sm max-w-md wrap-break-word"> 56 57 {{ .Detail }} 57 58 {{ if gt .CVEsFixed 0 }} 58 59 <span class="badge badge-xs badge-outline badge-error ml-1">{{ .CVEsFixed }} CVEs</span> ··· 66 67 </tbody> 67 68 </table> 68 69 </div> 69 - <p class="text-xs text-base-content/70">Generated by Claude Haiku. Suggestions are advisory only.</p> 70 + <p class="text-xs text-base-content/70">Generated by {{ or .Model "Claude" }}. Suggestions are advisory only.</p> 70 71 </div> 71 72 </div> 72 73 {{ else }}
+11 -3
pkg/appview/templates/partials/layers-section.html
··· 7 7 <span>Show empty layers</span> 8 8 </label> 9 9 </div> 10 + {{ if .ConfigFetchError }} 11 + {{/* Hold is reachable but the config blob fetch failed, so layer 12 + commands are missing. DB layers are still rendered below. */}} 13 + {{ template "alert" (dict "Type" "warning" "Message" "Layer commands couldn't be loaded from the hold. Showing what we have from the registry.") }} 14 + {{ end }} 10 15 {{ if .Layers }} 11 16 <div class="overflow-x-auto"> 12 17 <table class="table table-xs w-full layers-table"> 18 + <caption class="sr-only">Image layer history</caption> 13 19 <thead> 14 20 <tr class="text-xs"> 15 - <th class="w-8">#</th> 16 - <th>Command</th> 17 - <th class="text-right w-24">Size</th> 21 + <th scope="col" class="w-8">#</th> 22 + <th scope="col">Command</th> 23 + <th scope="col" class="text-right">Size</th> 18 24 </tr> 19 25 </thead> 20 26 <tbody> ··· 24 30 <td> 25 31 {{ if .Command }} 26 32 <code class="font-mono text-xs break-all line-clamp-2" title="{{ .Command }}">{{ .Command }}</code> 33 + {{ else if not .EmptyLayer }} 34 + <span class="text-xs text-base-content/40 italic">— no command recorded</span> 27 35 {{ end }} 28 36 </td> 29 37 <td class="text-right text-sm whitespace-nowrap" data-bytes="{{ .Size }}">{{ humanizeBytes .Size }}</td>
+8 -5
pkg/appview/templates/partials/other_holds_table.html
··· 5 5 </div> 6 6 <div class="overflow-x-auto"> 7 7 <table class="table table-sm"> 8 + <caption class="sr-only">Other holds you are a member of</caption> 8 9 <thead> 9 10 <tr> 10 - <th>Hold</th> 11 - <th>Role</th> 12 - <th class="text-center">Status</th> 13 - <th class="text-right">Storage</th> 11 + <th scope="col">Hold</th> 12 + <th scope="col">Role</th> 13 + <th scope="col" class="text-center">Status</th> 14 + <th scope="col" class="text-right">Storage</th> 14 15 </tr> 15 16 </thead> 16 17 <tbody> 17 18 {{ range . }} 18 19 <tr> 19 20 <td> 20 - <span class="font-medium">{{ .DisplayName }}</span> 21 + <span class="font-medium block max-w-55 truncate" title="{{ .DID }}">{{ or .DisplayName .DID }}</span> 22 + <code class="block font-mono text-xs text-base-content/50 truncate max-w-55">{{ .DID }}</code> 21 23 </td> 22 24 <td> 23 25 {{ if eq .Membership "owner" }}<span class="badge badge-xs badge-primary">Owner</span> ··· 40 42 hx-get="/api/storage?hold_did={{ .DID | urlquery }}&compact=true" 41 43 hx-trigger="load, tab:storage from:body once" 42 44 hx-swap="innerHTML" 45 + hx-on::response-error="this.innerHTML='&mdash;'" 43 46 class="text-sm font-mono"> 44 47 ... 45 48 </span>
+23 -7
pkg/appview/templates/partials/repo-tag-section.html
··· 1 1 {{ define "repo-tag-section" }} 2 - <div id="tag-content" data-owner="{{ .Owner.Handle }}" data-repo="{{ .Repository.Name }}"{{ if .SelectedTag }} data-digest="{{ if .SelectedTag.Info.IsMultiArch }}{{ (index .SelectedTag.Info.Platforms 0).Digest }}{{ else }}{{ .SelectedTag.Info.Digest }}{{ end }}"{{ end }}> 2 + <div id="tag-content" data-owner="{{ .Owner.Handle }}" data-repo="{{ .Repository.Name }}"{{ if .SelectedTag }} data-digest="{{ if and .SelectedTag.Info.IsMultiArch .SelectedTag.Info.Platforms }}{{ (index .SelectedTag.Info.Platforms 0).Digest }}{{ else }}{{ .SelectedTag.Info.Digest }}{{ end }}"{{ end }}> 3 3 {{ if .SelectedTag }} 4 4 <!-- Pull Command with Client Switcher --> 5 5 {{ template "pull-command-switcher" (dict "RegistryURL" .RegistryURL "OwnerHandle" .Owner.Handle "RepoName" .Repository.Name "Tag" .SelectedTag.Info.Tag.Tag "ArtifactType" .ArtifactType "OciClient" .OciClient "IsLoggedIn" (ne .User nil)) }} ··· 9 9 <div class="mt-2 flex flex-wrap gap-2 items-center text-xs text-base-content/70"> 10 10 <span>Hosted on:</span> 11 11 {{ range .NonDefaultHolds }} 12 - <span class="badge badge-outline badge-sm" title="{{ . }}">{{ displayHoldDID . }}</span> 12 + <span class="badge badge-outline badge-sm max-w-32 truncate" title="{{ . }}">{{ displayHoldDID . }}</span> 13 13 {{ end }} 14 - <span class="text-base-content/70">(different from your default hold)</span> 14 + <span class="text-base-content/70">(not your default hold)</span> 15 15 </div> 16 16 {{ end }} 17 17 ··· 36 36 <div class="card bg-base-200 border border-base-300 p-4"> 37 37 <div class="text-xs font-semibold uppercase tracking-wider text-base-content/70 mb-2">Vulnerabilities</div> 38 38 <div id="vuln-summary-card"> 39 + {{ if .SelectedTag.Info.Platforms }} 39 40 {{ $firstPlatform := index .SelectedTag.Info.Platforms 0 }} 40 41 <span id="scan-badge-{{ trimPrefix "sha256:" $firstPlatform.Digest }}"></span> 41 42 <span id="vuln-loading-text" class="text-sm text-base-content/70">Loading...</span> 43 + {{ end }} 42 44 </div> 43 45 </div> 44 46 ··· 77 79 <button class="repo-tab shrink-0 whitespace-nowrap px-4 sm:px-6 py-3 text-sm font-medium border-b-2 border-transparent text-base-content/60 transition-colors cursor-pointer" 78 80 data-tab="overview" 79 81 role="tab" 80 - aria-selected="false" 82 + aria-selected="true" 81 83 aria-controls="tab-overview" 82 84 id="overview-tab-btn" 83 85 data-action="switch-repo-tab"> ··· 130 132 {{ if and .AIAdvisorEnabled .User .IsOwner .SelectedTag }} 131 133 <div id="ai-advisor-section"> 132 134 <button id="ai-advisor-btn" class="btn btn-sm btn-outline gap-1" 133 - hx-get="/api/image-advisor/{{ .Owner.Handle }}/{{ .Repository.Name }}?digest={{ if .SelectedTag.Info.IsMultiArch }}{{ (index .SelectedTag.Info.Platforms 0).Digest }}{{ else }}{{ .SelectedTag.Info.Digest }}{{ end }}" 135 + hx-get="/api/image-advisor/{{ .Owner.Handle }}/{{ .Repository.Name }}?digest={{ if and .SelectedTag.Info.IsMultiArch .SelectedTag.Info.Platforms }}{{ (index .SelectedTag.Info.Platforms 0).Digest }}{{ else }}{{ .SelectedTag.Info.Digest }}{{ end }}" 134 136 hx-target="#ai-advisor-results" 135 137 hx-swap="innerHTML" 136 138 hx-indicator="#ai-advisor-spinner" 137 - hx-on::after-request="this.disabled=true"> 139 + hx-on::after-request="if(event.detail.successful){this.disabled=true}"> 138 140 {{ icon "sparkles" "size-4" }} 139 141 Analyze Image 140 142 </button> 141 143 <span id="ai-advisor-spinner" class="htmx-indicator"> 142 144 {{ icon "loader" "size-4 animate-spin" }} 143 145 </span> 144 - <div id="ai-advisor-results" class="mt-4"></div> 146 + <div id="ai-advisor-results" class="mt-4" role="status" aria-live="polite"></div> 145 147 </div> 146 148 {{ end }} 147 149 ··· 157 159 <div id="overview-rendered" class="prose prose-sm max-w-none"> 158 160 {{ if .ReadmeHTML }} 159 161 {{ .ReadmeHTML }} 162 + {{ else if .ReadmeFetchFailed }} 163 + {{/* README URL is configured but the fetch failed — distinguish 164 + from "no README yet" so owners know the source is broken, 165 + not missing. */}} 166 + <div class="text-center py-12"> 167 + {{ icon "alert-triangle" "size-12 text-warning mx-auto" }} 168 + <p class="text-base-content/70 mt-4">We couldn't load the README</p> 169 + <p class="text-base-content/60 text-sm mt-1">The configured README source didn't respond. It may be rate-limited or private.</p> 170 + {{ if .IsOwner }} 171 + <button class="btn btn-outline btn-sm mt-4" data-action="toggle-editor" data-show="true"> 172 + {{ icon "pencil" "size-4" }} Edit README 173 + </button> 174 + {{ end }} 175 + </div> 160 176 {{ else }} 161 177 {{ if .IsOwner }} 162 178 <div class="text-center py-12">
+9 -4
pkg/appview/templates/partials/repo-tags.html
··· 19 19 </button> 20 20 {{ end }} 21 21 {{ if and .ViewerDefaultHold .Entry.HoldEndpoint (ne .Entry.HoldEndpoint .ViewerDefaultHold) }} 22 - <span class="badge badge-xs badge-soft badge-warning" title="{{ .Entry.HoldEndpoint }}">{{ icon "hard-drive" "size-3" }} {{ displayHoldDID .Entry.HoldEndpoint }}</span> 22 + <span class="badge badge-xs badge-soft badge-warning max-w-32 truncate" title="{{ .Entry.HoldEndpoint }}">{{ icon "hard-drive" "size-3" }} {{ displayHoldDID .Entry.HoldEndpoint }}</span> 23 23 {{ end }} 24 24 </div> 25 25 <div class="flex items-center gap-2 shrink-0"> ··· 29 29 <button class="btn btn-ghost btn-sm text-error" 30 30 hx-ext="json-enc" 31 31 hx-delete="/api/tags" 32 - hx-vals='{"repo": "{{ .RepoName }}", "tag": "{{ .Entry.Label }}"}' 32 + hx-vals='{{ dict "repo" .RepoName "tag" .Entry.Label | toJSON }}' 33 33 hx-confirm="Delete tag {{ .Entry.Label }}?" 34 34 hx-target="closest .artifact-entry" 35 35 hx-swap="outerHTML" ··· 101 101 </td> 102 102 <td class="text-sm text-base-content whitespace-nowrap">{{ if .CompressedSize }}{{ humanizeBytes .CompressedSize }}{{ else }}-{{ end }}</td> 103 103 </tr> 104 + {{ else }} 105 + <tr><td colspan="4" class="text-base-content/60 text-center py-4">No platform details available for this manifest.</td></tr> 104 106 {{ end }} 105 107 </tbody> 106 108 </table> ··· 115 117 hx-get="/api/repo-tags/{{ .Owner.Handle }}/{{ .Repository.Name }}?offset={{ .NextOffset }}" 116 118 hx-target="#tags-list" 117 119 hx-swap="beforeend" 118 - hx-on::before-request="document.getElementById('load-more-container').remove()"> 120 + hx-indicator="#load-more-spinner" 121 + hx-on::after-request="if(event.detail.successful)document.getElementById('load-more-container').remove()"> 122 + <span id="load-more-spinner" class="htmx-indicator loading loading-spinner loading-sm"></span> 119 123 Load More 120 124 </button> 121 125 </div> ··· 137 141 <!-- Filter/Sort Controls --> 138 142 <div class="flex flex-wrap items-center gap-4"> 139 143 <div class="flex items-center gap-2"> 140 - <span class="text-sm font-medium whitespace-nowrap">Sort by</span> 144 + <label for="tag-sort" class="text-sm font-medium whitespace-nowrap">Sort by</label> 141 145 <select id="tag-sort" class="select select-sm select-bordered min-w-28" data-action="sort-tags"> 142 146 <option value="newest">Newest</option> 143 147 <option value="oldest">Oldest</option> ··· 146 150 </select> 147 151 </div> 148 152 <div class="flex-1 max-w-xs"> 153 + <label for="tag-filter" class="sr-only">Filter artifacts</label> 149 154 <input type="text" id="tag-filter" class="input input-sm input-bordered w-full" placeholder="Filter artifacts..." data-action="filter-tags"> 150 155 </div> 151 156 {{ if $.IsOwner }}
+22 -12
pkg/appview/templates/partials/sbom-details.html
··· 1 1 {{ define "sbom-details" }} 2 2 {{ if .Error }} 3 - <p>{{ .Error }}</p> 4 - {{ if .ScannedAt }}<p class="text-xs text-base-content/60">Scanned: {{ .ScannedAt }}</p>{{ end }} 3 + <div class="alert alert-error" role="alert"> 4 + {{ icon "alert-circle" "size-5 shrink-0" }} 5 + <span>{{ .Error }}</span> 6 + </div> 7 + {{ if .ScannedAt }}<p class="text-xs text-base-content/60 mt-2">Scanned: {{ .ScannedAt }}</p>{{ end }} 5 8 {{ else }} 6 9 <div class="space-y-4" data-csv-section data-csv-filename="sbom.csv"> 7 10 <div class="flex flex-wrap items-center gap-3"> 8 - <span class="font-semibold text-sm">{{ .Total }} packages</span> 11 + <span class="font-semibold text-sm">{{ .Total }} {{ pluralize .Total "package" "packages" }}</span> 9 12 {{ if .Packages }} 10 13 <details class="dropdown dropdown-end ml-auto"> 11 14 <summary class="btn btn-ghost btn-xs gap-1 list-none" aria-label="Export SBOM"> ··· 23 26 {{ if .ScannedAt }}<p class="text-xs text-base-content/60">Scanned: {{ .ScannedAt }}</p>{{ end }} 24 27 25 28 {{ if .Packages }} 26 - <div class="overflow-y-auto max-h-[32rem]"> 27 - <table class="table table-xs table-pin-rows w-full"> 29 + <div class="overflow-y-auto max-h-128"> 30 + <table class="table table-xs table-pin-rows w-full table-fixed"> 31 + <caption class="sr-only">SBOM package list</caption> 32 + <colgroup> 33 + <col class="w-5/12"> 34 + <col class="w-3/12"> 35 + <col class="w-3/12"> 36 + <col class="w-1/12"> 37 + </colgroup> 28 38 <thead> 29 39 <tr> 30 - <th>Package</th> 31 - <th>Version</th> 32 - <th>License</th> 33 - <th>Type</th> 40 + <th scope="col">Package</th> 41 + <th scope="col">Version</th> 42 + <th scope="col">License</th> 43 + <th scope="col">Type</th> 34 44 </tr> 35 45 </thead> 36 46 <tbody> 37 47 {{ range .Packages }} 38 48 <tr> 39 - <td class="text-xs">{{ .Name }}</td> 40 - <td class="font-mono text-xs">{{ .Version }}</td> 41 - <td class="text-xs"> 49 + <td class="text-xs truncate" title="{{ .Name }}">{{ .Name }}</td> 50 + <td class="font-mono text-xs truncate" title="{{ .Version }}">{{ .Version }}</td> 51 + <td class="text-xs truncate" title="{{ .License }}"> 42 52 {{ if eq .License "-" }} 43 53 <span class="text-base-content/40">-</span> 44 54 {{ else }}
+10 -3
pkg/appview/templates/partials/sbom-section.html
··· 1 1 {{ define "sbom-section" }} 2 - <div class="space-y-4 min-w-0 pt-6"> 3 - {{ if .SbomData }} 2 + <div class="space-y-4 min-w-0 pt-6" role="region" aria-live="polite"> 3 + {{ if eq .SbomReason "ok" }} 4 4 {{ template "sbom-details" .SbomData }} 5 + {{ else if eq .SbomReason "hold-unreachable" }} 6 + <div class="alert alert-warning" role="status"> 7 + {{ icon "wifi-off" "size-4 shrink-0" }} 8 + <span>We couldn't reach the hold to load the SBOM.</span> 9 + </div> 10 + {{ else if eq .SbomReason "fetch-failed" }} 11 + <p class="text-base-content/70">SBOM data couldn't be loaded. Try refreshing in a minute.</p> 5 12 {{ else }} 6 - <p class="text-base-content">No SBOM data available</p> 13 + <p class="text-base-content/70">No SBOM available yet. The scanner generates an SBOM alongside each scan.</p> 7 14 {{ end }} 8 15 </div> 9 16 {{ end }}
+31 -8
pkg/appview/templates/partials/search-results.html
··· 1 - {{/* Search results partial - renders repository cards in a grid */}} 2 - {{ template "card-grid" (dict 1 + {{ define "search-results" }} 2 + {{/* Search results partial — rendered server-side on initial page load and 3 + swapped in by htmx for Load More pagination. Receives searchResults struct: 4 + .Repositories, .SearchQuery, .HasMore, .NextOffset, .HasError */}} 5 + {{ if .HasError }} 6 + {{ template "state-error" (dict 7 + "Title" "Search is temporarily unavailable" 8 + "Subtext" "The search service had trouble running your query. Try again in a moment." 9 + "RetryURL" (printf "/api/search-results?q=%s" (urlquery .SearchQuery)) 10 + "RetryTarget" "#search-results" 11 + ) }} 12 + {{ else }} 13 + {{ template "card-grid" (dict 14 + "Repositories" .Repositories 15 + "Columns" 4 16 + "EmptyIcon" "search-x" 17 + "EmptyMessage" "No repositories found matching your search." 18 + "EmptySubtext" "Try a different search term or browse the homepage." 19 + "LoadMoreURL" (printf "/api/search-results?q=%s&offset=%d" (urlquery .SearchQuery) .NextOffset) 20 + "TargetID" "search-results-grid" 21 + "HasMore" .HasMore 22 + ) }} 23 + {{ end }} 24 + {{ end }} 25 + 26 + {{/* card-grid-append-search — Load More fragment for /api/search-results. */}} 27 + {{ define "card-grid-append-search" }} 28 + {{ template "card-grid-append" (dict 3 29 "Repositories" .Repositories 4 - "Columns" 4 5 - "EmptyIcon" "search-x" 6 - "EmptyMessage" "No repositories found matching your search." 7 - "EmptySubtext" "Try a different search term or browse the homepage." 8 - "LoadMoreURL" (printf "/api/search-results?q=%s&offset=%d" .SearchQuery .NextOffset) 9 - "TargetID" "search-results" 30 + "LoadMoreURL" (printf "/api/search-results?q=%s&offset=%d" (urlquery .SearchQuery) .NextOffset) 31 + "TargetID" "search-results-grid" 10 32 "HasMore" .HasMore 11 33 ) }} 34 + {{ end }}
+56
pkg/appview/templates/partials/settings-panel-advanced.html
··· 1 + {{ define "settings-panel-advanced" }} 2 + <section class="card bg-base-200 shadow-sm p-6 space-y-4"> 3 + <h2 class="text-xl font-semibold">Data Privacy</h2> 4 + <p class="text-base-content/70">Download a copy of all data we store about you.</p> 5 + 6 + <div> 7 + <a href="/api/export-data" class="btn btn-secondary gap-2" download> 8 + {{ icon "download" "size-4" }} 9 + Export All My Data 10 + </a> 11 + </div> 12 + 13 + <p class="text-sm text-base-content/60"> 14 + This includes your authorized devices, sessions, and hold memberships. 15 + Data stored on your PDS is already under your control. 16 + See our <a href="/privacy" class="link link-primary">Privacy Policy</a> for details. 17 + </p> 18 + </section> 19 + 20 + <section class="border-2 border-error rounded-lg p-6 space-y-4" role="region" aria-labelledby="danger-zone-heading"> 21 + <h2 id="danger-zone-heading" class="text-xl font-semibold text-error flex items-center gap-2"> 22 + {{ icon "alert-triangle" "size-5" }} 23 + Danger Zone 24 + </h2> 25 + 26 + <div class="space-y-4"> 27 + <div> 28 + <h3 class="font-semibold">Delete {{ .ClientShortName }} Data</h3> 29 + <p class="text-base-content/70 mt-1">Remove your data from {{ .ClientShortName }}. This action cannot be undone.</p> 30 + </div> 31 + 32 + <div class="alert bg-base-200"> 33 + {{ icon "info" "size-5 shrink-0" }} 34 + <span><strong>This does not delete your ATProto (Bluesky, Blacksky, Tangled) account.</strong><br>Only {{ .ClientShortName }}-specific data (authorized devices, hold memberships, settings) will be removed.</span> 35 + </div> 36 + 37 + <div class="space-y-2"> 38 + <label class="flex items-start gap-3 cursor-pointer"> 39 + <input type="checkbox" id="delete-pds-records" class="checkbox checkbox-sm mt-0.5"> 40 + <span class="text-sm">Also delete all <code class="cmd">io.atcr.*</code> records from my ATProto PDS</span> 41 + </label> 42 + <p class="text-xs text-base-content/60 ml-7"> 43 + This removes {{ .ClientShortName }} records (manifests, tags, stars, profile) stored in your PDS. 44 + Other records in your account are not impacted. 45 + </p> 46 + </div> 47 + 48 + <button type="button" id="delete-account-btn" class="btn btn-error btn-lg gap-2" 49 + data-client-short-name="{{ .ClientShortName }}" 50 + data-profile-handle="{{ .Profile.Handle }}"> 51 + {{ icon "trash-2" "size-5" }} 52 + Delete My {{ .ClientShortName }} Data 53 + </button> 54 + </div> 55 + </section> 56 + {{ end }}
+10
pkg/appview/templates/partials/settings-panel-billing.html
··· 1 + {{ define "settings-panel-billing" }} 2 + {{ if .Subscription.HideBilling }} 3 + <section class="card bg-base-200 shadow-sm p-6 space-y-3"> 4 + <h2 class="text-xl font-semibold">Billing</h2> 5 + <p class="text-base-content/70">Billing is not enabled on this deployment.</p> 6 + </section> 7 + {{ else }} 8 + {{ template "subscription_plans" .Subscription }} 9 + {{ end }} 10 + {{ end }}
+56
pkg/appview/templates/partials/settings-panel-devices.html
··· 1 + {{ define "settings-panel-devices" }} 2 + <section class="card bg-base-200 shadow-sm p-6 space-y-6"> 3 + <div> 4 + <h2 class="text-xl font-semibold">Authorized Devices</h2> 5 + <p class="text-base-content/70 mt-1">Devices authorized via <code class="cmd">docker-credential-atcr</code> credential helper.</p> 6 + </div> 7 + 8 + <div class="bg-base-200 rounded-lg p-4 space-y-4"> 9 + <h3 class="font-semibold">First Time Setup</h3> 10 + <ol class="list-decimal list-inside space-y-4 text-sm"> 11 + <li>Install credential helper: 12 + <pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>curl -fsSL {{ .SiteURL }}/static/install.sh | bash</code></pre> 13 + </li> 14 + <li>Configure Docker to use the helper. Add to <code class="cmd">~/.docker/config.json</code>: 15 + <pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>{ 16 + "credHelpers": { 17 + "{{ .RegistryURL }}": "atcr" 18 + } 19 + }</code></pre> 20 + </li> 21 + <li>Run any Docker command: 22 + <div class="mt-2">{{ template "docker-command" (print (pullPrefix .OciClient) .RegistryURL "/" .Profile.Handle "/myimage") }}</div> 23 + </li> 24 + <li>Browser will open for authorization - click Approve</li> 25 + <li>Done! Device is automatically authorized</li> 26 + </ol> 27 + 28 + <div class="pt-3 border-t border-base-300 text-sm"> 29 + <strong>Fallback:</strong> Use <a href="https://bsky.app/settings/app-passwords" target="_blank" class="link link-primary">app password</a> with <code class="cmd">docker login {{ .RegistryURL }}</code> for quick start (no device tracking) 30 + </div> 31 + </div> 32 + 33 + <div class="space-y-3"> 34 + <h3 class="font-semibold">Your Authorized Devices</h3> 35 + <div class="overflow-x-auto"> 36 + <table class="table table-zebra"> 37 + <thead> 38 + <tr> 39 + <th>Device Name</th> 40 + <th>IP Address</th> 41 + <th>Created</th> 42 + <th>Last Used</th> 43 + <th>Actions</th> 44 + </tr> 45 + </thead> 46 + <tbody id="devices-table" 47 + hx-get="/api/devices" 48 + hx-trigger="load, every 30s, devicesChanged from:body" 49 + hx-swap="innerHTML"> 50 + <tr><td colspan="5" class="text-center">{{ icon "loader-2" "size-4 animate-spin inline-block" }} Loading...</td></tr> 51 + </tbody> 52 + </table> 53 + </div> 54 + </div> 55 + </section> 56 + {{ end }}
+9
pkg/appview/templates/partials/settings-panel-dispatch.html
··· 1 + {{ define "settings-panel" }} 2 + {{ if eq .ActiveTab "user" }}{{ template "settings-panel-user" . }} 3 + {{ else if eq .ActiveTab "storage" }}{{ template "settings-panel-storage" . }} 4 + {{ else if eq .ActiveTab "billing" }}{{ template "settings-panel-billing" . }} 5 + {{ else if eq .ActiveTab "devices" }}{{ template "settings-panel-devices" . }} 6 + {{ else if eq .ActiveTab "webhooks" }}{{ template "settings-panel-webhooks" . }} 7 + {{ else if eq .ActiveTab "advanced" }}{{ template "settings-panel-advanced" . }} 8 + {{ end }} 9 + {{ end }}
+43
pkg/appview/templates/partials/settings-panel-storage.html
··· 1 + {{ define "settings-panel-storage" }} 2 + {{ if .AllHolds }} 3 + <div class="grid grid-cols-1 {{ if .OtherHolds }}lg:grid-cols-2{{ end }} gap-4"> 4 + <div class="space-y-4"> 5 + {{ template "hold_selector" . }} 6 + {{ if .ActiveHold }} 7 + {{ template "hold_card" .ActiveHold }} 8 + {{ else }} 9 + <div class="card bg-base-200 shadow-sm p-6 text-center text-base-content/60"> 10 + No active hold selected. Choose one above. 11 + </div> 12 + {{ end }} 13 + </div> 14 + {{ if .OtherHolds }} 15 + <div> 16 + {{ template "other_holds_table" .OtherHolds }} 17 + </div> 18 + {{ end }} 19 + </div> 20 + {{ else }} 21 + <div class="card bg-base-200 shadow-sm p-6 text-center text-base-content/60"> 22 + No holds configured. Push an image to get started. 23 + </div> 24 + {{ end }} 25 + 26 + <section class="card bg-base-200 shadow-sm p-6 space-y-4"> 27 + <h2 class="text-xl font-semibold">Storage Preferences</h2> 28 + <label class="flex items-start gap-3 cursor-pointer"> 29 + <input type="checkbox" class="toggle toggle-primary mt-0.5" 30 + hx-post="/api/profile/auto-remove-untagged" 31 + hx-trigger="change" 32 + hx-swap="none" 33 + {{ if .Profile.AutoRemoveUntagged }}checked{{ end }}> 34 + <div> 35 + <span class="font-medium">Automatically remove untagged manifests</span> 36 + <p class="text-sm text-base-content/60 mt-1"> 37 + When a tag is overwritten, the old manifest and its layers are cleaned up. 38 + Multi-arch child manifests are preserved. 39 + </p> 40 + </div> 41 + </label> 42 + </section> 43 + {{ end }}
+55
pkg/appview/templates/partials/settings-panel-user.html
··· 1 + {{ define "settings-panel-user" }} 2 + <section class="card bg-base-200 shadow-sm p-6 space-y-6"> 3 + <div> 4 + <h2 class="text-xl font-semibold">Preferences</h2> 5 + <p class="text-base-content/70 mt-1">Customize your experience across the site.</p> 6 + </div> 7 + 8 + <div class="flex items-center gap-4"> 9 + <div> 10 + <label for="oci-client-select" class="text-sm font-medium">Preferred client</label> 11 + <p id="oci-client-hint" class="text-xs text-base-content/70">Sets the pull command shown on repository pages. Choose <em>Image reference only</em> to copy without a command prefix.</p> 12 + </div> 13 + {{ $oci := .Profile.OciClient }} 14 + <select id="oci-client-select" aria-describedby="oci-client-hint" class="select select-sm select-bordered min-w-40" 15 + name="oci_client" 16 + hx-post="/api/profile/oci-client" 17 + hx-trigger="change" 18 + hx-swap="none"> 19 + <option value="docker"{{ if or (eq $oci "") (eq $oci "docker") }} selected{{ end }}>Docker</option> 20 + <option value="podman"{{ if eq $oci "podman" }} selected{{ end }}>Podman</option> 21 + <option value="buildah"{{ if eq $oci "buildah" }} selected{{ end }}>Buildah</option> 22 + <option value="nerdctl"{{ if eq $oci "nerdctl" }} selected{{ end }}>nerdctl</option> 23 + <option value="crane"{{ if eq $oci "crane" }} selected{{ end }}>crane</option> 24 + <option value="none"{{ if eq $oci "none" }} selected{{ end }}>Image reference only</option> 25 + </select> 26 + </div> 27 + 28 + {{ if .AIAdvisorEnabled }} 29 + <div class="divider my-2"></div> 30 + <div class="flex items-start gap-3"> 31 + {{ if .Profile.HasAIAdvisorAccess }} 32 + <label class="flex items-start gap-3 cursor-pointer"> 33 + <input type="checkbox" class="toggle toggle-primary mt-0.5" 34 + hx-post="/api/profile/ai-advisor" 35 + hx-trigger="change" 36 + hx-swap="none" 37 + {{ if .Profile.AIAdvisorEnabled }}checked{{ end }}> 38 + <div> 39 + <span class="font-medium">AI Image Advisor</span> 40 + <p class="text-xs text-base-content/60">Analyze your container images for optimization suggestions using AI.</p> 41 + </div> 42 + </label> 43 + {{ else }} 44 + <div> 45 + <span class="font-medium text-base-content/50">AI Image Advisor</span> 46 + <p class="text-xs text-base-content/70">Analyze your container images for optimization suggestions using AI.</p> 47 + <p class="text-xs text-primary mt-1"> 48 + <a href="/settings/billing">Upgrade your plan</a> to enable this feature. 49 + </p> 50 + </div> 51 + {{ end }} 52 + </div> 53 + {{ end }} 54 + </section> 55 + {{ end }}
+11
pkg/appview/templates/partials/settings-panel-webhooks.html
··· 1 + {{ define "settings-panel-webhooks" }} 2 + <section class="card bg-base-200 shadow-sm p-6 space-y-4"> 3 + <div> 4 + <h2 class="text-xl font-semibold">Webhooks</h2> 5 + <p class="text-base-content/70 mt-1">Get notified when images are pushed or vulnerability scans complete.</p> 6 + </div> 7 + <div id="webhooks-content"> 8 + {{ template "webhooks_list" .WebhooksData }} 9 + </div> 10 + </section> 11 + {{ end }}
+60
pkg/appview/templates/partials/state.html
··· 1 + {{/* 2 + Shared empty / error / pending state blocks. 3 + 4 + state-empty — genuine empty state (no data yet, nothing to show) 5 + state-error — request failed; includes optional Retry button 6 + state-pending — in-flight indicator (spinner + optional label) 7 + 8 + Fields (all optional unless noted): 9 + .Title string — main heading text 10 + .Subtext string — secondary supporting copy 11 + .Icon string — icon sprite id (defaults per block) 12 + .ActionURL string — primary CTA href (state-empty only) 13 + .ActionLabel string — primary CTA label (state-empty only) 14 + .RetryURL string — htmx GET URL for retry (state-error only) 15 + .RetryTarget string — htmx target selector (default: "closest [data-state-container]") 16 + */}} 17 + 18 + {{ define "state-empty" }} 19 + <div class="py-12 text-center" role="status"> 20 + <div class="text-base-content/60 mb-4"> 21 + {{ icon (or .Icon "inbox") "size-12 mx-auto mb-4" }} 22 + </div> 23 + <p class="text-lg">{{ or .Title "Nothing here yet." }}</p> 24 + {{ if .Subtext }}<p class="text-base-content/70 text-sm mt-2">{{ .Subtext }}</p>{{ end }} 25 + {{ if and .ActionURL .ActionLabel }} 26 + <a href="{{ .ActionURL }}" class="btn btn-primary btn-sm mt-4">{{ .ActionLabel }}</a> 27 + {{ end }} 28 + </div> 29 + {{ end }} 30 + 31 + {{ define "state-error" }} 32 + <div class="py-12 text-center" role="alert" aria-live="assertive"> 33 + <div class="text-error mb-4"> 34 + {{ icon (or .Icon "alert-triangle") "size-12 mx-auto mb-4" }} 35 + </div> 36 + <p class="text-lg">{{ or .Title "Something went wrong" }}</p> 37 + {{ if .Subtext }}<p class="text-base-content/70 text-sm mt-2">{{ .Subtext }}</p>{{ end }} 38 + {{ if .RetryURL }} 39 + {{ if .RetryTarget }} 40 + {{/* Partial retry: swap just the container back in via htmx. */}} 41 + <button class="btn btn-outline btn-sm mt-4" 42 + hx-get="{{ .RetryURL }}" 43 + hx-target="{{ .RetryTarget }}" 44 + hx-swap="outerHTML"> 45 + Try again 46 + </button> 47 + {{ else }} 48 + {{/* Full-page retry: regular anchor, falls back gracefully without htmx. */}} 49 + <a href="{{ .RetryURL }}" class="btn btn-outline btn-sm mt-4">Try again</a> 50 + {{ end }} 51 + {{ end }} 52 + </div> 53 + {{ end }} 54 + 55 + {{ define "state-pending" }} 56 + <div class="py-12 text-center" role="status" aria-live="polite"> 57 + <span class="loading loading-spinner loading-md"></span> 58 + {{ if .Title }}<p class="text-base-content/70 text-sm mt-2">{{ .Title }}</p>{{ end }} 59 + </div> 60 + {{ end }}
+19 -2
pkg/appview/templates/partials/storage_stats.html
··· 3 3 {{ if .Tier }} 4 4 <div class="flex justify-between items-center"> 5 5 <span class="text-base-content/60">Tier:</span> 6 - <span class="badge badge-xs badge-{{ .Tier }} font-semibold">{{ .Tier }}</span> 6 + {{/* Whitelist known tier names so an unrecognized tier falls back to 7 + a safe neutral badge instead of producing a broken class. */}} 8 + {{ if eq .Tier "deckhand" }} 9 + <span class="badge badge-xs badge-deckhand font-semibold">{{ .Tier }}</span> 10 + {{ else if eq .Tier "bosun" }} 11 + <span class="badge badge-xs badge-bosun font-semibold">{{ .Tier }}</span> 12 + {{ else if eq .Tier "quartermaster" }} 13 + <span class="badge badge-xs badge-quartermaster font-semibold">{{ .Tier }}</span> 14 + {{ else }} 15 + <span class="badge badge-xs badge-ghost font-semibold">{{ .Tier }}</span> 16 + {{ end }} 7 17 </div> 8 18 {{ end }} 9 19 <div class="flex justify-between items-center"> ··· 18 28 </div> 19 29 {{ if .HasLimit }} 20 30 <div class="flex items-center gap-2 py-2"> 21 - <progress class="progress {{ if ge .UsagePercent 95 }}progress-error{{ else if ge .UsagePercent 80 }}progress-warning{{ else }}progress-success{{ end }} w-full" value="{{ .UsagePercent }}" max="100"></progress> 31 + <progress class="progress {{ if ge .UsagePercent 95 }}progress-error{{ else if ge .UsagePercent 80 }}progress-warning{{ else }}progress-success{{ end }} w-full" value="{{ .UsagePercent }}" max="100" aria-label="Storage used: {{ .UsagePercent }} percent"></progress> 22 32 <span class="text-sm text-base-content/60 whitespace-nowrap">{{ .UsagePercent }}% used</span> 23 33 </div> 34 + {{/* Color alone on the progress bar doesn't meet AAA — repeat the warning 35 + as an alert block once usage crosses the threshold. */}} 36 + {{ if ge .UsagePercent 95 }} 37 + {{ template "alert" (dict "Type" "error" "Message" "You're nearly at your storage limit. Pushes may fail once you exceed it.") }} 38 + {{ else if ge .UsagePercent 80 }} 39 + {{ template "alert" (dict "Type" "warning" "Message" "You're using most of your storage quota. Consider cleaning up untagged images.") }} 40 + {{ end }} 24 41 {{ end }} 25 42 <div class="flex justify-between items-center"> 26 43 <span class="text-base-content/60">Unique Blobs:</span>
+8 -1
pkg/appview/templates/partials/subscription_info.html
··· 1 1 {{ define "subscription_plans" }} 2 2 {{ if not .HideBilling }} 3 - {{ if .Tiers }} 3 + {{ if not .Tiers }} 4 + {{/* Billing is enabled (HideBilling=false) but no tiers were returned — 5 + covers the config-loaded-but-empty case. Explicit copy beats silent render. */}} 6 + <section class="card bg-base-200 shadow-sm p-6"> 7 + <h3 class="text-xl font-semibold">Available Plans</h3> 8 + <p class="text-sm text-base-content/70 mt-2">Plan information is temporarily unavailable. Check back in a minute.</p> 9 + </section> 10 + {{ else }} 4 11 <section class="card bg-base-200 shadow-sm p-6 space-y-4"> 5 12 <h3 class="text-xl font-semibold">Available Plans</h3> 6 13 <div class="grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-4">
+4 -4
pkg/appview/templates/partials/upgrade-banner.html
··· 10 10 {{ if gt .Summary.VulnFixedBySev.Critical 0 }}<span class="font-semibold text-error">{{ .Summary.VulnFixedBySev.Critical }} Critical</span>{{ end }} 11 11 {{ if gt .Summary.VulnFixedBySev.High 0 }}{{ if gt .Summary.VulnFixedBySev.Critical 0 }}, {{ end }}<span class="font-semibold text-warning">{{ .Summary.VulnFixedBySev.High }} High</span>{{ end }} 12 12 {{ if gt .Summary.VulnFixedBySev.Medium 0 }}{{ if or (gt .Summary.VulnFixedBySev.Critical 0) (gt .Summary.VulnFixedBySev.High 0) }}, {{ end }}<span class="font-semibold">{{ .Summary.VulnFixedBySev.Medium }} Medium</span>{{ end }} 13 - {{ if and (eq .Summary.VulnFixedBySev.Critical 0) (eq .Summary.VulnFixedBySev.High 0) (eq .Summary.VulnFixedBySev.Medium 0) }}<span class="font-semibold">{{ .Summary.VulnFixedCount }} Low</span>{{ end }} 14 - vuln{{ if gt .Summary.VulnFixedCount 1 }}s{{ end }} 13 + {{ if and (eq .Summary.VulnFixedBySev.Critical 0) (eq .Summary.VulnFixedBySev.High 0) (eq .Summary.VulnFixedBySev.Medium 0) (gt .Summary.VulnFixedBySev.Low 0) }}<span class="font-semibold">{{ .Summary.VulnFixedBySev.Low }} Low</span>{{ end }} 14 + {{ pluralize .Summary.VulnFixedCount "vuln" "vulns" }} 15 15 {{ else }} 16 16 is available 17 17 {{ end }} ··· 22 22 is available 23 23 {{ end }} 24 24 {{ if ne .Summary.LayerCountFrom .Summary.LayerCountTo }} 25 - · {{ if gt .Summary.LayerCountTo .Summary.LayerCountFrom }}+{{ end }}{{ sub .Summary.LayerCountTo .Summary.LayerCountFrom }} layer{{ if ne (sub .Summary.LayerCountTo .Summary.LayerCountFrom) 1 }}s{{ end }} 25 + · {{ if gt .Summary.LayerCountTo .Summary.LayerCountFrom }}+{{ end }}{{ sub .Summary.LayerCountTo .Summary.LayerCountFrom }} {{ pluralize (sub .Summary.LayerCountTo .Summary.LayerCountFrom) "layer" "layers" }} 26 26 {{ end }} 27 27 {{ if ne .Summary.SizeDelta 0 }} 28 - ({{ humanizeByteDelta .Summary.SizeDelta }}) 28 + · {{ if gt .Summary.SizeDelta 0 }}{{ humanizeByteDelta .Summary.SizeDelta }} larger{{ else }}{{ humanizeByteDelta .Summary.SizeDelta }}<span class="sr-only"> smaller</span>{{ end }} 29 29 {{ end }} 30 30 </div> 31 31 <a href="{{ .DiffURL }}" class="btn btn-sm btn-info btn-outline shrink-0">View diff</a>
+7 -2
pkg/appview/templates/partials/vuln-badge.html
··· 1 1 {{ define "vuln-badge" }} 2 2 {{ if .Error }} 3 - {{/* Silently hide on error / no scan record — scan badges are non-critical */}} 3 + {{/* Hold unreachable. Warning color distinguishes from "not scanned" (gray). */}} 4 + <span class="badge badge-sm badge-warning" title="Hold is unreachable — try again in a moment"> 5 + {{ icon "wifi-off" "size-3" }} Hold offline 6 + </span> 7 + {{ else if .NotScanned }} 8 + <span class="badge badge-sm badge-ghost" title="No scan recorded yet">Not scanned</span> 4 9 {{ else if .ScanFailed }} 5 - {{/* Scan failed (no SBOM blob) — don't show misleading "Clean" badge */}} 10 + <span class="badge badge-sm badge-warning" title="Scanner ran but produced no SBOM">{{ icon "alert-triangle" "size-3" }} Scan failed</span> 6 11 {{ else if eq .Total 0 }} 7 12 <span class="badge badge-sm badge-success" title="No vulnerabilities found (scanned {{ .ScannedAt }})">{{ icon "shield-check" "size-3" }} Clean</span> 8 13 {{ else }}
+18 -14
pkg/appview/templates/partials/vuln-details.html
··· 1 1 {{ define "vuln-details" }} 2 2 {{ if .Error }} 3 - {{ if .Summary.Total }} 3 + {{ if gt .Summary.Total 0 }} 4 4 <!-- Summary available but no detailed report --> 5 5 <div class="space-y-4"> 6 6 <span class="vuln-strip" role="group" aria-label="Vulnerability summary by severity"> ··· 13 13 {{ if .ScannedAt }}<p class="text-xs text-base-content/60">Scanned: {{ .ScannedAt }}</p>{{ end }} 14 14 </div> 15 15 {{ else }} 16 - <p>{{ .Error }}</p> 16 + <div class="alert alert-warning" role="alert"> 17 + {{ icon "alert-triangle" "size-5 shrink-0" }} 18 + <span>{{ .Error }}</span> 19 + </div> 17 20 {{ end }} 18 21 {{ else }} 19 22 <div class="space-y-4" data-csv-section data-csv-filename="vulnerabilities.csv"> ··· 44 47 45 48 {{ if .Matches }} 46 49 <!-- CVE table --> 47 - <div class="overflow-x-auto overflow-y-auto max-h-[32rem]"> 48 - <table class="table table-xs table-pin-rows w-full min-w-[40rem]"> 50 + <div class="overflow-x-auto overflow-y-auto max-h-128"> 51 + <table class="table table-xs table-pin-rows w-full min-w-160"> 52 + <caption class="sr-only">Detected vulnerabilities</caption> 49 53 <thead> 50 54 <tr> 51 - <th>CVE</th> 52 - <th></th> 53 - <th>Package</th> 54 - <th>Version</th> 55 - <th>Fix</th> 55 + <th scope="col">CVE</th> 56 + <th scope="col"><span class="sr-only">Severity</span></th> 57 + <th scope="col">Package</th> 58 + <th scope="col">Version</th> 59 + <th scope="col">Fix</th> 56 60 </tr> 57 61 </thead> 58 62 <tbody> ··· 67 71 </td> 68 72 <td> 69 73 {{ if eq .Severity "Critical" }} 70 - <span class="badge badge-xs badge-error" title="Critical">C</span> 74 + <span class="badge badge-xs badge-error" title="Critical" aria-label="Critical"><span aria-hidden="true">C</span></span> 71 75 {{ else if eq .Severity "High" }} 72 - <span class="badge badge-xs badge-warning" title="High">H</span> 76 + <span class="badge badge-xs badge-warning" title="High" aria-label="High"><span aria-hidden="true">H</span></span> 73 77 {{ else if eq .Severity "Medium" }} 74 - <span class="badge badge-xs badge-soft badge-warning" title="Medium">M</span> 78 + <span class="badge badge-xs badge-soft badge-warning" title="Medium" aria-label="Medium"><span aria-hidden="true">M</span></span> 75 79 {{ else if eq .Severity "Low" }} 76 - <span class="badge badge-xs badge-info" title="Low">L</span> 80 + <span class="badge badge-xs badge-info" title="Low" aria-label="Low"><span aria-hidden="true">L</span></span> 77 81 {{ else }} 78 - <span class="badge badge-xs badge-ghost" title="{{ .Severity }}">?</span> 82 + <span class="badge badge-xs badge-ghost" title="{{ .Severity }}" aria-label="{{ or .Severity "Unknown severity" }}"><span aria-hidden="true">?</span></span> 79 83 {{ end }} 80 84 </td> 81 85 <td class="text-xs">
+15 -2
pkg/appview/templates/partials/vulns-section.html
··· 1 1 {{ define "vulns-section" }} 2 - <div class="space-y-4 min-w-0 pt-6"> 3 - {{ if .VulnData }} 2 + <div class="space-y-4 min-w-0 pt-6" role="region" aria-live="polite"> 3 + {{ if eq .VulnReason "ok" }} 4 4 {{ template "vuln-details" .VulnData }} 5 + {{ else if eq .VulnReason "hold-unreachable" }} 6 + <div class="alert alert-warning" role="status"> 7 + {{ icon "wifi-off" "size-4 shrink-0" }} 8 + <div> 9 + <p class="font-medium">We couldn't reach the hold</p> 10 + <p class="text-sm">Scan data is stored on the hold. It may be offline or unreachable right now.</p> 11 + </div> 12 + </div> 13 + {{ else if eq .VulnReason "fetch-failed" }} 14 + <div class="py-8 text-sm text-base-content/70 max-w-prose"> 15 + <p class="font-medium text-base-content">Scan data couldn't be loaded</p> 16 + <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> 17 + </div> 5 18 {{ else }} 6 19 <div class="py-8 text-sm text-base-content/70 max-w-prose"> 7 20 <p class="font-medium text-base-content">No vulnerability scan available yet</p>
+2 -1
pkg/appview/templates/partials/webhooks_list.html
··· 4 4 <form hx-post="/api/webhooks" 5 5 hx-target="#{{ .ContainerID }}" 6 6 hx-swap="innerHTML" 7 + hx-disabled-elt="find button[type='submit']" 7 8 class="space-y-4 bg-base-200 rounded-lg p-4"> 8 9 <h3 class="font-semibold">Add Webhook</h3> 9 10 ··· 30 31 <div class="space-y-2 mt-1"> 31 32 {{ range .TriggerInfo }} 32 33 <label class="flex items-start gap-3{{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }} opacity-50 cursor-not-allowed{{ else }} cursor-pointer{{ end }}"> 33 - <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 }}" 34 + <input type="checkbox" name="{{ .FormName }}" 34 35 class="checkbox checkbox-sm mt-0.5" 35 36 {{ if .DefaultChecked }}checked{{ end }} 36 37 {{ if and (not .AlwaysAvailable) (not $.Limits.AllTriggers) }}disabled{{ end }}>
+172
pkg/appview/ui.go
··· 7 7 "fmt" 8 8 "html/template" 9 9 "io/fs" 10 + "math/rand/v2" 10 11 "net/http" 11 12 "net/url" 12 13 "strings" ··· 82 83 computeAssetHashesFromFS(fsys) 83 84 } 84 85 86 + // humanizeCount renders any integer kind with a compact suffix (1.2K, 3.4M, 87 + // 5.6B). Accepts any so templates can pass int, int64, etc. without an 88 + // explicit conversion. Non-integer values render as "0". 89 + func humanizeCount(v any) string { 90 + var n int64 91 + switch x := v.(type) { 92 + case int: 93 + n = int64(x) 94 + case int8: 95 + n = int64(x) 96 + case int16: 97 + n = int64(x) 98 + case int32: 99 + n = int64(x) 100 + case int64: 101 + n = x 102 + case uint: 103 + n = int64(x) 104 + case uint8: 105 + n = int64(x) 106 + case uint16: 107 + n = int64(x) 108 + case uint32: 109 + n = int64(x) 110 + case uint64: 111 + n = int64(x) 112 + default: 113 + return "0" 114 + } 115 + neg := n < 0 116 + if neg { 117 + n = -n 118 + } 119 + var s string 120 + switch { 121 + case n < 1000: 122 + s = fmt.Sprintf("%d", n) 123 + case n < 1_000_000: 124 + s = fmt.Sprintf("%.1fK", float64(n)/1000) 125 + case n < 1_000_000_000: 126 + s = fmt.Sprintf("%.1fM", float64(n)/1_000_000) 127 + default: 128 + s = fmt.Sprintf("%.1fB", float64(n)/1_000_000_000) 129 + } 130 + s = strings.TrimSuffix(s, ".0K") 131 + s = strings.TrimSuffix(s, ".0M") 132 + s = strings.TrimSuffix(s, ".0B") 133 + if neg { 134 + return "-" + s 135 + } 136 + return s 137 + } 138 + 85 139 // AssetHash returns the cache-busting hash for an asset path 86 140 func AssetHash(path string) string { 87 141 if hash, ok := assetHashes[path]; ok { 88 142 return hash 89 143 } 90 144 return "" 145 + } 146 + 147 + // fontPreloadPaths returns the list of /fonts/*.woff2 files that exist in 148 + // the resolved public FS. Skips *-ext (extended glyph) subsets to avoid 149 + // preloading weights the primary latin face already covers. 150 + func fontPreloadPaths(overrides *BrandingOverrides) []string { 151 + fsys := resolvePublicFS(overrides) 152 + entries, err := fs.ReadDir(fsys, "public/fonts") 153 + if err != nil { 154 + return nil 155 + } 156 + var paths []string 157 + for _, e := range entries { 158 + name := e.Name() 159 + if e.IsDir() { 160 + continue 161 + } 162 + if !strings.HasSuffix(name, ".woff2") { 163 + continue 164 + } 165 + if strings.Contains(name, "-ext") { 166 + continue 167 + } 168 + if strings.Contains(name, "italic") { 169 + continue 170 + } 171 + paths = append(paths, "/fonts/"+name) 172 + } 173 + return paths 91 174 } 92 175 93 176 // CacheMiddleware adds Cache-Control headers to static file responses ··· 301 384 302 385 "assetHash": AssetHash, 303 386 387 + // seamarkHeroTagline picks a random Seamark hero tagline on the 388 + // server so the Seamark hero renders with its final copy on first 389 + // paint (crawlers get a real tagline; users see no JS flash). The 390 + // prior approach rewrote innerHTML ~50ms after load, which caused 391 + // a visible flash and always served "your beacon at sea." to bots. 392 + "seamarkHeroTagline": func() []string { 393 + options := [][]string{ 394 + {"guiding you ", "at", " sea."}, 395 + {"your beacon ", "at", " sea."}, 396 + {"never lost ", "at", " sea."}, 397 + {"find your way ", "at", " sea."}, 398 + {"charting courses ", "at", " sea."}, 399 + } 400 + return options[rand.IntN(len(options))] 401 + }, 402 + 403 + // fontPreloads returns the list of woff2 fonts present in /fonts/ 404 + // so head.html can emit <link rel="preload"> tags without hardcoding 405 + // filenames (which 404 silently when renamed). The *-ext subset 406 + // variants are skipped — we only preload the primary-latin subsets 407 + // that are used above the fold. 408 + "fontPreloads": func() []string { 409 + return fontPreloadPaths(overrides) 410 + }, 411 + 304 412 "formatDate": func(t time.Time) string { 305 413 return t.Format("Jan 2, 2006") 306 414 }, ··· 309 417 return t.IsZero() || t.Year() < 2000 310 418 }, 311 419 420 + // pluralize picks singular/plural based on count. Use whenever a 421 + // template would otherwise write "{{ if gt .N 1 }}s{{ end }}", which 422 + // breaks for 0, negatives, and anything non-English. 423 + // Usage: {{ pluralize .Count "package" "packages" }} 424 + "pluralize": func(n int, singular, plural string) string { 425 + if n == 1 || n == -1 { 426 + return singular 427 + } 428 + return plural 429 + }, 430 + 431 + // humanizeTime renders an absolute, human-readable timestamp. 432 + // Distinct from timeAgo (relative) — use for tooltips and places 433 + // where a stable formatted date is preferable to "3 days ago". 434 + // Zero times render as empty to avoid "Jan 1, 0001" leakage. 435 + "humanizeTime": func(t time.Time) string { 436 + if t.IsZero() || t.Year() < 2000 { 437 + return "" 438 + } 439 + return t.Format("Jan 2, 2006 at 3:04 PM MST") 440 + }, 441 + 442 + // humanizeCount renders integers with compact suffix (1.2K, 3.4M, 5.6B). 443 + // Numbers below 1000 render as-is. Negative values are prefixed with 444 + // a minus sign (rare for counts but correctness beats surprise). 445 + // Accepts any integer kind via reflect so templates can pass `int`, 446 + // `int64`, etc. without an explicit conversion helper. 447 + "humanizeCount": humanizeCount, 448 + 449 + // severityLabel maps a severity code (C/H/M/L/N/U or a full name) to 450 + // its canonical full-word label. Use alongside the color class so 451 + // screen readers announce the word even when sighted users see only 452 + // the initial: <span class="sr-only">{{ severityLabel "C" }}</span>C 453 + "severityLabel": func(code string) string { 454 + switch strings.ToUpper(strings.TrimSpace(code)) { 455 + case "C", "CRIT", "CRITICAL": 456 + return "Critical" 457 + case "H", "HIGH": 458 + return "High" 459 + case "M", "MED", "MEDIUM": 460 + return "Medium" 461 + case "L", "LOW": 462 + return "Low" 463 + case "N", "NEG", "NEGLIGIBLE": 464 + return "Negligible" 465 + case "U", "UNK", "UNKNOWN": 466 + return "Unknown" 467 + default: 468 + return code 469 + } 470 + }, 471 + 312 472 // icon renders an SVG icon from the sprite sheet 313 473 // Usage: {{ icon "star" "size-4 text-amber-400" }} 314 474 // The name is the icon ID in icons.svg, classes are applied to the SVG element ··· 359 519 return "docker pull " 360 520 } 361 521 return client + " pull " 522 + }, 523 + 524 + // toJSON marshals any value to a JSON string safe for use in HTML attributes. 525 + // json.Marshal escapes <, >, & and properly escapes " inside strings, 526 + // so the result can be used as template.HTML without further escaping. 527 + // Usage: hx-vals='{{ dict "repo" .Repo "tag" .Tag | toJSON }}' 528 + "toJSON": func(v any) template.HTML { 529 + b, err := json.Marshal(v) 530 + if err != nil { 531 + return template.HTML("{}") 532 + } 533 + return template.HTML(b) 362 534 }, 363 535 364 536 // extraCSS returns a <style> block with consumer CSS overrides, or empty string.
-1
pkg/appview/ui_test.go
··· 549 549 "login.html", 550 550 "settings.html", 551 551 "install.html", 552 - "manifest-modal", 553 552 "search-results.html", 554 553 "health-badge", 555 554 "alert",
+4 -2
pkg/hold/admin/public/icons.svg
··· 19 19 <symbol id="container" viewBox="0 0 24 24"><path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z"/><path d="M10 21.9V14L2.1 9.1"/><path d="m10 14 11.9-6.9"/><path d="M14 19.8v-8.1"/><path d="M18 17.5V9.4"/></symbol> 20 20 <symbol id="copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol> 21 21 <symbol id="cpu" viewBox="0 0 24 24"><path d="M12 20v2"/><path d="M12 2v2"/><path d="M17 20v2"/><path d="M17 2v2"/><path d="M2 12h2"/><path d="M2 17h2"/><path d="M2 7h2"/><path d="M20 12h2"/><path d="M20 17h2"/><path d="M20 7h2"/><path d="M7 20v2"/><path d="M7 2v2"/><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="8" y="8" width="8" height="8" rx="1"/></symbol> 22 - <symbol id="credit-card" viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></symbol> 23 22 <symbol id="database" viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></symbol> 24 23 <symbol id="download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol> 25 24 <symbol id="external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol> ··· 42 41 <symbol id="loader" viewBox="0 0 24 24"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></symbol> 43 42 <symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol> 44 43 <symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol> 44 + <symbol id="package" viewBox="0 0 24 24"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><polyline points="3.29 7 12 12 20.71 7"/><path d="m7.5 4.27 9 5.15"/></symbol> 45 + <symbol id="pause" viewBox="0 0 24 24"><rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/></symbol> 45 46 <symbol id="pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol> 47 + <symbol id="play" viewBox="0 0 24 24"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/></symbol> 46 48 <symbol id="plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol> 47 49 <symbol id="radio-tower" viewBox="0 0 24 24"><path d="M4.9 16.1C1 12.2 1 5.8 4.9 1.9"/><path d="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5"/><circle cx="12" cy="9" r="2"/><path d="M16.2 4.8c2 2 2.26 5.11.8 7.47"/><path d="M19.1 1.9a9.96 9.96 0 0 1 0 14.1"/><path d="M9.5 18h5"/><path d="m8 22 4-11 4 11"/></symbol> 48 50 <symbol id="refresh-ccw" viewBox="0 0 24 24"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></symbol> ··· 65 67 <symbol id="upload" viewBox="0 0 24 24"><path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></symbol> 66 68 <symbol id="user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol> 67 69 <symbol id="user-plus" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/></symbol> 68 - <symbol id="webhook" viewBox="0 0 24 24"><path d="M18 16.98h-5.99c-1.1 0-1.95.94-2.48 1.9A4 4 0 0 1 2 17c.01-.7.2-1.4.57-2"/><path d="m6 17 3.13-5.78c.53-.97.1-2.18-.5-3.1a4 4 0 1 1 6.89-4.06"/><path d="m12 6 3.13 5.73C15.66 12.7 16.9 13 18 13a4 4 0 0 1 0 8"/></symbol> 70 + <symbol id="wifi-off" viewBox="0 0 24 24"><path d="M12 20h.01"/><path d="M8.5 16.429a5 5 0 0 1 7 0"/><path d="M5 12.859a10 10 0 0 1 5.17-2.69"/><path d="M19 12.859a10 10 0 0 0-2.007-1.523"/><path d="M2 8.82a15 15 0 0 1 4.177-2.643"/><path d="M22 8.82a15 15 0 0 0-11.288-3.764"/><path d="m2 2 20 20"/></symbol> 69 71 <symbol id="x-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></symbol> 70 72 <symbol id="helm" viewBox="0 0 24 24"><path d="M12.337 0c-.475 0-.861 1.016-.861 2.269 0 .527.069 1.011.183 1.396a8.514 8.514 0 0 0-3.961 1.22 5.229 5.229 0 0 0-.595-1.093c-.606-.866-1.34-1.436-1.79-1.43a.381.381 0 0 0-.217.066c-.39.273-.123 1.326.596 2.353.267.381.559.705.84.948a8.683 8.683 0 0 0-1.528 1.716h1.734a7.179 7.179 0 0 1 5.381-2.421 7.18 7.18 0 0 1 5.382 2.42h1.733a8.687 8.687 0 0 0-1.32-1.53c.35-.249.735-.643 1.078-1.133.719-1.027.986-2.08.596-2.353a.382.382 0 0 0-.217-.065c-.45-.007-1.184.563-1.79 1.43a4.897 4.897 0 0 0-.676 1.325 8.52 8.52 0 0 0-3.899-1.42c.12-.39.193-.887.193-1.429 0-1.253-.386-2.269-.862-2.269zM1.624 9.443v5.162h1.358v-1.968h1.64v1.968h1.357V9.443H4.62v1.838H2.98V9.443zm5.912 0v5.162h3.21v-1.108H8.893v-.95h1.64v-1.142h-1.64v-.84h1.853V9.443zm4.698 0v5.162h3.218v-1.362h-1.86v-3.8zm4.706 0v5.162h1.364v-2.643l1.357 1.225 1.35-1.232v2.65h1.365V9.443h-.614l-2.1 1.914-2.109-1.914zm-11.82 7.28a8.688 8.688 0 0 0 1.412 1.548 5.206 5.206 0 0 0-.841.948c-.719 1.027-.985 2.08-.596 2.353.39.273 1.289-.338 2.007-1.364a5.23 5.23 0 0 0 .595-1.092 8.514 8.514 0 0 0 3.961 1.219 5.01 5.01 0 0 0-.183 1.396c0 1.253.386 2.269.861 2.269.476 0 .862-1.016.862-2.269 0-.542-.072-1.04-.193-1.43a8.52 8.52 0 0 0 3.9-1.42c.121.4.352.865.675 1.327.719 1.026 1.617 1.637 2.007 1.364.39-.273.123-1.326-.596-2.353-.343-.49-.727-.885-1.077-1.135a8.69 8.69 0 0 0 1.202-1.36h-1.771a7.174 7.174 0 0 1-5.227 2.252 7.174 7.174 0 0 1-5.226-2.252z" fill="currentColor" stroke="none"/></symbol> 71 73 </svg>
+5
themes/seamark/public/sitemap-static.xml
··· 2 2 <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> 3 3 <url> 4 4 <loc>https://seamark.dev/</loc> 5 + <lastmod>2026-04-21</lastmod> 5 6 <changefreq>daily</changefreq> 6 7 <priority>1.0</priority> 7 8 </url> 8 9 <url> 9 10 <loc>https://seamark.dev/search</loc> 11 + <lastmod>2026-04-21</lastmod> 10 12 <changefreq>daily</changefreq> 11 13 <priority>0.8</priority> 12 14 </url> 13 15 <url> 14 16 <loc>https://seamark.dev/install</loc> 17 + <lastmod>2026-04-21</lastmod> 15 18 <changefreq>monthly</changefreq> 16 19 <priority>0.9</priority> 17 20 </url> 18 21 <url> 19 22 <loc>https://seamark.dev/privacy</loc> 23 + <lastmod>2026-04-21</lastmod> 20 24 <changefreq>yearly</changefreq> 21 25 <priority>0.3</priority> 22 26 </url> 23 27 <url> 24 28 <loc>https://seamark.dev/terms</loc> 29 + <lastmod>2026-04-21</lastmod> 25 30 <changefreq>yearly</changefreq> 26 31 <priority>0.3</priority> 27 32 </url>
+3 -1
themes/seamark/public/static/seamark_seagull.svg
··· 7 7 viewBox="0 0 48 48" 8 8 version="1.1" 9 9 id="svg1" 10 + role="img" 11 + aria-labelledby="seamark-seagull-title seamark-seagull-desc" 10 12 xml:space="preserve" 11 13 xmlns="http://www.w3.org/2000/svg" 12 - xmlns:svg="http://www.w3.org/2000/svg"><defs 14 + xmlns:svg="http://www.w3.org/2000/svg"><title id="seamark-seagull-title">Seamark seagull</title><desc id="seamark-seagull-desc">A stylized seagull in orange and grey, the Seamark mascot.</desc><defs 13 15 id="defs1" /><g 14 16 id="layer1" 15 17 style="display:inline"><path
+11 -21
themes/seamark/templates/components/hero.html
··· 3 3 Hero section component - displays landing page hero for non-authenticated users 4 4 */}} 5 5 <section class="hero bg-base-200 min-h-[60vh] py-16 pb-24 relative overflow-hidden"> 6 - <img src="/static/seamark_seagull_buoy.png" alt="" class="hidden lg:block absolute left-[12%] top-1/3 w-60 h-auto z-10 pointer-events-none animate-rock" aria-hidden="true"> 7 6 <div class="hero-content text-center flex-col relative z-10 w-full"> 8 - <h1 id="hero-heading" class="text-4xl md:text-5xl font-bold">your beacon <span class="text-primary">at</span> sea.</h1> 9 - <script> 10 - (function() { 11 - var h = [ 12 - ["guiding you ", "at", " sea."], 13 - ["your beacon ", "at", " sea."], 14 - ["never lost ", "at", " sea."], 15 - ["find your way ", "at", " sea."], 16 - ["charting courses ", "at", " sea."] 17 - ]; 18 - var pick = h[Math.floor(Math.random() * h.length)]; 19 - var el = document.getElementById("hero-heading"); 20 - el.innerHTML = pick[0] + '<span class="text-primary">' + pick[1] + '</span>' + pick[2]; 21 - })(); 22 - </script> 23 - <p class="text-lg text-base-content/70 max-w-lg mt-4"> 24 - Push and pull Docker images on the AT Protocol.<br> 7 + {{/* Mascot is positioned relative to .hero-content so it can't overlap 8 + the CTA stack at borderline lg viewports. Hidden below 2xl when the 9 + layout would push it over the buttons. */}} 10 + <img src="/static/seamark_seagull_buoy.png" alt="" class="hidden 2xl:block absolute -left-40 top-1/2 -translate-y-1/2 w-52 h-auto z-10 pointer-events-none animate-rock" aria-hidden="true"> 11 + {{ $t := seamarkHeroTagline }} 12 + <h1 id="hero-heading" class="text-4xl md:text-5xl font-bold text-balance">{{ index $t 0 }}<span class="text-primary">{{ index $t 1 }}</span>{{ index $t 2 }}</h1> 13 + <p class="text-lg text-base-content/70 max-w-lg mt-4 text-balance"> 14 + Push and pull Docker images on the AT Protocol. 25 15 Browse public registries or control your data. 26 16 </p> 27 17 ··· 38 28 39 29 <!-- Benefit Cards --> 40 30 <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12 w-full max-w-4xl"> 41 - <div class="card bg-base-200 shadow-sm p-6 text-center"> 31 + <div class="card bg-base-100 border border-base-300 p-6 text-center"> 42 32 <div class="text-primary mb-4 flex justify-center"> 43 33 {{ icon "ship" "size-8" }} 44 34 </div> 45 35 <h2 class="font-semibold text-lg">Works with Docker</h2> 46 36 <p class="text-base-content/70 mt-2">Use docker push &amp; pull. No new tools to learn.</p> 47 37 </div> 48 - <div class="card bg-base-200 shadow-sm p-6 text-center"> 38 + <div class="card bg-base-100 border border-base-300 p-6 text-center"> 49 39 <div class="text-primary mb-4 flex justify-center"> 50 40 {{ icon "anchor" "size-8" }} 51 41 </div> 52 42 <h2 class="font-semibold text-lg">Your Data</h2> 53 43 <p class="text-base-content/70 mt-2">Join shared holds or captain your own storage.</p> 54 44 </div> 55 - <div class="card bg-base-200 shadow-sm p-6 text-center"> 45 + <div class="card bg-base-100 border border-base-300 p-6 text-center"> 56 46 <div class="text-primary mb-4 flex justify-center"> 57 47 {{ icon "compass" "size-8" }} 58 48 </div>
+4 -2
themes/seamark/theme.css
··· 12 12 } 13 13 14 14 [data-theme="light"] { 15 - --color-primary: oklch(48% 0.17 250); 15 + /* Primary/accent lightness dropped to ≤42% so white-on-button meets 16 + WCAG AA 4.5:1 for normal text. Hues preserved; only L moved. */ 17 + --color-primary: oklch(42% 0.17 250); 16 18 --color-primary-content: oklch(98% 0.01 250); 17 19 --color-secondary: oklch(76% 0.095 76.1); 18 20 --color-secondary-content: oklch(27.1% 0.026 76.4); 19 - --color-accent: oklch(56% 0.17 345); 21 + --color-accent: oklch(48% 0.17 345); 20 22 --color-accent-content: oklch(98% 0.008 345); 21 23 --color-base-100: oklch(98% 0.01 225); 22 24 --color-base-200: oklch(95% 0.02 225);