diff --git a/cmd/appview/main.go b/cmd/appview/main.go
index c12345e..8d07f03 100644
--- a/cmd/appview/main.go
+++ b/cmd/appview/main.go
@@ -13,6 +13,9 @@ import (
// Register our custom middleware
_ "atcr.io/pkg/appview/middleware"
+
+ // Register built-in themes
+ _ "atcr.io/themes/seamark"
)
var configFile string
@@ -79,7 +82,12 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to load config: %w", err)
}
- server, err := appview.NewAppViewServer(cfg, nil)
+ branding, err := appview.LookupTheme(cfg.UI.Theme)
+ if err != nil {
+ return err
+ }
+
+ server, err := appview.NewAppViewServer(cfg, branding)
if err != nil {
return fmt.Errorf("failed to initialize server: %w", err)
}
diff --git a/pkg/appview/config.go b/pkg/appview/config.go
index 910b912..fd5e02e 100644
--- a/pkg/appview/config.go
+++ b/pkg/appview/config.go
@@ -65,6 +65,9 @@ type ServerConfig struct {
type UIConfig struct {
// SQLite database path.
DatabasePath string `yaml:"database_path" comment:"SQLite database for OAuth sessions, stars, pull counts, and device approvals."`
+
+ // Visual theme name (e.g. "seamark"). Empty string uses default atcr.io branding.
+ Theme string `yaml:"theme" comment:"Visual theme name (e.g. \"seamark\"). Empty uses default atcr.io branding."`
}
// HealthConfig defines health check and cache settings
@@ -136,6 +139,7 @@ func setDefaults(v *viper.Viper) {
// UI defaults
v.SetDefault("ui.database_path", "/var/lib/atcr/ui.db")
+ v.SetDefault("ui.theme", "")
// Health defaults
v.SetDefault("health.cache_ttl", "15m")
diff --git a/pkg/appview/themes.go b/pkg/appview/themes.go
new file mode 100644
index 0000000..3993a6e
--- /dev/null
+++ b/pkg/appview/themes.go
@@ -0,0 +1,28 @@
+package appview
+
+import "fmt"
+
+// themeRegistry maps theme names to their BrandingOverrides constructors.
+var themeRegistry = make(map[string]func() *BrandingOverrides)
+
+// RegisterTheme registers a named theme. Call from init() in theme packages.
+func RegisterTheme(name string, fn func() *BrandingOverrides) {
+ if _, exists := themeRegistry[name]; exists {
+ panic(fmt.Sprintf("theme %q already registered", name))
+ }
+ themeRegistry[name] = fn
+}
+
+// LookupTheme returns BrandingOverrides for a registered theme name.
+// Returns nil for empty name (default branding). Returns an error for
+// unknown theme names.
+func LookupTheme(name string) (*BrandingOverrides, error) {
+ if name == "" {
+ return nil, nil
+ }
+ fn, ok := themeRegistry[name]
+ if !ok {
+ return nil, fmt.Errorf("unknown theme %q", name)
+ }
+ return fn(), nil
+}
diff --git a/themes/seamark/embed.go b/themes/seamark/embed.go
new file mode 100644
index 0000000..639b216
--- /dev/null
+++ b/themes/seamark/embed.go
@@ -0,0 +1,67 @@
+// Package seamark provides the Seamark visual theme for the ATCR AppView.
+// Import with a blank identifier to register: _ "atcr.io/themes/seamark"
+// Enable with ATCR_UI_THEME=seamark.
+package seamark
+
+import (
+ "embed"
+ "io/fs"
+
+ "atcr.io/pkg/appview"
+)
+
+//go:embed public
+var publicFS embed.FS
+
+//go:embed theme.css
+var themeCSS string
+
+func init() {
+ appview.RegisterTheme("seamark", branding)
+}
+
+func branding() *appview.BrandingOverrides {
+ // Strip the leading "public" directory so the overlay FS matches the
+ // structure expected by resolvePublicFS (which looks under "public/").
+ pubSub, err := fs.Sub(publicFS, "public")
+ if err != nil {
+ panic("seamark: bad embed layout: " + err.Error())
+ }
+
+ return &appview.BrandingOverrides{
+ PublicFS: prefixFS{sub: pubSub},
+ ExtraCSS: themeCSS,
+ }
+}
+
+// prefixFS re-adds the "public/" prefix so the overlay FS works with
+// atcr.io's resolvePublicFS which opens paths like "public/favicon.svg".
+type prefixFS struct{ sub fs.FS }
+
+func (p prefixFS) Open(name string) (fs.File, error) {
+ const prefix = "public/"
+ if len(name) > len(prefix) && name[:len(prefix)] == prefix {
+ return p.sub.Open(name[len(prefix):])
+ }
+ // Non-public paths: return not-found so the overlay falls through.
+ return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
+}
+
+func (p prefixFS) ReadDir(name string) ([]fs.DirEntry, error) {
+ const prefix = "public/"
+ if name == "public" {
+ return fs.ReadDir(p.sub, ".")
+ }
+ if len(name) > len(prefix) && name[:len(prefix)] == prefix {
+ return fs.ReadDir(p.sub, name[len(prefix):])
+ }
+ return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
+}
+
+func (p prefixFS) ReadFile(name string) ([]byte, error) {
+ const prefix = "public/"
+ if len(name) > len(prefix) && name[:len(prefix)] == prefix {
+ return fs.ReadFile(p.sub, name[len(prefix):])
+ }
+ return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrNotExist}
+}
diff --git a/themes/seamark/public/apple-touch-icon.png b/themes/seamark/public/apple-touch-icon.png
new file mode 100644
index 0000000..53ec455
Binary files /dev/null and b/themes/seamark/public/apple-touch-icon.png differ
diff --git a/themes/seamark/public/favicon-96x96.png b/themes/seamark/public/favicon-96x96.png
new file mode 100644
index 0000000..3650661
Binary files /dev/null and b/themes/seamark/public/favicon-96x96.png differ
diff --git a/themes/seamark/public/favicon.ico b/themes/seamark/public/favicon.ico
new file mode 100644
index 0000000..4c69326
Binary files /dev/null and b/themes/seamark/public/favicon.ico differ
diff --git a/themes/seamark/public/favicon.svg b/themes/seamark/public/favicon.svg
new file mode 100644
index 0000000..c4d9e17
--- /dev/null
+++ b/themes/seamark/public/favicon.svg
@@ -0,0 +1,51 @@
+
+
+
+
diff --git a/themes/seamark/public/icons.svg b/themes/seamark/public/icons.svg
new file mode 100644
index 0000000..cb8b9ca
--- /dev/null
+++ b/themes/seamark/public/icons.svg
@@ -0,0 +1,43 @@
+
\ No newline at end of file
diff --git a/themes/seamark/public/robots.txt b/themes/seamark/public/robots.txt
new file mode 100644
index 0000000..74aea2d
--- /dev/null
+++ b/themes/seamark/public/robots.txt
@@ -0,0 +1,33 @@
+# Seamark Container Registry - robots.txt
+
+User-agent: *
+
+# Allow public pages
+Allow: /
+Allow: /r/
+Allow: /u/
+Allow: /search
+Allow: /install
+Allow: /privacy
+Allow: /terms
+
+# Block API endpoints
+Disallow: /api/
+Disallow: /v2/
+
+# Block auth and session pages
+Disallow: /auth/
+Disallow: /login
+Disallow: /settings
+Disallow: /device/
+
+# Block OG image generation endpoints
+Disallow: /og/
+
+# Block static assets from indexing (optional, saves crawl budget)
+Disallow: /js/
+Disallow: /css/
+Disallow: /static/
+
+# Sitemap
+Sitemap: https://seamark.dev/sitemap.xml
diff --git a/themes/seamark/public/site.webmanifest b/themes/seamark/public/site.webmanifest
new file mode 100644
index 0000000..848e499
--- /dev/null
+++ b/themes/seamark/public/site.webmanifest
@@ -0,0 +1,21 @@
+{
+ "name": "Seamark",
+ "short_name": "Seamark",
+ "icons": [
+ {
+ "src": "/web-app-manifest-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/web-app-manifest-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "theme_color": "#274b70",
+ "background_color": "#274b70",
+ "display": "standalone"
+}
\ No newline at end of file
diff --git a/themes/seamark/public/sitemap-static.xml b/themes/seamark/public/sitemap-static.xml
new file mode 100644
index 0000000..de43324
--- /dev/null
+++ b/themes/seamark/public/sitemap-static.xml
@@ -0,0 +1,28 @@
+
+
+
+ https://seamark.dev/
+ daily
+ 1.0
+
+
+ https://seamark.dev/search
+ daily
+ 0.8
+
+
+ https://seamark.dev/install
+ monthly
+ 0.9
+
+
+ https://seamark.dev/privacy
+ yearly
+ 0.3
+
+
+ https://seamark.dev/terms
+ yearly
+ 0.3
+
+
diff --git a/themes/seamark/public/sitemap.xml b/themes/seamark/public/sitemap.xml
new file mode 100644
index 0000000..d463e44
--- /dev/null
+++ b/themes/seamark/public/sitemap.xml
@@ -0,0 +1,14 @@
+
+
+
+ https://seamark.dev/sitemap-static.xml
+
+
+
diff --git a/themes/seamark/public/web-app-manifest-192x192.png b/themes/seamark/public/web-app-manifest-192x192.png
new file mode 100644
index 0000000..6fd4f4d
Binary files /dev/null and b/themes/seamark/public/web-app-manifest-192x192.png differ
diff --git a/themes/seamark/public/web-app-manifest-512x512.png b/themes/seamark/public/web-app-manifest-512x512.png
new file mode 100644
index 0000000..25350ec
Binary files /dev/null and b/themes/seamark/public/web-app-manifest-512x512.png differ
diff --git a/themes/seamark/theme.css b/themes/seamark/theme.css
new file mode 100644
index 0000000..ca0cd67
--- /dev/null
+++ b/themes/seamark/theme.css
@@ -0,0 +1,14 @@
+/* Seamark color overrides (injected after atcr.io's compiled stylesheet) */
+[data-theme="dark"] {
+ --color-secondary: oklch(90% 0.182 98.111);
+ --color-secondary-content: oklch(30% 0.182 98.111);
+ --color-accent: oklch(66.6% 0.121 28);
+ --color-accent-content: oklch(28% 0.121 28);
+}
+
+[data-theme="light"] {
+ --color-secondary: oklch(76% 0.095 76.1);
+ --color-secondary-content: oklch(27.1% 0.026 76.4);
+ --color-accent: oklch(66.6% 0.121 28);
+ --color-accent-content: oklch(28% 0.121 28);
+}