···33// This file was automatically generated from Lexicon schemas.
44// Any manual changes will be overwritten on the next regeneration.
5566+pub mod resolve_lexicon;
67pub mod schema;
···1515#[allow(unused_imports)]
1616use std::sync::Arc;
1717#[allow(unused_imports)]
1818-use weaver_renderer::theme::Theme;
1818+use weaver_renderer::theme::{Theme, ResolvedTheme};
19192020#[cfg(feature = "server")]
2121use axum::{extract::Extension, response::IntoResponse};
···37373838 use weaver_api::sh_weaver::notebook::book::Book;
3939 use weaver_renderer::css::{generate_base_css, generate_syntax_css};
4040- use weaver_renderer::theme::default_theme;
4040+ use weaver_renderer::theme::{default_resolved_theme, resolve_theme};
41414242 let ident = AtIdentifier::new_owned(ident)?;
4343- let theme = if let Some(notebook) = fetcher.get_notebook(ident, notebook).await? {
4343+ let resolved_theme = if let Some(notebook) = fetcher.get_notebook(ident, notebook).await? {
4444 let book: Book = from_data(¬ebook.0.record).unwrap();
4545- if let Some(theme) = book.theme {
4646- if let Ok(theme) = fetcher.client.get_record::<Theme>(&theme.uri).await {
4747- theme
4848- .into_output()
4949- .map(|t| t.value)
5050- .unwrap_or(default_theme())
4545+ if let Some(theme_ref) = book.theme {
4646+ if let Ok(theme_response) = fetcher.client.get_record::<Theme>(&theme_ref.uri).await {
4747+ if let Ok(theme_output) = theme_response.into_output() {
4848+ let theme: Theme = theme_output.into();
4949+ // Try to resolve the theme (fetch colour schemes from PDS)
5050+ resolve_theme(fetcher.client.as_ref(), &theme)
5151+ .await
5252+ .unwrap_or_else(|_| default_resolved_theme())
5353+ } else {
5454+ default_resolved_theme()
5555+ }
5156 } else {
5252- default_theme()
5757+ default_resolved_theme()
5358 }
5459 } else {
5555- default_theme()
6060+ default_resolved_theme()
5661 }
5762 } else {
5858- default_theme()
6363+ default_resolved_theme()
5964 };
6060- let mut css = generate_base_css(&theme);
6565+6666+ let mut css = generate_base_css(&resolved_theme);
6167 css.push_str(
6262- &generate_syntax_css(&theme)
6868+ &generate_syntax_css(&resolved_theme)
6369 .await
6470 .map_err(|e| CapturedError::from_display(e))
6571 .unwrap_or_default(),
+208-6
crates/weaver-server/src/components/entry.rs
···2233#[cfg(feature = "server")]
44use crate::blobcache::BlobCache;
55-use crate::fetch;
55+use crate::{
66+ components::avatar::{Avatar, AvatarFallback, AvatarImage},
77+ fetch,
88+};
69use dioxus::prelude::*;
1010+1111+const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css");
712#[allow(unused_imports)]
813use dioxus::{fullstack::extract::Extension, CapturedError};
99-use jacquard::{prelude::IdentityResolver, smol_str::ToSmolStr};
1414+use jacquard::{
1515+ from_data, prelude::IdentityResolver, smol_str::ToSmolStr, types::string::Datetime,
1616+};
1017#[allow(unused_imports)]
1118use jacquard::{
1219 smol_str::SmolStr,
···1926#[component]
2027pub fn Entry(ident: AtIdentifier<'static>, book_title: SmolStr, title: SmolStr) -> Element {
2128 let ident_clone = ident.clone();
2929+ let book_title_clone = book_title.clone();
2230 let entry = use_resource(use_reactive!(|(ident, book_title, title)| async move {
2331 let fetcher = use_context::<fetch::CachedFetcher>();
2432 let entry = fetcher
···48564957 match &*entry.read_unchecked() {
5058 Some(Some(entry_data)) => {
5151- rsx! { EntryMarkdownDirect {
5252- content: entry_data.1.clone(),
5353- ident: ident_clone
5959+ rsx! { EntryPage {
6060+ book_entry_view: entry_data.0.clone(),
6161+ entry_record: entry_data.1.clone(),
6262+ ident: ident_clone,
6363+ book_title: book_title_clone
5464 } }
5565 }
5666 Some(None) => {
···6070 }
6171}
62727373+/// Full entry page with metadata, content, and navigation
7474+#[component]
7575+fn EntryPage(
7676+ book_entry_view: BookEntryView<'static>,
7777+ entry_record: entry::Entry<'static>,
7878+ ident: AtIdentifier<'static>,
7979+ book_title: SmolStr,
8080+) -> Element {
8181+ // Extract metadata
8282+ let entry_view = &book_entry_view.entry;
8383+ let title = entry_view
8484+ .title
8585+ .as_ref()
8686+ .map(|t| t.as_ref())
8787+ .unwrap_or("Untitled");
8888+8989+ rsx! {
9090+ // Set page title
9191+ document::Title { "{title}" }
9292+ document::Link { rel: "stylesheet", href: ENTRY_CSS }
9393+9494+ div { class: "entry-page-layout",
9595+ // Left gutter with prev button
9696+ if let Some(ref prev) = book_entry_view.prev {
9797+ div { class: "nav-gutter nav-prev",
9898+ NavButton {
9999+ direction: "prev",
100100+ entry: prev.entry.clone(),
101101+ ident: ident.clone(),
102102+ book_title: book_title.clone()
103103+ }
104104+ }
105105+ }
106106+107107+ // Main content area
108108+ div { class: "entry-content-main",
109109+ // Metadata header
110110+ EntryMetadata {
111111+ entry_view: entry_view.clone(),
112112+ ident: ident.clone(),
113113+ created_at: entry_record.created_at.clone()
114114+ }
115115+116116+ // Rendered markdown
117117+ EntryMarkdownDirect {
118118+ content: entry_record,
119119+ ident: ident.clone()
120120+ }
121121+ }
122122+123123+ // Right gutter with next button
124124+ if let Some(ref next) = book_entry_view.next {
125125+ div { class: "nav-gutter nav-next",
126126+ NavButton {
127127+ direction: "next",
128128+ entry: next.entry.clone(),
129129+ ident: ident.clone(),
130130+ book_title: book_title.clone()
131131+ }
132132+ }
133133+ }
134134+ }
135135+ }
136136+}
137137+63138#[component]
64139pub fn EntryCard(entry: BookEntryView<'static>) -> Element {
65140 rsx! {}
66141}
67142143143+/// Metadata header showing title, authors, date, tags
144144+#[component]
145145+fn EntryMetadata(
146146+ entry_view: weaver_api::sh_weaver::notebook::EntryView<'static>,
147147+ ident: AtIdentifier<'static>,
148148+ created_at: Datetime,
149149+) -> Element {
150150+ use weaver_api::app_bsky::actor::profile::Profile;
151151+152152+ let title = entry_view
153153+ .title
154154+ .as_ref()
155155+ .map(|t| t.as_ref())
156156+ .unwrap_or("Untitled");
157157+158158+ let indexed_at_chrono = entry_view.indexed_at.as_ref();
159159+160160+ rsx! {
161161+ header { class: "entry-metadata",
162162+ h1 { class: "entry-title", "{title}" }
163163+164164+ div { class: "entry-meta-info",
165165+ // Authors
166166+ if !entry_view.authors.is_empty() {
167167+ div { class: "entry-authors",
168168+ for (i, author) in entry_view.authors.iter().enumerate() {
169169+ if i > 0 { span { ", " } }
170170+ {
171171+ // Parse author profile from the nested value field
172172+ match from_data::<Profile>(author.record.get_at_path(".value").unwrap()) {
173173+ Ok(profile) => {
174174+ let avatar = profile.avatar
175175+ .map(|avatar| {
176176+ let cid = avatar.blob().cid();
177177+ let did = entry_view.uri.authority();
178178+ format!("https://cdn.bsky.app/img/avatar/plain/{did}/{cid}@jpeg")
179179+ });
180180+ let display_name = profile.display_name
181181+ .as_ref()
182182+ .map(|n| n.as_ref())
183183+ .unwrap_or("Unknown");
184184+ rsx! {
185185+ if let Some(avatar) = avatar {
186186+ Avatar {
187187+ AvatarImage {
188188+ src: avatar
189189+ }
190190+ }
191191+ }
192192+ span { class: "author-name", "{display_name}" }
193193+ span { class: "meta-label", "@{ident}" }
194194+ }
195195+ }
196196+ Err(_) => {
197197+ rsx! {
198198+ span { class: "author-name", "Author {author.index}" }
199199+ }
200200+ }
201201+ }
202202+ }
203203+ }
204204+ }
205205+ }
206206+207207+ // Date
208208+ div { class: "entry-date",
209209+ {
210210+ let formatted_date = created_at.as_ref().format("%B %d, %Y").to_string();
211211+212212+ rsx! {
213213+ time { datetime: "{entry_view.indexed_at.as_str()}", "{formatted_date}" }
214214+215215+ }
216216+ }
217217+ }
218218+219219+ // Tags
220220+ if let Some(ref tags) = entry_view.tags {
221221+ div { class: "entry-tags",
222222+ // TODO: Parse tags structure
223223+ span { class: "meta-label", "Tags: " }
224224+ span { "[tags]" }
225225+ }
226226+ }
227227+ }
228228+ }
229229+ }
230230+}
231231+232232+/// Navigation button for prev/next entries
233233+#[component]
234234+fn NavButton(
235235+ direction: &'static str,
236236+ entry: weaver_api::sh_weaver::notebook::EntryView<'static>,
237237+ ident: AtIdentifier<'static>,
238238+ book_title: SmolStr,
239239+) -> Element {
240240+ use crate::Route;
241241+242242+ let entry_title = entry
243243+ .title
244244+ .as_ref()
245245+ .map(|t| t.as_ref())
246246+ .unwrap_or("Untitled");
247247+248248+ let label = if direction == "prev" {
249249+ "← Previous"
250250+ } else {
251251+ "Next →"
252252+ };
253253+ let arrow = if direction == "prev" { "←" } else { "→" };
254254+255255+ rsx! {
256256+ Link {
257257+ to: Route::Entry {
258258+ ident: ident.clone(),
259259+ book_title: book_title.clone(),
260260+ title: entry_title.to_string().into()
261261+ },
262262+ class: "nav-button nav-button-{direction}",
263263+ div { class: "nav-arrow", "{arrow}" }
264264+ div { class: "nav-label", "{label}" }
265265+ div { class: "nav-title", "{entry_title}" }
266266+ }
267267+ }
268268+}
269269+68270#[derive(Props, Clone, PartialEq)]
69271pub struct EntryMarkdownProps {
70272 #[props(default)]
···124326125327 // Render to HTML
126328 let mut html_buf = String::new();
127127- let _ = ClientWriter::<_, ()>::new(&mut html_buf).run(events.into_iter());
329329+ let _ = ClientWriter::<_, _, ()>::new(events.into_iter(), &mut html_buf).run();
128330 Some(html_buf)
129331 }));
130332
+1-1
crates/weaver-server/src/components/mod.rs
···10101111mod identity;
1212pub use identity::{Repository, RepositoryIndex};
1313-//pub mod avatar;
1313+pub mod avatar;
+51-2
crates/weaver-server/src/main.rs
···11// The dioxus prelude contains a ton of common items used in dioxus apps. It's a good idea to import wherever you
22// need dioxus
33use components::{Entry, Repository, RepositoryIndex};
44-use dioxus::{fullstack::FullstackContext, prelude::*};
55-use jacquard::{client::BasicClient, smol_str::SmolStr, types::string::AtIdentifier};
44+#[allow(unused)]
55+use dioxus::{
66+ fullstack::{response::Extension, FullstackContext},
77+ prelude::*,
88+ CapturedError,
99+};
1010+#[allow(unused)]
1111+use jacquard::{
1212+ client::BasicClient,
1313+ smol_str::SmolStr,
1414+ types::{cid::Cid, string::AtIdentifier},
1515+};
1616+617use std::sync::Arc;
1818+#[allow(unused)]
719use views::{Home, Navbar, Notebook, NotebookIndex, NotebookPage};
820921#[cfg(feature = "server")]
···135147 }
136148 }
137149}
150150+151151+#[get("/{notebook}/image/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)]
152152+pub async fn image_named(
153153+ notebook: SmolStr,
154154+ name: SmolStr,
155155+) -> Result<dioxus_fullstack::response::Response> {
156156+ use axum::response::IntoResponse;
157157+ use mime_sniffer::MimeTypeSniffer;
158158+ if let Some(bytes) = blob_cache.get_named(&name) {
159159+ let blob = bytes.clone();
160160+ let mime = blob.sniff_mime_type().unwrap_or("application/octet-stream");
161161+ Ok((
162162+ [(dioxus_fullstack::http::header::CONTENT_TYPE, mime)],
163163+ bytes,
164164+ )
165165+ .into_response())
166166+ } else {
167167+ Err(CapturedError::from_display("no image"))
168168+ }
169169+}
170170+171171+#[get("/{notebook}/blob/{cid}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)]
172172+pub async fn blob(notebook: SmolStr, cid: SmolStr) -> Result<dioxus_fullstack::response::Response> {
173173+ use axum::response::IntoResponse;
174174+ use mime_sniffer::MimeTypeSniffer;
175175+ if let Some(bytes) = blob_cache.get_cid(&Cid::new_owned(cid.as_bytes())?) {
176176+ let blob = bytes.clone();
177177+ let mime = blob.sniff_mime_type().unwrap_or("application/octet-stream");
178178+ Ok((
179179+ [(dioxus_fullstack::http::header::CONTENT_TYPE, mime)],
180180+ bytes,
181181+ )
182182+ .into_response())
183183+ } else {
184184+ Err(CapturedError::from_display("no blob"))
185185+ }
186186+}
+2
crates/weaver-server/src/views/navbar.rs
···11use crate::Route;
22use dioxus::prelude::*;
3344+const THEME_DEFAULTS_CSS: Asset = asset!("/assets/styling/theme-defaults.css");
45const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css");
5667/// The Navbar component that will be rendered on all pages of our app since every page is under the layout.
···1112#[component]
1213pub fn Navbar() -> Element {
1314 rsx! {
1515+ document::Link { rel: "stylesheet", href: THEME_DEFAULTS_CSS }
1416 document::Link { rel: "stylesheet", href: NAVBAR_CSS }
15171618 div {
···11+# Theme and Colour Scheme Redesign
22+33+**Date:** 2025-11-03
44+**Status:** Design approved, ready for implementation
55+66+## Overview
77+88+Redesign the theme lexicon to support expressive colour schemes comparable to base16 and Rose Pine, while maintaining flexibility for both preset themes and power user customization.
99+1010+## Requirements
1111+1212+- Support 16+ semantic colour slots (comparable to base16 expressiveness)
1313+- Enable importing/adapting popular themes (base16, Rose Pine, etc.)
1414+- Separate light/dark modes as distinct themes (users can mix and match)
1515+- Semantic naming over positional identifiers (readable, consistent across themes)
1616+- Support preset picker UI + power user manual customization
1717+1818+## Design
1919+2020+### Two-Lexicon Structure
2121+2222+Split colour schemes from themes to enable reusability and mixing:
2323+2424+**1. `sh.weaver.notebook.colourScheme`** - Standalone colour palette record
2525+- Standalone AT Protocol record
2626+- Contains name, variant (dark/light), and 16 colour slots
2727+- Can be published and referenced by multiple themes
2828+- Enables sharing palettes between users
2929+3030+**2. `sh.weaver.notebook.theme`** - Complete theme with colour references
3131+- References two colourScheme records (dark and light) via strongRefs
3232+- Contains fonts, spacing, codeTheme (unchanged from previous design)
3333+- Users can point to any published colour schemes, including others' palettes
3434+3535+### 16 Semantic Colour Slots
3636+3737+Organized into 5 semantic categories with consistent naming:
3838+3939+**Backgrounds (3):**
4040+- `base` - Primary background for page/frame
4141+- `surface` - Secondary background for panels/cards
4242+- `overlay` - Tertiary background for popovers/dialogs
4343+4444+**Text (1):**
4545+- `text` - Primary readable text colour (baseline)
4646+4747+**Foreground variations (3):**
4848+- `muted` - De-emphasized text (disabled, metadata)
4949+- `subtle` - Medium emphasis text (comments, labels)
5050+- `emphasis` - Emphasized text (bold, important)
5151+5252+**Accents (3):**
5353+- `primary` - Primary brand/accent colour
5454+- `secondary` - Secondary accent colour
5555+- `tertiary` - Tertiary accent colour
5656+5757+**Status (3):**
5858+- `error` - Error state colour
5959+- `warning` - Warning state colour
6060+- `success` - Success state colour
6161+6262+**Role (3):**
6363+- `border` - Border/divider colour
6464+- `link` - Hyperlink colour
6565+- `highlight` - Selection/highlight colour
6666+6767+### Mapping to Existing Schemes
6868+6969+**Base16 compatibility:**
7070+- Backgrounds map to base00-base02
7171+- Foregrounds map to base03-base07
7272+- Accents/status/roles map to base08-base0F
7373+7474+**Rose Pine compatibility:**
7575+- Direct semantic mapping (base→base, surface→surface, overlay→overlay)
7676+- text/muted/subtle map to text/muted/subtle
7777+- Accent colours map to love/gold/rose/pine/foam/iris
7878+7979+## Implementation Impact
8080+8181+### Files to Create
8282+- `lexicons/notebook/colourScheme.json` ✓ (created)
8383+8484+### Files to Modify
8585+- `lexicons/notebook/theme.json` ✓ (modified)
8686+- Run `./lexicon-codegen.sh` to regenerate Rust types
8787+- Update `crates/weaver-common/src/lexicons/mod.rs` (remove duplicate `mod sh;`)
8888+8989+### Code Changes Needed
9090+- Update theme rendering code to use strongRef lookups
9191+- Update CSS generation to use new 16-slot colour names
9292+- Create default colour schemes (at least one dark, one light)
9393+- Update any existing theme records/configs to new format
9494+9595+### Future Enhancements
9696+- Theme picker UI with preset browser
9797+- Colour scheme validation/linting
9898+- Auto-generate light from dark (and vice versa) where appropriate
9999+- Import converters for base16 YAML, Rose Pine JSON
···11+I grew up, like a lot of people on Bluesky, in the era of the internet where most of your online social interactions took place via text. I had a MySpace account, MSN messenger and Google Chat, I first got on Facebook back when they required a school email to sign up, I had a Tumblr, though not a LiveJournal. I was super into reddit for a long time. Big fan of Fanfiction.net and later Archive of Our Own.
22+33+Social media in the conventional sense has been in a lot of ways a small part of the story of my time on the internet. Because while I wasn't huge into independent internet forums, the broader independent blogosphere of my teens and early adulthood shaped my worldview, and I was an avid reader and sometime participant there. I am an atheist in large part because of a blog called [Common Sense Atheism](http://commonsenseatheism.com) (which I started reading in part because the author, Luke Muehlhauser, was criticising both Richard Dawkins and some Christian apologetics I was familiar with). Luke's blog was part of cluster of blogs out of which grew the [rationalists](https://www.lesswrong.com/), one of, for better or for worse, the most influential intellectual movements of the 21st century, who are, via people like [Scott ](https://slatestarcodex.com/) [Alexander](https://www.astralcodexten.com/), both downstream and upstream of the tech billionaire ideology. I also read blogs like [boingboing.net](https://boingboing.net/), was a big fan of Cory Doctorow. I figured out I am trans in part because of [Thing of Things](https://thingofthings.wordpress.com/2017/05/05/the-cluster-structure-of-genderspace/), a blog by Ozy Frantz, a transmasc person in the broader rationalist and Effective Altruist blogosphere. One thing these all have in common is length. Part of the reason I only really got onto Twitter in 2020 or so was because the concept of microblogging, of having to fit your thoughts into such a small package, baffled me for ages. Amusingly I now think that being on Twitter and now Bluesky made me a better writer. [Restrictions breed creativity](https://articles.starcitygames.com/articles/restrictions-breed-creativity/), after all.
44+55+But through all of this I was never really satisfied with the options that were out there for long-form writing. Wordpress, even their hosted version, required a lot of setup to really be functional, Tumblr's system for comment/replies was and remains insane, hosting my own seemed like too much money to burn on something nobody might even read at the time, and honestly I felt like I kinda missed the boat on discoverability, as the internet grew larger and more centralised, with platforms like Substack eating what was left of the blogosphere. But at the same time, its success proves that there is very much a desire for long-form writing, enough that people will pay for it, and that investors will back it. There are thoughts and forms of writing that you simply cannot fit into a post or even a thread of posts, and which don't make sense on a topic-based forum, or a place like Archive of our Own. Plus, I'm loathe to enable a centralised platform like Substack where the owners are unfortunately friendly to fascists.
66+77+That's where the `at://` protocol and Weaver comes in.
88+### The pitch
99+Weaver is designed to be a highly flexible platform for medium and long-form writing on atproto. I was inspired by how weaver birds build their own homes, and by the notebooks, physical and virtual, that I create in the course of my work, to ideate, to document, and to inform. The initial proof-of-concept is essentially a static site generator, able to turn a Markdown text file or a folder of Markdown files, such as an Obsidian vault or git repository documentation, into a static "notebook" site. The file is uploaded to your PDS, where it can be accessed, either directly, via a minimal app server layer that provides a friendlier web address than an XRPC request or CDN link, or hosted on a platform of your choice, be that your own server or any other static site hosting service. The intermediate goal is an elegant and intuitive writing platform with collaborative editing and straightforward, immediate publishing via a web-app. The ultimate goal is to build a platform suitable for professional writers and journalists, an open alternative to platforms like Substack, with ways for readers to support writers, all on the AT protocol.
1010+1111+### So what about...
1212+When I started working on Weaver back in the spring, the only real games in town for long-form blogging based on atproto, aside from rolling your own, [piss.beauty](https://piss.beauty) style, were [whtwnd.com](https://whtwnd.com/) and [leaflet.pub](https://leaflet.pub/home). Leaflet's good, and it's gotten a lot better in the last year, but it doesn't offer quite what I'm looking for either. For one, I am a Markdown fangirl, for better or for worse, and while Leaflet allows you to use Markdown for formatting, it doesn't speak it natively. There are [more alternatives now](https://connectedplaces.leaflet.pub/3m4qgpc7h3223), which makes sense as this space definitely feels like one that has gaps to fill. And the `at://` protocol, while it was developed in concert with a microblogging app, is actually pretty damn good for "macro"blogging, too. The interoperability the protocol allows is incredible. Weaver's app server can display Whitewind posts very easily. With some effort on my part, it can faithfully render Leaflet posts. It doesn't care what app your profile is on, it uses the [partial understanding](https://sdr-podcast.com/episodes/partial-understanding/) [capabilities](https://bsky.app/profile/nonbinary.computer/post/3m44ooo42xc2j) of the [jacquard](https://tangled.org/@nonbinary.computer/jacquard/) library I created to pull useful data out of it.