Downloads page polish — design spec
Status: In progress · Author: Claude (with Christophe) · Date: 2026-07-23 · Repo: north-os (apps/web)
Related: 2026-07-22-desktop-release-pipeline-design.html (shipped the MVP /downloads page + lib/downloads.ts), DESIGN.md
Decision
Upgrade the MVP /downloads page into a polished, sober download surface: the visitor's platform is detected server-side from the User-Agent request header, their installer renders first and visually primary, the other platform stays available as a secondary card. Add a compact "Download the desktop app" micro-widget at the bottom of the main app sidebar, directly above the account button, linking to /downloads — hidden inside the desktop app via the existing data-browser-only CSS mechanism. The data layer apps/web/lib/downloads.ts is unchanged (its zod schema is security-pinned: installer URLs are origin/prefix/extension-validated).
Current state
apps/web/app/(settings)/downloads/page.tsx— async server component; renders two equalPlatformCards (macOS DMG, Windows Setup.exe) in a fixed order, version / release date / size rows, a "What's new" notes section, and aFeedUnavailablegraceful state. No platform awareness.apps/web/lib/downloads.ts— fetcheslatest.jsonfromupdates.heynorth.dev(5-min Next cache, 4s timeout), zod-validated; every failure collapses to{ available: false }. Also exportsformatFileSize/formatReleaseDate/formatMacRequirement.- Discoverability today: a "Download desktop apps" item buried in the user dropdown (
user-menu.tsx) and a "Desktop apps" entry in the settings sidebar (settings-shell.tsx). Nothing in the main app sidebar. packages/ui/src/styles/globals.cssalready ships the desktop-surface mechanism: the native shell setsdata-desktop-surface="desktop"on<html>, and[data-browser-only]elements getdisplay:nonethere (browser: visible, no-op).
Design
1 · Server-side platform detection
- New helper
apps/web/lib/user-platform.ts:detectDesktopPlatform(userAgent: string | null): "mac" | "windows" | null. Pure function, unit-tested. - Rules:
Windows NT→"windows";Macintosh/Mac OS X→"mac"only after excluding mobile (iPhone/iPad UAs contain "like Mac OS X" — any ofiPhone|iPad|Android|Mobile→null). Anything else (Linux, bots, missing header) →null. - The page reads the header via
(await headers()).get("user-agent"). The page is already dynamic (resolveSettingsContextreads the session), so this adds no static-render regression — and it stays out oflib/downloads.ts, which remains a pure manifest reader.
2 · Page layout — primary / secondary
- Detected platform first in the grid and visually primary: its CTA uses the default
Button(bg-primary text-primary-foreground); the other platform's CTA drops tovariant="outline"with a quieter label ("Also available for Windows/macOS" stays as the card header — CTA label keeps "Download for X"). - Undetected platform (
null): current behavior preserved — macOS first, both CTAs default. No guessing, no client-side sniffing fallback. - Everything else is kept: version / released / size
dlrows, "What's new in {version}" notes section, and theFeedUnavailablestate, byte-for-byte in behavior. - Tone per DESIGN.md: existing card pattern (
bg-card+border-border+bg-backgroundheader recess,rounded-lg) is already compliant — this spec only reorders and re-weights, adds no new tokens.
3 · Sidebar micro-widget
- Location:
apps/web/components/app-shell/app-sidebar.tsx, inside<SidebarFooter>, rendered above<UserMenu />. - Shape: one
SidebarMenuitem —SidebarMenuButton asChildwrapping a<Link href="/downloads">with theMonitorDownicon (matches the user-menu and settings-shell entries) and label "Download the desktop app". Quiet:text-muted-foregroundat rest, standard hover wash from the sidebar button.rounded-md,size-4icon — the same visual grammar as every other sidebar row. No badge, no accent color, no animation. - Hidden inside the desktop app via
data-browser-onlyon the wrapping element. This is the right mechanism: it already exists inglobals.css, costs zero JS, has no hydration flash (pure CSS keyed off an attribute the native shell injects pre-paint), and is exactly what the collapse-trigger chrome uses today. No server-side detection needed — the desktop shell is identified by the injected attribute, not the UA. - Settings shell gets no widget: it already has a "Desktop apps" nav item. The user-menu dropdown item stays — the widget is additive discoverability, not a replacement.
Out of scope / invariants
lib/downloads.tsis not touched — the zod installer-URL pinning (origin + platform prefix + extension) is a security control and stays as-is.- No client-side UA sniffing, no cookies, no persistence of platform choice.
- No Linux build, no mobile pages, no changes to the release pipeline or
latest.jsonshape. - No dismissal state for the sidebar widget (it is one quiet row; if HSE feedback says otherwise, that's a follow-up).
Verification
bun test apps/web/lib/user-platform.test.ts— UA matrix: mac Chrome/Safari, Windows Chrome/Edge, iPhone, iPad, Android, Linux, Googlebot, empty, null.bun run typecheck+bun run lintfrom root pass.- ui-test tier 1:
/downloadswith a mac UA shows macOS card first + primary CTA; with a Windows UA shows Windows first; feed-down (blocked origin) shows the unavailable card; sidebar shows the widget above the account button in the browser.