~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

Anyone can make a theme for the pages and publish it, and choose one by day and one at night

Merged
Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/custom-themes

What changes

A theme is a record. dev.knotless.theme holds 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, attention and neutral, and syntax's. Every colour is #rrggbb, and four are required. The rest fall back on light's or dark's, as colorScheme says, or for surfaces and texts are mixed from the background and text ([data-theme^='custom-'] in tokens.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 with addedAt), 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. listThemes takes uris (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.night keeps 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 writes theme-night and palette-night too.

The editor at /settings/appearance/new starts 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 keeps theme=custom-dark and a palette cookie, 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, the theme and list_themes tools 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.mjs into web/src/lib/contrast.ts, which the harness imports (Node 24 runs the .ts) and the editor uses. The pairs move into web/src/lib/contrast.json. crates/knotless/src/theme.rs is the same measure in Rust, reading the same tokens.css and the same JSON. The CLI and MCP check with it before writing, and createTheme checks too. A unit test pins it to the TypeScript check's answers.

Two fixes found on the way.

  • contrast.ts looked 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.
  • A hand-written palette of only its four required colours failed: links on their tint, and markup tags. Those two derived colours are now mixed to read (--accent-soft at 8%, --tag pulled toward the text).

The index takes in themes from the firehose and Jetstream, and backfills them. dev.knotless.theme is 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 those createTheme writes.

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.theme records (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, not red). 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 #rrggbb colours 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 the style attribute.

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.mjs and node harness/contrast.mjs pass here: all 8 built-in themes still pass through the moved code. svelte-check passes.
  • New themes_from_the_pages gateway test. It covers create, refusals (empty name, colour not hex, missing required, no login), listing with paging, by actor and by q, ProfileNotFound, 404 without an index, and deleting only one's own.
  • settings_from_the_pages now also covers a custom palette in putPreferences, the theme/palette cookies at login, and refusals.
  • Unit tests for palette parsing, the cookie format, and the Rust contrast check: parsing tokens.css, reading colours, and matching the TypeScript check's 12 shortfalls for a sepia palette, ratios included. These ran here and pass.
  • New agents_make_themes MCP 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_pages now also refuses a palette that reads badly.
  • I drew Appearance, the browse dialog, the editor and Your themes (menu open, after a delete) in a browser against a stand-in serve, desktop light and phone dark.
  • cargo check of the three crates and their tests passed here. Clippy, the PDS tests and coverage are left to CI.
  • themes_from_the_pages covers listThemes by uris (the order named, a deleted one left out, over 50 refused), and settings_from_the_pages a day/night pair (its cookies at login, a night half refused) and themesPref (kept, left be by a theme choice, emptied, and each refusal). Both ran here against the PDS and pass.
  • I drew the pair's tabs, the collecting dialog (empty, a search with none, scrolled), Your themes empty, full and after a remove, desktop light and phone dark.
  • No shot pictures should change: the settings pages aren't among the shots. app.html loads /theme.js, so every page's HTML differs and is shot, but none should move.

Shortcomings

  • putPreferences holds 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.
  • The colour maths is in TypeScript and in Rust. They read the same tokens and pairs, and a test holds Rust to the TypeScript check's answers, but a change to how one measures needs the other changed too.
  • Theme names have no moderation beyond takedowns.
  • knotless theme use updates your preferences. A browser already signed in keeps its theme until it next logs in.
  • The dialog lists newest first and finds by name prefix only, with no ranking.
  • Your themes lists 100 made at most, and 50 added.
  • Removing an added theme that is in use leaves it in use, shown as "Your own".
  • The command line and MCP can't add a theme or choose a day/night pair yet.

Review

The design: start at lexicons/dev/knotless/theme.json, then crates/knotless-serve/src/web/themes.rs, then web/src/lib/theme.ts and hooks.server.ts.

🤖 Generated with Claude Code

Approved

This 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 #rrggbb to reach the <html> style attribute. The server only keeps hex colours, and paletteOf re-checks each cookie value against a 6-digit hex pattern. data-theme can only be one of the allowed ids.
  • Writes: deleteTheme only accepts the caller's own at:// prefix and a safe rkey. createTheme validates the name, scheme and required colours before writing.
  • Docs: docs/reference/api.md and docs/design/serve.md are 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, test runs the 100% coverage gate and the index query-plan check on the new index.themes query. lexicons runs the schema lint, which I didn't run.
Approved

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.

Approved

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.

Merged as fdf3037