Aether
April 19, 2026 · View on GitHub
Desktop theming app. Wails 2 (Go backend) + Svelte 5 frontend (TypeScript, Tailwind v4). Extracts color palettes from wallpapers and applies cohesive themes across a Linux desktop (Omarchy-friendly, works standalone).
Detailed project docs (outside the repo)
Architecture decisions, design rationale, flow diagrams, ADRs, feature specs, and historical context live at:
~/Documents/bjarne/projects/Aether
Read that folder before making non-trivial changes or when the user asks "how does X work". Write new detailed docs there — the repo's docs/ folder is reserved for user-facing documentation (CLI, installation, custom apps, templates). Content in ~/Documents/bjarne/projects/Aether is deliberately outside git.
See the aether-docs skill (.claude/skills/aether-docs/SKILL.md) for the full convention.
Commands
From repo root (requires Go + wails CLI + Node):
make dev— live-reload dev buildmake build— production build (build/bin/aether); on Linux also buildsaether-wpservice binarymake test—go test ./internal/... ./cli/...make install— system install (Linux: copies to/usr/bin/; macOS:/Applications/)
Inside frontend/:
npm run check— svelte-check type-checking (run before committing frontend code)npm run dev— Vite dev server (usually invoked bywails dev)
Pre-commit hook runs prettier on changed JS/TS/Svelte files and gofmt on Go. Don't bypass it.
Layout
app.go # Wails app struct; ALL exported methods auto-bound to frontend
main.go # entry point
cmd/aether-wp/ # Linux-only wallpaper service binary (GTK + gtk-layer-shell)
internal/
├── extraction/ # palette extraction (OKLab median-cut)
├── theme/ # theme state, applier, format classifiers (IsVideoFile, IsImageFile)
├── wallpaper/ # file scanning, thumbnails, video-frame extraction (ffmpeg)
├── blueprint/ # saved theme snapshots
├── color/ # color math (RGB/OKLab/HSL/Adjustments)
├── template/ # per-app template engine
├── wallhaven/ # wallhaven.cc API client
├── batch/ # batch palette generation
├── favorites/, omarchy/, platform/
frontend/
├── src/
│ ├── App.svelte # top-level router
│ ├── app.css # Tailwind + theme tokens (light-mode class flips them)
│ ├── lib/
│ │ ├── components/ # UI (editor/, sidebar/, color-picker/, blueprints/, wallhaven/, ...)
│ │ ├── stores/*.svelte.ts # reactive state (Svelte 5 runes, .svelte.ts extension required)
│ │ ├── utils/ # color.ts, canvas-filters.ts, keyboard.ts, debounce.ts
│ │ ├── constants/colors.ts # ANSI / extended color labels
│ │ └── types/theme.ts # shared TS types + DEFAULT_PALETTE / DEFAULT_ADJUSTMENTS
└── wailsjs/go/ # generated bindings — DO NOT HAND-EDIT EXCEPT AS LAST RESORT
localfilehandler.go # localhost HTTP media server for streaming videos (sends CORS)
Svelte 5 conventions
- Stores live in
*.svelte.tsfiles and exposegetX()/setX()functions that read/mutate module-scoped$state. Never export the state directly — callers use getters so TS can see the boundary. - Components use runes:
$state,$derived,$props,$effect. Nowritable/derivedfromsvelte/store. $effectdependency tracking pattern: to force an effect to track non-reactive-read values, touch them:
Throw-away$effect(() => { const _ = JSON.stringify(points); // deep mutation tracker const __ = histogram.length; const ___ = getLightMode(); draw(); });_/__/___names are the established pattern (seeCurvesEditor.svelte,WallpaperEditor.svelte).- Single-use
$derivedis fine to inline. Multi-use or non-trivial expressions get a named derived.
Theming & styles
- Theme tokens in
frontend/src/app.cssunder@theme { ... }(dark) and:root.light-mode { ... }(overrides). Tokens:bg-bg-primary/secondary/surface/elevated/hover,text-fg-primary/secondary/dimmed,border-border/border-focus,text-accent,bg-accent-muted,text-destructive/success/warning. - Light mode toggles via
document.documentElement.classList.add('light-mode')— tied to thegetLightMode()store and applied byActionBar.svelte'shandleApply. - Overlay coloring rule: UI that sits on top of the wallpaper image uses fixed scrim colors (
bg-black/60..80+text-white) because image content is arbitrary. UI that sits on top of app chrome uses theme tokens (bg-bg-secondary+text-fg-primary+border-border) so it inverts correctly in light mode. Nevertext-fg-primaryon a black scrim — it goes invisible in light mode. - Canvas rendering: canvas can't inherit CSS vars. Read
getLightMode()in yourdraw()and branch on ink color (seeCurvesEditor.svelte'sink(alpha)helper). - Tooltips: use the native
title=attribute (pattern inWallpaperHero.svelte,HeaderBar.svelte). No custom tooltip component. border-radius: 0is enforced globally (seeapp.css). Don't fight it.
Wails bindings gotchas
- Generated request classes (e.g.
main.ApplyThemeRequest,main.SaveBlueprintRequest) include an instance methodconvertValues, so plain object literals fail structural type-checks. Workaround:import type {main}, then cast{...} as unknown as main.ApplyThemeRequest. Don'tas any— lose field type-checking. Adjustmentsis a class with numeric fields but no index signature, so it's not assignable toRecord<string, number>. Spread it:adjustments: {...getAdjustments()}— the resulting plain object is structurally compatible.- New Go method on
App→ need to updatefrontend/wailsjs/go/main/App.d.ts,App.js, and if it returns/accepts a named Go struct alsofrontend/wailsjs/go/models.ts. ThewailsCLI regenerates these duringwails dev/wails build, but manual additions are required when hand-building the frontend.
Extraction pipeline (Go)
extraction.ExtractColors(path, lightMode, mode) flow:
GetCacheKey+ mode suffix viabuildCacheKey— returns cached[16]stringif hit (LoadCachedPalette).ExtractDominantColors(path, N)→LoadAndSamplePixels(decode, downscale toImageScaleSize, sample up toMaxPixelsToSample) →ExtractDominantColorsFromPixels(RGB→OKLab →boostChromaticPixels→MedianCut→ sort by count → hex).GeneratePaletteByMode(dominantColors, lightMode, mode)— dispatches to the mode-specific generator (monochromatic / analogous / pastel / material / colorful / muted / bright / auto-detect).NormalizeBrightness— final readability pass.SavePaletteToCache.
extraction.ExtractColorsFromImages(paths, lightMode, mode) blends multiple images by concatenating LoadAndSamplePixels outputs before step 3. Skips non-image entries via theme.IsImageFile. app.go wrapper resolves video paths to frames via wallpaper.ExtractVideoFrame first.
Video + eyedropper specifics
- Videos served by the localhost media server (
localfilehandler.go) ashttp://127.0.0.1:<port>/media?path=.... Different origin from Wails, so it sendsAccess-Control-Allow-Origin: *and<video>elements usecrossorigin="anonymous"— required for canvas pixel sampling without SecurityError. - Full-res image cache returns data URLs (same-origin, never CORS-tainted).
- Eyedropper uses in-image canvas sampling (not
window.EyeDropper— not available in webkit2gtk). Hot-path:mapEventToSourceinWallpaperHero.sveltehandles object-cover vs object-contain scale math + letterbox bounds.
Testing + verification
- Go:
make testruns./internal/... ./cli/.... No frontend unit tests. - Frontend:
npm run checkis the type-gate. FiveApplyThemeRequest/Adjustments/Blueprinterrors fixed in commite0ccce4— if they reappear, it's because someone bypassed the pattern. - No E2E tests. Manual verification by running
wails dev.
Output style when the user asks to commit
- Maintainer convention: no
Co-Authored-By: Claudetrailer on commits (user prefers clean commits). Use short imperative subjects matching repo style (Add ...,Fix ...,Move ...,Make ...). The pre-commit hook runs prettier/gofmt — don't bypass with--no-verify.