diff --git a/CLAUDE.md b/CLAUDE.md index ef5f768..5f22e53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,17 +1,26 @@ -# quintodrome-quineglobal-com +# quineglobal-sites -Website and auto-update feed server for [Quintodrome](https://github.com/quine-global/quintodrome), a native desktop app that wraps [Navidrome](https://www.navidrome.org) (a self-hosted music server) for macOS, Windows, and Linux. +Monorepo for Quine Global's website + auto-update feed servers: currently +[Quintodrome](https://github.com/quine-global/quintodrome) (a native desktop +app wrapping [Navidrome](https://www.navidrome.org)) and +[Quine Hyper](https://github.com/quine-global/hyper) (a fork of the Hyper +terminal). Both are the same server shape — release feed, download page, +Hazel/Squirrel-style auto-update API — with only branding, asset-naming +rules, and landing-page content differing per site. ## Commands +All commands take `SITE=quintodrome` (default) or `SITE=hyper`. + ```bash -make dev # start Go server + Tailwind watcher (Ctrl-C stops both) -make start # Go server only -make test # go test -shuffle on ./... -make css # one-shot Tailwind build -make lint # golangci-lint -make build # Docker image (linux/amd64) -make push # build + push to forge.quinefoundation.com +make dev # start Go server + Tailwind watcher for SITE (Ctrl-C stops both) +make dev SITE=hyper # same, for the hyper site +make start # Go server only +make test # go test -shuffle on ./... (covers every site) +make css # one-shot Tailwind build (shared across sites) +make lint # golangci-lint +make build # Docker image for SITE (linux/amd64) +make push # build + push to forge.quinefoundation.com ``` ## Architecture @@ -19,27 +28,36 @@ make push # build + push to forge.quinefoundation.com Pure Go HTTP server (`go-chi/chi`). HTML is rendered server-side with `maragu.dev/gomponents` (no templates). CSS is Tailwind, compiled to `public/styles/app.css`. ``` -cmd/app/ entry point -http/ route handlers, server, release cache, update-check logic -html/ gomponents page renderers and shared types (Release, DownloadProps, etc.) -public/ static assets (images, compiled CSS) -scripts/ release refresh helper script +internal/site/ shared server framework: Server, routes, release cache, update-check protocol +internal/component/ shared gomponents chrome: page/nav/footer, download page, FeatureCard, DeviceWindow +sites/quintodrome/ Quintodrome's Config, classifyAsset, landing-page content, cmd/main.go +sites/hyper/ Quine Hyper's Config, classifyAsset, landing-page content, cmd/main.go +public/ static assets shared by all sites (images, compiled CSS) +scripts/ release refresh helper script (shared, identical across sites) ``` +A site is defined entirely by `sites//site.go`'s `Config` value +(`internal/site.Config`, embedding `component.Branding` and +`component.DownloadConfig`) plus that package's `classifyAsset` and +`HomeContent`. Everything else — HTTP routing, the release cache, the +update-check handler, the download page's mechanics — is shared code in +`internal/site` and `internal/component`. + ## Key concepts -**Release cache** (`http/releases.go`): fetches GitHub releases from the `quine-global/quintodrome` repo via the GitHub API, caches them in memory. `TryRefresh` is debounced (called per-request, no-ops if cache is fresh or a fetch is already running). `ForceRefresh` is used by the internal `/internal/refresh` endpoint. +**Release cache** (`internal/site/releases.go`): fetches GitHub releases from `Config.RepoOwner/Config.RepoName` via the GitHub API, caches them in memory per-server. `TryRefresh` is debounced (called per-request, no-ops if cache is fresh or a fetch is already running). `ForceRefresh` is used by the internal `/internal/refresh` endpoint. -**Asset classification** (`classifyAsset` in `http/releases.go`): maps GitHub asset filenames to `(os, arch)` keys used in `Release.Assets`. Supported platforms: `mac/{arm64,x64}` (`.dmg`), `windows/x64` (`.exe`), `linux/{arm64,x64}` (AppImage), `linux-rpm`, `linux-deb`, `linux-snap`, `linux-pacman`, `linux-flatpak` (each `{arm64,x64}`). Arch is detected from either `arm64` or `aarch64` in the filename. armv7l and other unrecognised ARM variants are explicitly excluded (return `ok=false`) so they don't get misclassified as x64. Quintodrome's actual releases currently ship `.dmg`/`.exe`/`.flatpak`/`.snap` only (no Linux AppImage/deb/rpm/pacman yet); `.msi` and `.app.zip` variants are intentionally left unclassified in favor of `.exe`/`.dmg`. The upstream repo also publishes `navidrome_*` server-binary assets (tar.gz/deb/rpm for the bundled Navidrome server itself) — those are unrelated to the desktop app and are not classified here. +**Asset classification** (`sites//assets.go`): each site's own `classifyAsset` maps GitHub release asset filenames to `(os, arch)` keys used in `Release.Assets`, since real release artifact naming conventions differ per product (this is intentionally *not* shared logic). Supported OS keys: `mac`, `windows`, `linux` (AppImage), `linux-rpm`, `linux-deb`, `linux-snap`, `linux-pacman`, `linux-flatpak`. armv7l and other unrecognised ARM variants are explicitly excluded (return `ok=false`) rather than misclassified as x64. -**Auto-update feed** (`http/updatecheck.go`): implements a Hazel/Squirrel-style update protocol. Responds to `GET /api/update-check` and `GET /update` with 204 (no update) or 200 JSON `{name, notes, pub_date, url}`. Handles stable and canary tracks; platform strings from the client (`darwin`, `win32`, `deb`, etc.) are normalised via `normalizePlatformForAssets`. +**Auto-update feed** (`internal/site/updatecheck.go`): implements a Hazel/Squirrel-style update protocol, shared by all sites. Responds to `GET /api/update-check` and `GET /update` with 204 (no update) or 200 JSON `{name, notes, pub_date, url}`. Handles stable and canary tracks; platform strings from the client (`darwin`, `win32`, `deb`, etc.) are normalised via `normalizePlatformForAssets`. -**Download page** (`http/home.go`, `html/download.go`): detects platform from `User-Agent`, supports manual override via `?os=` and `?arch=` query params. Linux defaults to the Flatpak format (`?os=linux-flatpak`) since that's what's actually published today; the format toggle also offers Snap, AppImage, DEB, RPM, and Pacman for forward compatibility. +**Download page** (`internal/site/home.go`, `internal/component/download.go`): detects platform from `User-Agent` (Linux default comes from `Config.DefaultLinuxOS`), supports manual override via `?os=` and `?arch=` query params. The Linux format toggle's button list, order, and per-format architecture availability are entirely driven by `Config.LinuxFormats` — e.g. Quintodrome offers Flatpak first and ships Snap for both arm64/x64, while Hyper offers AppImage first and only ships Snap for x64 (hidden on arm64 automatically since the config declares it x64-only). ## Testing -Tests live alongside source in `package http` (internal) or `package http_test` (integration). Run with `make test`. Notable test files: +Tests live alongside source: `package site`/`site_test` in `internal/site`, `package quintodrome`/`hyper` in each site dir. Run with `make test`. Notable test files: -- `http/releases_test.go` — `classifyAsset` and `parseTag` -- `http/updatecheck_test.go` — `semverCmp`, `newerCanary`, `normalizePlatformForAssets` -- `http/server_test.go` — integration test that starts the real server +- `sites//assets_test.go` — per-site `classifyAsset` cases +- `internal/site/releases_test.go` — shared `parseTag` +- `internal/site/updatecheck_test.go` — shared `semverCmp`, `newerCanary`, `normalizePlatformForAssets` +- `internal/site/server_test.go` — integration test that starts a real server against a minimal test `Config` diff --git a/Dockerfile b/Dockerfile index 0189fc6..4e397ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,8 @@ WORKDIR /app RUN set -x && apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y curl -COPY html ./html/ +COPY internal ./internal/ +COPY sites ./sites/ FROM golang AS gobuilder WORKDIR /app @@ -15,7 +16,8 @@ RUN go mod download COPY . ./ ARG TARGETARCH -RUN GOOS=linux GOARCH=${TARGETARCH} go build -buildvcs=false -ldflags="-s -w" -o ./app ./cmd/app +ARG SITE=quintodrome +RUN GOOS=linux GOARCH=${TARGETARCH} go build -buildvcs=false -ldflags="-s -w" -o ./app ./sites/${SITE}/cmd FROM debian:stable-slim AS runner WORKDIR /app diff --git a/Makefile b/Makefile index 8a75829..930fa9b 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,17 @@ +# SITE selects which site under sites/ to build/run: quintodrome or hyper. +SITE ?= quintodrome + .PHONY: benchmark benchmark: go test -bench=. .PHONY: build build: - docker build --platform linux/amd64 . -t forge.quinefoundation.com/ironmagma/quintodrome-quineglobal-com + docker build --platform linux/amd64 --build-arg SITE=$(SITE) . -t forge.quinefoundation.com/ironmagma/$(SITE)-quineglobal-com .PHONY: push push: build - docker push forge.quinefoundation.com/ironmagma/quintodrome-quineglobal-com + docker push forge.quinefoundation.com/ironmagma/$(SITE)-quineglobal-com .PHONY: cover cover: @@ -24,7 +27,7 @@ css-watch: .PHONY: dev dev: - @trap 'kill 0' SIGINT SIGTERM; npm run css:watch & go run ./cmd/app; wait + @trap 'kill 0' SIGINT SIGTERM; npm run css:watch & go run ./sites/$(SITE)/cmd; wait .PHONY: install install: @@ -36,7 +39,7 @@ lint: .PHONY: start start: - go run ./cmd/app + go run ./sites/$(SITE)/cmd .PHONY: test test: diff --git a/sites/quintodrome/html/download.go b/internal/component/download.go similarity index 82% rename from sites/quintodrome/html/download.go rename to internal/component/download.go index 75cb771..54948a3 100644 --- a/sites/quintodrome/html/download.go +++ b/internal/component/download.go @@ -1,4 +1,4 @@ -package html +package component import ( "fmt" @@ -47,6 +47,40 @@ type DownloadProps struct { Releases []Release } +// LinuxFormat is one Linux packaging format a site offers on its download +// page (e.g. Flatpak, Snap, AppImage), and which architectures it ships for. +type LinuxFormat struct { + OS string // asset-classification key, e.g. "linux-flatpak" + Label string // button label, e.g. "Flatpak" + Archs []string // architectures this format is available for, e.g. []string{"x64"} +} + +// DownloadConfig is the per-site knobs for the download page: which Linux +// format is assumed by default, and the ordered list of formats offered. +type DownloadConfig struct { + DefaultLinuxOS string // detectPlatform()'s Linux default, e.g. "linux-flatpak" + Formats []LinuxFormat +} + +func (dc DownloadConfig) formatSupportsArch(os, arch string) bool { + for _, f := range dc.Formats { + if f.OS == os { + return containsString(f.Archs, arch) + } + } + // Not a tracked Linux format (e.g. plain "linux") — no arch restriction. + return true +} + +func containsString(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + type platformOption struct { OS string Arch string @@ -96,9 +130,9 @@ func platformLabel(os, arch string) string { } // DownloadPage renders the download page with OS/arch selection and version list. -func DownloadPage(props PageProps, dl DownloadProps) Node { - props.Title = "Download — Quintodrome" - props.Description = "Download Quintodrome for macOS, Windows, or Linux." +func DownloadPage(b Branding, dc DownloadConfig, props PageProps, dl DownloadProps) Node { + props.Title = "Download — " + b.Name + props.Description = "Download " + b.Name + " for macOS, Windows, or Linux." // Split into stable and canary. var stable, canary []Release @@ -112,41 +146,41 @@ func DownloadPage(props PageProps, dl DownloadProps) Node { content := []Node{ If(dl.SelectedOS == "mac" && !dl.ArchExplicit, macArchDetectScript()), - H1(Class("font-mono text-3xl font-bold text-white"), Text("Download Quintodrome")), + H1(Class("font-mono text-3xl font-bold text-white"), Text("Download "+b.Name)), Div(Class("mt-8"), P(Class("font-mono text-xs uppercase tracking-widest text-zinc-500 mb-3"), Text("Platform")), Div(Class("flex flex-wrap gap-2"), - Group(platformButtons(dl)), + Group(platformButtons(dc, dl)), ), If(strings.HasPrefix(dl.SelectedOS, "linux"), - linuxFormatToggle(dl), + linuxFormatToggle(dc, dl), ), ), } if len(stable) > 0 { - content = append(content, stableSection(stable, dl)) + content = append(content, stableSection(b, stable, dl)) } if len(canary) > 0 { - content = append(content, canarySection(canary, dl)) + content = append(content, canarySection(b, canary, dl)) } content = append(content, Div(Class("mt-6 text-center"), A( - Href("https://github.com/quine-global/quintodrome/releases"), + Href(b.GitHubURL+"/releases"), Class("font-mono text-xs text-zinc-500 hover:text-white transition-colors underline underline-offset-4"), Text("All releases on GitHub ↗"), ), ), ) - return page(props, + return Page(b, props, Div(Class("mx-auto max-w-xl py-16"), Group(content), ), ) } -func stableSection(stable []Release, dl DownloadProps) Node { +func stableSection(b Branding, stable []Release, dl DownloadProps) Node { latest := stable[0] older := stable[1:] url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch) @@ -165,7 +199,7 @@ func stableSection(stable []Release, dl DownloadProps) Node { Text("Latest Stable"), ), H2(Class("mt-1 font-mono text-xl font-bold text-white"), - Text("Quintodrome v"+latest.DisplayVersion()), + Text(b.Name+" v"+latest.DisplayVersion()), ), P(Class("mt-1 font-mono text-xs text-zinc-500"), Text(latest.Date+" · "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)), @@ -189,7 +223,7 @@ func stableSection(stable []Release, dl DownloadProps) Node { } } -func canarySection(canary []Release, dl DownloadProps) Node { +func canarySection(b Branding, canary []Release, dl DownloadProps) Node { latest := canary[0] older := canary[1:] url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch) @@ -208,7 +242,7 @@ func canarySection(canary []Release, dl DownloadProps) Node { Text("Latest Canary"), ), H2(Class("mt-1 font-mono text-base font-bold text-white"), - Text("Quintodrome v"+latest.DisplayVersion()), + Text(b.Name+" v"+latest.DisplayVersion()), ), P(Class("mt-1 font-mono text-xs text-zinc-500"), Text(latest.Date+" · "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)), @@ -276,7 +310,7 @@ func releaseRows(releases []Release, dl DownloadProps, linkCls string) []Node { return nodes } -func platformButtons(dl DownloadProps) []Node { +func platformButtons(dc DownloadConfig, dl DownloadProps) []Node { nodes := make([]Node, len(platforms)) // Treat any linux-* variant as linux for platform button highlight/detection purposes. effectiveSelectedOS := dl.SelectedOS @@ -291,9 +325,10 @@ func platformButtons(dl DownloadProps) []Node { isSelected := p.OS == effectiveSelectedOS && p.Arch == dl.SelectedArch isDetected := p.OS == effectiveDetectedOS && p.Arch == dl.DetectedArch - // Preserve the linux format when switching arch. + // Preserve the linux format when switching arch, unless that format + // doesn't ship for the target arch (e.g. an x64-only Snap build). targetOS := p.OS - if p.OS == "linux" && strings.HasPrefix(dl.SelectedOS, "linux-") { + if p.OS == "linux" && strings.HasPrefix(dl.SelectedOS, "linux-") && dc.formatSupportsArch(dl.SelectedOS, p.Arch) { targetOS = dl.SelectedOS } @@ -347,7 +382,7 @@ func macArchDetectScript() Node { })();`)) } -func linuxFormatToggle(dl DownloadProps) Node { +func linuxFormatToggle(dc DownloadConfig, dl DownloadProps) Node { selectedCls := "inline-flex items-center rounded-md bg-primary-500 px-3 py-1.5 font-mono text-xs font-semibold text-black" unselectedCls := "inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 font-mono text-xs text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors" cls := func(targetOS string) string { @@ -360,13 +395,12 @@ func linuxFormatToggle(dl DownloadProps) Node { return fmt.Sprintf("/download?os=%s&arch=%s", targetOS, dl.SelectedArch) } - buttons := []Node{ - A(Href(href("linux-flatpak")), Class(cls("linux-flatpak")), Text("Flatpak")), - A(Href(href("linux-snap")), Class(cls("linux-snap")), Text("Snap")), - A(Href(href("linux")), Class(cls("linux")), Text("AppImage")), - A(Href(href("linux-deb")), Class(cls("linux-deb")), Text("DEB")), - A(Href(href("linux-rpm")), Class(cls("linux-rpm")), Text("RPM")), - A(Href(href("linux-pacman")), Class(cls("linux-pacman")), Text("Pacman")), + var buttons []Node + for _, f := range dc.Formats { + if !containsString(f.Archs, dl.SelectedArch) { + continue + } + buttons = append(buttons, A(Href(href(f.OS)), Class(cls(f.OS)), Text(f.Label))) } return Div(Class("mt-4"), diff --git a/internal/component/featurecard.go b/internal/component/featurecard.go new file mode 100644 index 0000000..c25ca89 --- /dev/null +++ b/internal/component/featurecard.go @@ -0,0 +1,17 @@ +package component + +import ( + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +// FeatureCard is one card in the "Why " features grid. +func FeatureCard(title string, body Node, code string) Node { + return Div(Class("rounded-xl border border-zinc-800 bg-zinc-900 p-8"), + H3(Class("font-mono text-base font-semibold text-white"), Text(title)), + P(Class("mt-3 font-mono text-sm text-zinc-400 leading-relaxed"), body), + Div(Class("mt-5 inline-block rounded-md bg-zinc-800 px-3 py-1.5"), + Span(Class("font-mono text-xs text-primary-400"), Text(code)), + ), + ) +} diff --git a/internal/component/mockup.go b/internal/component/mockup.go new file mode 100644 index 0000000..e27e194 --- /dev/null +++ b/internal/component/mockup.go @@ -0,0 +1,22 @@ +package component + +import ( + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +// DeviceWindow is the shared "device window" chrome (rounded border, shadow, +// traffic-light title bar) used by each site's home-page mockup. Sites +// supply their own body content (a music player, a terminal, etc.). +func DeviceWindow(titleBarLabel string, body Node) Node { + return Div(Class("mx-auto mb-20 max-w-3xl overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-2xl"), + // Title bar + Div(Class("flex items-center gap-2 border-b border-zinc-800 bg-zinc-900 px-4 py-3"), + Div(Class("h-3 w-3 rounded-full bg-red-500 opacity-80")), + Div(Class("h-3 w-3 rounded-full bg-yellow-500 opacity-80")), + Div(Class("h-3 w-3 rounded-full bg-primary-500 opacity-80")), + Span(Class("ml-4 font-mono text-xs text-zinc-500"), Text(titleBarLabel)), + ), + body, + ) +} diff --git a/sites/hyper/html/common.go b/internal/component/page.go similarity index 67% rename from sites/hyper/html/common.go rename to internal/component/page.go index 3c0ebea..a04db53 100644 --- a/sites/hyper/html/common.go +++ b/internal/component/page.go @@ -1,5 +1,7 @@ -// Package HTML holds all the common HTML components and utilities. -package html +// Package component holds all the common HTML components and utilities +// shared across sites. Site-specific branding is threaded through via +// [Branding] and [DownloadConfig] rather than hardcoded. +package component import ( "crypto/sha256" @@ -17,14 +19,26 @@ import ( var hashOnce sync.Once var appCSSPath string -// PageProps are properties for the [page] component. +// PageProps are properties for the [Page] component. type PageProps struct { Title string Description string } -// page layout with nav, main content, and footer. -func page(props PageProps, children ...Node) Node { +// Branding holds the per-site identity used by the shared page chrome. +type Branding struct { + Name string // e.g. "Quintodrome" + LogoPath string // e.g. "/images/quintodrome-logo.png" + GitHubURL string // e.g. "https://github.com/quine-global/quintodrome" + + // FooterNote is the "Built on ." fragment rendered after the + // shared "© 2026 Quine Global. " copyright line. Each site supplies + // its own link and trailing text (e.g. "Navidrome." vs "Hyper by Vercel."). + FooterNote Node +} + +// Page layout with nav, main content, and footer. +func Page(b Branding, props PageProps, children ...Node) Node { hashOnce.Do(func() { appCSSPath = getHashedPath("public/styles/app.css") }) @@ -41,26 +55,26 @@ func page(props PageProps, children ...Node) Node { Meta(Name("viewport"), Content("width=device-width, initial-scale=1")), }, Body: Group{ - siteNav(), + siteNav(b), Main(Class("mx-auto max-w-5xl px-6 py-12"), Group(children), ), - siteFooter(), + siteFooter(b), }, }) } -func siteNav() Node { +func siteNav(b Branding) Node { return Header(Class("border-b border-zinc-800 bg-zinc-950"), Div(Class("mx-auto flex max-w-5xl items-center justify-between px-6 py-4"), A(Href("/"), Class("flex items-center gap-3 no-underline"), - Img(Src("/images/hyper-logo.png"), Alt("Quine Hyper"), Class("h-8 w-8 rounded")), + Img(Src(b.LogoPath), Alt(b.Name), Class("h-8 w-8 rounded")), Span(Class("font-mono text-lg font-bold tracking-tight text-white"), - Text("Quine Hyper"), + Text(b.Name), ), ), Div(Class("flex items-center gap-4"), - A(Href("https://github.com/quine-global/hyper"), + A(Href(b.GitHubURL), Class("font-mono text-sm text-zinc-400 hover:text-white transition-colors"), Text("GitHub"), ), @@ -73,20 +87,19 @@ func siteNav() Node { ) } -func siteFooter() Node { +func siteFooter(b Branding) Node { return Footer(Class("mt-24 border-t border-zinc-800 py-8"), Div(Class("mx-auto flex max-w-5xl flex-col items-center justify-between gap-4 px-6 sm:flex-row"), Div(Class("flex items-center gap-3"), Img(Src("/images/quineglobal-logo.png"), Alt("Quine Global"), Class("h-6 w-6 rounded-full")), Span(Class("font-mono text-sm text-zinc-500"), - Text("© 2026 Quine Global. Built on "), - A(Href("https://hyper.is"), Class("text-zinc-400 hover:text-white underline"), Text("Hyper")), - Text(" by Vercel."), + Text("© 2026 Quine Global. "), + b.FooterNote, ), ), Div(Class("flex gap-6 font-mono text-sm"), A(Href("/"), Class("text-zinc-500 hover:text-white transition-colors"), Text("Home")), - A(Href("https://github.com/quine-global/hyper"), Class("text-zinc-500 hover:text-white transition-colors"), Text("GitHub")), + A(Href(b.GitHubURL), Class("text-zinc-500 hover:text-white transition-colors"), Text("GitHub")), A(Href("/download"), Class("text-zinc-500 hover:text-white transition-colors"), Text("Download")), ), ), diff --git a/internal/site/config.go b/internal/site/config.go new file mode 100644 index 0000000..f94b8d4 --- /dev/null +++ b/internal/site/config.go @@ -0,0 +1,31 @@ +package site + +import ( + . "maragu.dev/gomponents" + + "app/internal/component" +) + +// Config is the full set of per-site knobs the shared server and page +// chrome need. Each site under sites/ builds one Config value and passes +// it to [NewServer]. +type Config struct { + component.Branding + component.DownloadConfig + + // RepoOwner/RepoName identify the GitHub repo releases are fetched from + // and linked to, e.g. "quine-global" / "quintodrome". + RepoOwner string + RepoName string + + // ClassifyAsset maps a GitHub release asset filename to an (os, arch) + // key used in Release.Assets. Each site's real release artifacts have + // their own naming quirks, so this is not shared logic. + ClassifyAsset func(name string) (os, arch string, ok bool) + + // HomeContent renders the site's landing page (hero, mockup, feature + // cards, origin story). It's expected to call component.Page itself. + // It takes the full Config (rather than closing over the site's package- + // level Config var) to avoid a package initialization cycle. + HomeContent func(cfg Config, props component.PageProps) Node +} diff --git a/sites/quintodrome/http/home.go b/internal/site/home.go similarity index 78% rename from sites/quintodrome/http/home.go rename to internal/site/home.go index ac59e8c..69aa346 100644 --- a/sites/quintodrome/http/home.go +++ b/internal/site/home.go @@ -1,4 +1,4 @@ -package http +package site import ( "context" @@ -10,20 +10,20 @@ import ( . "maragu.dev/gomponents" ghttp "maragu.dev/gomponents/http" - "app/sites/quintodrome/html" + "app/internal/component" ) // Home registers the index and download handlers. func (s *Server) Home(r chi.Router) { r.Get("/", ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) { - return html.HomePage(html.PageProps{}), nil + return s.config.HomeContent(s.config, component.PageProps{}), nil })) r.Get("/download", ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) { // On-demand refresh, debounced to once per 2 minutes across all requests. go s.releases.TryRefresh(context.Background(), 2*time.Minute) - detectedOS, detectedArch := detectPlatform(r.Header.Get("User-Agent")) + detectedOS, detectedArch := s.detectPlatform(r.Header.Get("User-Agent")) selectedOS := detectedOS selectedArch := detectedArch @@ -37,7 +37,7 @@ func (s *Server) Home(r chi.Router) { archExplicit = true } - return html.DownloadPage(html.PageProps{}, html.DownloadProps{ + return component.DownloadPage(s.config.Branding, s.config.DownloadConfig, component.PageProps{}, component.DownloadProps{ DetectedOS: detectedOS, DetectedArch: detectedArch, SelectedOS: selectedOS, @@ -48,7 +48,7 @@ func (s *Server) Home(r chi.Router) { })) } -func detectPlatform(ua string) (os, arch string) { +func (s *Server) detectPlatform(ua string) (os, arch string) { u := strings.ToLower(ua) switch { case strings.Contains(u, "windows"): @@ -56,8 +56,7 @@ func detectPlatform(ua string) (os, arch string) { case strings.Contains(u, "macintosh"), strings.Contains(u, "mac os"): return "mac", "arm64" // default Apple Silicon; user can override case strings.Contains(u, "linux"): - // Quintodrome currently ships Linux builds as Flatpak/Snap only (no AppImage). - return "linux-flatpak", "x64" + return s.config.DefaultLinuxOS, "x64" default: return "mac", "arm64" } diff --git a/sites/hyper/http/refresh.go b/internal/site/refresh.go similarity index 97% rename from sites/hyper/http/refresh.go rename to internal/site/refresh.go index a367335..25b3790 100644 --- a/sites/hyper/http/refresh.go +++ b/internal/site/refresh.go @@ -1,4 +1,4 @@ -package http +package site import ( "fmt" diff --git a/sites/quintodrome/http/releases.go b/internal/site/releases.go similarity index 60% rename from sites/quintodrome/http/releases.go rename to internal/site/releases.go index 03d32d3..a3c6643 100644 --- a/sites/quintodrome/http/releases.go +++ b/internal/site/releases.go @@ -1,8 +1,9 @@ -package http +package site import ( "context" "encoding/json" + "fmt" "log/slog" "net/http" "strconv" @@ -10,26 +11,29 @@ import ( "sync" "time" - "app/sites/quintodrome/html" + "app/internal/component" ) -const releasesAPI = "https://api.github.com/repos/quine-global/quintodrome/releases?per_page=30" - // ReleaseCache is a thread-safe cache of GitHub releases. type ReleaseCache struct { log *slog.Logger + config Config mu sync.RWMutex - releases []html.Release + releases []component.Release lastFetched time.Time fetchMu sync.Mutex // TryLock prevents concurrent fetches } +func (c *ReleaseCache) releasesAPI() string { + return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases?per_page=30", c.config.RepoOwner, c.config.RepoName) +} + // Get returns cached releases, falling back to hardcoded data if cache is empty. -func (c *ReleaseCache) Get() []html.Release { +func (c *ReleaseCache) Get() []component.Release { c.mu.RLock() defer c.mu.RUnlock() if len(c.releases) == 0 { - return []html.Release{} + return []component.Release{} } return c.releases } @@ -40,7 +44,7 @@ func (c *ReleaseCache) ForceRefresh(ctx context.Context) error { c.fetchMu.Lock() defer c.fetchMu.Unlock() - releases, err := fetchReleases(ctx) + releases, err := c.fetchReleases(ctx) if err != nil { c.log.Warn("Force refresh failed", "error", err) return err @@ -74,7 +78,7 @@ func (c *ReleaseCache) TryRefresh(ctx context.Context, minAge time.Duration) { return } - releases, err := fetchReleases(ctx) + releases, err := c.fetchReleases(ctx) if err != nil { c.log.Warn("Failed to fetch releases from GitHub", "error", err) return @@ -104,8 +108,8 @@ type ghAsset struct { BrowserDownloadURL string `json:"browser_download_url"` } -func fetchReleases(ctx context.Context) ([]html.Release, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, releasesAPI, nil) +func (c *ReleaseCache) fetchReleases(ctx context.Context) ([]component.Release, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.releasesAPI(), nil) if err != nil { return nil, err } @@ -123,13 +127,13 @@ func fetchReleases(ctx context.Context) ([]html.Release, error) { return nil, err } - releases := make([]html.Release, 0, len(raw)) + releases := make([]component.Release, 0, len(raw)) for _, gr := range raw { if gr.Draft { continue } version, canaryBuild := parseTag(strings.TrimPrefix(gr.TagName, "v")) - r := html.Release{ + r := component.Release{ TagName: gr.TagName, Version: version, CanaryBuild: canaryBuild, @@ -139,7 +143,7 @@ func fetchReleases(ctx context.Context) ([]html.Release, error) { Assets: make(map[string]map[string]string), } for _, a := range gr.Assets { - os, arch, ok := classifyAsset(a.Name) + os, arch, ok := c.config.ClassifyAsset(a.Name) if !ok { continue } @@ -171,56 +175,3 @@ func parseTag(tag string) (version string, canaryBuild int) { tag = strings.TrimSuffix(tag, "q") return tag, 0 } - -// classifyAsset maps an asset filename to an OS and architecture. -func classifyAsset(name string) (os, arch string, ok bool) { - l := strings.ToLower(name) - isArm64 := strings.Contains(l, "arm64") || strings.Contains(l, "aarch64") - isArmv7l := strings.Contains(l, "armv7l") - switch { - case strings.HasSuffix(l, ".exe"): - return "windows", "x64", true - case isArm64 && strings.HasSuffix(l, ".dmg"): - return "mac", "arm64", true - case strings.HasSuffix(l, ".dmg"): - return "mac", "x64", true - case isArm64 && strings.HasSuffix(l, ".appimage"): - return "linux", "arm64", true - case isArmv7l && strings.HasSuffix(l, ".appimage"): - return "", "", false - case strings.HasSuffix(l, ".appimage"): - return "linux", "x64", true - case isArm64 && strings.HasSuffix(l, ".rpm"): - return "linux-rpm", "arm64", true - case isArmv7l && strings.HasSuffix(l, ".rpm"): - return "", "", false - case strings.HasSuffix(l, ".rpm"): - return "linux-rpm", "x64", true - case isArm64 && strings.HasSuffix(l, ".deb"): - return "linux-deb", "arm64", true - case isArmv7l && strings.HasSuffix(l, ".deb"): - return "", "", false - case strings.HasSuffix(l, ".deb"): - return "linux-deb", "x64", true - case isArmv7l && strings.HasSuffix(l, ".snap"): - return "", "", false - case isArm64 && strings.HasSuffix(l, ".snap"): - return "linux-snap", "arm64", true - case strings.HasSuffix(l, ".snap"): - return "linux-snap", "x64", true - case isArm64 && strings.HasSuffix(l, ".pacman"): - return "linux-pacman", "arm64", true - case isArmv7l && strings.HasSuffix(l, ".pacman"): - return "", "", false - case strings.HasSuffix(l, ".pacman"): - return "linux-pacman", "x64", true - case isArm64 && strings.HasSuffix(l, ".flatpak"): - return "linux-flatpak", "arm64", true - case isArmv7l && strings.HasSuffix(l, ".flatpak"): - return "", "", false - case strings.HasSuffix(l, ".flatpak"): - return "linux-flatpak", "x64", true - default: - return "", "", false - } -} diff --git a/internal/site/releases_test.go b/internal/site/releases_test.go new file mode 100644 index 0000000..5bad01c --- /dev/null +++ b/internal/site/releases_test.go @@ -0,0 +1,30 @@ +package site + +import ( + "testing" + + "maragu.dev/is" +) + +func TestParseTag(t *testing.T) { + cases := []struct { + tag string + wantVer string + wantBuild int + }{ + {"4.0.0", "4.0.0", 0}, + {"4.0.0-q", "4.0.0", 0}, + {"4.0.0q", "4.0.0", 0}, + {"4.0.0-q-canary.12", "4.0.0", 12}, + {"4.0.0-q-canary.1", "4.0.0", 1}, + {"1.0.0-alpha", "1.0.0-alpha", 0}, + } + + for _, c := range cases { + t.Run(c.tag, func(t *testing.T) { + gotVer, gotBuild := parseTag(c.tag) + is.Equal(t, c.wantVer, gotVer) + is.Equal(t, c.wantBuild, gotBuild) + }) + } +} diff --git a/sites/hyper/http/routes.go b/internal/site/routes.go similarity index 85% rename from sites/hyper/http/routes.go rename to internal/site/routes.go index eb795bb..82b402f 100644 --- a/sites/hyper/http/routes.go +++ b/internal/site/routes.go @@ -1,4 +1,4 @@ -package http +package site import ( "github.com/go-chi/chi/v5" @@ -25,6 +25,6 @@ func (s *Server) setupRoutes() { }) // Also expose the update feed at the short /update path for convenient - // feed URLs (e.g. https://hyper.quineglobal.com/update?platform=...&version=...) + // feed URLs (e.g. https://.quineglobal.com/update?platform=...&version=...) s.mux.Get("/update", s.updateCheckHandler()) } diff --git a/sites/hyper/http/server.go b/internal/site/server.go similarity index 82% rename from sites/hyper/http/server.go rename to internal/site/server.go index b8823f0..0e21b86 100644 --- a/sites/hyper/http/server.go +++ b/internal/site/server.go @@ -1,5 +1,7 @@ -// Package http has the [Server] and HTTP handlers. -package http +// Package site is the shared HTTP server framework: the [Server] type, +// routes, release cache, and update-check protocol. Site-specific identity +// and content are injected via [Config]. +package site import ( "context" @@ -15,13 +17,15 @@ import ( // Server holds dependencies for the HTTP server as well as the HTTP server itself. type Server struct { log *slog.Logger + config Config mux chi.Router server *http.Server releases *ReleaseCache } type NewServerOptions struct { - Log *slog.Logger + Log *slog.Logger + Config Config } func NewServer(opts NewServerOptions) *Server { @@ -33,8 +37,9 @@ func NewServer(opts NewServerOptions) *Server { return &Server{ log: opts.Log, + config: opts.Config, mux: mux, - releases: &ReleaseCache{log: opts.Log}, + releases: &ReleaseCache{log: opts.Log, config: opts.Config}, server: &http.Server{ Addr: ":8081", Handler: mux, diff --git a/internal/site/server_test.go b/internal/site/server_test.go new file mode 100644 index 0000000..c24aee4 --- /dev/null +++ b/internal/site/server_test.go @@ -0,0 +1,58 @@ +package site_test + +import ( + "io" + http2 "net/http" + "strings" + "testing" + "time" + + "maragu.dev/is" + + . "maragu.dev/gomponents" + + "app/internal/component" + "app/internal/site" +) + +func testConfig() site.Config { + return site.Config{ + Branding: component.Branding{ + Name: "Test", + LogoPath: "/images/test-logo.png", + GitHubURL: "https://github.com/quine-global/test", + }, + DownloadConfig: component.DownloadConfig{ + DefaultLinuxOS: "linux", + }, + RepoOwner: "quine-global", + RepoName: "test", + ClassifyAsset: func(name string) (string, string, bool) { return "", "", false }, + HomeContent: func(cfg site.Config, props component.PageProps) Node { + return component.Page(cfg.Branding, props) + }, + } +} + +func TestServer_Start(t *testing.T) { + t.Run("can start and stop server", func(t *testing.T) { + s := site.NewServer(site.NewServerOptions{Config: testConfig()}) + + go func() { + is.NotError(t, s.Start()) + }() + defer func() { + is.NotError(t, s.Stop()) + }() + + // I know we could check that the server is running here, but it's easier to just wait a bit + time.Sleep(10 * time.Millisecond) + + res, err := http2.Get("http://localhost:8081/") + is.NotError(t, err) + is.Equal(t, http2.StatusOK, res.StatusCode) + body, err := io.ReadAll(res.Body) + is.NotError(t, err) + is.True(t, strings.HasPrefix(string(body), "")) + }) +} diff --git a/sites/hyper/http/static.go b/internal/site/static.go similarity index 96% rename from sites/hyper/http/static.go rename to internal/site/static.go index a6323ba..2fb303d 100644 --- a/sites/hyper/http/static.go +++ b/internal/site/static.go @@ -1,4 +1,4 @@ -package http +package site import ( "net/http" diff --git a/sites/quintodrome/http/updatecheck.go b/internal/site/updatecheck.go similarity index 93% rename from sites/quintodrome/http/updatecheck.go rename to internal/site/updatecheck.go index 2f463af..a789928 100644 --- a/sites/quintodrome/http/updatecheck.go +++ b/internal/site/updatecheck.go @@ -1,4 +1,4 @@ -package http +package site import ( "encoding/json" @@ -10,7 +10,7 @@ import ( "github.com/go-chi/chi/v5" - "app/sites/quintodrome/html" + "app/internal/component" ) // hazelUpdate is the response format expected by desktop auto-updaters @@ -66,7 +66,7 @@ func (s *Server) updateCheckHandler() http.HandlerFunc { // Find the newest release that is newer than what the client has, // on the correct track (stable vs canary), and preferably has assets // for the requested platform/arch. - var candidate *html.Release + var candidate *component.Release for _, rel := range releases { if rel.IsCanary { if clientIsCanary && newerCanary(rel, clientBase, clientBuild, true) { @@ -96,7 +96,7 @@ func (s *Server) updateCheckHandler() http.HandlerFunc { // Build the Hazel-compatible response. resp := hazelUpdate{ Name: candidate.TagName, - Notes: fmt.Sprintf("Quintodrome %s", candidate.TagName), + Notes: fmt.Sprintf("%s %s", s.config.Name, candidate.TagName), PubDate: candidate.PublishedAt.UTC().Format(time.RFC3339), URL: candidate.DownloadURL(internalOS, arch), } @@ -107,7 +107,7 @@ func (s *Server) updateCheckHandler() http.HandlerFunc { if resp.URL == "" { // Provide a generic GitHub releases URL as a fallback. // The client already falls back to constructing a tag URL. - resp.URL = fmt.Sprintf("https://github.com/quine-global/quintodrome/releases/tag/%s", candidate.TagName) + resp.URL = fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", s.config.RepoOwner, s.config.RepoName, candidate.TagName) } w.Header().Set("Content-Type", "application/json") @@ -152,7 +152,7 @@ func normalizePlatformForAssets(p string) string { // newerCanary reports whether rel is newer than the client's current canary position. // For a canary client, the build number is the tie-breaker on the same base version. // For a stable client opting into canary info, any canary on the same or higher base qualifies. -func newerCanary(rel html.Release, clientBase string, clientBuild int, clientIsCanary bool) bool { +func newerCanary(rel component.Release, clientBase string, clientBuild int, clientIsCanary bool) bool { cmp := semverCmp(rel.Version, clientBase) if cmp > 0 { return true diff --git a/sites/hyper/http/updatecheck_test.go b/internal/site/updatecheck_test.go similarity index 93% rename from sites/hyper/http/updatecheck_test.go rename to internal/site/updatecheck_test.go index 46ce9fa..e76cfaa 100644 --- a/sites/hyper/http/updatecheck_test.go +++ b/internal/site/updatecheck_test.go @@ -1,11 +1,11 @@ -package http +package site import ( "testing" "maragu.dev/is" - "app/html" + "app/internal/component" ) func TestSemverCmp(t *testing.T) { @@ -28,8 +28,8 @@ func TestSemverCmp(t *testing.T) { } func TestNewerCanary(t *testing.T) { - rel := func(base string, build int) html.Release { - return html.Release{Version: base, CanaryBuild: build, IsCanary: true} + rel := func(base string, build int) component.Release { + return component.Release{Version: base, CanaryBuild: build, IsCanary: true} } t.Run("higher base is newer for canary client", func(t *testing.T) { diff --git a/package.json b/package.json index a4964b0..e305507 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "quintodrome-quineglobal-com", + "name": "quineglobal-sites", "private": true, "devDependencies": { "tailwindcss": "^3.4.0" diff --git a/sites/hyper/public/images/hyper-logo.png b/public/images/hyper-logo.png similarity index 100% rename from sites/hyper/public/images/hyper-logo.png rename to public/images/hyper-logo.png diff --git a/public/styles/app.css b/public/styles/app.css index f12bd6f..d4eacc8 100644 --- a/public/styles/app.css +++ b/public/styles/app.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{--tw-bg-opacity:1;background-color:rgb(9 9 11/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(244 244 245/var(--tw-text-opacity,1));font-family:JetBrains Mono,ui-monospace,monospace}.static{position:static}.mx-auto{margin-left:auto;margin-right:auto}.mb-20{margin-bottom:5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.ml-4{margin-left:1rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-24{margin-top:6rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.h-12{height:3rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-6{height:1.5rem}.h-8{height:2rem}.min-h-\[200px\]{min-height:200px}.w-12{width:3rem}.w-2{width:.5rem}.w-3{width:.75rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-\[135px\]{width:135px}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0{gap:0}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-zinc-800>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(39 39 42/var(--tw-divide-opacity,1))}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-canary-500\/30{border-color:rgba(234,179,8,.3)}.border-seafoam-400\/30{border-color:rgba(80,217,168,.3)}.border-zinc-700{--tw-border-opacity:1;border-color:rgb(63 63 70/var(--tw-border-opacity,1))}.border-zinc-800{--tw-border-opacity:1;border-color:rgb(39 39 42/var(--tw-border-opacity,1))}.bg-black\/20{background-color:rgba(0,0,0,.2)}.bg-canary-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-primary-400{--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1))}.bg-primary-500{--tw-bg-opacity:1;background-color:rgb(139 92 246/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-seafoam-400{--tw-bg-opacity:1;background-color:rgb(80 217 168/var(--tw-bg-opacity,1))}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-zinc-700{--tw-bg-opacity:1;background-color:rgb(63 63 70/var(--tw-bg-opacity,1))}.bg-zinc-800{--tw-bg-opacity:1;background-color:rgb(39 39 42/var(--tw-bg-opacity,1))}.bg-zinc-900{--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity,1))}.bg-zinc-950{--tw-bg-opacity:1;background-color:rgb(9 9 11/var(--tw-bg-opacity,1))}.p-10{padding:2.5rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-20{padding-bottom:5rem}.pl-5{padding-left:1.25rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-relaxed{line-height:1.625}.tracking-tight{letter-spacing:-.025em}.tracking-widest{letter-spacing:.1em}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-canary-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.text-canary-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}.text-primary-400{--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.text-seafoam-400{--tw-text-opacity:1;color:rgb(80 217 168/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-zinc-300{--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.text-zinc-400{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.text-zinc-500{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.text-zinc-600{--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.underline-offset-4{text-underline-offset:4px}.opacity-80{opacity:.8}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:border-zinc-500:hover{--tw-border-opacity:1;border-color:rgb(113 113 122/var(--tw-border-opacity,1))}.hover\:bg-canary-400:hover{--tw-bg-opacity:1;background-color:rgb(250 204 21/var(--tw-bg-opacity,1))}.hover\:bg-primary-400:hover{--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1))}.hover\:bg-seafoam-500:hover{--tw-bg-opacity:1;background-color:rgb(61 184 138/var(--tw-bg-opacity,1))}.hover\:text-canary-300:hover{--tw-text-opacity:1;color:rgb(253 224 71/var(--tw-text-opacity,1))}.hover\:text-primary-300:hover{--tw-text-opacity:1;color:rgb(196 181 253/var(--tw-text-opacity,1))}.hover\:text-seafoam-500:hover{--tw-text-opacity:1;color:rgb(61 184 138/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}@media (min-width:640px){.sm\:flex-row{flex-direction:row}.sm\:text-6xl{font-size:3.75rem;line-height:1}}@media (min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{--tw-bg-opacity:1;background-color:rgb(9 9 11/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(244 244 245/var(--tw-text-opacity,1));font-family:JetBrains Mono,ui-monospace,monospace}.static{position:static}.mx-auto{margin-left:auto;margin-right:auto}.mb-20{margin-bottom:5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.ml-4{margin-left:1rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-24{margin-top:6rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.h-1\.5{height:.375rem}.h-12{height:3rem}.h-2{height:.5rem}.h-20{height:5rem}.h-3{height:.75rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.min-h-\[200px\]{min-height:200px}.w-1\/3{width:33.333333%}.w-12{width:3rem}.w-2{width:.5rem}.w-20{width:5rem}.w-3{width:.75rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-\[135px\]{width:135px}.w-full{width:100%}.min-w-0{min-width:0}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0{gap:0}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-zinc-800>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(39 39 42/var(--tw-divide-opacity,1))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis;white-space:nowrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-canary-500\/30{border-color:rgba(234,179,8,.3)}.border-seafoam-400\/30{border-color:rgba(80,217,168,.3)}.border-zinc-700{--tw-border-opacity:1;border-color:rgb(63 63 70/var(--tw-border-opacity,1))}.border-zinc-800{--tw-border-opacity:1;border-color:rgb(39 39 42/var(--tw-border-opacity,1))}.bg-black\/20{background-color:rgba(0,0,0,.2)}.bg-canary-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-primary-400{--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1))}.bg-primary-500{--tw-bg-opacity:1;background-color:rgb(139 92 246/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-seafoam-400{--tw-bg-opacity:1;background-color:rgb(80 217 168/var(--tw-bg-opacity,1))}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-zinc-700{--tw-bg-opacity:1;background-color:rgb(63 63 70/var(--tw-bg-opacity,1))}.bg-zinc-800{--tw-bg-opacity:1;background-color:rgb(39 39 42/var(--tw-bg-opacity,1))}.bg-zinc-900{--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity,1))}.bg-zinc-950{--tw-bg-opacity:1;background-color:rgb(9 9 11/var(--tw-bg-opacity,1))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.from-primary-400{--tw-gradient-from:#a78bfa var(--tw-gradient-from-position);--tw-gradient-to:rgba(167,139,250,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-primary-500{--tw-gradient-to:#8b5cf6 var(--tw-gradient-to-position)}.p-10{padding:2.5rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-20{padding-bottom:5rem}.pl-5{padding-left:1.25rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-relaxed{line-height:1.625}.tracking-tight{letter-spacing:-.025em}.tracking-widest{letter-spacing:.1em}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-canary-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.text-canary-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}.text-primary-400{--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.text-seafoam-400{--tw-text-opacity:1;color:rgb(80 217 168/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-zinc-300{--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.text-zinc-400{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.text-zinc-500{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.text-zinc-600{--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.underline-offset-4{text-underline-offset:4px}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:border-zinc-500:hover{--tw-border-opacity:1;border-color:rgb(113 113 122/var(--tw-border-opacity,1))}.hover\:bg-canary-400:hover{--tw-bg-opacity:1;background-color:rgb(250 204 21/var(--tw-bg-opacity,1))}.hover\:bg-primary-400:hover{--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1))}.hover\:bg-seafoam-500:hover{--tw-bg-opacity:1;background-color:rgb(61 184 138/var(--tw-bg-opacity,1))}.hover\:text-canary-300:hover{--tw-text-opacity:1;color:rgb(253 224 71/var(--tw-text-opacity,1))}.hover\:text-primary-300:hover{--tw-text-opacity:1;color:rgb(196 181 253/var(--tw-text-opacity,1))}.hover\:text-seafoam-500:hover{--tw-text-opacity:1;color:rgb(61 184 138/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}@media (min-width:640px){.sm\:flex-row{flex-direction:row}.sm\:text-6xl{font-size:3.75rem;line-height:1}}@media (min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}} \ No newline at end of file diff --git a/sites/hyper/.gitignore b/sites/hyper/.gitignore deleted file mode 100644 index d6984cc..0000000 --- a/sites/hyper/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -/cover.out -/node_modules/ -/package-lock.json diff --git a/sites/hyper/CLAUDE.md b/sites/hyper/CLAUDE.md index 8e3fee5..2ab8192 100644 --- a/sites/hyper/CLAUDE.md +++ b/sites/hyper/CLAUDE.md @@ -1,45 +1,6 @@ -# hyper-quineglobal-com +# sites/hyper -Website and auto-update feed server for [Quine Hyper](https://github.com/quine-global/hyper), a fork of the Hyper terminal. - -## Commands - -```bash -make dev # start Go server + Tailwind watcher (Ctrl-C stops both) -make start # Go server only -make test # go test -shuffle on ./... -make css # one-shot Tailwind build -make lint # golangci-lint -make build # Docker image (linux/amd64) -make push # build + push to forge.quinefoundation.com -``` - -## Architecture - -Pure Go HTTP server (`go-chi/chi`). HTML is rendered server-side with `maragu.dev/gomponents` (no templates). CSS is Tailwind, compiled to `public/styles/app.css`. - -``` -cmd/app/ entry point -http/ route handlers, server, release cache, update-check logic -html/ gomponents page renderers and shared types (Release, DownloadProps, etc.) -public/ static assets (images, compiled CSS) -scripts/ release refresh helper script -``` - -## Key concepts - -**Release cache** (`http/releases.go`): fetches GitHub releases from the `quine-global/hyper` repo via the GitHub API, caches them in memory. `TryRefresh` is debounced (called per-request, no-ops if cache is fresh or a fetch is already running). `ForceRefresh` is used by the internal `/internal/refresh` endpoint. - -**Asset classification** (`classifyAsset` in `http/releases.go`): maps GitHub asset filenames to `(os, arch)` keys used in `Release.Assets`. Supported platforms: `mac/{arm64,x64}`, `windows/x64`, `linux/{arm64,x64}` (AppImage), `linux-rpm/{arm64,x64}`. armv7l and other unrecognised ARM variants are explicitly excluded (return `ok=false`) so they don't get misclassified as x64. - -**Auto-update feed** (`http/updatecheck.go`): implements the Hazel/Squirrel update protocol originally served at `releases.hyper.is`. Responds to `GET /api/update-check` and `GET /update` with 204 (no update) or 200 JSON `{name, notes, pub_date, url}`. Handles stable and canary tracks; platform strings from the Electron client (`darwin`, `win32`, `deb`, etc.) are normalised via `normalizePlatformForAssets`. - -**Download page** (`http/home.go`, `html/download.go`): detects platform from `User-Agent`, supports manual override via `?os=` and `?arch=` query params. Linux format toggle (AppImage vs RPM) is a separate `?os=linux-rpm` variant. - -## Testing - -Tests live alongside source in `package http` (internal) or `package http_test` (integration). Run with `make test`. Notable test files: - -- `http/releases_test.go` — `classifyAsset` and `parseTag` -- `http/updatecheck_test.go` — `semverCmp`, `newerCanary`, `normalizePlatformForAssets` -- `http/server_test.go` — integration test that starts the real server +Quine Hyper's site-specific config. See the root `CLAUDE.md` for the shared +server/component architecture — this directory only holds `Config` +(branding, GitHub repo, Linux download formats), `classifyAsset`, and the +landing-page content (`home.go`, `mockup.go`). diff --git a/sites/hyper/Dockerfile b/sites/hyper/Dockerfile deleted file mode 100644 index 0189fc6..0000000 --- a/sites/hyper/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -FROM debian:stable AS cssbuilder -WORKDIR /app - -RUN set -x && apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y curl - -COPY html ./html/ - -FROM golang AS gobuilder -WORKDIR /app - -COPY go.mod go.sum ./ -RUN go mod download - -COPY . ./ - -ARG TARGETARCH -RUN GOOS=linux GOARCH=${TARGETARCH} go build -buildvcs=false -ldflags="-s -w" -o ./app ./cmd/app - -FROM debian:stable-slim AS runner -WORKDIR /app - -RUN set -x && apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates && \ - rm -rf /var/lib/apt/lists/* - -COPY public ./public/ -COPY --from=gobuilder /app/app ./ - -EXPOSE 8080 - -CMD ["./app"] diff --git a/sites/hyper/LICENSE b/sites/hyper/LICENSE deleted file mode 100644 index 4ea9178..0000000 --- a/sites/hyper/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) Maragu ApS - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/sites/hyper/Makefile b/sites/hyper/Makefile deleted file mode 100644 index 12a4e1c..0000000 --- a/sites/hyper/Makefile +++ /dev/null @@ -1,43 +0,0 @@ -.PHONY: benchmark -benchmark: - go test -bench=. - -.PHONY: build -build: - docker build --platform linux/amd64 . -t forge.quinefoundation.com/ironmagma/hyper-quineglobal-com - -.PHONY: push -push: build - docker push forge.quinefoundation.com/ironmagma/hyper-quineglobal-com - -.PHONY: cover -cover: - go tool cover -html=cover.out - -.PHONY: css -css: - npm run css - -.PHONY: css-watch -css-watch: - npm run css:watch - -.PHONY: dev -dev: - @trap 'kill 0' SIGINT SIGTERM; npm run css:watch & go run ./cmd/app; wait - -.PHONY: install -install: - npm install - -.PHONY: lint -lint: - golangci-lint run - -.PHONY: start -start: - go run ./cmd/app - -.PHONY: test -test: - go test -coverprofile=cover.out -shuffle on ./... diff --git a/sites/hyper/README.md b/sites/hyper/README.md deleted file mode 100644 index 7708526..0000000 --- a/sites/hyper/README.md +++ /dev/null @@ -1,97 +0,0 @@ -# gomponents-starter-kit - -Logo - -[![GoDoc](https://pkg.go.dev/badge/github.com/maragudk/gomponents-starter-kit)](https://pkg.go.dev/github.com/maragudk/gomponents-starter-kit) -[![Go](https://github.com/maragudk/gomponents-starter-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/maragudk/gomponents-starter-kit/actions/workflows/ci.yml) -[![Go](https://github.com/maragudk/gomponents-starter-kit/actions/workflows/cd.yml/badge.svg)](https://github.com/maragudk/gomponents-starter-kit/actions/workflows/cd.yml) - -A starter kit for building a web app with gomponents, HTMX, and TailwindCSS in Go. - -Made with ✨sparkles✨ by [maragu](https://www.maragu.dev/). - -Does your company depend on this project? [Contact me at markus@maragu.dk](mailto:markus@maragu.dk?Subject=Supporting%20your%20project) to discuss options for a one-time or recurring invoice to ensure its continued thriving. - -## Getting started - -The easiest way to get started is to [Use this template](https://github.com/new?template_name=gomponents-starter-kit&template_owner=maragudk) to create a new repository. Or you could clone this repository the traditional way: - -```shell -git clone git@github.com:maragudk/gomponents-starter-kit.git your-app-name -``` - -After that, you can start the app with: - -```shell -make start -``` - -If you make style changes, watch the CSS with: - -```shell -make watch-css -``` - -You can run tests and linting with: - -```shell -make test lint -``` - -### Enabling TailwindCSS auto-complete in your IDE - -[TailwindCSS has auto-complete of classnames (and more) through IDE plugins](https://tailwindcss.com/docs/editor-setup). - -After you've installed the TailwindCSS plugin for your IDE, it needs some configuration to work with gomponents. Here's the config for VS Code and JetBrains IDEs: - -
-VSCode - -Edit `vscode-settings.json` and add the following: - -```json -{ - "tailwindCSS.includeLanguages": { - "go": "html", - }, - "tailwindCSS.experimental.classRegex": [ - ["Class(?:es)?[({]([^)}]*)[)}]", "[\"`]([^\"`]*)[\"`]"] - ], -} -``` - -[See the official plugin page for more info](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) -
- -
-JetBrains/GoLand - -Go to `Settings` -> `Languages & Frameworks` -> `Style Sheets` -> `Tailwind CSS` and add the following (don't delete the other config): - -```json -{ - "includeLanguages": { - "go": "html" - }, - "experimental": { - "classRegex": [ - ["Class(?:es)?[({]([^)}]*)[)}]", "[\"`]([^\"`]*)[\"`]"] - ] - } -} -``` - -[See the official plugin page for more info](https://plugins.jetbrains.com/plugin/15321-tailwind-css) -
- -## Deploying - -The [CD workflow](.github/workflows/cd.yml) automatically builds a multi-platform Docker image and pushes it to the Github container registry GHCR.io, tagged with the commit hash as well as `latest`. - -You can try building the image locally with: - -```shell -make build-docker -``` - -Note that [you need the containerd image store enabled](https://docs.docker.com/desktop/containerd/#enable-the-containerd-image-store) for this to work. diff --git a/sites/hyper/assets.go b/sites/hyper/assets.go new file mode 100644 index 0000000..9dfa6ee --- /dev/null +++ b/sites/hyper/assets.go @@ -0,0 +1,52 @@ +package hyper + +import "strings" + +// classifyAsset maps an asset filename to an OS and architecture. +func classifyAsset(name string) (os, arch string, ok bool) { + l := strings.ToLower(name) + switch { + case strings.HasSuffix(l, ".exe"): + return "windows", "x64", true + case strings.Contains(l, "arm64") && strings.HasSuffix(l, ".dmg"): + return "mac", "arm64", true + case strings.HasSuffix(l, ".dmg"): + return "mac", "x64", true + case strings.Contains(l, "arm64") && strings.HasSuffix(l, ".appimage"): + return "linux", "arm64", true + case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".appimage"): + return "", "", false + case strings.HasSuffix(l, ".appimage"): + return "linux", "x64", true + case (strings.Contains(l, "arm64") || strings.Contains(l, "aarch64")) && strings.HasSuffix(l, ".rpm"): + return "linux-rpm", "arm64", true + case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".rpm"): + return "", "", false + case strings.HasSuffix(l, ".rpm"): + return "linux-rpm", "x64", true + case strings.Contains(l, "arm64") && strings.HasSuffix(l, ".deb"): + return "linux-deb", "arm64", true + case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".deb"): + return "", "", false + case strings.HasSuffix(l, ".deb"): + return "linux-deb", "x64", true + case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".snap"): + return "", "", false + case strings.HasSuffix(l, ".snap"): + return "linux-snap", "x64", true + case (strings.Contains(l, "arm64") || strings.Contains(l, "aarch64")) && strings.HasSuffix(l, ".pacman"): + return "linux-pacman", "arm64", true + case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".pacman"): + return "", "", false + case strings.HasSuffix(l, ".pacman"): + return "linux-pacman", "x64", true + case (strings.Contains(l, "arm64") || strings.Contains(l, "aarch64")) && strings.HasSuffix(l, ".flatpak"): + return "linux-flatpak", "arm64", true + case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".flatpak"): + return "", "", false + case strings.HasSuffix(l, ".flatpak"): + return "linux-flatpak", "x64", true + default: + return "", "", false + } +} diff --git a/sites/hyper/http/releases_test.go b/sites/hyper/assets_test.go similarity index 83% rename from sites/hyper/http/releases_test.go rename to sites/hyper/assets_test.go index 54467e3..97a85eb 100644 --- a/sites/hyper/http/releases_test.go +++ b/sites/hyper/assets_test.go @@ -1,4 +1,4 @@ -package http +package hyper import ( "testing" @@ -8,10 +8,10 @@ import ( func TestClassifyAsset(t *testing.T) { cases := []struct { - name string - wantOS string - wantArch string - wantOK bool + name string + wantOS string + wantArch string + wantOK bool }{ // Windows {"QuineHyper.Setup.4.0.0.exe", "windows", "x64", true}, @@ -87,25 +87,3 @@ func TestClassifyAsset(t *testing.T) { }) } } - -func TestParseTag(t *testing.T) { - cases := []struct { - tag string - wantVer string - wantBuild int - }{ - {"4.0.0", "4.0.0", 0}, - {"4.0.0-q", "4.0.0", 0}, - {"4.0.0q", "4.0.0", 0}, - {"4.0.0-q-canary.12", "4.0.0", 12}, - {"4.0.0-q-canary.1", "4.0.0", 1}, - } - - for _, c := range cases { - t.Run(c.tag, func(t *testing.T) { - gotVer, gotBuild := parseTag(c.tag) - is.Equal(t, c.wantVer, gotVer) - is.Equal(t, c.wantBuild, gotBuild) - }) - } -} diff --git a/sites/hyper/cmd/app/main.go b/sites/hyper/cmd/main.go similarity index 91% rename from sites/hyper/cmd/app/main.go rename to sites/hyper/cmd/main.go index 289e508..134da10 100644 --- a/sites/hyper/cmd/app/main.go +++ b/sites/hyper/cmd/main.go @@ -10,7 +10,8 @@ import ( "golang.org/x/sync/errgroup" "maragu.dev/env" - "app/http" + "app/internal/site" + "app/sites/hyper" ) func main() { @@ -35,8 +36,9 @@ func start(log *slog.Logger) error { defer stop() // Set up the HTTP server, injecting the database and logger - s := http.NewServer(http.NewServerOptions{ - Log: log, + s := site.NewServer(site.NewServerOptions{ + Log: log, + Config: hyper.Config, }) // Use an errgroup to wait for separate goroutines which can error diff --git a/sites/hyper/go.mod b/sites/hyper/go.mod deleted file mode 100644 index 05cb6e5..0000000 --- a/sites/hyper/go.mod +++ /dev/null @@ -1,14 +0,0 @@ -module app - -go 1.23 - -require ( - github.com/go-chi/chi/v5 v5.1.0 - golang.org/x/sync v0.10.0 - maragu.dev/env v0.2.0 - maragu.dev/gomponents v1.0.0 - maragu.dev/httph v0.3.3 - maragu.dev/is v0.2.0 -) - -require github.com/mitchellh/mapstructure v1.5.0 // indirect diff --git a/sites/hyper/go.sum b/sites/hyper/go.sum deleted file mode 100644 index 9df5731..0000000 --- a/sites/hyper/go.sum +++ /dev/null @@ -1,16 +0,0 @@ -github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= -github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/maragudk/is v0.1.0 h1:obq9anZNmOYcaNbeT0LMyjIexdNeYTw/TLAPD/BnZHA= -github.com/maragudk/is v0.1.0/go.mod h1:W/r6+TpnISu+a88OLXQy5JQGCOhXQXXLD2e5b4xMn5c= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -maragu.dev/env v0.2.0 h1:nQKitDEB65ArZsh6E7vxzodOqY9bxEVFdBg+tskS1ys= -maragu.dev/env v0.2.0/go.mod h1:t5CCbaEnjCM5mewiAVVzTS4N+oXTus2+SRnzKQbQVME= -maragu.dev/gomponents v1.0.0 h1:eeLScjq4PqP1l+r5z/GC+xXZhLHXa6RWUWGW7gSfLh4= -maragu.dev/gomponents v1.0.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM= -maragu.dev/httph v0.3.3 h1:YfOKkqwmAJykOENmxeoCmYY+firwPEpv4aIY7gDCTsc= -maragu.dev/httph v0.3.3/go.mod h1:EaoadbqczoICEGrtKrtlb1KD6gj6m65en3Wt2OchlJ0= -maragu.dev/is v0.2.0 h1:poeuVEA5GG3vrDpGmzo2KjWtIMZmqUyvGnOB0/pemig= -maragu.dev/is v0.2.0/go.mod h1:bviaM5S0fBshCw7wuumFGTju/izopZ/Yvq4g7Klc7y8= diff --git a/sites/hyper/html/home.go b/sites/hyper/home.go similarity index 61% rename from sites/hyper/html/home.go rename to sites/hyper/home.go index ae144d6..c91b18b 100644 --- a/sites/hyper/html/home.go +++ b/sites/hyper/home.go @@ -1,16 +1,19 @@ -package html +package hyper import ( . "maragu.dev/gomponents" . "maragu.dev/gomponents/html" + + "app/internal/component" + "app/internal/site" ) -// HomePage is the Quine Hyper landing page. -func HomePage(props PageProps) Node { +// HomeContent is the Quine Hyper landing page. +func HomeContent(cfg site.Config, props component.PageProps) Node { props.Title = "Quine Hyper — The thoughtful terminal" props.Description = "Quine Hyper is a beautiful, extensible terminal emulator forked from Vercel's Hyper. Built on web technology with a thriving plugin ecosystem." - return page(props, + return component.Page(cfg.Branding, props, // Hero Div(Class("py-20 text-center"), Div(Class("mb-6 inline-flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900 px-4 py-1.5"), @@ -28,7 +31,7 @@ func HomePage(props PageProps) Node { Class("inline-flex items-center gap-2 rounded-lg bg-primary-500 px-8 py-3 font-mono text-base font-semibold text-black hover:bg-primary-400 transition-colors"), Text("Download for free"), ), - A(Href("https://github.com/quine-global/hyper"), + A(Href(cfg.GitHubURL), Class("inline-flex items-center gap-2 rounded-lg border border-zinc-700 bg-zinc-900 px-8 py-3 font-mono text-base font-semibold text-white hover:border-zinc-500 transition-colors"), Text("View on GitHub"), ), @@ -36,32 +39,7 @@ func HomePage(props PageProps) Node { ), // Terminal window mockup - Div(Class("mx-auto mb-20 max-w-3xl overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-2xl"), - // Title bar - Div(Class("flex items-center gap-2 border-b border-zinc-800 bg-zinc-900 px-4 py-3"), - Div(Class("h-3 w-3 rounded-full bg-red-500 opacity-80")), - Div(Class("h-3 w-3 rounded-full bg-yellow-500 opacity-80")), - Div(Class("h-3 w-3 rounded-full bg-primary-500 opacity-80")), - Span(Class("ml-4 font-mono text-xs text-zinc-500"), Text("quine-hyper — bash — 80×24")), - ), - // Terminal body - Div(Class("p-6 font-mono text-sm leading-relaxed"), - termPrompt("deep-thought --answer"), - termOutput("42"), - termPrompt("hyper install infinite-improbability"), - termOutput("✔ Engaging drive..."), - termOutput("✔ Probability: 2:1 against."), - termOutput("✔ Don't Panic."), - termPrompt("ls ~/projects"), - termOutput("drwxr-xr-x earth/"), - termOutput("drwxr-xr-x magrathea/"), - termOutput("drwxr-xr-x slartibartfast/"), - Div(Class("flex items-center gap-0 text-primary-400"), - Span(Class("text-zinc-500"), Text("❯ ")), - Span(Class("animate-pulse"), Text("█")), - ), - ), - ), + terminalMockup(), // Features Div(Class("pb-20"), @@ -69,7 +47,7 @@ func HomePage(props PageProps) Node { Text("Why Quine Hyper"), ), Div(Class("mt-12 grid grid-cols-1 gap-6 md:grid-cols-2"), - featureCard("Plugin Ecosystem", + component.FeatureCard("Plugin Ecosystem", Group{ Text("Install plugins and themes from npm. Browse "), A(Href("https://hyper.is/plugins"), @@ -85,15 +63,15 @@ func HomePage(props PageProps) Node { }, "npm install hyper-snazzy", ), - featureCard("Cross-Platform", + component.FeatureCard("Cross-Platform", Text("Runs on macOS, Windows, and Linux. One consistent experience across every machine you use."), "Works everywhere you do.", ), - featureCard("Web-Native", + component.FeatureCard("Web-Native", Text("Built on Electron, React, and xterm.js. Hack on the terminal itself using the same tools you use every day."), "HTML + CSS + JS = your terminal", ), - featureCard("Fully Customizable", + component.FeatureCard("Fully Customizable", Text("Tweak every detail with CSS. Change fonts, colors, animations, and layouts — no config file limits."), "hyper.json → your rules", ), @@ -110,7 +88,7 @@ func HomePage(props PageProps) Node { Text(", the open-source terminal by Vercel. We're building on that foundation with new capabilities and active development."), ), Div(Class("mt-8"), - A(Href("https://github.com/quine-global/hyper"), + A(Href(cfg.GitHubURL), Class("inline-block rounded-lg border border-zinc-700 bg-zinc-800 px-6 py-3 font-mono text-sm font-semibold text-white hover:border-zinc-500 transition-colors"), Text("Star us on GitHub"), ), @@ -118,24 +96,3 @@ func HomePage(props PageProps) Node { ), ) } - -func termPrompt(cmd string) Node { - return Div(Class("flex items-center gap-2"), - Span(Class("text-primary-400"), Text("❯")), - Span(Class("text-white"), Text(cmd)), - ) -} - -func termOutput(line string) Node { - return Div(Class("pl-5 text-zinc-400"), Text(line)) -} - -func featureCard(title string, body Node, code string) Node { - return Div(Class("rounded-xl border border-zinc-800 bg-zinc-900 p-8"), - H3(Class("font-mono text-base font-semibold text-white"), Text(title)), - P(Class("mt-3 font-mono text-sm text-zinc-400 leading-relaxed"), body), - Div(Class("mt-5 inline-block rounded-md bg-zinc-800 px-3 py-1.5"), - Span(Class("font-mono text-xs text-primary-400"), Text(code)), - ), - ) -} diff --git a/sites/hyper/html/download.go b/sites/hyper/html/download.go deleted file mode 100644 index c6274f7..0000000 --- a/sites/hyper/html/download.go +++ /dev/null @@ -1,417 +0,0 @@ -package html - -import ( - "fmt" - "strings" - "time" - - . "maragu.dev/gomponents" - . "maragu.dev/gomponents/html" -) - -// Release is a single GitHub release with per-platform download URLs. -type Release struct { - TagName string // Full GitHub tag, e.g. "v4.0.0-q-canary.12" - Version string // Base semver, e.g. "4.0.0" - CanaryBuild int // >0 for canary, e.g. 12 from "v4.0.0-q-canary.12" - Date string // Human date for UI, e.g. "May 2026" - PublishedAt time.Time // For update feed pub_date - IsCanary bool - Assets map[string]map[string]string // os -> arch -> URL -} - -// DownloadURL returns the asset URL for the given OS and arch, or "" if unavailable. -func (r Release) DownloadURL(os, arch string) string { - if m := r.Assets[os]; m != nil { - return m[arch] - } - return "" -} - -// DisplayVersion returns a human-readable version string. -// Canary builds render as "4.0.0 Canary #12". -func (r Release) DisplayVersion() string { - if r.CanaryBuild > 0 { - return fmt.Sprintf("%s Canary #%d", r.Version, r.CanaryBuild) - } - return r.Version -} - -func buildFallback(version, date string, published time.Time, canary bool) Release { - assets := make(map[string]map[string]string) - for _, os := range []string{"mac", "windows", "linux"} { - assets[os] = make(map[string]string) - for _, arch := range []string{"arm64", "x64"} { - assets[os][arch] = fallbackURL(version, os, arch) - } - } - tag := "v" + version - if canary { - tag = "v" + version + "-q-canary.1" // placeholder build - } - return Release{ - TagName: tag, - Version: version, - Date: date, - PublishedAt: published, - IsCanary: canary, - Assets: assets, - } -} - -func fallbackURL(version, os, arch string) string { - switch os { - case "windows": - return fmt.Sprintf("https://github.com/quine-global/hyper/releases/download/v%s/QuineHyper.Setup.%s.exe", version, version) - case "linux": - if arch == "arm64" { - return fmt.Sprintf("https://github.com/quine-global/hyper/releases/download/v%s/QuineHyper-%s-arm64.AppImage", version, version) - } - return fmt.Sprintf("https://github.com/quine-global/hyper/releases/download/v%s/QuineHyper-%s.AppImage", version, version) - default: // mac - if arch == "arm64" { - return fmt.Sprintf("https://github.com/quine-global/hyper/releases/download/v%s/QuineHyper-%s-arm64.dmg", version, version) - } - return fmt.Sprintf("https://github.com/quine-global/hyper/releases/download/v%s/QuineHyper-%s.dmg", version, version) - } -} - -// DownloadProps carries platform selection and release list for the download page. -type DownloadProps struct { - DetectedOS string - DetectedArch string - SelectedOS string - SelectedArch string - ArchExplicit bool // true if arch came from an explicit ?arch= query param rather than auto-detection - Releases []Release -} - -type platformOption struct { - OS string - Arch string - Label string -} - -var platforms = []platformOption{ - {"mac", "arm64", "macOS · Apple Silicon"}, - {"mac", "x64", "macOS · Intel"}, - {"windows", "x64", "Windows · x64"}, - {"linux", "x64", "Linux · x64"}, - {"linux", "arm64", "Linux · ARM64"}, -} - -func fileExt(os string) string { - switch os { - case "windows": - return ".exe" - case "linux": - return ".AppImage" - case "linux-deb": - return ".deb" - case "linux-rpm": - return ".rpm" - case "linux-snap": - return ".snap" - case "linux-pacman": - return ".pacman" - case "linux-flatpak": - return ".flatpak" - default: - return ".dmg" - } -} - -func platformLabel(os, arch string) string { - effectiveOS := os - if os == "linux-rpm" { - effectiveOS = "linux" - } - for _, p := range platforms { - if p.OS == effectiveOS && p.Arch == arch { - return p.Label - } - } - return effectiveOS + " · " + arch -} - -// DownloadPage renders the download page with OS/arch selection and version list. -func DownloadPage(props PageProps, dl DownloadProps) Node { - props.Title = "Download — Quine Hyper" - props.Description = "Download Quine Hyper for macOS, Windows, or Linux." - - // Split into stable and canary. - var stable, canary []Release - for _, r := range dl.Releases { - if r.IsCanary { - canary = append(canary, r) - } else { - stable = append(stable, r) - } - } - - content := []Node{ - If(dl.SelectedOS == "mac" && !dl.ArchExplicit, macArchDetectScript()), - H1(Class("font-mono text-3xl font-bold text-white"), Text("Download Quine Hyper")), - Div(Class("mt-8"), - P(Class("font-mono text-xs uppercase tracking-widest text-zinc-500 mb-3"), Text("Platform")), - Div(Class("flex flex-wrap gap-2"), - Group(platformButtons(dl)), - ), - If(strings.HasPrefix(dl.SelectedOS, "linux"), - linuxFormatToggle(dl), - ), - ), - } - if len(stable) > 0 { - content = append(content, stableSection(stable, dl)) - } - if len(canary) > 0 { - content = append(content, canarySection(canary, dl)) - } - content = append(content, - Div(Class("mt-6 text-center"), - A( - Href("https://github.com/quine-global/hyper/releases"), - Class("font-mono text-xs text-zinc-500 hover:text-white transition-colors underline underline-offset-4"), - Text("All releases on GitHub ↗"), - ), - ), - ) - - return page(props, - Div(Class("mx-auto max-w-xl py-16"), - Group(content), - ), - ) -} - -func stableSection(stable []Release, dl DownloadProps) Node { - latest := stable[0] - older := stable[1:] - url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch) - - return Group{ - Div(Class("mt-10"), - Div(Class("mb-4 flex items-center gap-3"), - P(Class("font-mono text-xs uppercase tracking-widest text-seafoam-400"), Text("Stable releases")), - ), - - // Primary download card - Div(Class("min-h-[200px] flex items-center rounded-xl border border-seafoam-400/30 bg-zinc-900 p-6"), - Div(Class("flex w-full items-center justify-between gap-4 flex-wrap"), - Div( - Span(Class("font-mono text-xs font-semibold uppercase tracking-widest text-seafoam-400"), - Text("Latest Stable"), - ), - H2(Class("mt-1 font-mono text-xl font-bold text-white"), - Text("Quine Hyper v"+latest.DisplayVersion()), - ), - P(Class("mt-1 font-mono text-xs text-zinc-500"), - Text(latest.Date+" · "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)), - ), - ), - Div(Class("flex-1")), - downloadButton(url, "bg-seafoam-400 hover:bg-seafoam-500 text-black"), - ), - ), - ), - - // Older stable releases - If(len(older) > 0, - Div(Class("mt-8"), - P(Class("font-mono text-xs uppercase tracking-widest text-zinc-500 mb-4"), Text("Older releases")), - Div(Class("divide-y divide-zinc-800 rounded-xl border border-zinc-800"), - Group(releaseRows(older, dl, "text-seafoam-400 hover:text-seafoam-500")), - ), - ), - ), - } -} - -func canarySection(canary []Release, dl DownloadProps) Node { - latest := canary[0] - older := canary[1:] - url := latest.DownloadURL(dl.SelectedOS, dl.SelectedArch) - - return Div(Class("mt-10"), - Div(Class("mb-4 flex items-center gap-3"), - P(Class("font-mono text-xs uppercase tracking-widest text-canary-500"), Text("Canary releases")), - Span(Class("font-mono text-xs text-zinc-600"), Text("— unstable, may contain bugs")), - ), - - // Latest canary card - Div(Class("rounded-xl border border-canary-500/30 bg-zinc-900 p-6"), - Div(Class("flex items-center justify-between gap-4 flex-wrap"), - Div( - Span(Class("font-mono text-xs font-semibold uppercase tracking-widest text-canary-400"), - Text("Latest Canary"), - ), - H2(Class("mt-1 font-mono text-base font-bold text-white"), - Text("Quine Hyper v"+latest.DisplayVersion()), - ), - P(Class("mt-1 font-mono text-xs text-zinc-500"), - Text(latest.Date+" · "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)), - ), - ), - downloadButton(url, "bg-canary-500 hover:bg-canary-400 text-black", "py-2 text-xs"), - ), - ), - - // Older canary releases - If(len(older) > 0, - Div(Class("mt-4 divide-y divide-zinc-800 rounded-xl border border-zinc-800"), - Group(releaseRows(older, dl, "text-canary-400 hover:text-canary-300")), - ), - ), - ) -} - -func downloadButton(url, colorCls string, extraCls ...string) Node { - base := "inline-flex items-center justify-center gap-2 rounded-lg w-[135px] font-mono font-semibold transition-colors shrink-0 " - - sizeCls := "px-6 py-3 text-sm" - if len(extraCls) > 0 && extraCls[0] != "" { - sizeCls = extraCls[0] - } - - btnCls := base + sizeCls + " " + colorCls - unavailableCls := base + sizeCls + " border border-zinc-800 text-zinc-600" - - if url == "" { - return Span(Class(unavailableCls), Text("Not available")) - } - return A( - Href(url), - Class(btnCls), - Text("↓ Download"), - ) -} - -func releaseRows(releases []Release, dl DownloadProps, linkCls string) []Node { - nodes := make([]Node, len(releases)) - for i, r := range releases { - url := r.DownloadURL(dl.SelectedOS, dl.SelectedArch) - var link Node - if url != "" { - link = A( - Href(url), - Class("font-mono text-xs transition-colors "+linkCls), - Text("↓ "+platformLabel(dl.SelectedOS, dl.SelectedArch)+fileExt(dl.SelectedOS)), - ) - } else { - link = Span( - Class("font-mono text-xs text-zinc-600"), - Text("Not available"), - ) - } - nodes[i] = Div(Class("flex items-center justify-between px-5 py-4"), - Div(Class("flex items-center gap-4"), - Span(Class("font-mono text-sm font-semibold text-white"), Text("v"+r.DisplayVersion())), - Span(Class("font-mono text-xs text-zinc-500"), Text(r.Date)), - ), - link, - ) - } - return nodes -} - -func platformButtons(dl DownloadProps) []Node { - nodes := make([]Node, len(platforms)) - // Treat linux-rpm as linux for platform button highlight/detection purposes. - effectiveSelectedOS := dl.SelectedOS - if strings.HasPrefix(dl.SelectedOS, "linux") { - effectiveSelectedOS = "linux" - } - for i, p := range platforms { - isSelected := p.OS == effectiveSelectedOS && p.Arch == dl.SelectedArch - isDetected := p.OS == dl.DetectedOS && p.Arch == dl.DetectedArch - - // Preserve the linux format when switching arch; snap is x64-only so don't carry it to arm64. - targetOS := p.OS - if p.OS == "linux" && strings.HasPrefix(dl.SelectedOS, "linux-") { - if dl.SelectedOS != "linux-snap" || p.Arch == "x64" { - targetOS = dl.SelectedOS - } - } - - var cls string - if isSelected { - cls = "inline-flex items-center gap-1.5 rounded-md bg-primary-500 px-3 py-1.5 font-mono text-xs font-semibold text-black" - } else { - cls = "inline-flex items-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 font-mono text-xs text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors" - } - - children := []Node{Text(p.Label)} - if isDetected && !isSelected { - children = append(children, - Span(Class("rounded bg-zinc-700 px-1 py-0.5 text-zinc-400 text-[10px]"), Text("auto")), - ) - } else if isDetected && isSelected { - children = append(children, - Span(Class("rounded bg-black/20 px-1 py-0.5 text-[10px]"), Text("auto")), - ) - } - - nodes[i] = A( - Href(fmt.Sprintf("/download?os=%s&arch=%s", targetOS, p.Arch)), - Class(cls), - Group(children), - ) - } - return nodes -} - -// macArchDetectScript corrects the arm64 default guess on real Intel Macs. -// Browser User-Agent strings report "Intel Mac OS X" even on Apple Silicon, so -// the server can't tell arm64 from x64 from headers alone. The WebGL renderer -// string is the one client-side signal that actually differs: Apple Silicon -// reports something containing "Apple" (e.g. "Apple M2"); real Intel/AMD/Nvidia -// GPUs never do. If it doesn't look like Apple Silicon, reload with ?arch=x64. -func macArchDetectScript() Node { - return Script(Raw(`(function(){ - try { - var c = document.createElement('canvas'); - var gl = c.getContext('webgl') || c.getContext('experimental-webgl'); - if (!gl) return; - var ext = gl.getExtension('WEBGL_debug_renderer_info'); - if (!ext) return; - var renderer = String(gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || ''); - if (!renderer || /Apple/i.test(renderer)) return; - var url = new URL(window.location.href); - url.searchParams.set('arch', 'x64'); - window.location.replace(url.toString()); - } catch (e) {} -})();`)) -} - -func linuxFormatToggle(dl DownloadProps) Node { - selectedCls := "inline-flex items-center rounded-md bg-primary-500 px-3 py-1.5 font-mono text-xs font-semibold text-black" - unselectedCls := "inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 font-mono text-xs text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors" - cls := func(targetOS string) string { - if dl.SelectedOS == targetOS { - return selectedCls - } - return unselectedCls - } - href := func(targetOS string) string { - return fmt.Sprintf("/download?os=%s&arch=%s", targetOS, dl.SelectedArch) - } - - buttons := []Node{ - A(Href(href("linux")), Class(cls("linux")), Text("AppImage")), - A(Href(href("linux-deb")), Class(cls("linux-deb")), Text("DEB")), - A(Href(href("linux-rpm")), Class(cls("linux-rpm")), Text("RPM")), - A(Href(href("linux-pacman")), Class(cls("linux-pacman")), Text("Pacman")), - A(Href(href("linux-flatpak")), Class(cls("linux-flatpak")), Text("Flatpak")), - } - if dl.SelectedArch == "x64" { - buttons = append(buttons, A(Href(href("linux-snap")), Class(cls("linux-snap")), Text("Snap"))) - } - - return Div(Class("mt-4"), - P(Class("font-mono text-xs uppercase tracking-widest text-zinc-500 mb-3"), Text("Format")), - Div(Class("flex items-center gap-2"), - Group(buttons), - ), - ) -} diff --git a/sites/hyper/http/home.go b/sites/hyper/http/home.go deleted file mode 100644 index bee4e8e..0000000 --- a/sites/hyper/http/home.go +++ /dev/null @@ -1,63 +0,0 @@ -package http - -import ( - "context" - "net/http" - "strings" - "time" - - "github.com/go-chi/chi/v5" - . "maragu.dev/gomponents" - ghttp "maragu.dev/gomponents/http" - - "app/html" -) - -// Home registers the index and download handlers. -func (s *Server) Home(r chi.Router) { - r.Get("/", ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) { - return html.HomePage(html.PageProps{}), nil - })) - - r.Get("/download", ghttp.Adapt(func(w http.ResponseWriter, r *http.Request) (Node, error) { - // On-demand refresh, debounced to once per 2 minutes across all requests. - go s.releases.TryRefresh(context.Background(), 2*time.Minute) - - detectedOS, detectedArch := detectPlatform(r.Header.Get("User-Agent")) - - selectedOS := detectedOS - selectedArch := detectedArch - - if p := r.URL.Query().Get("os"); p == "mac" || p == "windows" || p == "linux" || p == "linux-rpm" || p == "linux-deb" || p == "linux-snap" || p == "linux-pacman" || p == "linux-flatpak" { - selectedOS = p - } - archExplicit := false - if a := r.URL.Query().Get("arch"); a == "arm64" || a == "x64" { - selectedArch = a - archExplicit = true - } - - return html.DownloadPage(html.PageProps{}, html.DownloadProps{ - DetectedOS: detectedOS, - DetectedArch: detectedArch, - SelectedOS: selectedOS, - SelectedArch: selectedArch, - ArchExplicit: archExplicit, - Releases: s.releases.Get(), - }), nil - })) -} - -func detectPlatform(ua string) (os, arch string) { - u := strings.ToLower(ua) - switch { - case strings.Contains(u, "windows"): - return "windows", "x64" - case strings.Contains(u, "macintosh"), strings.Contains(u, "mac os"): - return "mac", "arm64" // default Apple Silicon; user can override - case strings.Contains(u, "linux"): - return "linux", "x64" - default: - return "mac", "arm64" - } -} diff --git a/sites/hyper/http/releases.go b/sites/hyper/http/releases.go deleted file mode 100644 index a4261ed..0000000 --- a/sites/hyper/http/releases.go +++ /dev/null @@ -1,222 +0,0 @@ -package http - -import ( - "context" - "encoding/json" - "log/slog" - "net/http" - "strconv" - "strings" - "sync" - "time" - - "app/html" -) - -const releasesAPI = "https://api.github.com/repos/quine-global/hyper/releases?per_page=30" - -// ReleaseCache is a thread-safe cache of GitHub releases. -type ReleaseCache struct { - log *slog.Logger - mu sync.RWMutex - releases []html.Release - lastFetched time.Time - fetchMu sync.Mutex // TryLock prevents concurrent fetches -} - -// Get returns cached releases, falling back to hardcoded data if cache is empty. -func (c *ReleaseCache) Get() []html.Release { - c.mu.RLock() - defer c.mu.RUnlock() - if len(c.releases) == 0 { - return []html.Release{} - } - return c.releases -} - -// ForceRefresh does a blocking, unconditional fetch regardless of cache age. -// It waits for any in-progress fetch to finish before starting its own. -func (c *ReleaseCache) ForceRefresh(ctx context.Context) error { - c.fetchMu.Lock() - defer c.fetchMu.Unlock() - - releases, err := fetchReleases(ctx) - if err != nil { - c.log.Warn("Force refresh failed", "error", err) - return err - } - if len(releases) == 0 { - return nil - } - - c.mu.Lock() - c.releases = releases - c.lastFetched = time.Now() - c.mu.Unlock() - - c.log.Info("Force-refreshed releases from GitHub", "count", len(releases)) - return nil -} - -// TryRefresh fetches fresh releases if the cache is older than minAge. -// If a fetch is already in progress it returns immediately (non-blocking). -func (c *ReleaseCache) TryRefresh(ctx context.Context, minAge time.Duration) { - if !c.fetchMu.TryLock() { - return - } - defer c.fetchMu.Unlock() - - c.mu.RLock() - stale := time.Since(c.lastFetched) >= minAge - c.mu.RUnlock() - - if !stale { - return - } - - releases, err := fetchReleases(ctx) - if err != nil { - c.log.Warn("Failed to fetch releases from GitHub", "error", err) - return - } - if len(releases) == 0 { - return // keep existing cache rather than replacing with empty - } - - c.mu.Lock() - c.releases = releases - c.lastFetched = time.Now() - c.mu.Unlock() - - c.log.Info("Refreshed releases from GitHub", "count", len(releases)) -} - -type ghRelease struct { - TagName string `json:"tag_name"` - Prerelease bool `json:"prerelease"` - Draft bool `json:"draft"` - PublishedAt time.Time `json:"published_at"` - Assets []ghAsset `json:"assets"` -} - -type ghAsset struct { - Name string `json:"name"` - BrowserDownloadURL string `json:"browser_download_url"` -} - -func fetchReleases(ctx context.Context) ([]html.Release, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, releasesAPI, nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", "application/vnd.github+json") - req.Header.Set("X-GitHub-Api-Version", "2022-11-28") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var raw []ghRelease - if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { - return nil, err - } - - releases := make([]html.Release, 0, len(raw)) - for _, gr := range raw { - if gr.Draft { - continue - } - version, canaryBuild := parseTag(strings.TrimPrefix(gr.TagName, "v")) - r := html.Release{ - TagName: gr.TagName, - Version: version, - CanaryBuild: canaryBuild, - Date: gr.PublishedAt.Format("Jan 2006"), - PublishedAt: gr.PublishedAt, - IsCanary: gr.Prerelease || canaryBuild > 0, - Assets: make(map[string]map[string]string), - } - for _, a := range gr.Assets { - os, arch, ok := classifyAsset(a.Name) - if !ok { - continue - } - if r.Assets[os] == nil { - r.Assets[os] = make(map[string]string) - } - // Keep first match per os/arch (most specific asset listed first by GitHub) - if _, exists := r.Assets[os][arch]; !exists { - r.Assets[os][arch] = a.BrowserDownloadURL - } - } - releases = append(releases, r) - } - return releases, nil -} - -// parseTag splits a tag like "4.0.0-q-canary.12" into base version "4.0.0" and build number 12. -// Returns build 0 for stable tags. -func parseTag(tag string) (version string, canaryBuild int) { - // Canary: "4.0.0-q-canary.12" → ("4.0.0", 12) - const canaryMarker = "-q-canary." - if idx := strings.Index(tag, canaryMarker); idx != -1 { - if n, err := strconv.Atoi(tag[idx+len(canaryMarker):]); err == nil { - return tag[:idx], n - } - } - // Stable Quine builds: "4.0.0-q" or "4.0.0q" → "4.0.0" - tag = strings.TrimSuffix(tag, "-q") - tag = strings.TrimSuffix(tag, "q") - return tag, 0 -} - -// classifyAsset maps an asset filename to an OS and architecture. -func classifyAsset(name string) (os, arch string, ok bool) { - l := strings.ToLower(name) - switch { - case strings.HasSuffix(l, ".exe"): - return "windows", "x64", true - case strings.Contains(l, "arm64") && strings.HasSuffix(l, ".dmg"): - return "mac", "arm64", true - case strings.HasSuffix(l, ".dmg"): - return "mac", "x64", true - case strings.Contains(l, "arm64") && strings.HasSuffix(l, ".appimage"): - return "linux", "arm64", true - case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".appimage"): - return "", "", false - case strings.HasSuffix(l, ".appimage"): - return "linux", "x64", true - case (strings.Contains(l, "arm64") || strings.Contains(l, "aarch64")) && strings.HasSuffix(l, ".rpm"): - return "linux-rpm", "arm64", true - case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".rpm"): - return "", "", false - case strings.HasSuffix(l, ".rpm"): - return "linux-rpm", "x64", true - case strings.Contains(l, "arm64") && strings.HasSuffix(l, ".deb"): - return "linux-deb", "arm64", true - case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".deb"): - return "", "", false - case strings.HasSuffix(l, ".deb"): - return "linux-deb", "x64", true - case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".snap"): - return "", "", false - case strings.HasSuffix(l, ".snap"): - return "linux-snap", "x64", true - case (strings.Contains(l, "arm64") || strings.Contains(l, "aarch64")) && strings.HasSuffix(l, ".pacman"): - return "linux-pacman", "arm64", true - case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".pacman"): - return "", "", false - case strings.HasSuffix(l, ".pacman"): - return "linux-pacman", "x64", true - case (strings.Contains(l, "arm64") || strings.Contains(l, "aarch64")) && strings.HasSuffix(l, ".flatpak"): - return "linux-flatpak", "arm64", true - case strings.Contains(l, "armv7l") && strings.HasSuffix(l, ".flatpak"): - return "", "", false - case strings.HasSuffix(l, ".flatpak"): - return "linux-flatpak", "x64", true - default: - return "", "", false - } -} diff --git a/sites/hyper/http/server_test.go b/sites/hyper/http/server_test.go deleted file mode 100644 index e7fa7eb..0000000 --- a/sites/hyper/http/server_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package http_test - -import ( - "io" - http2 "net/http" - "strings" - "testing" - "time" - - "maragu.dev/is" - - "app/http" -) - -func TestServer_Start(t *testing.T) { - t.Run("can start and stop server", func(t *testing.T) { - s := http.NewServer(http.NewServerOptions{}) - - go func() { - is.NotError(t, s.Start()) - }() - defer func() { - is.NotError(t, s.Stop()) - }() - - // I know we could check that the server is running here, but it's easier to just wait a bit - time.Sleep(10 * time.Millisecond) - - res, err := http2.Get("http://localhost:8081/") - is.NotError(t, err) - is.Equal(t, http2.StatusOK, res.StatusCode) - body, err := io.ReadAll(res.Body) - is.NotError(t, err) - is.True(t, strings.HasPrefix(string(body), "")) - }) -} diff --git a/sites/hyper/http/updatecheck.go b/sites/hyper/http/updatecheck.go deleted file mode 100644 index 782e12d..0000000 --- a/sites/hyper/http/updatecheck.go +++ /dev/null @@ -1,191 +0,0 @@ -package http - -import ( - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "github.com/go-chi/chi/v5" - - "app/html" -) - -// hazelUpdate is the response format expected by Electron autoUpdater -// (the same protocol used by the original releases.hyper.is / Hazel). -type hazelUpdate struct { - Name string `json:"name"` - Notes string `json:"notes,omitempty"` - PubDate string `json:"pub_date,omitempty"` - URL string `json:"url,omitempty"` -} - -// UpdateCheck registers the auto-update feed endpoints. -// -// - GET /api/update-check (primary, matches existing expectations) -// - GET /update (convenience short path) -// -// This implements the update protocol used by Hyper's Electron autoUpdater -// (the same one originally served from releases.hyper.is via Hazel). -// -// Query params: -// -// version — client's current version, e.g. "4.0.0-q-canary.12" or "v4.0.0" -// platform — darwin | win32 | linux | deb (or os= for compatibility) -// arch — x64 | arm64 -// channel — "stable" | "canary" (explicit preference; takes precedence for track selection) -// -// Response: -// - 204 No Content → no update available -// - 200 JSON → update available (Hazel/Squirrel format: {name, notes, pub_date, url?}) -func (s *Server) UpdateCheck(r chi.Router) { - r.Get("/api/update-check", s.updateCheckHandler()) -} - -func (s *Server) updateCheckHandler() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - - rawVersion := strings.TrimPrefix(q.Get("version"), "v") - platform := firstNonEmpty(q.Get("platform"), q.Get("os")) - arch := q.Get("arch") - channel := strings.ToLower(q.Get("channel")) - - if rawVersion == "" { - http.Error(w, "version is required", http.StatusBadRequest) - return - } - - clientBase, clientBuild := parseTag(rawVersion) - clientIsCanary := clientBuild > 0 || channel == "canary" || channel == "canaryupdates" - - // Map incoming platform names from the Hyper client to our internal asset keys. - internalOS := normalizePlatformForAssets(platform) - - releases := s.releases.Get() - - // Find the newest release that is newer than what the client has, - // on the correct track (stable vs canary), and preferably has assets - // for the requested platform/arch. - var candidate *html.Release - for _, rel := range releases { - if rel.IsCanary { - if clientIsCanary && newerCanary(rel, clientBase, clientBuild, true) { - candidate = &rel - break - } - // Non-canary client can still be offered canary if they opted in, - // but by default we only auto-offer canary to canary clients - // (matching original behavior of separate canary/stable feeds). - if !clientIsCanary && newerCanary(rel, clientBase, clientBuild, false) { - candidate = &rel - break - } - } else { - if !clientIsCanary && semverCmp(rel.Version, clientBase) > 0 { - candidate = &rel - break - } - } - } - - if candidate == nil { - w.WriteHeader(http.StatusNoContent) - return - } - - // Build the Hazel-compatible response. - resp := hazelUpdate{ - Name: candidate.TagName, - Notes: fmt.Sprintf("Quine Hyper %s", candidate.TagName), - PubDate: candidate.PublishedAt.UTC().Format(time.RFC3339), - URL: candidate.DownloadURL(internalOS, arch), - } - - // If we have no precise asset URL for this platform/arch, still tell the - // client there is an update (especially important for Linux where the - // client only uses the feed to learn *that* a new version exists). - if resp.URL == "" { - // Provide a generic GitHub releases URL as a fallback. - // The client already falls back to constructing a tag URL. - resp.URL = fmt.Sprintf("https://github.com/quine-global/hyper/releases/tag/%s", candidate.TagName) - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(resp); err != nil { - s.log.Warn("Failed to encode update response", "error", err) - } - } -} - -func firstNonEmpty(a, b string) string { - if a != "" { - return a - } - return b -} - -// normalizePlatformForAssets converts values the Hyper client sends -// (darwin, win32, deb, etc.) into the keys used in Release.Assets. -func normalizePlatformForAssets(p string) string { - switch strings.ToLower(p) { - case "darwin", "mac", "osx": - return "mac" - case "win32", "win", "windows": - return "windows" - case "linux", "appimage": - return "linux" - case "linux-deb", "deb": - return "linux-deb" - case "linux-rpm", "rpm": - return "linux-rpm" - case "linux-snap", "snap": - return "linux-snap" - case "linux-pacman", "pacman": - return "linux-pacman" - case "linux-flatpak", "flatpak": - return "linux-flatpak" - default: - return p - } -} - -// newerCanary reports whether rel is newer than the client's current canary position. -// For a canary client, the build number is the tie-breaker on the same base version. -// For a stable client opting into canary info, any canary on the same or higher base qualifies. -func newerCanary(rel html.Release, clientBase string, clientBuild int, clientIsCanary bool) bool { - cmp := semverCmp(rel.Version, clientBase) - if cmp > 0 { - return true - } - if clientIsCanary { - return cmp == 0 && rel.CanaryBuild > clientBuild - } - // Stable client: same-base canary is ahead of the stable release. - return cmp == 0 -} - -// semverCmp compares two "major.minor.patch" version strings. -// Returns -1, 0, or 1. -func semverCmp(a, b string) int { - pa := strings.SplitN(a, ".", 3) - pb := strings.SplitN(b, ".", 3) - for i := 0; i < 3; i++ { - var na, nb int - if i < len(pa) { - na, _ = strconv.Atoi(pa[i]) - } - if i < len(pb) { - nb, _ = strconv.Atoi(pb[i]) - } - if na != nb { - if na < nb { - return -1 - } - return 1 - } - } - return 0 -} diff --git a/sites/hyper/mockup.go b/sites/hyper/mockup.go new file mode 100644 index 0000000..7424914 --- /dev/null +++ b/sites/hyper/mockup.go @@ -0,0 +1,41 @@ +package hyper + +import ( + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" + + "app/internal/component" +) + +// terminalMockup is the terminal device-window body shown on the landing page. +func terminalMockup() Node { + return component.DeviceWindow("quine-hyper — bash — 80×24", + Div(Class("p-6 font-mono text-sm leading-relaxed"), + termPrompt("deep-thought --answer"), + termOutput("42"), + termPrompt("hyper install infinite-improbability"), + termOutput("✔ Engaging drive..."), + termOutput("✔ Probability: 2:1 against."), + termOutput("✔ Don't Panic."), + termPrompt("ls ~/projects"), + termOutput("drwxr-xr-x earth/"), + termOutput("drwxr-xr-x magrathea/"), + termOutput("drwxr-xr-x slartibartfast/"), + Div(Class("flex items-center gap-0 text-primary-400"), + Span(Class("text-zinc-500"), Text("❯ ")), + Span(Class("animate-pulse"), Text("█")), + ), + ), + ) +} + +func termPrompt(cmd string) Node { + return Div(Class("flex items-center gap-2"), + Span(Class("text-primary-400"), Text("❯")), + Span(Class("text-white"), Text(cmd)), + ) +} + +func termOutput(line string) Node { + return Div(Class("pl-5 text-zinc-400"), Text(line)) +} diff --git a/sites/hyper/package.json b/sites/hyper/package.json deleted file mode 100644 index a4abd4b..0000000 --- a/sites/hyper/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "hyper-quineglobal-com", - "private": true, - "devDependencies": { - "tailwindcss": "^3.4.0" - }, - "scripts": { - "css": "tailwindcss -i public/styles/input.css -o public/styles/app.css --minify", - "css:watch": "tailwindcss -i public/styles/input.css -o public/styles/app.css --watch" - } -} diff --git a/sites/hyper/public/images/quineglobal-logo.png b/sites/hyper/public/images/quineglobal-logo.png deleted file mode 100644 index cff0d77..0000000 Binary files a/sites/hyper/public/images/quineglobal-logo.png and /dev/null differ diff --git a/sites/hyper/public/styles/app.css b/sites/hyper/public/styles/app.css deleted file mode 100644 index f12bd6f..0000000 --- a/sites/hyper/public/styles/app.css +++ /dev/null @@ -1 +0,0 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}body{--tw-bg-opacity:1;background-color:rgb(9 9 11/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(244 244 245/var(--tw-text-opacity,1));font-family:JetBrains Mono,ui-monospace,monospace}.static{position:static}.mx-auto{margin-left:auto;margin-right:auto}.mb-20{margin-bottom:5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.ml-4{margin-left:1rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-12{margin-top:3rem}.mt-24{margin-top:6rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.h-12{height:3rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-6{height:1.5rem}.h-8{height:2rem}.min-h-\[200px\]{min-height:200px}.w-12{width:3rem}.w-2{width:.5rem}.w-3{width:.75rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-\[135px\]{width:135px}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0{gap:0}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-zinc-800>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(39 39 42/var(--tw-divide-opacity,1))}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-canary-500\/30{border-color:rgba(234,179,8,.3)}.border-seafoam-400\/30{border-color:rgba(80,217,168,.3)}.border-zinc-700{--tw-border-opacity:1;border-color:rgb(63 63 70/var(--tw-border-opacity,1))}.border-zinc-800{--tw-border-opacity:1;border-color:rgb(39 39 42/var(--tw-border-opacity,1))}.bg-black\/20{background-color:rgba(0,0,0,.2)}.bg-canary-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-primary-400{--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1))}.bg-primary-500{--tw-bg-opacity:1;background-color:rgb(139 92 246/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-seafoam-400{--tw-bg-opacity:1;background-color:rgb(80 217 168/var(--tw-bg-opacity,1))}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-zinc-700{--tw-bg-opacity:1;background-color:rgb(63 63 70/var(--tw-bg-opacity,1))}.bg-zinc-800{--tw-bg-opacity:1;background-color:rgb(39 39 42/var(--tw-bg-opacity,1))}.bg-zinc-900{--tw-bg-opacity:1;background-color:rgb(24 24 27/var(--tw-bg-opacity,1))}.bg-zinc-950{--tw-bg-opacity:1;background-color:rgb(9 9 11/var(--tw-bg-opacity,1))}.p-10{padding:2.5rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-20{padding-bottom:5rem}.pl-5{padding-left:1.25rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-relaxed{line-height:1.625}.tracking-tight{letter-spacing:-.025em}.tracking-widest{letter-spacing:.1em}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-canary-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.text-canary-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}.text-primary-400{--tw-text-opacity:1;color:rgb(167 139 250/var(--tw-text-opacity,1))}.text-seafoam-400{--tw-text-opacity:1;color:rgb(80 217 168/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-zinc-300{--tw-text-opacity:1;color:rgb(212 212 216/var(--tw-text-opacity,1))}.text-zinc-400{--tw-text-opacity:1;color:rgb(161 161 170/var(--tw-text-opacity,1))}.text-zinc-500{--tw-text-opacity:1;color:rgb(113 113 122/var(--tw-text-opacity,1))}.text-zinc-600{--tw-text-opacity:1;color:rgb(82 82 91/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.underline-offset-4{text-underline-offset:4px}.opacity-80{opacity:.8}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.hover\:border-zinc-500:hover{--tw-border-opacity:1;border-color:rgb(113 113 122/var(--tw-border-opacity,1))}.hover\:bg-canary-400:hover{--tw-bg-opacity:1;background-color:rgb(250 204 21/var(--tw-bg-opacity,1))}.hover\:bg-primary-400:hover{--tw-bg-opacity:1;background-color:rgb(167 139 250/var(--tw-bg-opacity,1))}.hover\:bg-seafoam-500:hover{--tw-bg-opacity:1;background-color:rgb(61 184 138/var(--tw-bg-opacity,1))}.hover\:text-canary-300:hover{--tw-text-opacity:1;color:rgb(253 224 71/var(--tw-text-opacity,1))}.hover\:text-primary-300:hover{--tw-text-opacity:1;color:rgb(196 181 253/var(--tw-text-opacity,1))}.hover\:text-seafoam-500:hover{--tw-text-opacity:1;color:rgb(61 184 138/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}@media (min-width:640px){.sm\:flex-row{flex-direction:row}.sm\:text-6xl{font-size:3.75rem;line-height:1}}@media (min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}} \ No newline at end of file diff --git a/sites/hyper/public/styles/input.css b/sites/hyper/public/styles/input.css deleted file mode 100644 index debde1d..0000000 --- a/sites/hyper/public/styles/input.css +++ /dev/null @@ -1,10 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - body { - @apply bg-zinc-950 text-zinc-100; - font-family: 'JetBrains Mono', ui-monospace, monospace; - } -} diff --git a/sites/hyper/scripts/refresh-releases.sh b/sites/hyper/scripts/refresh-releases.sh deleted file mode 100755 index 8469340..0000000 --- a/sites/hyper/scripts/refresh-releases.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh -# Force an immediate re-fetch of GitHub release assets. -# Usage (from host): ./scripts/refresh-releases.sh [http://host:8081] -# Usage (in container): ./scripts/refresh-releases.sh -set -e - -url="${1:-http://localhost:8081}/internal/refresh" - -if command -v curl >/dev/null 2>&1; then - curl -fsS -X POST "$url" -elif command -v wget >/dev/null 2>&1; then - wget -q -O- --post-data='' "$url" -else - echo "error: curl or wget required" >&2 - exit 1 -fi - -echo -echo "Releases refreshed." diff --git a/sites/hyper/site.go b/sites/hyper/site.go new file mode 100644 index 0000000..6318142 --- /dev/null +++ b/sites/hyper/site.go @@ -0,0 +1,44 @@ +// Package hyper holds the Quine Hyper site's identity: branding, asset +// classification, download-format list, and landing-page content. +// Everything else (server, routes, release cache, update-check protocol, +// page chrome) lives in the shared internal/site and internal/component +// packages. +package hyper + +import ( + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" + + "app/internal/component" + "app/internal/site" +) + +// Config is the Quine Hyper site configuration, passed to site.NewServer. +var Config = site.Config{ + Branding: component.Branding{ + Name: "Quine Hyper", + LogoPath: "/images/hyper-logo.png", + GitHubURL: "https://github.com/quine-global/hyper", + FooterNote: Group{ + Text("Built on "), + A(Href("https://hyper.is"), Class("text-zinc-400 hover:text-white underline"), Text("Hyper")), + Text(" by Vercel."), + }, + }, + DownloadConfig: component.DownloadConfig{ + DefaultLinuxOS: "linux", + Formats: []component.LinuxFormat{ + {OS: "linux", Label: "AppImage", Archs: []string{"arm64", "x64"}}, + {OS: "linux-deb", Label: "DEB", Archs: []string{"arm64", "x64"}}, + {OS: "linux-rpm", Label: "RPM", Archs: []string{"arm64", "x64"}}, + {OS: "linux-pacman", Label: "Pacman", Archs: []string{"arm64", "x64"}}, + {OS: "linux-flatpak", Label: "Flatpak", Archs: []string{"arm64", "x64"}}, + // Hyper only ships Snap for x64. + {OS: "linux-snap", Label: "Snap", Archs: []string{"x64"}}, + }, + }, + RepoOwner: "quine-global", + RepoName: "hyper", + ClassifyAsset: classifyAsset, + HomeContent: HomeContent, +} diff --git a/sites/hyper/tailwind.config.js b/sites/hyper/tailwind.config.js deleted file mode 100644 index b7be8f5..0000000 --- a/sites/hyper/tailwind.config.js +++ /dev/null @@ -1,25 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: ["./html/**/*.go", "./http/**/*.go"], - theme: { - extend: { - colors: { - primary: { - 300: '#c4b5fd', - 400: '#a78bfa', - 500: '#8b5cf6', - }, - seafoam: { - 400: '#50d9a8', - 500: '#3db88a', - }, - canary: { - 300: '#fde047', - 400: '#facc15', - 500: '#eab308', - }, - }, - }, - }, - plugins: [], -} diff --git a/sites/quintodrome/assets.go b/sites/quintodrome/assets.go new file mode 100644 index 0000000..4e1df3f --- /dev/null +++ b/sites/quintodrome/assets.go @@ -0,0 +1,56 @@ +package quintodrome + +import "strings" + +// classifyAsset maps an asset filename to an OS and architecture. +func classifyAsset(name string) (os, arch string, ok bool) { + l := strings.ToLower(name) + isArm64 := strings.Contains(l, "arm64") || strings.Contains(l, "aarch64") + isArmv7l := strings.Contains(l, "armv7l") + switch { + case strings.HasSuffix(l, ".exe"): + return "windows", "x64", true + case isArm64 && strings.HasSuffix(l, ".dmg"): + return "mac", "arm64", true + case strings.HasSuffix(l, ".dmg"): + return "mac", "x64", true + case isArm64 && strings.HasSuffix(l, ".appimage"): + return "linux", "arm64", true + case isArmv7l && strings.HasSuffix(l, ".appimage"): + return "", "", false + case strings.HasSuffix(l, ".appimage"): + return "linux", "x64", true + case isArm64 && strings.HasSuffix(l, ".rpm"): + return "linux-rpm", "arm64", true + case isArmv7l && strings.HasSuffix(l, ".rpm"): + return "", "", false + case strings.HasSuffix(l, ".rpm"): + return "linux-rpm", "x64", true + case isArm64 && strings.HasSuffix(l, ".deb"): + return "linux-deb", "arm64", true + case isArmv7l && strings.HasSuffix(l, ".deb"): + return "", "", false + case strings.HasSuffix(l, ".deb"): + return "linux-deb", "x64", true + case isArmv7l && strings.HasSuffix(l, ".snap"): + return "", "", false + case isArm64 && strings.HasSuffix(l, ".snap"): + return "linux-snap", "arm64", true + case strings.HasSuffix(l, ".snap"): + return "linux-snap", "x64", true + case isArm64 && strings.HasSuffix(l, ".pacman"): + return "linux-pacman", "arm64", true + case isArmv7l && strings.HasSuffix(l, ".pacman"): + return "", "", false + case strings.HasSuffix(l, ".pacman"): + return "linux-pacman", "x64", true + case isArm64 && strings.HasSuffix(l, ".flatpak"): + return "linux-flatpak", "arm64", true + case isArmv7l && strings.HasSuffix(l, ".flatpak"): + return "", "", false + case strings.HasSuffix(l, ".flatpak"): + return "linux-flatpak", "x64", true + default: + return "", "", false + } +} diff --git a/sites/quintodrome/http/releases_test.go b/sites/quintodrome/assets_test.go similarity index 87% rename from sites/quintodrome/http/releases_test.go rename to sites/quintodrome/assets_test.go index 37ec8c3..22789f2 100644 --- a/sites/quintodrome/http/releases_test.go +++ b/sites/quintodrome/assets_test.go @@ -1,4 +1,4 @@ -package http +package quintodrome import ( "testing" @@ -98,26 +98,3 @@ func TestClassifyAsset(t *testing.T) { }) } } - -func TestParseTag(t *testing.T) { - cases := []struct { - tag string - wantVer string - wantBuild int - }{ - {"4.0.0", "4.0.0", 0}, - {"4.0.0-q", "4.0.0", 0}, - {"4.0.0q", "4.0.0", 0}, - {"4.0.0-q-canary.12", "4.0.0", 12}, - {"4.0.0-q-canary.1", "4.0.0", 1}, - {"1.0.0-alpha", "1.0.0-alpha", 0}, - } - - for _, c := range cases { - t.Run(c.tag, func(t *testing.T) { - gotVer, gotBuild := parseTag(c.tag) - is.Equal(t, c.wantVer, gotVer) - is.Equal(t, c.wantBuild, gotBuild) - }) - } -} diff --git a/sites/quintodrome/cmd/app/main.go b/sites/quintodrome/cmd/main.go similarity index 90% rename from sites/quintodrome/cmd/app/main.go rename to sites/quintodrome/cmd/main.go index 96d3e34..0ded0ca 100644 --- a/sites/quintodrome/cmd/app/main.go +++ b/sites/quintodrome/cmd/main.go @@ -10,7 +10,8 @@ import ( "golang.org/x/sync/errgroup" "maragu.dev/env" - "app/sites/quintodrome/http" + "app/internal/site" + "app/sites/quintodrome" ) func main() { @@ -35,8 +36,9 @@ func start(log *slog.Logger) error { defer stop() // Set up the HTTP server, injecting the database and logger - s := http.NewServer(http.NewServerOptions{ - Log: log, + s := site.NewServer(site.NewServerOptions{ + Log: log, + Config: quintodrome.Config, }) // Use an errgroup to wait for separate goroutines which can error diff --git a/sites/quintodrome/html/home.go b/sites/quintodrome/home.go similarity index 60% rename from sites/quintodrome/html/home.go rename to sites/quintodrome/home.go index 6dda196..ceffd88 100644 --- a/sites/quintodrome/html/home.go +++ b/sites/quintodrome/home.go @@ -1,16 +1,19 @@ -package html +package quintodrome import ( . "maragu.dev/gomponents" . "maragu.dev/gomponents/html" + + "app/internal/component" + "app/internal/site" ) -// HomePage is the Quintodrome landing page. -func HomePage(props PageProps) Node { +// HomeContent is the Quintodrome landing page. +func HomeContent(cfg site.Config, props component.PageProps) Node { props.Title = "Quintodrome — Your music, self-hosted" props.Description = "Quintodrome wraps Navidrome, your self-hosted music server, into a native desktop app for macOS, Windows, and Linux." - return page(props, + return component.Page(cfg.Branding, props, // Hero Div(Class("py-20 text-center"), Div(Class("mb-6 inline-flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900 px-4 py-1.5"), @@ -28,7 +31,7 @@ func HomePage(props PageProps) Node { Class("inline-flex items-center gap-2 rounded-lg bg-primary-500 px-8 py-3 font-mono text-base font-semibold text-black hover:bg-primary-400 transition-colors"), Text("Download for free"), ), - A(Href("https://github.com/quine-global/quintodrome"), + A(Href(cfg.GitHubURL), Class("inline-flex items-center gap-2 rounded-lg border border-zinc-700 bg-zinc-900 px-8 py-3 font-mono text-base font-semibold text-white hover:border-zinc-500 transition-colors"), Text("View on GitHub"), ), @@ -36,39 +39,7 @@ func HomePage(props PageProps) Node { ), // Player mockup - Div(Class("mx-auto mb-20 max-w-3xl overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-2xl"), - // Title bar - Div(Class("flex items-center gap-2 border-b border-zinc-800 bg-zinc-900 px-4 py-3"), - Div(Class("h-3 w-3 rounded-full bg-red-500 opacity-80")), - Div(Class("h-3 w-3 rounded-full bg-yellow-500 opacity-80")), - Div(Class("h-3 w-3 rounded-full bg-primary-500 opacity-80")), - Span(Class("ml-4 font-mono text-xs text-zinc-500"), Text("quintodrome — now playing")), - ), - // Now-playing body - Div(Class("p-8"), - Div(Class("flex items-center gap-5"), - Div(Class("h-20 w-20 shrink-0 rounded-lg bg-gradient-to-br from-primary-400 to-primary-500")), - Div(Class("min-w-0 text-left"), - P(Class("truncate font-mono text-base font-semibold text-white"), Text("Brandenburg Concerto No. 3")), - P(Class("mt-1 truncate font-mono text-sm text-zinc-400"), Text("J.S. Bach — Library/Classical")), - ), - ), - Div(Class("mt-6"), - Div(Class("h-1.5 w-full overflow-hidden rounded-full bg-zinc-800"), - Div(Class("h-full w-1/3 rounded-full bg-primary-400")), - ), - Div(Class("mt-2 flex justify-between font-mono text-xs text-zinc-500"), - Span(Text("1:42")), - Span(Text("5:07")), - ), - ), - Div(Class("mt-6 flex items-center justify-center gap-6 font-mono text-lg text-zinc-400"), - Span(Text("⏮")), - Span(Class("text-2xl text-white"), Text("⏸")), - Span(Text("⏭")), - ), - ), - ), + musicPlayerMockup(), // Features Div(Class("pb-20"), @@ -76,15 +47,15 @@ func HomePage(props PageProps) Node { Text("Why Quintodrome"), ), Div(Class("mt-12 grid grid-cols-1 gap-6 md:grid-cols-2"), - featureCard("Zero Setup", + component.FeatureCard("Zero Setup", Text("Launch the app and it provisions and starts its own music server for you — no admin account to create, no config file to write."), "double-click → listening", ), - featureCard("Cross-Platform", + component.FeatureCard("Cross-Platform", Text("Native apps for macOS, Windows, and Linux, all wrapping the same self-hosted server underneath."), "Works everywhere you do.", ), - featureCard("Your Library, Your Rules", + component.FeatureCard("Your Library, Your Rules", Group{ Text("Point it at your music folder and it's yours: no subscription, no catalog gaps, no algorithm. Built on "), A(Href("https://www.navidrome.org"), @@ -95,7 +66,7 @@ func HomePage(props PageProps) Node { }, "~/Music → your library", ), - featureCard("Subsonic Compatible", + component.FeatureCard("Subsonic Compatible", Text("Speaks the Subsonic/OpenSubsonic API, so your library also works with the mobile and desktop clients you already use."), "stream anywhere, from one server", ), @@ -112,7 +83,7 @@ func HomePage(props PageProps) Node { Text(", the open-source self-hosted music server. We wrap it in a native desktop shell so there's nothing to install or configure separately."), ), Div(Class("mt-8"), - A(Href("https://github.com/quine-global/quintodrome"), + A(Href(cfg.GitHubURL), Class("inline-block rounded-lg border border-zinc-700 bg-zinc-800 px-6 py-3 font-mono text-sm font-semibold text-white hover:border-zinc-500 transition-colors"), Text("Star us on GitHub"), ), @@ -120,13 +91,3 @@ func HomePage(props PageProps) Node { ), ) } - -func featureCard(title string, body Node, code string) Node { - return Div(Class("rounded-xl border border-zinc-800 bg-zinc-900 p-8"), - H3(Class("font-mono text-base font-semibold text-white"), Text(title)), - P(Class("mt-3 font-mono text-sm text-zinc-400 leading-relaxed"), body), - Div(Class("mt-5 inline-block rounded-md bg-zinc-800 px-3 py-1.5"), - Span(Class("font-mono text-xs text-primary-400"), Text(code)), - ), - ) -} diff --git a/sites/quintodrome/html/common.go b/sites/quintodrome/html/common.go deleted file mode 100644 index 9040f6c..0000000 --- a/sites/quintodrome/html/common.go +++ /dev/null @@ -1,109 +0,0 @@ -// Package HTML holds all the common HTML components and utilities. -package html - -import ( - "crypto/sha256" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - - . "maragu.dev/gomponents" - . "maragu.dev/gomponents/components" - . "maragu.dev/gomponents/html" -) - -var hashOnce sync.Once -var appCSSPath string - -// PageProps are properties for the [page] component. -type PageProps struct { - Title string - Description string -} - -// page layout with nav, main content, and footer. -func page(props PageProps, children ...Node) Node { - hashOnce.Do(func() { - appCSSPath = getHashedPath("public/styles/app.css") - }) - - return HTML5(HTML5Props{ - Title: props.Title, - Description: props.Description, - Language: "en", - Head: []Node{ - Link(Rel("stylesheet"), Href(appCSSPath)), - Link(Rel("preconnect"), Href("https://fonts.googleapis.com")), - Link(Rel("preconnect"), Href("https://fonts.gstatic.com"), Attr("crossorigin", "")), - Link(Rel("stylesheet"), Href("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap")), - Meta(Name("viewport"), Content("width=device-width, initial-scale=1")), - }, - Body: Group{ - siteNav(), - Main(Class("mx-auto max-w-5xl px-6 py-12"), - Group(children), - ), - siteFooter(), - }, - }) -} - -func siteNav() Node { - return Header(Class("border-b border-zinc-800 bg-zinc-950"), - Div(Class("mx-auto flex max-w-5xl items-center justify-between px-6 py-4"), - A(Href("/"), Class("flex items-center gap-3 no-underline"), - Img(Src("/images/quintodrome-logo.png"), Alt("Quintodrome"), Class("h-8 w-8 rounded")), - Span(Class("font-mono text-lg font-bold tracking-tight text-white"), - Text("Quintodrome"), - ), - ), - Div(Class("flex items-center gap-4"), - A(Href("https://github.com/quine-global/quintodrome"), - Class("font-mono text-sm text-zinc-400 hover:text-white transition-colors"), - Text("GitHub"), - ), - A(Href("/download"), - Class("rounded-md bg-primary-500 px-4 py-2 font-mono text-sm font-semibold text-black hover:bg-primary-400 transition-colors"), - Text("Download"), - ), - ), - ), - ) -} - -func siteFooter() Node { - return Footer(Class("mt-24 border-t border-zinc-800 py-8"), - Div(Class("mx-auto flex max-w-5xl flex-col items-center justify-between gap-4 px-6 sm:flex-row"), - Div(Class("flex items-center gap-3"), - Img(Src("/images/quineglobal-logo.png"), Alt("Quine Global"), Class("h-6 w-6 rounded-full")), - Span(Class("font-mono text-sm text-zinc-500"), - Text("© 2026 Quine Global. Built on "), - A(Href("https://www.navidrome.org"), Class("text-zinc-400 hover:text-white underline"), Text("Navidrome")), - Text("."), - ), - ), - Div(Class("flex gap-6 font-mono text-sm"), - A(Href("/"), Class("text-zinc-500 hover:text-white transition-colors"), Text("Home")), - A(Href("https://github.com/quine-global/quintodrome"), Class("text-zinc-500 hover:text-white transition-colors"), Text("GitHub")), - A(Href("/download"), Class("text-zinc-500 hover:text-white transition-colors"), Text("Download")), - ), - ), - ) -} - -func getHashedPath(path string) string { - externalPath := strings.TrimPrefix(path, "public") - ext := filepath.Ext(path) - if ext == "" { - panic("no extension found") - } - - data, err := os.ReadFile(path) - if err != nil { - return fmt.Sprintf("%v.x%v", strings.TrimSuffix(externalPath, ext), ext) - } - - return fmt.Sprintf("%v.%x%v", strings.TrimSuffix(externalPath, ext), sha256.Sum256(data), ext) -} diff --git a/sites/quintodrome/http/refresh.go b/sites/quintodrome/http/refresh.go deleted file mode 100644 index a367335..0000000 --- a/sites/quintodrome/http/refresh.go +++ /dev/null @@ -1,20 +0,0 @@ -package http - -import ( - "fmt" - "net/http" - - "github.com/go-chi/chi/v5" -) - -// InternalRefresh registers POST /internal/refresh to force an immediate GitHub release cache refresh. -func (s *Server) InternalRefresh(r chi.Router) { - r.Post("/internal/refresh", func(w http.ResponseWriter, r *http.Request) { - if err := s.releases.ForceRefresh(r.Context()); err != nil { - http.Error(w, "refresh failed: "+err.Error(), http.StatusBadGateway) - return - } - w.Header().Set("Content-Type", "text/plain") - fmt.Fprintln(w, "ok") - }) -} diff --git a/sites/quintodrome/http/routes.go b/sites/quintodrome/http/routes.go deleted file mode 100644 index cf6f265..0000000 --- a/sites/quintodrome/http/routes.go +++ /dev/null @@ -1,30 +0,0 @@ -package http - -import ( - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "maragu.dev/httph" -) - -// setupRoutes for the server. -func (s *Server) setupRoutes() { - s.InternalRefresh(s.mux) - s.UpdateCheck(s.mux) - - s.mux.Group(func(r chi.Router) { - r.Use(middleware.Compress(5)) - - // Sets up a static file handler with cache busting middleware. - r.Group(func(r chi.Router) { - r.Use(httph.VersionedAssets) - - Static(r) - }) - - s.Home(r) - }) - - // Also expose the update feed at the short /update path for convenient - // feed URLs (e.g. https://quintodrome.quineglobal.com/update?platform=...&version=...) - s.mux.Get("/update", s.updateCheckHandler()) -} diff --git a/sites/quintodrome/http/server.go b/sites/quintodrome/http/server.go deleted file mode 100644 index b8823f0..0000000 --- a/sites/quintodrome/http/server.go +++ /dev/null @@ -1,83 +0,0 @@ -// Package http has the [Server] and HTTP handlers. -package http - -import ( - "context" - "errors" - "io" - "log/slog" - "net/http" - "time" - - "github.com/go-chi/chi/v5" -) - -// Server holds dependencies for the HTTP server as well as the HTTP server itself. -type Server struct { - log *slog.Logger - mux chi.Router - server *http.Server - releases *ReleaseCache -} - -type NewServerOptions struct { - Log *slog.Logger -} - -func NewServer(opts NewServerOptions) *Server { - if opts.Log == nil { - opts.Log = slog.New(slog.NewTextHandler(io.Discard, nil)) - } - - mux := chi.NewMux() - - return &Server{ - log: opts.Log, - mux: mux, - releases: &ReleaseCache{log: opts.Log}, - server: &http.Server{ - Addr: ":8081", - Handler: mux, - ReadTimeout: 5 * time.Second, - ReadHeaderTimeout: 5 * time.Second, - WriteTimeout: 5 * time.Second, - IdleTimeout: 5 * time.Second, - }, - } -} - -// Start the server and set up routes. -func (s *Server) Start() error { - s.log.Info("Starting http server", "address", "http://localhost:8081") - - s.setupRoutes() - - // Fetch releases immediately, then refresh every hour in the background. - go s.releases.TryRefresh(context.Background(), 0) - go func() { - ticker := time.NewTicker(time.Hour) - defer ticker.Stop() - for range ticker.C { - s.releases.TryRefresh(context.Background(), time.Hour) - } - }() - - if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - return err - } - return nil -} - -// Stop the server gracefully. -func (s *Server) Stop() error { - s.log.Info("Stopping http server") - - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - - if err := s.server.Shutdown(ctx); err != nil { - return err - } - s.log.Info("Stopped http server") - return nil -} diff --git a/sites/quintodrome/http/server_test.go b/sites/quintodrome/http/server_test.go deleted file mode 100644 index b06b070..0000000 --- a/sites/quintodrome/http/server_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package http_test - -import ( - "io" - http2 "net/http" - "strings" - "testing" - "time" - - "maragu.dev/is" - - "app/sites/quintodrome/http" -) - -func TestServer_Start(t *testing.T) { - t.Run("can start and stop server", func(t *testing.T) { - s := http.NewServer(http.NewServerOptions{}) - - go func() { - is.NotError(t, s.Start()) - }() - defer func() { - is.NotError(t, s.Stop()) - }() - - // I know we could check that the server is running here, but it's easier to just wait a bit - time.Sleep(10 * time.Millisecond) - - res, err := http2.Get("http://localhost:8081/") - is.NotError(t, err) - is.Equal(t, http2.StatusOK, res.StatusCode) - body, err := io.ReadAll(res.Body) - is.NotError(t, err) - is.True(t, strings.HasPrefix(string(body), "")) - }) -} diff --git a/sites/quintodrome/http/static.go b/sites/quintodrome/http/static.go deleted file mode 100644 index a6323ba..0000000 --- a/sites/quintodrome/http/static.go +++ /dev/null @@ -1,15 +0,0 @@ -package http - -import ( - "net/http" - - "github.com/go-chi/chi/v5" -) - -// Static assets handler, which serves files from the root that have an extension, and everything from -// the images, scripts, and styles directories. -func Static(r chi.Router) { - staticHandler := http.FileServer(http.Dir("public")) - r.Get(`/{:[^.]+\.[^.]+}`, staticHandler.ServeHTTP) - r.Get(`/{:images|scripts|styles}/*`, staticHandler.ServeHTTP) -} diff --git a/sites/quintodrome/http/updatecheck_test.go b/sites/quintodrome/http/updatecheck_test.go deleted file mode 100644 index bdad74f..0000000 --- a/sites/quintodrome/http/updatecheck_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package http - -import ( - "testing" - - "maragu.dev/is" - - "app/sites/quintodrome/html" -) - -func TestSemverCmp(t *testing.T) { - cases := []struct { - a, b string - want int - }{ - {"4.0.0", "4.0.0", 0}, - {"4.1.0", "4.0.0", 1}, - {"4.0.0", "4.1.0", -1}, - {"4.0.1", "4.0.0", 1}, - {"5.0.0", "4.9.9", 1}, - {"4.10.0", "4.9.0", 1}, - } - for _, c := range cases { - t.Run(c.a+"_vs_"+c.b, func(t *testing.T) { - is.Equal(t, c.want, semverCmp(c.a, c.b)) - }) - } -} - -func TestNewerCanary(t *testing.T) { - rel := func(base string, build int) html.Release { - return html.Release{Version: base, CanaryBuild: build, IsCanary: true} - } - - t.Run("higher base is newer for canary client", func(t *testing.T) { - is.True(t, newerCanary(rel("4.1.0", 1), "4.0.0", 5, true)) - }) - t.Run("same base higher build is newer for canary client", func(t *testing.T) { - is.True(t, newerCanary(rel("4.0.0", 6), "4.0.0", 5, true)) - }) - t.Run("same base same build is not newer for canary client", func(t *testing.T) { - is.True(t, !newerCanary(rel("4.0.0", 5), "4.0.0", 5, true)) - }) - t.Run("lower base is not newer for canary client", func(t *testing.T) { - is.True(t, !newerCanary(rel("3.9.0", 99), "4.0.0", 1, true)) - }) - t.Run("same base canary is newer than stable client", func(t *testing.T) { - is.True(t, newerCanary(rel("4.0.0", 1), "4.0.0", 0, false)) - }) - t.Run("higher base canary is newer than stable client", func(t *testing.T) { - is.True(t, newerCanary(rel("4.1.0", 1), "4.0.0", 0, false)) - }) - t.Run("lower base canary is not newer than stable client", func(t *testing.T) { - is.True(t, !newerCanary(rel("3.9.0", 99), "4.0.0", 0, false)) - }) -} - -func TestNormalizePlatformForAssets(t *testing.T) { - cases := []struct { - in string - want string - }{ - {"darwin", "mac"}, - {"Darwin", "mac"}, - {"mac", "mac"}, - {"win32", "windows"}, - {"windows", "windows"}, - {"linux", "linux"}, - {"appimage", "linux"}, - {"deb", "linux-deb"}, - {"linux-deb", "linux-deb"}, - {"linux-rpm", "linux-rpm"}, - {"rpm", "linux-rpm"}, - {"linux-snap", "linux-snap"}, - {"snap", "linux-snap"}, - {"linux-pacman", "linux-pacman"}, - {"pacman", "linux-pacman"}, - {"linux-flatpak", "linux-flatpak"}, - {"flatpak", "linux-flatpak"}, - {"", ""}, - } - for _, c := range cases { - t.Run(c.in, func(t *testing.T) { - is.Equal(t, c.want, normalizePlatformForAssets(c.in)) - }) - } -} - -func TestFirstNonEmpty(t *testing.T) { - is.Equal(t, "a", firstNonEmpty("a", "b")) - is.Equal(t, "b", firstNonEmpty("", "b")) - is.Equal(t, "", firstNonEmpty("", "")) -} diff --git a/sites/quintodrome/mockup.go b/sites/quintodrome/mockup.go new file mode 100644 index 0000000..68dbef9 --- /dev/null +++ b/sites/quintodrome/mockup.go @@ -0,0 +1,38 @@ +package quintodrome + +import ( + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" + + "app/internal/component" +) + +// musicPlayerMockup is the "now playing" device-window body shown on the +// landing page. +func musicPlayerMockup() Node { + return component.DeviceWindow("quintodrome — now playing", + Div(Class("p-8"), + Div(Class("flex items-center gap-5"), + Div(Class("h-20 w-20 shrink-0 rounded-lg bg-gradient-to-br from-primary-400 to-primary-500")), + Div(Class("min-w-0 text-left"), + P(Class("truncate font-mono text-base font-semibold text-white"), Text("Brandenburg Concerto No. 3")), + P(Class("mt-1 truncate font-mono text-sm text-zinc-400"), Text("J.S. Bach — Library/Classical")), + ), + ), + Div(Class("mt-6"), + Div(Class("h-1.5 w-full overflow-hidden rounded-full bg-zinc-800"), + Div(Class("h-full w-1/3 rounded-full bg-primary-400")), + ), + Div(Class("mt-2 flex justify-between font-mono text-xs text-zinc-500"), + Span(Text("1:42")), + Span(Text("5:07")), + ), + ), + Div(Class("mt-6 flex items-center justify-center gap-6 font-mono text-lg text-zinc-400"), + Span(Text("⏮")), + Span(Class("text-2xl text-white"), Text("⏸")), + Span(Text("⏭")), + ), + ), + ) +} diff --git a/sites/quintodrome/site.go b/sites/quintodrome/site.go new file mode 100644 index 0000000..68c6c66 --- /dev/null +++ b/sites/quintodrome/site.go @@ -0,0 +1,44 @@ +// Package quintodrome holds the Quintodrome site's identity: branding, +// asset classification, download-format list, and landing-page content. +// Everything else (server, routes, release cache, update-check protocol, +// page chrome) lives in the shared internal/site and internal/component +// packages. +package quintodrome + +import ( + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" + + "app/internal/component" + "app/internal/site" +) + +// Config is the Quintodrome site configuration, passed to site.NewServer. +var Config = site.Config{ + Branding: component.Branding{ + Name: "Quintodrome", + LogoPath: "/images/quintodrome-logo.png", + GitHubURL: "https://github.com/quine-global/quintodrome", + FooterNote: Group{ + Text("Built on "), + A(Href("https://www.navidrome.org"), Class("text-zinc-400 hover:text-white underline"), Text("Navidrome")), + Text("."), + }, + }, + DownloadConfig: component.DownloadConfig{ + // Quintodrome currently ships Linux builds as Flatpak/Snap only (no AppImage). + DefaultLinuxOS: "linux-flatpak", + Formats: []component.LinuxFormat{ + {OS: "linux-flatpak", Label: "Flatpak", Archs: []string{"arm64", "x64"}}, + {OS: "linux-snap", Label: "Snap", Archs: []string{"arm64", "x64"}}, + {OS: "linux", Label: "AppImage", Archs: []string{"arm64", "x64"}}, + {OS: "linux-deb", Label: "DEB", Archs: []string{"arm64", "x64"}}, + {OS: "linux-rpm", Label: "RPM", Archs: []string{"arm64", "x64"}}, + {OS: "linux-pacman", Label: "Pacman", Archs: []string{"arm64", "x64"}}, + }, + }, + RepoOwner: "quine-global", + RepoName: "quintodrome", + ClassifyAsset: classifyAsset, + HomeContent: HomeContent, +} diff --git a/tailwind.config.js b/tailwind.config.js index b7be8f5..85b569c 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,6 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - content: ["./html/**/*.go", "./http/**/*.go"], + content: ["./internal/**/*.go", "./sites/**/*.go"], theme: { extend: { colors: {