···3// This file was automatically generated from Lexicon schemas.
4// Any manual changes will be overwritten on the next regeneration.
506pub mod schema;
···3// This file was automatically generated from Lexicon schemas.
4// Any manual changes will be overwritten on the next regeneration.
56+pub mod resolve_lexicon;
7pub mod schema;
···23#[cfg(feature = "server")]
4use crate::blobcache::BlobCache;
5+use crate::{
6+ components::avatar::{Avatar, AvatarFallback, AvatarImage},
7+ fetch,
8+};
9use dioxus::prelude::*;
10+11+const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css");
12#[allow(unused_imports)]
13use dioxus::{fullstack::extract::Extension, CapturedError};
14+use jacquard::{
15+ from_data, prelude::IdentityResolver, smol_str::ToSmolStr, types::string::Datetime,
16+};
17#[allow(unused_imports)]
18use jacquard::{
19 smol_str::SmolStr,
···26#[component]
27pub fn Entry(ident: AtIdentifier<'static>, book_title: SmolStr, title: SmolStr) -> Element {
28 let ident_clone = ident.clone();
29+ let book_title_clone = book_title.clone();
30 let entry = use_resource(use_reactive!(|(ident, book_title, title)| async move {
31 let fetcher = use_context::<fetch::CachedFetcher>();
32 let entry = fetcher
···5657 match &*entry.read_unchecked() {
58 Some(Some(entry_data)) => {
59+ rsx! { EntryPage {
60+ book_entry_view: entry_data.0.clone(),
61+ entry_record: entry_data.1.clone(),
62+ ident: ident_clone,
63+ book_title: book_title_clone
64 } }
65 }
66 Some(None) => {
···70 }
71}
7273+/// Full entry page with metadata, content, and navigation
74+#[component]
75+fn EntryPage(
76+ book_entry_view: BookEntryView<'static>,
77+ entry_record: entry::Entry<'static>,
78+ ident: AtIdentifier<'static>,
79+ book_title: SmolStr,
80+) -> Element {
81+ // Extract metadata
82+ let entry_view = &book_entry_view.entry;
83+ let title = entry_view
84+ .title
85+ .as_ref()
86+ .map(|t| t.as_ref())
87+ .unwrap_or("Untitled");
88+89+ rsx! {
90+ // Set page title
91+ document::Title { "{title}" }
92+ document::Link { rel: "stylesheet", href: ENTRY_CSS }
93+94+ div { class: "entry-page-layout",
95+ // Left gutter with prev button
96+ if let Some(ref prev) = book_entry_view.prev {
97+ div { class: "nav-gutter nav-prev",
98+ NavButton {
99+ direction: "prev",
100+ entry: prev.entry.clone(),
101+ ident: ident.clone(),
102+ book_title: book_title.clone()
103+ }
104+ }
105+ }
106+107+ // Main content area
108+ div { class: "entry-content-main",
109+ // Metadata header
110+ EntryMetadata {
111+ entry_view: entry_view.clone(),
112+ ident: ident.clone(),
113+ created_at: entry_record.created_at.clone()
114+ }
115+116+ // Rendered markdown
117+ EntryMarkdownDirect {
118+ content: entry_record,
119+ ident: ident.clone()
120+ }
121+ }
122+123+ // Right gutter with next button
124+ if let Some(ref next) = book_entry_view.next {
125+ div { class: "nav-gutter nav-next",
126+ NavButton {
127+ direction: "next",
128+ entry: next.entry.clone(),
129+ ident: ident.clone(),
130+ book_title: book_title.clone()
131+ }
132+ }
133+ }
134+ }
135+ }
136+}
137+138#[component]
139pub fn EntryCard(entry: BookEntryView<'static>) -> Element {
140 rsx! {}
141}
142143+/// Metadata header showing title, authors, date, tags
144+#[component]
145+fn EntryMetadata(
146+ entry_view: weaver_api::sh_weaver::notebook::EntryView<'static>,
147+ ident: AtIdentifier<'static>,
148+ created_at: Datetime,
149+) -> Element {
150+ use weaver_api::app_bsky::actor::profile::Profile;
151+152+ let title = entry_view
153+ .title
154+ .as_ref()
155+ .map(|t| t.as_ref())
156+ .unwrap_or("Untitled");
157+158+ let indexed_at_chrono = entry_view.indexed_at.as_ref();
159+160+ rsx! {
161+ header { class: "entry-metadata",
162+ h1 { class: "entry-title", "{title}" }
163+164+ div { class: "entry-meta-info",
165+ // Authors
166+ if !entry_view.authors.is_empty() {
167+ div { class: "entry-authors",
168+ for (i, author) in entry_view.authors.iter().enumerate() {
169+ if i > 0 { span { ", " } }
170+ {
171+ // Parse author profile from the nested value field
172+ match from_data::<Profile>(author.record.get_at_path(".value").unwrap()) {
173+ Ok(profile) => {
174+ let avatar = profile.avatar
175+ .map(|avatar| {
176+ let cid = avatar.blob().cid();
177+ let did = entry_view.uri.authority();
178+ format!("https://cdn.bsky.app/img/avatar/plain/{did}/{cid}@jpeg")
179+ });
180+ let display_name = profile.display_name
181+ .as_ref()
182+ .map(|n| n.as_ref())
183+ .unwrap_or("Unknown");
184+ rsx! {
185+ if let Some(avatar) = avatar {
186+ Avatar {
187+ AvatarImage {
188+ src: avatar
189+ }
190+ }
191+ }
192+ span { class: "author-name", "{display_name}" }
193+ span { class: "meta-label", "@{ident}" }
194+ }
195+ }
196+ Err(_) => {
197+ rsx! {
198+ span { class: "author-name", "Author {author.index}" }
199+ }
200+ }
201+ }
202+ }
203+ }
204+ }
205+ }
206+207+ // Date
208+ div { class: "entry-date",
209+ {
210+ let formatted_date = created_at.as_ref().format("%B %d, %Y").to_string();
211+212+ rsx! {
213+ time { datetime: "{entry_view.indexed_at.as_str()}", "{formatted_date}" }
214+215+ }
216+ }
217+ }
218+219+ // Tags
220+ if let Some(ref tags) = entry_view.tags {
221+ div { class: "entry-tags",
222+ // TODO: Parse tags structure
223+ span { class: "meta-label", "Tags: " }
224+ span { "[tags]" }
225+ }
226+ }
227+ }
228+ }
229+ }
230+}
231+232+/// Navigation button for prev/next entries
233+#[component]
234+fn NavButton(
235+ direction: &'static str,
236+ entry: weaver_api::sh_weaver::notebook::EntryView<'static>,
237+ ident: AtIdentifier<'static>,
238+ book_title: SmolStr,
239+) -> Element {
240+ use crate::Route;
241+242+ let entry_title = entry
243+ .title
244+ .as_ref()
245+ .map(|t| t.as_ref())
246+ .unwrap_or("Untitled");
247+248+ let label = if direction == "prev" {
249+ "← Previous"
250+ } else {
251+ "Next →"
252+ };
253+ let arrow = if direction == "prev" { "←" } else { "→" };
254+255+ rsx! {
256+ Link {
257+ to: Route::Entry {
258+ ident: ident.clone(),
259+ book_title: book_title.clone(),
260+ title: entry_title.to_string().into()
261+ },
262+ class: "nav-button nav-button-{direction}",
263+ div { class: "nav-arrow", "{arrow}" }
264+ div { class: "nav-label", "{label}" }
265+ div { class: "nav-title", "{entry_title}" }
266+ }
267+ }
268+}
269+270#[derive(Props, Clone, PartialEq)]
271pub struct EntryMarkdownProps {
272 #[props(default)]
···326327 // Render to HTML
328 let mut html_buf = String::new();
329+ let _ = ClientWriter::<_, _, ()>::new(events.into_iter(), &mut html_buf).run();
330 Some(html_buf)
331 }));
332
+1-1
crates/weaver-server/src/components/mod.rs
···1011mod identity;
12pub use identity::{Repository, RepositoryIndex};
13-//pub mod avatar;
···1011mod identity;
12pub use identity::{Repository, RepositoryIndex};
13+pub mod avatar;
+51-2
crates/weaver-server/src/main.rs
···1// The dioxus prelude contains a ton of common items used in dioxus apps. It's a good idea to import wherever you
2// need dioxus
3use components::{Entry, Repository, RepositoryIndex};
4-use dioxus::{fullstack::FullstackContext, prelude::*};
5-use jacquard::{client::BasicClient, smol_str::SmolStr, types::string::AtIdentifier};
000000000006use std::sync::Arc;
07use views::{Home, Navbar, Notebook, NotebookIndex, NotebookPage};
89#[cfg(feature = "server")]
···135 }
136 }
137}
0000000000000000000000000000000000000
···1// The dioxus prelude contains a ton of common items used in dioxus apps. It's a good idea to import wherever you
2// need dioxus
3use components::{Entry, Repository, RepositoryIndex};
4+#[allow(unused)]
5+use dioxus::{
6+ fullstack::{response::Extension, FullstackContext},
7+ prelude::*,
8+ CapturedError,
9+};
10+#[allow(unused)]
11+use jacquard::{
12+ client::BasicClient,
13+ smol_str::SmolStr,
14+ types::{cid::Cid, string::AtIdentifier},
15+};
16+17use std::sync::Arc;
18+#[allow(unused)]
19use views::{Home, Navbar, Notebook, NotebookIndex, NotebookPage};
2021#[cfg(feature = "server")]
···147 }
148 }
149}
150+151+#[get("/{notebook}/image/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)]
152+pub async fn image_named(
153+ notebook: SmolStr,
154+ name: SmolStr,
155+) -> Result<dioxus_fullstack::response::Response> {
156+ use axum::response::IntoResponse;
157+ use mime_sniffer::MimeTypeSniffer;
158+ if let Some(bytes) = blob_cache.get_named(&name) {
159+ let blob = bytes.clone();
160+ let mime = blob.sniff_mime_type().unwrap_or("application/octet-stream");
161+ Ok((
162+ [(dioxus_fullstack::http::header::CONTENT_TYPE, mime)],
163+ bytes,
164+ )
165+ .into_response())
166+ } else {
167+ Err(CapturedError::from_display("no image"))
168+ }
169+}
170+171+#[get("/{notebook}/blob/{cid}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)]
172+pub async fn blob(notebook: SmolStr, cid: SmolStr) -> Result<dioxus_fullstack::response::Response> {
173+ use axum::response::IntoResponse;
174+ use mime_sniffer::MimeTypeSniffer;
175+ if let Some(bytes) = blob_cache.get_cid(&Cid::new_owned(cid.as_bytes())?) {
176+ let blob = bytes.clone();
177+ let mime = blob.sniff_mime_type().unwrap_or("application/octet-stream");
178+ Ok((
179+ [(dioxus_fullstack::http::header::CONTENT_TYPE, mime)],
180+ bytes,
181+ )
182+ .into_response())
183+ } else {
184+ Err(CapturedError::from_display("no blob"))
185+ }
186+}
+2
crates/weaver-server/src/views/navbar.rs
···1use crate::Route;
2use dioxus::prelude::*;
304const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css");
56/// The Navbar component that will be rendered on all pages of our app since every page is under the layout.
···11#[component]
12pub fn Navbar() -> Element {
13 rsx! {
014 document::Link { rel: "stylesheet", href: NAVBAR_CSS }
1516 div {
···1use crate::Route;
2use dioxus::prelude::*;
34+const THEME_DEFAULTS_CSS: Asset = asset!("/assets/styling/theme-defaults.css");
5const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css");
67/// The Navbar component that will be rendered on all pages of our app since every page is under the layout.
···12#[component]
13pub fn Navbar() -> Element {
14 rsx! {
15+ document::Link { rel: "stylesheet", href: THEME_DEFAULTS_CSS }
16 document::Link { rel: "stylesheet", href: NAVBAR_CSS }
1718 div {
···1+# Theme and Colour Scheme Redesign
2+3+**Date:** 2025-11-03
4+**Status:** Design approved, ready for implementation
5+6+## Overview
7+8+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.
9+10+## Requirements
11+12+- Support 16+ semantic colour slots (comparable to base16 expressiveness)
13+- Enable importing/adapting popular themes (base16, Rose Pine, etc.)
14+- Separate light/dark modes as distinct themes (users can mix and match)
15+- Semantic naming over positional identifiers (readable, consistent across themes)
16+- Support preset picker UI + power user manual customization
17+18+## Design
19+20+### Two-Lexicon Structure
21+22+Split colour schemes from themes to enable reusability and mixing:
23+24+**1. `sh.weaver.notebook.colourScheme`** - Standalone colour palette record
25+- Standalone AT Protocol record
26+- Contains name, variant (dark/light), and 16 colour slots
27+- Can be published and referenced by multiple themes
28+- Enables sharing palettes between users
29+30+**2. `sh.weaver.notebook.theme`** - Complete theme with colour references
31+- References two colourScheme records (dark and light) via strongRefs
32+- Contains fonts, spacing, codeTheme (unchanged from previous design)
33+- Users can point to any published colour schemes, including others' palettes
34+35+### 16 Semantic Colour Slots
36+37+Organized into 5 semantic categories with consistent naming:
38+39+**Backgrounds (3):**
40+- `base` - Primary background for page/frame
41+- `surface` - Secondary background for panels/cards
42+- `overlay` - Tertiary background for popovers/dialogs
43+44+**Text (1):**
45+- `text` - Primary readable text colour (baseline)
46+47+**Foreground variations (3):**
48+- `muted` - De-emphasized text (disabled, metadata)
49+- `subtle` - Medium emphasis text (comments, labels)
50+- `emphasis` - Emphasized text (bold, important)
51+52+**Accents (3):**
53+- `primary` - Primary brand/accent colour
54+- `secondary` - Secondary accent colour
55+- `tertiary` - Tertiary accent colour
56+57+**Status (3):**
58+- `error` - Error state colour
59+- `warning` - Warning state colour
60+- `success` - Success state colour
61+62+**Role (3):**
63+- `border` - Border/divider colour
64+- `link` - Hyperlink colour
65+- `highlight` - Selection/highlight colour
66+67+### Mapping to Existing Schemes
68+69+**Base16 compatibility:**
70+- Backgrounds map to base00-base02
71+- Foregrounds map to base03-base07
72+- Accents/status/roles map to base08-base0F
73+74+**Rose Pine compatibility:**
75+- Direct semantic mapping (base→base, surface→surface, overlay→overlay)
76+- text/muted/subtle map to text/muted/subtle
77+- Accent colours map to love/gold/rose/pine/foam/iris
78+79+## Implementation Impact
80+81+### Files to Create
82+- `lexicons/notebook/colourScheme.json` ✓ (created)
83+84+### Files to Modify
85+- `lexicons/notebook/theme.json` ✓ (modified)
86+- Run `./lexicon-codegen.sh` to regenerate Rust types
87+- Update `crates/weaver-common/src/lexicons/mod.rs` (remove duplicate `mod sh;`)
88+89+### Code Changes Needed
90+- Update theme rendering code to use strongRef lookups
91+- Update CSS generation to use new 16-slot colour names
92+- Create default colour schemes (at least one dark, one light)
93+- Update any existing theme records/configs to new format
94+95+### Future Enhancements
96+- Theme picker UI with preset browser
97+- Colour scheme validation/linting
98+- Auto-generate light from dark (and vice versa) where appropriate
99+- Import converters for base16 YAML, Rose Pine JSON
···1+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.
2+3+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.
4+5+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.
6+7+That's where the `at://` protocol and Weaver comes in.
8+### The pitch
9+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.
10+11+### So what about...
12+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.