learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs

refactor: Update lexicon namespaces

* add `card_type` database column

* put animations back in tutorial overlay

+22 -19
crates/core/src/at_uri.rs
··· 4 //! Format: `at://<authority>/<collection>/<rkey>` 5 //! 6 //! - authority: DID or handle 7 - //! - collection: NSID (e.g., "app.malfestio.deck") 8 //! - rkey: Record key (usually a TID) 9 10 use std::fmt; ··· 14 pub struct AtUri { 15 /// The authority (DID or handle) 16 pub authority: String, 17 - /// The collection NSID (e.g., "app.malfestio.deck") 18 pub collection: String, 19 /// The record key 20 pub rkey: String, ··· 34 35 /// Create an AT-URI for a deck record. 36 pub fn deck(did: &str, rkey: &str) -> Self { 37 - Self::new(did, "app.malfestio.deck", rkey) 38 } 39 40 /// Create an AT-URI for a card record. 41 pub fn card(did: &str, rkey: &str) -> Self { 42 - Self::new(did, "app.malfestio.card", rkey) 43 } 44 45 /// Create an AT-URI for a note record. 46 pub fn note(did: &str, rkey: &str) -> Self { 47 - Self::new(did, "app.malfestio.note", rkey) 48 } 49 50 /// Parse an AT-URI string. ··· 126 127 #[test] 128 fn test_new_at_uri() { 129 - let uri = AtUri::new("did:plc:abc123", "app.malfestio.deck", "3k5abc123"); 130 assert_eq!(uri.authority, "did:plc:abc123"); 131 - assert_eq!(uri.collection, "app.malfestio.deck"); 132 assert_eq!(uri.rkey, "3k5abc123"); 133 } 134 135 #[test] 136 fn test_display() { 137 - let uri = AtUri::new("did:plc:abc123", "app.malfestio.deck", "3k5abc123"); 138 - assert_eq!(uri.to_string(), "at://did:plc:abc123/app.malfestio.deck/3k5abc123"); 139 } 140 141 #[test] 142 fn test_parse_valid() { 143 - let uri = AtUri::parse("at://did:plc:abc123/app.malfestio.deck/3k5abc123").unwrap(); 144 assert_eq!(uri.authority, "did:plc:abc123"); 145 - assert_eq!(uri.collection, "app.malfestio.deck"); 146 assert_eq!(uri.rkey, "3k5abc123"); 147 } 148 149 #[test] 150 fn test_parse_with_handle() { 151 - let uri = AtUri::parse("at://alice.bsky.social/app.malfestio.note/abc123").unwrap(); 152 assert_eq!(uri.authority, "alice.bsky.social"); 153 assert!(uri.is_handle()); 154 assert!(!uri.is_did()); ··· 156 157 #[test] 158 fn test_parse_missing_scheme() { 159 - let result = AtUri::parse("did:plc:abc123/app.malfestio.deck/3k5abc123"); 160 assert_eq!(result, Err(AtUriError::MissingScheme)); 161 } 162 163 #[test] 164 fn test_parse_invalid_format() { 165 - let result = AtUri::parse("at://did:plc:abc123/app.malfestio.deck"); 166 assert_eq!(result, Err(AtUriError::InvalidFormat)); 167 } 168 169 #[test] 170 fn test_parse_empty_authority() { 171 - let result = AtUri::parse("at:///app.malfestio.deck/rkey"); 172 assert_eq!(result, Err(AtUriError::EmptyAuthority)); 173 } 174 ··· 180 181 #[test] 182 fn test_roundtrip() { 183 - let original = "at://did:plc:abc123/app.malfestio.deck/3k5abc123"; 184 let uri = AtUri::parse(original).unwrap(); 185 assert_eq!(uri.to_string(), original); 186 } ··· 188 #[test] 189 fn test_convenience_constructors() { 190 let deck = AtUri::deck("did:plc:abc", "tid123"); 191 - assert_eq!(deck.collection, "app.malfestio.deck"); 192 193 let card = AtUri::card("did:plc:abc", "tid456"); 194 - assert_eq!(card.collection, "app.malfestio.card"); 195 196 let note = AtUri::note("did:plc:abc", "tid789"); 197 - assert_eq!(note.collection, "app.malfestio.note"); 198 } 199 200 #[test]
··· 4 //! Format: `at://<authority>/<collection>/<rkey>` 5 //! 6 //! - authority: DID or handle 7 + //! - collection: NSID (e.g., "org.stormlightlabs.malfestio.deck") 8 //! - rkey: Record key (usually a TID) 9 10 use std::fmt; ··· 14 pub struct AtUri { 15 /// The authority (DID or handle) 16 pub authority: String, 17 + /// The collection NSID (e.g., "org.stormlightlabs.malfestio.deck") 18 pub collection: String, 19 /// The record key 20 pub rkey: String, ··· 34 35 /// Create an AT-URI for a deck record. 36 pub fn deck(did: &str, rkey: &str) -> Self { 37 + Self::new(did, "org.stormlightlabs.malfestio.deck", rkey) 38 } 39 40 /// Create an AT-URI for a card record. 41 pub fn card(did: &str, rkey: &str) -> Self { 42 + Self::new(did, "org.stormlightlabs.malfestio.card", rkey) 43 } 44 45 /// Create an AT-URI for a note record. 46 pub fn note(did: &str, rkey: &str) -> Self { 47 + Self::new(did, "org.stormlightlabs.malfestio.note", rkey) 48 } 49 50 /// Parse an AT-URI string. ··· 126 127 #[test] 128 fn test_new_at_uri() { 129 + let uri = AtUri::new("did:plc:abc123", "org.stormlightlabs.malfestio.deck", "3k5abc123"); 130 assert_eq!(uri.authority, "did:plc:abc123"); 131 + assert_eq!(uri.collection, "org.stormlightlabs.malfestio.deck"); 132 assert_eq!(uri.rkey, "3k5abc123"); 133 } 134 135 #[test] 136 fn test_display() { 137 + let uri = AtUri::new("did:plc:abc123", "org.stormlightlabs.malfestio.deck", "3k5abc123"); 138 + assert_eq!( 139 + uri.to_string(), 140 + "at://did:plc:abc123/org.stormlightlabs.malfestio.deck/3k5abc123" 141 + ); 142 } 143 144 #[test] 145 fn test_parse_valid() { 146 + let uri = AtUri::parse("at://did:plc:abc123/org.stormlightlabs.malfestio.deck/3k5abc123").unwrap(); 147 assert_eq!(uri.authority, "did:plc:abc123"); 148 + assert_eq!(uri.collection, "org.stormlightlabs.malfestio.deck"); 149 assert_eq!(uri.rkey, "3k5abc123"); 150 } 151 152 #[test] 153 fn test_parse_with_handle() { 154 + let uri = AtUri::parse("at://alice.bsky.social/org.stormlightlabs.malfestio.note/abc123").unwrap(); 155 assert_eq!(uri.authority, "alice.bsky.social"); 156 assert!(uri.is_handle()); 157 assert!(!uri.is_did()); ··· 159 160 #[test] 161 fn test_parse_missing_scheme() { 162 + let result = AtUri::parse("did:plc:abc123/org.stormlightlabs.malfestio.deck/3k5abc123"); 163 assert_eq!(result, Err(AtUriError::MissingScheme)); 164 } 165 166 #[test] 167 fn test_parse_invalid_format() { 168 + let result = AtUri::parse("at://did:plc:abc123/org.stormlightlabs.malfestio.deck"); 169 assert_eq!(result, Err(AtUriError::InvalidFormat)); 170 } 171 172 #[test] 173 fn test_parse_empty_authority() { 174 + let result = AtUri::parse("at:///org.stormlightlabs.malfestio.deck/rkey"); 175 assert_eq!(result, Err(AtUriError::EmptyAuthority)); 176 } 177 ··· 183 184 #[test] 185 fn test_roundtrip() { 186 + let original = "at://did:plc:abc123/org.stormlightlabs.malfestio.deck/3k5abc123"; 187 let uri = AtUri::parse(original).unwrap(); 188 assert_eq!(uri.to_string(), original); 189 } ··· 191 #[test] 192 fn test_convenience_constructors() { 193 let deck = AtUri::deck("did:plc:abc", "tid123"); 194 + assert_eq!(deck.collection, "org.stormlightlabs.malfestio.deck"); 195 196 let card = AtUri::card("did:plc:abc", "tid456"); 197 + assert_eq!(card.collection, "org.stormlightlabs.malfestio.card"); 198 199 let note = AtUri::note("did:plc:abc", "tid789"); 200 + assert_eq!(note.collection, "org.stormlightlabs.malfestio.note"); 201 } 202 203 #[test]
+14 -7
crates/server/src/repository/card.rs
··· 23 #[async_trait] 24 pub trait CardRepository: Send + Sync { 25 async fn create(&self, params: CreateCardParams) -> Result<Card, CardRepoError>; 26 - 27 async fn list_by_deck(&self, deck_id: &str) -> Result<Vec<Card>, CardRepoError>; 28 - 29 async fn verify_deck_ownership(&self, deck_id: &str, owner_did: &str) -> Result<bool, CardRepoError>; 30 - 31 async fn update_at_uri(&self, card_id: &str, at_uri: &str) -> Result<(), CardRepoError>; 32 } 33 ··· 67 } 68 69 let card_id = uuid::Uuid::new_v4(); 70 client 71 .execute( 72 - "INSERT INTO cards (id, owner_did, deck_id, front, back, media_url, hints) 73 - VALUES ($1, $2, $3, $4, $5, $6, $7)", 74 &[ 75 &card_id, 76 &params.owner_did, ··· 79 &params.back, 80 &params.media_url, 81 &params.hints, 82 ], 83 ) 84 .await ··· 118 119 let rows = client 120 .query( 121 - "SELECT id, owner_did, deck_id, front, back, media_url, hints 122 FROM cards 123 WHERE deck_id = $1 124 ORDER BY created_at ASC", ··· 131 for row in rows { 132 let id: uuid::Uuid = row.get("id"); 133 let card_deck_id: uuid::Uuid = row.get("deck_id"); 134 135 cards.push(Card { 136 id: id.to_string(), ··· 139 front: row.get("front"), 140 back: row.get("back"), 141 media_url: row.get("media_url"), 142 - card_type: CardType::default(), 143 hints: row.get("hints"), 144 }); 145 }
··· 23 #[async_trait] 24 pub trait CardRepository: Send + Sync { 25 async fn create(&self, params: CreateCardParams) -> Result<Card, CardRepoError>; 26 async fn list_by_deck(&self, deck_id: &str) -> Result<Vec<Card>, CardRepoError>; 27 async fn verify_deck_ownership(&self, deck_id: &str, owner_did: &str) -> Result<bool, CardRepoError>; 28 async fn update_at_uri(&self, card_id: &str, at_uri: &str) -> Result<(), CardRepoError>; 29 } 30 ··· 64 } 65 66 let card_id = uuid::Uuid::new_v4(); 67 + let card_type_str = match params.card_type { 68 + CardType::Basic => "basic", 69 + CardType::Cloze => "cloze", 70 + }; 71 client 72 .execute( 73 + "INSERT INTO cards (id, owner_did, deck_id, front, back, media_url, hints, card_type) 74 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", 75 &[ 76 &card_id, 77 &params.owner_did, ··· 80 &params.back, 81 &params.media_url, 82 &params.hints, 83 + &card_type_str, 84 ], 85 ) 86 .await ··· 120 121 let rows = client 122 .query( 123 + "SELECT id, owner_did, deck_id, front, back, media_url, hints, card_type 124 FROM cards 125 WHERE deck_id = $1 126 ORDER BY created_at ASC", ··· 133 for row in rows { 134 let id: uuid::Uuid = row.get("id"); 135 let card_deck_id: uuid::Uuid = row.get("deck_id"); 136 + let card_type_str: Option<String> = row.get("card_type"); 137 + let card_type = match card_type_str.as_deref() { 138 + Some("cloze") => CardType::Cloze, 139 + _ => CardType::Basic, 140 + }; 141 142 cards.push(Card { 143 id: id.to_string(), ··· 146 front: row.get("front"), 147 back: row.get("back"), 148 media_url: row.get("media_url"), 149 + card_type, 150 hints: row.get("hints"), 151 }); 152 }
+1 -1
docs/at-notes.md
··· 71 72 Format: `at://<did>/<collection>/<rkey>` 73 74 - Example: `at://did:plc:abc123/app.malfestio.deck/3k5abc123` 75 76 ## Firehose / Jetstream 77
··· 71 72 Format: `at://<did>/<collection>/<rkey>` 73 74 + Example: `at://did:plc:abc123/org.stormlightlabs.malfestio.deck/3k5abc123` 75 76 ## Firehose / Jetstream 77
+14 -14
docs/data-model-mapping.md
··· 13 14 ## Mapping Table 15 16 - | Public Lexicon | Internal DB Table(s) | Notes | 17 - | :----------------------------- | :------------------- | :------------------------------------------------------------- | 18 - | `app.malfestio.deck` | `decks` | Public metadata. | 19 - | `app.malfestio.card` | `cards` | Content. | 20 - | `app.malfestio.note` | `notes` | Standalone notes. | 21 - | `app.malfestio.source.*` | `sources` | Metadata for articles/lectures. | 22 - | `app.malfestio.collection` | `collections` | | 23 - | `app.malfestio.thread.comment` | `comments` | | 24 - | _(None)_ | `reviews` | **Private**. Logs of every review event. | 25 - | _(None)_ | `study_progress` | **Private**. Current SRS state for a card (box/interval/ease). | 26 - | _(None)_ | `user_settings` | **Private**. Daily goals, UI references. | 27 - | _(None)_ | `drafts` | **Private**. Content being authored before publishing. | 28 29 ## Sync Strategy 30 31 - **Publishing**: 32 - Write to `drafts` -> User clicks "Publish" -> Sign record -> Push to PDS -> Move `drafts` content to `decks`/`cards` tables (or mark as synced). 33 - **Consuming**: 34 - Pull from PDS (firehose or direct sync) -> Validate signature -> Upsert into local tables.
··· 13 14 ## Mapping Table 15 16 + | Public Lexicon | Internal DB Table(s) | Notes | 17 + | :-------------------------------------------- | :------------------- | :------------------------------------------------------------- | 18 + | `org.stormlightlabs.malfestio.deck` | `decks` | Public metadata. | 19 + | `org.stormlightlabs.malfestio.card` | `cards` | Content. | 20 + | `org.stormlightlabs.malfestio.note` | `notes` | Standalone notes. | 21 + | `org.stormlightlabs.malfestio.source.*` | `sources` | Metadata for articles/lectures. | 22 + | `org.stormlightlabs.malfestio.collection` | `collections` | | 23 + | `org.stormlightlabs.malfestio.thread.comment` | `comments` | | 24 + | _(None)_ | `reviews` | **Private**. Logs of every review event. | 25 + | _(None)_ | `study_progress` | **Private**. Current SRS state for a card (box/interval/ease). | 26 + | _(None)_ | `user_settings` | **Private**. Daily goals, UI references. | 27 + | _(None)_ | `drafts` | **Private**. Content being authored before publishing. | 28 29 ## Sync Strategy 30 31 - **Publishing**: 32 + Write to `drafts` -> User clicks "Publish" -> Sign record -> Push to PDS -> Move `drafts` content to `decks`/`cards` tables (or mark as synced). 33 - **Consuming**: 34 + Pull from PDS (firehose or direct sync) -> Validate signature -> Upsert into local tables.
+10 -10
docs/information-architecture.md
··· 44 45 Mapping screens to underlying data entities (Lexicon Records + Private State). 46 47 - | Screen / Component | Primary Data Entity | Secondary Entities | Private/Public | 48 - | :----------------- | :----------------------------- | :---------------------------------------- | :---------------------------- | 49 - | **Deck Overview** | `app.malfestio.deck` | `app.malfestio.card` (refs), User Profile | **Public** | 50 - | **Study Session** | N/A (Ephemeral) | `app.malfestio.card`, Private Review Log | **Private** | 51 - | **Card View** | `app.malfestio.card` | `app.malfestio.note`, Media Blobs | **Public** | 52 - | **Editor** | Draft State (Local) | Source (`article`), `note` | **Private (Draft) -> Public** | 53 - | **Source View** | `app.malfestio.source.article` | `app.malfestio.note` (linked) | **Public** | 54 - | **Note View** | `app.malfestio.note` | Backlinks (`card`/`deck`) | **Public** | 55 - | **Library** | `app.malfestio.collection` | Bookmarks, User Prefs | **Mixed** | 56 - | **Comments** | `app.malfestio.thread.comment` | User Profile | **Public** | 57 58 ## URL Structure 59
··· 44 45 Mapping screens to underlying data entities (Lexicon Records + Private State). 46 47 + | Screen / Component | Primary Data Entity | Secondary Entities | Private/Public | 48 + | :----------------- | :-------------------------------------------- | :------------------------------------------------------- | :---------------------------- | 49 + | **Deck Overview** | `org.stormlightlabs.malfestio.deck` | `org.stormlightlabs.malfestio.card` (refs), User Profile | **Public** | 50 + | **Study Session** | N/A (Ephemeral) | `org.stormlightlabs.malfestio.card`, Private Review Log | **Private** | 51 + | **Card View** | `org.stormlightlabs.malfestio.card` | `org.stormlightlabs.malfestio.note`, Media Blobs | **Public** | 52 + | **Editor** | Draft State (Local) | Source (`article`), `note` | **Private (Draft) -> Public** | 53 + | **Source View** | `org.stormlightlabs.malfestio.source.article` | `org.stormlightlabs.malfestio.note` (linked) | **Public** | 54 + | **Note View** | `org.stormlightlabs.malfestio.note` | Backlinks (`card`/`deck`) | **Public** | 55 + | **Library** | `org.stormlightlabs.malfestio.collection` | Bookmarks, User Prefs | **Mixed** | 56 + | **Comments** | `org.stormlightlabs.malfestio.thread.comment` | User Profile | **Public** | 57 58 ## URL Structure 59
+7 -7
docs/share-vs-private-rules.md
··· 15 16 These entities are visible to anyone with access to the PDS (essentially public). 17 18 - - Deck (`app.malfestio.deck`): The collection of cards/notes. 19 - - Card (`app.malfestio.card`): The flashcard content (Front/Back). 20 - - Note (`app.malfestio.note`): The source knowledge note. 21 - - Article (`app.malfestio.source.article`): Metadata/snapshot of an external article. 22 - - Lecture (`app.malfestio.source.lecture`): Metadata/outline of an external video/audio. 23 - - Collection (`app.malfestio.collection`): Curated lists of decks. 24 - - Comment (`app.malfestio.thread.comment`): Public discussion. 25 26 > **Rule**: If a user puts sensitive information in a Card, they must be warned that 27 > publishing the Deck makes it public.
··· 15 16 These entities are visible to anyone with access to the PDS (essentially public). 17 18 + - Deck (`org.stormlightlabs.malfestio.deck`): The collection of cards/notes. 19 + - Card (`org.stormlightlabs.malfestio.card`): The flashcard content (Front/Back). 20 + - Note (`org.stormlightlabs.malfestio.note`): The source knowledge note. 21 + - Article (`org.stormlightlabs.malfestio.source.article`): Metadata/snapshot of an external article. 22 + - Lecture (`org.stormlightlabs.malfestio.source.lecture`): Metadata/outline of an external video/audio. 23 + - Collection (`org.stormlightlabs.malfestio.collection`): Curated lists of decks. 24 + - Comment (`org.stormlightlabs.malfestio.thread.comment`): Public discussion. 25 26 > **Rule**: If a user puts sensitive information in a Card, they must be warned that 27 > publishing the Deck makes it public.
+1 -1
docs/todo.md
··· 131 132 **Indexing:** 133 134 - - [ ] Subscribe to `com.atproto.sync.subscribeRepos` (or Jetstream) for `app.malfestio.*` records 135 - [ ] Index posts with compound cursor (timestamp::CID) for deterministic pagination 136 - [ ] Garbage collect indexed data older than 48 hours (except pinned content) 137
··· 131 132 **Indexing:** 133 134 + - [ ] Subscribe to `com.atproto.sync.subscribeRepos` (or Jetstream) for `org.stormlightlabs.malfestio.*` records 135 - [ ] Index posts with compound cursor (timestamp::CID) for deterministic pagination 136 - [ ] Garbage collect indexed data older than 48 hours (except pinned content) 137
+8 -8
lexicons/README.md
··· 11 12 ### Namespace + NSID conventions 13 14 - - `app.malfestio.note` 15 - - `app.malfestio.card` 16 - - `app.malfestio.deck` 17 - - `app.malfestio.source.article` 18 - - `app.malfestio.source.lecture` 19 - - `app.malfestio.collection` 20 - - `app.malfestio.thread.comment` 21 22 ### Lexicon basics 23 ··· 38 2. **No Renaming**: Do not rename fields. 39 If a semantic change is needed, add a new field and deprecate the old one. 40 3. **No Type Changes**: Once published, a field's type is fixed. 41 - 4. **Version by Copying**: If a breaking change is absolutely required, create a new Lexicon with a new major version or a new name (e.g., `app.malfestio.noteV2`).
··· 11 12 ### Namespace + NSID conventions 13 14 + - `org.stormlightlabs.malfestio.note` 15 + - `org.stormlightlabs.malfestio.card` 16 + - `org.stormlightlabs.malfestio.deck` 17 + - `org.stormlightlabs.malfestio.source.article` 18 + - `org.stormlightlabs.malfestio.source.lecture` 19 + - `org.stormlightlabs.malfestio.collection` 20 + - `org.stormlightlabs.malfestio.thread.comment` 21 22 ### Lexicon basics 23 ··· 38 2. **No Renaming**: Do not rename fields. 39 If a semantic change is needed, add a new field and deprecate the old one. 40 3. **No Type Changes**: Once published, a field's type is fixed. 41 + 4. **Version by Copying**: If a breaking change is absolutely required, create a new Lexicon with a new major version or a new name (e.g., `org.stormlightlabs.malfestio.noteV2`).
+1 -1
lexicons/app/malfestio/card.json lexicons/org/stormlightlabs/malfestio/card.json
··· 1 { 2 "lexicon": 1, 3 - "id": "app.malfestio.card", 4 "defs": { 5 "main": { 6 "type": "record",
··· 1 { 2 "lexicon": 1, 3 + "id": "org.stormlightlabs.malfestio.card", 4 "defs": { 5 "main": { 6 "type": "record",
+1 -1
lexicons/app/malfestio/collection.json lexicons/org/stormlightlabs/malfestio/collection.json
··· 1 { 2 "lexicon": 1, 3 - "id": "app.malfestio.collection", 4 "defs": { 5 "main": { 6 "type": "record",
··· 1 { 2 "lexicon": 1, 3 + "id": "org.stormlightlabs.malfestio.collection", 4 "defs": { 5 "main": { 6 "type": "record",
+1 -1
lexicons/app/malfestio/deck.json lexicons/org/stormlightlabs/malfestio/deck.json
··· 1 { 2 "lexicon": 1, 3 - "id": "app.malfestio.deck", 4 "defs": { 5 "main": { 6 "type": "record",
··· 1 { 2 "lexicon": 1, 3 + "id": "org.stormlightlabs.malfestio.deck", 4 "defs": { 5 "main": { 6 "type": "record",
+1 -1
lexicons/app/malfestio/note.json lexicons/org/stormlightlabs/malfestio/note.json
··· 1 { 2 "lexicon": 1, 3 - "id": "app.malfestio.note", 4 "defs": { 5 "main": { 6 "type": "record",
··· 1 { 2 "lexicon": 1, 3 + "id": "org.stormlightlabs.malfestio.note", 4 "defs": { 5 "main": { 6 "type": "record",
+1 -1
lexicons/app/malfestio/source/article.json lexicons/org/stormlightlabs/malfestio/source/article.json
··· 1 { 2 "lexicon": 1, 3 - "id": "app.malfestio.source.article", 4 "defs": { 5 "main": { 6 "type": "record",
··· 1 { 2 "lexicon": 1, 3 + "id": "org.stormlightlabs.malfestio.source.article", 4 "defs": { 5 "main": { 6 "type": "record",
+1 -1
lexicons/app/malfestio/source/lecture.json lexicons/org/stormlightlabs/malfestio/source/lecture.json
··· 1 { 2 "lexicon": 1, 3 - "id": "app.malfestio.source.lecture", 4 "defs": { 5 "main": { 6 "type": "record",
··· 1 { 2 "lexicon": 1, 3 + "id": "org.stormlightlabs.malfestio.source.lecture", 4 "defs": { 5 "main": { 6 "type": "record",
+1 -1
lexicons/app/malfestio/thread/comment.json lexicons/org/stormlightlabs/malfestio/thread/comment.json
··· 1 { 2 "lexicon": 1, 3 - "id": "app.malfestio.thread.comment", 4 "defs": { 5 "main": { 6 "type": "record",
··· 1 { 2 "lexicon": 1, 3 + "id": "org.stormlightlabs.malfestio.thread.comment", 4 "defs": { 5 "main": { 6 "type": "record",
+5
migrations/012_2026_01_02_add_card_type.sql
···
··· 1 + -- Migration: Add card_type column to cards table 2 + -- Date: 2026-01-02 3 + -- Issue: card_type was not being persisted, always returning 'basic' 4 + 5 + ALTER TABLE cards ADD COLUMN IF NOT EXISTS card_type TEXT DEFAULT 'basic';
+22 -18
web/src/components/TutorialOverlay.tsx
··· 11 const tooltipWidth = 320; 12 13 switch (placement) { 14 - case "top": 15 return { 16 - top: target.top - padding - 8, 17 left: target.left + target.width / 2 - tooltipWidth / 2, 18 - transform: "translateY(-100%)", 19 }; 20 case "bottom": 21 return { top: target.top + target.height + padding, left: target.left + target.width / 2 - tooltipWidth / 2 }; 22 case "left": ··· 94 exit={{ opacity: 0 }} 95 transition={{ duration: 0.2 }} 96 class="fixed inset-0 z-50 pointer-events-none"> 97 - <Show when={targetPos()}> 98 {(pos) => ( 99 <> 100 <svg ··· 104 <mask id="spotlight-mask"> 105 <rect width="100%" height="100%" fill="white" /> 106 <rect 107 - x={pos().left - 8} 108 - y={pos().top - 8} 109 - width={pos().width + 16} 110 - height={pos().height + 16} 111 rx="8" 112 fill="black" /> 113 </mask> ··· 120 onClick={() => tutorial.skipTutorial()} /> 121 </svg> 122 123 - <div 124 class="absolute border-2 border-[#0F62FE] rounded-lg pointer-events-none" 125 style={{ 126 - top: `${pos().top - 8}px`, 127 - left: `${pos().left - 8}px`, 128 - width: `${pos().width + 16}px`, 129 - height: `${pos().height + 16}px`, 130 "box-shadow": "0 0 0 4px rgba(15, 98, 254, 0.3)", 131 }} /> 132 133 - <div 134 class="absolute w-80 bg-[#262626] border border-[#393939] rounded-lg shadow-xl p-4 pointer-events-auto" 135 style={{ 136 - top: `${getTooltipPosition(pos(), tutorial.currentStep()!.placement).top}px`, 137 - left: `${getTooltipPosition(pos(), tutorial.currentStep()!.placement).left}px`, 138 - transform: getTooltipPosition(pos(), tutorial.currentStep()!.placement).transform, 139 }}> 140 <div class="h-1 bg-[#393939] rounded-full mb-4 overflow-hidden"> 141 <div ··· 178 </Index> 179 </div> 180 <p class="text-xs text-[#525252] text-center mt-3">Use ← → arrow keys or Esc to skip</p> 181 - </div> 182 </> 183 )} 184 </Show>
··· 11 const tooltipWidth = 320; 12 13 switch (placement) { 14 + case "top": { 15 + const tooltipHeight = 240; 16 return { 17 + top: target.top - padding - 16 - tooltipHeight, 18 left: target.left + target.width / 2 - tooltipWidth / 2, 19 }; 20 + } 21 case "bottom": 22 return { top: target.top + target.height + padding, left: target.left + target.width / 2 - tooltipWidth / 2 }; 23 case "left": ··· 95 exit={{ opacity: 0 }} 96 transition={{ duration: 0.2 }} 97 class="fixed inset-0 z-50 pointer-events-none"> 98 + <Show when={targetPos()} keyed> 99 {(pos) => ( 100 <> 101 <svg ··· 105 <mask id="spotlight-mask"> 106 <rect width="100%" height="100%" fill="white" /> 107 <rect 108 + x={pos.left - 8} 109 + y={pos.top - 8} 110 + width={pos.width + 16} 111 + height={pos.height + 16} 112 rx="8" 113 fill="black" /> 114 </mask> ··· 121 onClick={() => tutorial.skipTutorial()} /> 122 </svg> 123 124 + <Motion.div 125 class="absolute border-2 border-[#0F62FE] rounded-lg pointer-events-none" 126 style={{ 127 + top: `${pos.top - 8}px`, 128 + left: `${pos.left - 8}px`, 129 + width: `${pos.width + 16}px`, 130 + height: `${pos.height + 16}px`, 131 "box-shadow": "0 0 0 4px rgba(15, 98, 254, 0.3)", 132 }} /> 133 134 + <Motion.div 135 + initial={{ opacity: 0, scale: 0.95 }} 136 + animate={{ opacity: 1, scale: 1 }} 137 + transition={{ duration: 0.2 }} 138 class="absolute w-80 bg-[#262626] border border-[#393939] rounded-lg shadow-xl p-4 pointer-events-auto" 139 style={{ 140 + top: `${getTooltipPosition(pos, tutorial.currentStep()!.placement).top}px`, 141 + left: `${getTooltipPosition(pos, tutorial.currentStep()!.placement).left}px`, 142 + transform: getTooltipPosition(pos, tutorial.currentStep()!.placement).transform, 143 }}> 144 <div class="h-1 bg-[#393939] rounded-full mb-4 overflow-hidden"> 145 <div ··· 182 </Index> 183 </div> 184 <p class="text-xs text-[#525252] text-center mt-3">Use ← → arrow keys or Esc to skip</p> 185 + </Motion.div> 186 </> 187 )} 188 </Show>
+2 -2
web/src/components/tests/DeckPreview.test.tsx
··· 9 vi.mock( 10 "@solidjs/router", 11 () => ({ 12 - useSearchParams: () => [{ uri: "at://did:plc:test/app.malfestio.deck/123" }], 13 useNavigate: () => vi.fn(), 14 A: (props: { href: string; children: JSX.Element }) => <a href={props.href}>{props.children}</a>, 15 }), ··· 29 ok: true, 30 json: async () => ({ 31 deck: { 32 - id: "at://did:plc:test/app.malfestio.deck/123", 33 owner_did: "did:plc:test", 34 title: "Remote Deck", 35 description: "A test deck",
··· 9 vi.mock( 10 "@solidjs/router", 11 () => ({ 12 + useSearchParams: () => [{ uri: "at://did:plc:test/org.stormlightlabs.malfestio.deck/123" }], 13 useNavigate: () => vi.fn(), 14 A: (props: { href: string; children: JSX.Element }) => <a href={props.href}>{props.children}</a>, 15 }), ··· 29 ok: true, 30 json: async () => ({ 31 deck: { 32 + id: "at://did:plc:test/org.stormlightlabs.malfestio.deck/123", 33 owner_did: "did:plc:test", 34 title: "Remote Deck", 35 description: "A test deck",
+1 -1
web/src/components/tests/Library.test.tsx
··· 32 title: "Federated Deck", 33 description: "From another server", 34 tags: ["remote"], 35 - at_uri: "at://did:plc:other/app.malfestio.deck/deck1", 36 }, 37 rank: 1, 38 source: "remote",
··· 32 title: "Federated Deck", 33 description: "From another server", 34 tags: ["remote"], 35 + at_uri: "at://did:plc:other/org.stormlightlabs.malfestio.deck/deck1", 36 }, 37 rank: 1, 38 source: "remote",