chadtmiller.com/knotless
git that lives in your Atmosphere account
Anyone can make a theme for the pages and publish it, and choose one by day and one at night
MergedThis adds the dev.knotless.theme record, listThemes, createTheme and deleteTheme, and a palette editor. A chosen theme's palette is copied into preferences and drawn from a palette cookie. The shared contrast check moves to web/src/lib/contrast.ts.
I read lexicons/dev/knotless/theme.json, web/src/lib/theme.ts, web/src/hooks.server.ts and crates/knotless-serve/src/web/themes.rs in full. I did not read the rest of the diff (the editor pages, the index query, the tests, the contrast move). It was too large to open here.
- Injection: I found no way for anything but
#rrggbbto reach the<html>styleattribute. The server only keeps hex colours, andpaletteOfre-checks each cookie value against a 6-digit hex pattern.data-themecan only be one of the allowed ids. - Writes:
deleteThemeonly accepts the caller's ownat://prefix and a safe rkey.createThemevalidates the name, scheme and required colours before writing. - Docs:
docs/reference/api.mdanddocs/design/serve.mdare updated, and the pull says a change file is included. - Checks: all six were still queued or running when I looked, so I couldn't see their results. Please confirm they pass. In particular,
testruns the 100% coverage gate and the index query-plan check on the newindex.themesquery.lexiconsruns the schema lint, which I didn't run.
The one new commit changes [data-theme^='custom-'] in web/src/tokens.css. --heat now follows --accent instead of --primary. --notice-fg is now the yellow mixed 60/40 with the text colour. Both are text colours, so they should come from colours a theme gives to be read. The contrast pairs in contrast.ts already include notice-fg on notice, so the editor's AA check covers the new value. I found nothing wrong with it.
My earlier approval raised no change requests, so there was nothing to follow up. The docs check passes, but test, web, shots and lexicons were still queued or running when I looked. Please confirm they pass before merging. web runs svelte-check and test runs the coverage gate, and I didn't run either.
The new commit moves the theme editor's sample page above the colour form as a single column, dropping the sticky side-by-side grid and its phone override. It also adds [data-theme^='custom-'] to the light block in tokens.css. contrast.ts now looks up that same selector text, so the harness and the stylesheet agree. A custom-dark theme still wins: [data-theme='custom-dark'] sits in the dark block at line 304, after the light block, at the same specificity. The sample's data-theme and style attributes carry over unchanged, so the earlier injection findings still hold. Nothing was asked for in the earlier rounds, so there was nothing to follow up. I found nothing wrong.
Of the checks, only docs has passed. lexicons and check were still running, and test, web and shots were queued. Please confirm they pass before merging. web runs svelte-check and test runs the coverage gate, and I didn't run either. lexicons and test exercise the contrast.ts selector change.
9 passed, 1 skipped
What changes
A theme is a record.
dev.knotless.themeholds a name and a palette, each colour named for the role it plays, as design tokens are (Primer's):background,text,textMuted,primary,accent, the states'success,danger,done,attentionandneutral, andsyntax's. Every colour is#rrggbb, and four are required. The rest fall back on light's or dark's, ascolorSchemesays, or for surfaces and texts are mixed from the background and text ([data-theme^='custom-']intokens.css). A theme written by hand needs only a few colours.Appearance, now a group in the settings nav (Themes, Make a theme), as GitHub groups a section's pages. It keeps the built-in themes and adds Your themes: one list of the themes you made and the ones you added, the latest first by when each was made or added, each card saying who made it. You can choose each one, delete one you made or remove one you added from its "…" menu. A palette in use that is neither shows first, as "Your own". With none, an empty state says how to get some, above "Browse themes" and "Make a theme".
Finding a theme and choosing one are apart, as Obsidian's community themes are. "Browse themes" opens a dialog of everyone's, 48 at a time, found by the start of their name, where a +/✓ button adds each to Your themes or takes it out. Added themes are copies in a new
themesPref(50 at most, each withaddedAt), so one its maker deletes stays. Everyone's themes are never listed on Appearance itself, so it stays short however many there are. The dialog keeps one height whatever it finds, its list fading where there is more above or below, and says "No themes match" with a way to clear the search.listThemestakesuris(50 at most), so Appearance asks once who made each added theme.Day and night. "One theme" or "Sync with system", as GitHub names it. With sync, Day theme and Night theme are tabs over the themes, each card tagged with a sun or moon for the half it is chosen for.
appearancePref.nightkeeps the night half, the server writes both halves on<html>(data-day,data-night, a palette of either in a<style>), and/theme.js, a static file the CSP allows, picks the half the system's light or dark says before the first paint and again as it changes. Logging in writestheme-nightandpalette-nighttoo.The editor at
/settings/appearance/newstarts from any theme there is. It draws a sample page above the form as each colour is picked. It lists every text that falls short of AA on what it is drawn on, and won't use or publish a theme until none do. "Publish and use" writes the record (createTheme). "Use without publishing" keeps it in your settings alone.Choosing one copies its palette into
appearancePref(theme: custom,palette,source). Its maker changing or deleting it later leaves your pages as they are. The browser keepstheme=custom-darkand apalettecookie, which the pages' server writes on<html>as custom properties from the first paint. Logging in writes both.The command line and MCP make themes too.
knotless themes [<handle>] [--find <name>]lists them.knotless theme check <file>says which text falls short.knotless theme publish <name> <file> [--use]publishes a palette.knotless theme use <system | light | … | at:// | file.json>draws your pages in one.knotless theme delete <uri>deletes one. Over MCP, thethemeandlist_themestools do the same, so an agent can make a theme, check it, fix what it says, and publish.One contrast check, everywhere. The colour maths moves from
harness/contrast.mjsintoweb/src/lib/contrast.ts, which the harness imports (Node 24 runs the.ts) and the editor uses. The pairs move intoweb/src/lib/contrast.json.crates/knotless/src/theme.rsis the same measure in Rust, reading the sametokens.cssand the same JSON. The CLI and MCP check with it before writing, andcreateThemechecks too. A unit test pins it to the TypeScript check's answers.Two fixes found on the way.
contrast.tslooked up the custom-theme block by a selector that also ends the light block's, so the editor was checking made themes against Light's colours. It now matches a selector only where it starts.--accent-softat 8%,--tagpulled toward the text).The index takes in themes from the firehose and Jetstream, and backfills them.
dev.knotless.themeis among the collections the streams pass on (knotless::jetstream::knotless) and the backfill reads, so a theme published from the command line, MCP or any other client is listed, not only thosecreateThemewrites.Shadows. A made theme's popovers and dialogs cast its background darkened toward black, as strong as Light's or Dark's shadow. Before, they used its text colour, which in a dark theme was a pale glow.
Logging in again asks to write
dev.knotless.themerecords (change file added).Why
The ask was whether people could bring their own theme through a published lexicon. I looked for a shared one first. The closest is standard.site's
site.standard.theme.basic(Leaflet, pckt), but it has four colours and the pages are drawn in twenty-odd. Its{r,g,b}objects are also clumsier to check and write into CSS than hex. So this uses knotless's own shape, and importing a standard.site palette can come later on top of it.The palette's fields are named by role, not hue (
danger, notred). A theme can then make danger orange without a field called red holding orange. The pages' own tokens took the same roles in web/semantic-colours, which this is merged with.The palette is copied into preferences rather than referenced. That pins it, and the server needs no lookup to draw a page. Only
#rrggbbcolours reach the page: serve holds a palette to its lexicon, and the pages' server reads only hex from the cookie. Nothing else can get into thestyleattribute.A dialog, not a gallery on the page, so Appearance doesn't grow with the network. A directory with ranking (most used, starred) can come later behind the same dialog.
How it was checked
node harness/lexicons.mjsandnode harness/contrast.mjspass here: all 8 built-in themes still pass through the moved code. svelte-check passes.themes_from_the_pagesgateway test. It covers create, refusals (empty name, colour not hex, missing required, no login), listing with paging, by actor and byq,ProfileNotFound, 404 without an index, and deleting only one's own.settings_from_the_pagesnow also covers a custom palette inputPreferences, thetheme/palettecookies at login, and refusals.tokens.css, reading colours, and matching the TypeScript check's 12 shortfalls for a sepia palette, ratios included. These ran here and pass.agents_make_themesMCP test, against an appview. It checks, refuses, publishes and uses, lists by account and by name, chooses built-in, published and unpublished themes, and deletes only one's own. It covers the same over the command line.themes_from_the_pagesnow also refuses a palette that reads badly.cargo checkof the three crates and their tests passed here. Clippy, the PDS tests and coverage are left to CI.themes_from_the_pagescoverslistThemesbyuris(the order named, a deleted one left out, over 50 refused), andsettings_from_the_pagesa day/night pair (its cookies at login, a night half refused) andthemesPref(kept, left be by a theme choice, emptied, and each refusal). Both ran here against the PDS and pass.app.htmlloads/theme.js, so every page's HTML differs and is shot, but none should move.Shortcomings
putPreferencesholds a palette to its lexicon, not to contrast. A palette put straight through the API that reads badly only draws that account's own pages badly. A published theme that reads badly, written to a PDS by another client, can still be chosen.knotless theme useupdates your preferences. A browser already signed in keeps its theme until it next logs in.Review
The design: start at
lexicons/dev/knotless/theme.json, thencrates/knotless-serve/src/web/themes.rs, thenweb/src/lib/theme.tsandhooks.server.ts.🤖 Generated with Claude Code