~ 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 chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/custom-themes
.changeset/themes-of-your-own.mdnew+9−0
@@ -0,0 +1,9 @@
---
default: minor
---
# Themes of your own
The pages' Appearance settings make a theme of your own colours, checked as you choose them for text that reads on what it is drawn on, and publish it as a `dev.knotless.theme` record for anyone to choose from Appearance. Choosing one copies its colours into your preferences, so its author changing or deleting it later leaves your pages as they are. Logging in again asks to write `dev.knotless.theme` records.
The command line does the same: `knotless theme check`, `publish`, `use` and `delete`, and `knotless themes` to find them; and over MCP, the `theme` and `list_themes` tools. A palette is held to the same contrast wherever it is made.
crates/knotless/src/cli.rs+88−0
@@ -172,6 +172,21 @@ const USAGE: &str = "usage: knotless <command>
follows [<handle>] the accounts you, or an account, follow
followers [<handle>] the accounts following you, or an account,
as the appview counts them
themes [<handle>] [--find <name>] themes made on knotless, the latest first:
everyone's or an account's, or those
whose name starts so
theme check <file> whether every text of a palette (JSON, as
dev.knotless.theme#palette; - reads
stdin) reads at 4.5 to 1, as the pages
hold their own themes
theme publish <name> <file> [--use] publish a palette, once checked, for anyone
to choose; --use draws your pages in it
theme use <theme> draw your pages in a theme, wherever you
log in next: system, light, dark, latte,
mocha, dracula, pckt, plyr, blacksky, one
published (at:// URI), or a palette file
of your own (.json, or -), unpublished
theme delete <uri> delete a theme you published
owners <text> [--limit <n>] accounts owning public repositories whose
handle holds the text, as the appview's
search finds them, with how many they own
@@ -895,6 +910,65 @@ async fn notification_settings(ctx: &Ctx, args: &mut Args<'_>, out: &mut dyn Wri
Ok(())
}
/// A palette, from the file `path`, or stdin for `-`, as JSON: the palette
/// itself, or a `dev.knotless.theme` record holding one.
fn palette_from(path: &str, stdin: &mut dyn std::io::Read) -> Result<serde_json::Value> {
let said = match path {
"-" => text(path, stdin)?,
_ => std::fs::read_to_string(path).with_context(|| format!("reading {path}"))?,
};
let given: serde_json::Value = serde_json::from_str(&said).context("a palette is JSON")?;
Ok(given.get("palette").cloned().unwrap_or(given))
}
/// `knotless theme`: checks a palette, publishes one as a theme, draws the
/// pages in one, or deletes one published.
async fn theme(
ctx: &Ctx,
args: &mut Args<'_>,
stdin: &mut dyn std::io::Read,
out: &mut dyn Write,
) -> Result<()> {
use crate::theme::{self, Choice};
match args.next("check, publish, use or delete")? {
"check" => {
theme::checked(&palette_from(args.next("<file>")?, stdin)?)?;
writeln!(out, "every text reads at {} to 1 or more", theme::AA)?;
}
"publish" => {
let name = args.next("<name>")?;
let palette = palette_from(args.next("<file>")?, stdin)?;
let using = match args.rest.next().map(String::as_str) {
Some("--use") => true,
Some(other) => bail!("unexpected {other}"),
None => false,
};
let uri = theme::publish(ctx, name, &palette).await?;
if using {
theme::choose(ctx, &Choice::Published(uri.clone())).await?;
}
writeln!(out, "{uri}")?;
}
"use" => {
let said = args.next("<theme>")?;
let choice = match said {
file if file == "-" || file.ends_with(".json") => {
Choice::Palette(palette_from(file, stdin)?)
}
named => Choice::parse(named)?,
};
let now = theme::choose(ctx, &choice).await?;
writeln!(out, "drawn in {now}")?;
}
"delete" => {
theme::delete(ctx, args.next("<uri>")?).await?;
writeln!(out, "deleted")?;
}
other => bail!("{other}: knotless theme check, publish, use or delete"),
}
Ok(())
}
async fn command(
ctx: &Ctx,
argv: &[String],
@@ -1038,6 +1112,20 @@ async fn command(
writeln!(out, "{}", actor.handle)?;
}
}
"themes" => {
let (mut who, mut find) = (None, None);
while let Some(arg) = args.rest.next() {
match arg.as_str() {
"--find" => find = Some(args.next("<name>")?),
handle if who.is_none() && !handle.starts_with('-') => who = Some(handle),
other => bail!("unexpected {other}"),
}
}
for t in crate::theme::themes(ctx, who, find, 100).await? {
writeln!(out, "{}\t{}\t{}", t.name, t.creator.handle, t.uri)?;
}
}
"theme" => return theme(ctx, &mut args, stdin, out).await,
"owners" => {
let text = args.next("<text>")?;
let limit = match args.rest.next().map(String::as_str) {
crates/knotless/src/jetstream.rs+5−2
@@ -55,8 +55,9 @@ pub struct Op {
/// Whether `collection` is one knotless writes, the forge reads, or a
/// runner reports in, counts minutes in, or is asked in; a star, a
/// follow, a release, or an organization's membership, on either side; a
/// runner's key; or one an account's profile is made of.
/// follow, a release, a theme for the pages, or an organization's
/// membership, on either side; a runner's key; or one an account's profile
/// is made of.
pub fn knotless(collection: &str) -> bool {
nsid::ALL.contains(&collection)
|| nsid::FORGE.contains(&collection)
@@ -68,6 +69,7 @@ pub fn knotless(collection: &str) -> bool {
nsid::FOLLOW,
nsid::REACTION,
nsid::RELEASE,
nsid::THEME,
nsid::ORG,
nsid::ORG_MEMBER,
nsid::ORG_ACCEPTANCE,
@@ -290,6 +292,7 @@ mod tests {
&nsid::FOLLOW,
&nsid::REACTION,
&nsid::RELEASE,
&nsid::THEME,
&nsid::ORG,
&nsid::ORG_MEMBER,
&nsid::ORG_ACCEPTANCE,
crates/knotless/src/lib.rs+1−0
@@ -63,6 +63,7 @@ pub mod status;
pub mod storage;
pub mod store;
pub mod telemetry;
pub mod theme;
pub mod triage;
pub mod update;
pub mod url;
crates/knotless/src/oauth.rs+3−0
@@ -51,6 +51,8 @@ pub static SCOPE: LazyLock<String> = LazyLock::new(|| {
&nsid::RERUN,
// Versions of a package, published from a repository.
&nsid::RELEASE,
// Themes for the pages, published from their settings.
&nsid::THEME,
// A runner's checks and the minutes it counts, written as its
// account once it logs in at `/oauth/bot`.
&nsid::CHECK,
@@ -845,6 +847,7 @@ mod tests {
assert!(SCOPE.contains(" repo:dev.knotless.pull "));
assert!(SCOPE.contains(" repo:dev.knotless.check "));
assert!(SCOPE.contains(" repo:dev.knotless.minutes "));
assert!(SCOPE.contains(" repo:dev.knotless.theme "));
assert!(SCOPE.contains(" blob:text/plain "));
assert!(SCOPE.contains(" rpc:dev.knotless.putRunOutput?aud=* "));
assert_eq!(SCOPE.matches(" blob:image/png ").count(), 1);
crates/knotless/src/records.rs+2−0
@@ -67,6 +67,8 @@ pub mod nsid {
/// One version of a package a repository publishes, in its owner's
/// account.
pub const RELEASE: &str = "dev.knotless.release";
/// A theme for the pages, in the account that made it.
pub const THEME: &str = "dev.knotless.theme";
}
pub const PACK_MIME: &str = "application/x-git-packed-objects";
crates/knotless/src/theme.rsnew+771−0
771 lines changed: not loaded until asked for.
crates/knotless-mcp/src/lib.rs+100−1
@@ -27,6 +27,7 @@ use knotless::records::{self, LineRef, RerunVar};
use knotless::release;
use knotless::remote::Remote;
use knotless::social;
use knotless::theme::{self, Choice};
use knotless::triage;
use knotless::url::RepoUrl;
use rmcp::handler::server::wrapper::Parameters;
@@ -69,6 +70,7 @@ pub const READS: &[&str] = &[
"merge_queue",
"list_stars",
"list_follows",
"list_themes",
"checks",
"workflows",
"minutes",
@@ -716,6 +718,36 @@ pub struct Follows {
pub followers: Option<bool>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Themes {
/// The handle or DID whose themes to list; everyone's when left out.
pub account: Option<String>,
/// Only those whose name starts with this, in any case, by name.
pub find: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct ThemeChange {
/// A palette, as `dev.knotless.theme#palette`: `colorScheme` (`light`
/// or `dark`), `background`, `text`, `primary` and `accent`, and any
/// of `panel`, `soft`, `border`, `textMuted`, `textFaint`,
/// `onPrimary`, `success`, `danger`, `done`, `attention`, `neutral`
/// and `syntax` (`comments`, `strings`, `keywords`, `numbers`,
/// `functions`, `types`, `tags`), each `#rrggbb`. Given alone, only
/// checked.
pub palette: Option<Value>,
/// Publishes the palette under this name, for anyone to choose.
pub name: Option<String>,
/// What to draw your pages in: `system`, `light`, `dark`, `latte`,
/// `mocha`, `dracula`, `pckt`, `plyr`, `blacksky`, a published
/// theme's `at://` URI, or `palette` for the palette given (as
/// published, when named).
#[serde(rename = "use")]
pub using: Option<String>,
/// Deletes this theme of yours, by `at://` URI.
pub delete: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct ProfileLink {
pub url: String,
@@ -2571,6 +2603,73 @@ impl Server {
)
}
#[tool(
description = "Themes made for knotless's pages, as the appview lists them: everyone's or an account's, the latest first, or with find, those whose name starts so. Each with its at:// URI, its maker, and its palette, to choose with theme's use."
)]
async fn list_themes(
&self,
Parameters(p): Parameters<Themes>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let found = theme::themes(&self.ctx, p.account.as_deref(), p.find.as_deref(), 100).await?;
let themes: Vec<Value> = found
.into_iter()
.map(|t| json!({"uri": t.uri, "name": t.name, "creator": t.creator.handle, "palette": t.palette, "createdAt": t.created_at}))
.collect();
Ok(json!({ "themes": themes }))
}
.await,
)
}
#[tool(
description = "Make a theme for knotless's pages, choose one, or delete one of yours. A palette given alone is only checked: whether every text reads at 4.5 to 1 on what it is drawn on, as the pages hold their own themes, and which falls short. With name, it is published as a dev.knotless.theme for anyone to choose; with use, your pages are drawn in it, or in a theme named, wherever you log in next. A palette that reads badly is neither published nor used."
)]
async fn theme(
&self,
Parameters(p): Parameters<ThemeChange>,
) -> Result<CallToolResult, McpError> {
answer(
async {
if let Some(uri) = p.delete {
theme::delete(&self.ctx, &uri).await?;
return Ok(json!({ "deleted": uri }));
}
let published = match (&p.name, &p.palette) {
(Some(name), Some(palette)) => Some(theme::publish(&self.ctx, name, palette).await?),
(Some(_), None) => anyhow::bail!("name publishes the palette given, and none is"),
_ => None,
};
let choice = match (p.using.as_deref(), &published, &p.palette) {
(None, _, _) => None,
(Some("palette"), Some(uri), _) => Some(Choice::Published(uri.clone())),
(Some("palette"), None, Some(palette)) => Some(Choice::Palette(palette.clone())),
(Some("palette"), None, None) => anyhow::bail!("use palette draws in the palette given, and none is"),
(Some(named), _, _) => Some(Choice::parse(named)?),
};
let mut said = json!({});
if let Some(uri) = published {
said["uri"] = uri.into();
}
if let Some(choice) = choice {
said["using"] = theme::choose(&self.ctx, &choice).await?.into();
}
if said != json!({}) {
return Ok(said);
}
let given = p.palette.ok_or_else(|| anyhow::anyhow!("give a palette, a name, use or delete"))?;
let kept = theme::written(&given).map_err(anyhow::Error::msg)?;
let short: Vec<Value> = theme::shortfalls(&kept)
.iter()
.map(|s| json!({"text": s.text, "on": s.on, "ratio": (s.ratio * 100.0).round() / 100.0}))
.collect();
Ok(json!({"readable": short.is_empty(), "shortfalls": short}))
}
.await,
)
}
#[tool(
description = "Set how you show on knotless, where that differs from your Bluesky profile, and say what your knotless profile now holds. A field left out stays as it is; one set to an empty string is taken out, so your Bluesky profile's shows again. With none, only says what it holds."
)]
@@ -3125,7 +3224,7 @@ impl Server {
#[tool_handler(
router = self.router(),
name = "knotless",
instructions = "Git repositories stored in atproto accounts. Name a repository as handle/name. Before changing a repository, read its AGENTS.md (or CLAUDE.md) with read_file if it has one: how that repository wants changes made and checked. Read with list_tree, read_file, search, blame, log and diff: no clone needed. Change files with commit: no checkout needed. Collaborate with fork, template, open_pull, open_issue, list_items, show_item, comment, set_state, label_item (see list_labels and define_label) and merge (with queue, into the merge queue: see merge_queue and leave_queue), and undo a merged pull with revert; change what you wrote with edit_item, and take a comment back with delete_comment. Workflows are YAML files under .knotless/workflows/, run by the runners a repository names (see runners) on the commit a push or a pull brings: `on: {push: {refs: [refs/heads/*]}, pull: {base: [refs/heads/main], paths: [web/**]}}` (a file with no `on` runs on every push to a branch; `paths`, globs, runs it only on a change touching one, and passes it as skipped otherwise), `image` (the one container all the steps run in, in turn, so a step sees what earlier ones installed), `env`, `secrets` (names; never given to a pull), and `steps: [{name, run, timeout}]`, each `run` a shell script, stopping at the first that fails. After a push, checks says whether they passed, workflows which a ref has and what runs each, and rerun asks for them again, or, as owner, for one of a pull's workflows by name, or one at a ref, with variables: one with `on: {asked: {refs: [refs/heads/main]}}` runs only so. Moderate your repositories with hide_item and block_account (see list_blocks), and report with report. React to pulls, issues and comments with react. Star repositories with star, and see an account's with list_stars; follow accounts with follow, and see whom an account follows, or who follows it, with list_follows. See what others did that concerns you with notifications, and choose which you hear of with notification_settings. Set how you show on knotless with set_profile, and which email addresses show commits as yours with set_email. Publish a version of a package from a repository with publish, see its versions with releases, and read a file of one with read_release. Make your account an organization, or run one you are an admin of, with org, once you have delegated to a gateway with delegate. An error naming a `knotless` command names a tool here: `knotless delegate` is delegate, `knotless org join` is org with join, and so on."
instructions = "Git repositories stored in atproto accounts. Name a repository as handle/name. Before changing a repository, read its AGENTS.md (or CLAUDE.md) with read_file if it has one: how that repository wants changes made and checked. Read with list_tree, read_file, search, blame, log and diff: no clone needed. Change files with commit: no checkout needed. Collaborate with fork, template, open_pull, open_issue, list_items, show_item, comment, set_state, label_item (see list_labels and define_label) and merge (with queue, into the merge queue: see merge_queue and leave_queue), and undo a merged pull with revert; change what you wrote with edit_item, and take a comment back with delete_comment. Workflows are YAML files under .knotless/workflows/, run by the runners a repository names (see runners) on the commit a push or a pull brings: `on: {push: {refs: [refs/heads/*]}, pull: {base: [refs/heads/main], paths: [web/**]}}` (a file with no `on` runs on every push to a branch; `paths`, globs, runs it only on a change touching one, and passes it as skipped otherwise), `image` (the one container all the steps run in, in turn, so a step sees what earlier ones installed), `env`, `secrets` (names; never given to a pull), and `steps: [{name, run, timeout}]`, each `run` a shell script, stopping at the first that fails. After a push, checks says whether they passed, workflows which a ref has and what runs each, and rerun asks for them again, or, as owner, for one of a pull's workflows by name, or one at a ref, with variables: one with `on: {asked: {refs: [refs/heads/main]}}` runs only so. Moderate your repositories with hide_item and block_account (see list_blocks), and report with report. React to pulls, issues and comments with react. Star repositories with star, and see an account's with list_stars; follow accounts with follow, and see whom an account follows, or who follows it, with list_follows. Make, choose and delete themes for knotless's pages with theme, and find others' with list_themes. See what others did that concerns you with notifications, and choose which you hear of with notification_settings. Set how you show on knotless with set_profile, and which email addresses show commits as yours with set_email. Publish a version of a package from a repository with publish, see its versions with releases, and read a file of one with read_release. Make your account an organization, or run one you are an admin of, with org, once you have delegated to a gateway with delegate. An error naming a `knotless` command names a tool here: `knotless delegate` is delegate, `knotless org join` is org with join, and so on."
)]
impl ServerHandler for Server {
async fn call_tool(
crates/knotless-mcp/tests/mcp.rs+168−0
@@ -2180,6 +2180,174 @@ fn agents_choose_what_they_hear() {
assert_eq!(theirs["reasons"], all["reasons"]);
}
/// An agent makes a theme for the pages: a palette checked for contrast
/// as the pages check their own, published for anyone to choose, chosen,
/// found, and deleted; the command line does the same.
#[test]
fn agents_make_themes() {
let rt = tokio::runtime::Runtime::new().unwrap();
let (base, _appview, _proxy) = appview(&rt, "");
let mut maker = logged_in();
maker.set("KNOTLESS_APPVIEW", &base);
maker.set("KNOTLESS_DID_WEB_SCHEME", "http");
let mut other = logged_in();
other.set("KNOTLESS_APPVIEW", &base);
other.set("KNOTLESS_DID_WEB_SCHEME", "http");
let (m, o) = (Client::new(&maker), Client::new(&other));
let paper = json!({"colorScheme": "light", "background": "#ffffff", "text": "#1f2328",
"primary": "#1f6feb", "accent": "#0969da"});
let mut pale = paper.clone();
pale["textMuted"] = json!("#eeeeee");
let mine = json!({"account": maker.account.handle});
assert_eq!(m.json("list_themes", mine.clone()), json!({"themes": []}));
// Checked alone: what falls short, named as the palette names it.
let fine = m.json("theme", json!({"palette": paper}));
assert_eq!(fine, json!({"readable": true, "shortfalls": []}));
let short = m.json("theme", json!({"palette": pale}));
assert_eq!(short["readable"], false);
assert_eq!(short["shortfalls"][0]["text"], "textMuted");
assert_eq!(short["shortfalls"][0]["on"], "background");
let refused = |c: &Client, args: Value, why: &str| {
let (said, err) = c.call("theme", args);
assert!(err && said.contains(why), "{said}");
};
refused(
&m,
json!({"palette": {"colorScheme": "dark"}}),
"background",
);
refused(&m, json!({}), "give a palette");
refused(&m, json!({"name": "Paper"}), "none is");
refused(&m, json!({"use": "palette"}), "none is");
refused(&m, json!({"use": "sepia"}), "is no theme");
refused(&m, json!({"name": "Pale", "palette": pale}), "hard to read");
refused(&m, json!({"name": " ", "palette": paper}), "a theme's name");
assert!(maker.records("dev.knotless.theme").is_empty());
// Published and used at once; found by anyone, as the appview lists it.
let made = m.json(
"theme",
json!({"name": "Quokka paper", "palette": paper, "use": "palette"}),
);
assert_eq!(made["using"], "Quokka paper");
let uri = made["uri"].as_str().unwrap().to_string();
assert!(
uri.starts_with(&format!("at://{}/dev.knotless.theme/", maker.account.did)),
"{uri}"
);
let found = || o.json("list_themes", json!({"find": "quokka"}))["themes"].clone();
wait_until(|| !found().as_array().unwrap().is_empty());
assert_eq!(found()[0]["uri"], uri.as_str());
assert_eq!(found()[0]["creator"], maker.account.handle.as_str());
assert_eq!(found()[0]["palette"], paper);
let theirs = o.json("list_themes", mine);
assert_eq!(theirs["themes"][0]["name"], "Quokka paper");
// Chosen by another, as published; or one of the pages', or the system's.
for (choice, drawn) in [
(uri.as_str(), "Quokka paper"),
("mocha", "mocha"),
("system", "the system's light or dark"),
] {
assert_eq!(
o.json("theme", json!({"use": choice})),
json!({"using": drawn})
);
}
let own = o.json("theme", json!({"palette": paper, "use": "palette"}));
assert_eq!(own, json!({"using": "your own"}));
let gone = format!(
"at://{}/dev.knotless.theme/3zzzzzzzzzzzz",
maker.account.did
);
refused(&o, json!({"use": gone}), "no such theme");
refused(&o, json!({"use": "at://nowhere"}), "is no theme");
// A record no palette is, as any client could write.
maker.put_record("dev.knotless.theme", "3broken", json!({"$type": "dev.knotless.theme", "name": "Broken", "palette": {"colorScheme": "sepia"}, "createdAt": "2026-01-01T00:00:00Z"}));
let broken = format!("at://{}/dev.knotless.theme/3broken", maker.account.did);
refused(&o, json!({"use": broken}), "is no palette");
// Only its maker deletes it.
refused(&o, json!({"delete": uri}), "no theme of yours");
assert_eq!(
m.json("theme", json!({"delete": uri})),
json!({"deleted": uri})
);
assert_eq!(maker.records("dev.knotless.theme").len(), 1);
// The command line, the same.
let file = maker.path("night.json");
let night = json!({"colorScheme": "dark", "background": "#0d1117", "text": "#e6edf3",
"primary": "#2f81f7", "accent": "#58a6ff"});
std::fs::write(&file, night.to_string()).unwrap();
let file = file.to_str().unwrap();
let cli = |args: &[&str], stdin: &[u8]| maker.cli(args, stdin);
assert_eq!(
cli(&["theme", "check", file], b""),
(0, "every text reads at 4.5 to 1 or more".into())
);
let record = json!({"name": "Pale", "palette": pale}).to_string();
// Refused as an error, on stderr, as the binary says it.
let (ok, _, why) = maker.knotless(&["theme", "check", "-"], record.as_bytes());
assert!(!ok && why.contains("textMuted on background"), "{why}");
let (code, uri) = cli(
&["theme", "publish", "Wombat night", "-", "--use"],
night.to_string().as_bytes(),
);
assert_eq!(code, 0, "{uri}");
assert!(uri.contains("/dev.knotless.theme/"), "{uri}");
// Published alone, not used; and not with what it does not know.
let (code, alone) = cli(
&["theme", "publish", "Wombat dusk", "-"],
night.to_string().as_bytes(),
);
assert_eq!(code, 0, "{alone}");
assert_eq!(
cli(&["theme", "delete", &alone], b""),
(0, "deleted".into())
);
let bogus = cli(
&["theme", "publish", "Wombat", "-", "--bogus"],
night.to_string().as_bytes(),
);
assert_eq!(bogus.0, 1);
assert_eq!(
cli(&["theme", "use", "dracula"], b""),
(0, "drawn in dracula".into())
);
assert_eq!(
cli(&["theme", "use", file], b""),
(0, "drawn in your own".into())
);
assert_eq!(
cli(&["theme", "use", "-"], night.to_string().as_bytes()),
(0, "drawn in your own".into())
);
let named = || cli(&["themes", "--find", "wombat"], b"");
wait_until(|| named().1.starts_with("Wombat night\t"));
assert_eq!(
named().1,
format!("Wombat night\t{}\t{uri}", maker.account.handle)
);
let (code, all) = cli(&["themes"], b"");
assert!(code == 0 && all.contains(&uri), "{all}");
let (code, mine) = cli(&["themes", &maker.account.handle], b"");
assert!(code == 0 && mine.contains(&uri), "{mine}");
assert_eq!(cli(&["theme", "delete", &uri], b""), (0, "deleted".into()));
let wrong: [&[&str]; 8] = [
&["themes", "--bogus"],
&["themes", "a.test", "b.test"],
&["theme", "frob"],
&["theme", "publish", "Night", file, "--bogus"],
&["theme", "check", "/nowhere.json"],
&["theme", "check", "-"],
&["theme", "use", "sepia"],
&["theme"],
];
for args in wrong {
assert_eq!(cli(args, b"not json").0, 1, "{args:?}");
}
}
/// An agent reacts to an issue and a comment, sees the counts the appview
/// keeps in show_item, and takes a reaction back; the command line does
/// the same.
crates/knotless-serve/src/web/account_settings.rs+92−12
@@ -14,7 +14,7 @@
use super::notifications::asking;
use super::stars::logged_in;
use super::*;
use crate::index::{APPEARANCE, NOTIFICATIONS};
use crate::index::{APPEARANCE, NOTIFICATIONS, THEMES};
use crate::viewer::Viewer;
use axum::Extension;
use knotless::moderation::{self, Blocked};
@@ -402,6 +402,10 @@ fn repo_url(uri: &str) -> Result<RepoUrl> {
/// The kinds of preference, as `dev.knotless.getPreferences` names them.
const APPEARANCE_PREF: &str = "dev.knotless.getPreferences#appearancePref";
const NOTIFICATION_PREF: &str = "dev.knotless.getPreferences#notificationPref";
const THEMES_PREF: &str = "dev.knotless.getPreferences#themesPref";
/// The most themes an account adds to its own.
const ADDED: usize = 50;
/// `dev.knotless.getPreferences#notificationPref`: every reason, on unless
/// `off` names it.
@@ -428,12 +432,15 @@ fn notification_pref(off: &[String], followed_only: bool) -> serde_json::Value {
}
/// `did`'s preferences, as `getPreferences` and `putPreferences` answer
/// them: its appearance if it set one, and its notifications always.
/// them: its appearance and the themes it added if it set them, and its
/// notifications always.
fn preferences(index: &Index, did: &str) -> Result<Response> {
let mut all = Vec::new();
if let Some(mut appearance) = indexed(index.preference(did, APPEARANCE))? {
appearance["$type"] = APPEARANCE_PREF.into();
all.push(appearance);
for (kind, named) in [(APPEARANCE, APPEARANCE_PREF), (THEMES, THEMES_PREF)] {
if let Some(mut set) = indexed(index.preference(did, kind))? {
set["$type"] = named.into();
all.push(set);
}
}
let (off, followed_only) = indexed(index.notification_preferences(did))?;
all.push(notification_pref(&off, followed_only));
@@ -446,17 +453,25 @@ fn preferences(index: &Index, did: &str) -> Result<Response> {
/// cookie as it is, and only reasons the appview gives; else why not.
fn kept(p: &serde_json::Value) -> std::result::Result<(&'static str, serde_json::Value), String> {
if p["$type"] == APPEARANCE_PREF {
let mut kept = serde_json::json!({});
if let Some(theme) = p.get("theme") {
let fits = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-';
let named = |t: &&str| t.len() <= 64 && t.bytes().all(fits);
let why = || format!("{theme} is no theme: one is named in a-z, 0-9 and -, 64 at most");
kept["theme"] = theme.as_str().filter(named).ok_or_else(why)?.into();
let mut kept = choice(p)?;
if let Some(night) = p.get("night") {
kept["night"] = choice(night).map_err(|why| format!("night: {why}"))?;
}
return Ok((APPEARANCE, kept));
}
if p["$type"] == THEMES_PREF {
let given = p["themes"].as_array().ok_or("themes: a list is wanted")?;
if given.len() > ADDED {
return Err(format!("themes: {ADDED} at most"));
}
let each = given
.iter()
.map(added)
.collect::<std::result::Result<Vec<_>, _>>();
return Ok((THEMES, serde_json::json!({ "themes": each? })));
}
if p["$type"] != NOTIFICATION_PREF {
let kinds = [APPEARANCE_PREF, NOTIFICATION_PREF].join(", ");
let kinds = [APPEARANCE_PREF, NOTIFICATION_PREF, THEMES_PREF].join(", ");
return Err(format!("{} is no kind of preference: {kinds}", p["$type"]));
}
let given = p
@@ -481,6 +496,71 @@ fn kept(p: &serde_json::Value) -> std::result::Result<(&'static str, serde_json:
Ok((NOTIFICATIONS, kept))
}
/// A theme the pages are drawn in, as `appearancePref` and its `night`
/// give it: a theme named as the pages name theirs, to go in a cookie as
/// it is, or the colours of one of the account's own, held to their
/// lexicon, and where they came from.
fn choice(p: &serde_json::Value) -> std::result::Result<serde_json::Value, String> {
let mut kept = serde_json::json!({});
if let Some(theme) = p.get("theme") {
let fits = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-';
let named = |t: &&str| t.len() <= 64 && t.bytes().all(fits);
let why = || format!("{theme} is no theme: one is named in a-z, 0-9 and -, 64 at most");
kept["theme"] = theme.as_str().filter(named).ok_or_else(why)?.into();
}
if let Some(palette) = p.get("palette") {
kept["palette"] = super::themes::written(palette)?;
}
if kept["theme"] == "custom" && kept.get("palette").is_none() {
return Err("theme custom draws in a palette, and none is given".into());
}
if let Some(source) = p.get("source") {
kept["source"] = theme_uri(source)
.ok_or_else(|| format!("source: {source} is no dev.knotless.theme"))?;
}
Ok(kept)
}
/// `given` if it is a dev.knotless.theme's `at://` URI.
fn theme_uri(given: &serde_json::Value) -> Option<serde_json::Value> {
let theme = |s: &&str| s.starts_with("at://") && s.contains("/dev.knotless.theme/");
let fits = |s: &&str| s.len() <= 512 && !s.contains(char::is_whitespace);
given.as_str().filter(theme).filter(fits).map(Into::into)
}
/// `t`, a theme added to `themesPref`, as its lexicon bounds it: where
/// it came from and its colours, with its name, maker and when it was
/// added, if given.
fn added(t: &serde_json::Value) -> std::result::Result<serde_json::Value, String> {
let uri = &t["uri"];
let why = || format!("themes: {uri} is no dev.knotless.theme");
let mut kept = serde_json::json!({ "uri": theme_uri(uri).ok_or_else(why)? });
kept["palette"] =
super::themes::written(&t["palette"]).map_err(|why| format!("themes: {why}"))?;
if let Some(name) = t.get("name") {
let short = |n: &&str| n.chars().count() <= knotless::theme::NAME;
let why = || {
format!(
"themes: {name} is no name, of {} characters at most",
knotless::theme::NAME
)
};
kept["name"] = name.as_str().filter(short).ok_or_else(why)?.into();
}
if let Some(creator) = t.get("creator") {
let did =
|d: &&str| d.starts_with("did:") && d.len() <= 2048 && !d.contains(char::is_whitespace);
let why = || format!("themes: creator {creator} is no DID");
kept["creator"] = creator.as_str().filter(did).ok_or_else(why)?.into();
}
if let Some(at) = t.get("addedAt") {
let when = |a: &&str| a.len() <= 64 && a.parse::<jiff::Timestamp>().is_ok();
let why = || format!("themes: addedAt {at} is no time");
kept["addedAt"] = at.as_str().filter(when).ok_or_else(why)?.into();
}
Ok(kept)
}
/// `dev.knotless.getPreferences`.
pub async fn get_preferences(
State(app): State<Arc<App>>,
crates/knotless-serve/src/web/themes.rsnew+256−0
@@ -0,0 +1,256 @@
//! Themes for the pages, as `dev.knotless.theme` records hold them:
//! `listThemes`, from the index, and `createTheme` and `deleteTheme`, as
//! the account logged in to the pages; and a palette as the cookie the
//! pages draw from holds it. A palette is held to its lexicon, and to
//! contrast, by knotless's own `theme`, as the command line's is.
use super::stars::logged_in;
use super::*;
use crate::viewer::Viewer;
use axum::Extension;
use knotless::keys;
use serde_json::{Value, json};
use knotless::theme::{COLOURS, NAME, SYNTAX};
pub use knotless::theme::{palette, written};
/// `palette`, as [`palette`] keeps it, as the cookie the pages draw it
/// from holds it: its scheme, then each colour as `name-rrggbb`, between
/// dots (web/src/lib/theme.ts reads it).
pub fn cookie(palette: &Value) -> String {
let mut said = vec![
palette["colorScheme"]
.as_str()
.unwrap_or_default()
.to_string(),
];
let given = |at: &Value, names: &[(&str, &str)]| -> Vec<String> {
let each = names.iter().filter_map(|(name, _)| {
let c = at[*name].as_str()?;
Some(format!("{name}-{}", c.trim_start_matches('#')))
});
each.collect()
};
said.extend(given(palette, &COLOURS));
said.extend(given(&palette["syntax"], &SYNTAX));
said.join(".")
}
/// `dev.knotless.defs#themeView`.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeView {
uri: String,
creator: ActorView,
name: String,
palette: Value,
created_at: String,
}
/// `record`, `did`'s theme at `uri`, as it is shown, if it is one.
async fn view(app: &App, uri: String, did: &str, record: &Value) -> Option<ThemeView> {
let name = record["name"].as_str().filter(|n| !n.trim().is_empty())?;
let palette = palette(&record["palette"])?;
Some(ThemeView {
uri,
creator: actor(app, did).await,
name: name.chars().take(NAME).collect(),
palette,
created_at: record["createdAt"].as_str().unwrap_or_default().into(),
})
}
#[derive(Deserialize)]
pub struct ThemesParams {
actor: Option<String>,
q: Option<String>,
limit: Option<u32>,
cursor: Option<String>,
}
#[derive(Serialize)]
pub struct ThemesOutput {
themes: Vec<ThemeView>,
#[serde(skip_serializing_if = "Option::is_none")]
cursor: Option<String>,
}
/// `dev.knotless.listThemes`.
pub async fn list_themes(
State(app): State<Arc<App>>,
Query(p): Query<ThemesParams>,
Query(pairs): Query<Vec<(String, String)>>,
) -> Response {
let Some(index) = app.index.clone() else {
return Shown(StatusCode::NOT_FOUND, "this server keeps no index".into()).into_response();
};
// By URI, as an account's added themes name them, `uris` repeating.
let uris: Vec<&str> = pairs
.iter()
.filter(|(k, _)| k == "uris")
.map(|(_, v)| v.as_str())
.collect();
if !uris.is_empty() {
return named(&app, &index, &uris).await.into_response();
}
let did = match &p.actor {
Some(actor) => match knotless::identity::resolve(&app.ctx, actor).await {
Ok(id) => Some(id.did),
Err(e) => return no_profile(e),
},
None => None,
};
themes(&app, &index, did.as_deref(), p)
.await
.into_response()
}
async fn themes(app: &App, index: &Index, did: Option<&str>, p: ThemesParams) -> Result<Response> {
let limit = p.limit.unwrap_or(50).clamp(1, 100);
// By name, a page alone; else the latest first, a page at a time.
let page = match p.q.as_deref().filter(|q| !q.trim().is_empty()) {
Some(q) => index.themes_named(q, limit).map(|found| (found, None)),
None => index.themes(did, limit, p.cursor.as_deref()),
};
let (found, cursor) = page
.map_err(anyhow::Error::from)
.map_err(AppError::internal)?;
let each = found
.iter()
.map(|(uri, did, record)| view(app, uri.clone(), did, record));
let themes = futures::future::join_all(each).await;
Ok(answer(ThemesOutput {
themes: themes.into_iter().flatten().collect(),
cursor,
}))
}
/// The most themes `listThemes` answers by URI, as many as an account adds.
const URIS: usize = 50;
/// The themes `uris` names, in its order: those the index has.
async fn named(app: &App, index: &Index, uris: &[&str]) -> Result<Response> {
if uris.len() > URIS {
let why = format!("uris names {URIS} themes at most");
return Err(Shown(StatusCode::BAD_REQUEST, why));
}
let found = index.themes_at(uris);
let found = found
.map_err(anyhow::Error::from)
.map_err(AppError::internal)?;
let each = found
.iter()
.map(|(uri, did, record)| view(app, uri.clone(), did, record));
let themes = futures::future::join_all(each).await;
Ok(answer(ThemesOutput {
themes: themes.into_iter().flatten().collect(),
cursor: None,
}))
}
#[derive(Deserialize)]
pub struct CreateTheme {
name: String,
palette: Value,
}
/// The account logged in to the pages' own records, to write in.
async fn mine(app: &App, did: &str) -> Result<(Remote, knotless::session::Session)> {
let ctx = crate::login::ctx_for(app, did);
let me = session::me(&ctx).await.map_err(AppError::upstream)?;
let url = RepoUrl::parse(&format!("knotless://{did}/_")).map_err(AppError::internal)?;
let remote = Remote::connect(&ctx, &url).await;
Ok((remote.map_err(AppError::upstream)?, me))
}
/// `dev.knotless.createTheme`: writes the theme, and indexes it at once,
/// so the pages list it as soon as it is made.
pub async fn create_theme(
State(app): State<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<CreateTheme>,
) -> Result<Response> {
let did = logged_in(&app, viewer, &headers, "publish a theme")?;
let name = input.name.trim();
if name.is_empty() || name.chars().count() > NAME {
let why = format!("a theme's name is 1 to {NAME} characters");
return Err(Shown(StatusCode::BAD_REQUEST, why));
}
// Every text in it read on what it is drawn on, as the pages check it.
let palette = knotless::theme::checked(&input.palette);
let palette = palette.map_err(|why| Shown(StatusCode::BAD_REQUEST, why.to_string()))?;
let record = json!({
"$type": nsid::THEME,
"name": name,
"palette": palette,
"createdAt": knotless::records::now(),
});
let (remote, me) = mine(&app, &did).await?;
let rkey = keys::tid();
let write = knotless::remote::create(nsid::THEME, &rkey, record.clone());
let wrote = remote.apply(vec![write], &me.auth()).await;
wrote.map_err(AppError::upstream)?;
// Indexed now, where there is an index, to be listed at once.
let index = app.index.as_ref();
let put = index.map(|i| i.put(&did, nsid::THEME, &rkey, &record));
let put = put.transpose().map_err(anyhow::Error::from);
put.map_err(AppError::internal)?;
let uri = format!("at://{did}/{}/{rkey}", nsid::THEME);
let shown = view(&app, uri, &did, &record).await;
Ok(answer(shown))
}
#[derive(Deserialize)]
pub struct DeleteTheme {
uri: String,
}
/// `dev.knotless.deleteTheme`: deletes one of the account's own themes,
/// and takes it out of the index at once.
pub async fn delete_theme(
State(app): State<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<DeleteTheme>,
) -> Result<Response> {
let did = logged_in(&app, viewer, &headers, "delete a theme")?;
let prefix = format!("at://{did}/{}/", nsid::THEME);
let key = |b: u8| b.is_ascii_alphanumeric() || b"._:~-".contains(&b);
let rkey = input.uri.strip_prefix(&prefix);
let rkey = rkey.filter(|k| !k.is_empty() && k.bytes().all(key));
let Some(rkey) = rkey else {
let why = format!("{} is no theme of yours", input.uri);
return Err(Shown(StatusCode::BAD_REQUEST, why));
};
let (remote, me) = mine(&app, &did).await?;
let write = knotless::remote::delete(nsid::THEME, rkey);
let wrote = remote.apply(vec![write], &me.auth()).await;
wrote.map_err(AppError::upstream)?;
let index = app.index.as_ref();
let deleted = index.map(|i| i.delete(&did, nsid::THEME, rkey));
let deleted = deleted.transpose().map_err(anyhow::Error::from);
deleted.map_err(AppError::internal)?;
Ok(answer(json!({})))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_palette_goes_in_a_cookie() {
let kept = json!({
"colorScheme": "dark",
"background": "#11aa22",
"text": "#eeeeee",
"primary": "#123456",
"accent": "#abcdef",
"syntax": {"strings": "#00ff00"},
});
assert_eq!(
cookie(&kept),
"dark.background-11aa22.text-eeeeee.primary-123456.accent-abcdef.strings-00ff00"
);
}
}
crates/knotless-serve/src/index.rs+128−5
crates/knotless-serve/src/lib.rs+1−0
crates/knotless-serve/src/login.rs+46−15
crates/knotless-serve/src/plans.rs+2−1
crates/knotless-serve/src/web.rs+5−0
crates/knotless-serve/tests/gateway.rs+223−0
docs/design/serve.md+81−5
docs/reference/api.md+5−2
docs/reference/cli.md+5−0
docs/reference/mcp.md+1−0
harness/e2e/flows.spec.mjs+2−0
harness/contrast.mjs+8−88
lexicons/dev/knotless/createTheme.jsonnew+30−0
lexicons/dev/knotless/defs.json+12−0
lexicons/dev/knotless/deleteTheme.jsonnew+29−0
lexicons/dev/knotless/getPreferences.json+106−4
lexicons/dev/knotless/listThemes.jsonnew+36−0
lexicons/dev/knotless/putPreferences.json+2−1
lexicons/dev/knotless/theme.jsonnew+58−0
web/src/lib/atoms/HalfTag.sveltenew+8−0
web/src/lib/molecules/ThemeCard.sveltenew+57−0
web/src/lib/organisms/ThemeBrowser.sveltenew+92−0
web/src/lib/organisms/ThemeEditor.sveltenew+130−0
web/src/lib/organisms/ThemeSettings.svelte+188−19
web/src/lib/templates/SettingsPage.svelte+15−4
web/src/lib/contrast.jsonnew+15−0
web/src/lib/contrast.tsnew+119−0
web/src/lib/icons.ts+4−2
web/src/lib/preferences.ts+5−0
web/src/lib/rows.ts+23−0
web/src/lib/theme.ts+193−8
web/src/routes/settings/appearance/new/+page.sveltenew+6−0
web/src/routes/settings/appearance/+page.svelte+1−1
web/src/routes/settings/appearance/+page.tsnew+42−0
web/src/routes/settings/+layout.svelte+12−2
web/src/routes/+layout.server.ts+7−3
web/src/app.css+78−7
web/src/app.html+1−0
web/src/hooks.server.ts+31−4
web/src/tokens.css+34−2
web/static/theme.jsnew+18−0
AGENTS.md+4−2