diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs
index a3bb588..5533a77 100644
--- a/crates/app/src/assets.rs
+++ b/crates/app/src/assets.rs
@@ -39,6 +39,7 @@ pub const LOADER_JS: &str = include_str!("./public/js/loader.js");
 pub const ATTO_JS: &str = include_str!("./public/js/atto.js");
 pub const ME_JS: &str = include_str!("./public/js/me.js");
 pub const STREAMS_JS: &str = include_str!("./public/js/streams.js");
+pub const CARP_JS: &str = include_str!("./public/js/carp.js");
 
 // html
 pub const BODY: &str = include_str!("./public/html/body.lisp");
diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml
index b725251..11491fc 100644
--- a/crates/app/src/langs/en-US.toml
+++ b/crates/app/src/langs/en-US.toml
@@ -135,6 +135,8 @@ version = "1.0.0"
 "communities:label.file" = "File"
 "communities:label.drafts" = "Drafts"
 "communities:label.load" = "Load"
+"communities:action.draw" = "Draw"
+"communities:action.remove_drawing" = "Remove drawing"
 
 "notifs:action.mark_as_read" = "Mark as read"
 "notifs:action.mark_as_unread" = "Mark as unread"
diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css
index fbb1d4d..3d7dd62 100644
--- a/crates/app/src/public/css/root.css
+++ b/crates/app/src/public/css/root.css
@@ -389,3 +389,11 @@ blockquote {
         transform: rotateZ(360deg);
     }
 }
+
+canvas {
+    border-radius: var(--radius);
+    border: solid 5px var(--color-primary);
+    background: white;
+    box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
+        var(--color-shadow);
+}
diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css
index f592c77..0603ee1 100644
--- a/crates/app/src/public/css/style.css
+++ b/crates/app/src/public/css/style.css
@@ -1250,3 +1250,32 @@ details.accordion .inner {
 .CodeMirror-focused .CodeMirror-placeholder {
     opacity: 50%;
 }
+
+.CodeMirror-gutters {
+    border-color: var(--color-super-lowered) !important;
+    background-color: var(--color-lowered) !important;
+}
+
+.CodeMirror-hints {
+    background: var(--color-raised) !important;
+    box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
+        var(--color-shadow);
+    border-radius: var(--radius) !important;
+    padding: var(--pad-1) !important;
+    border-color: var(--color-super-lowered) !important;
+}
+
+.CodeMirror-hints li {
+    color: var(--color-text-raised) !important;
+    border-radius: var(--radius) !important;
+    transition:
+        background 0.15s,
+        color 0.15s;
+    font-size: 10px;
+    padding: calc(var(--pad-1) / 2) var(--pad-2);
+}
+
+.CodeMirror-hints li.CodeMirror-hint-active {
+    background-color: var(--color-primary) !important;
+    color: var(--color-text-primary) !important;
+}
diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp
index 16a47d8..a8e398b 100644
--- a/crates/app/src/public/html/body.lisp
+++ b/crates/app/src/public/html/body.lisp
@@ -18,6 +18,42 @@
                 (div ("class" "skel") ("style" "width: 25%; height: 25px;"))
                 (div ("class" "skel") ("style" "width: 100%; height: 150px"))))))
 
+(template
+    ("id" "carp_canvas")
+    (div
+        ("class" "flex flex-col gap-2")
+        (div ("ui_ident" "canvas_loc"))
+        (div
+            ("class" "flex justify-between gap-2")
+            (div
+                ("class" "flex gap-2")
+                (input
+                    ("type" "color")
+                    ("style" "width: 5rem")
+                    ("ui_ident" "color_picker"))
+
+                (input
+                    ("type" "range")
+                    ("min" "1")
+                    ("max" "25")
+                    ("step" "1")
+                    ("value" "2")
+                    ("ui_ident" "stroke_range")))
+
+            (div
+                ("class" "flex  gap-2")
+                (button
+                    ("title" "Undo")
+                    ("ui_ident" "undo")
+                    ("type" "button")
+                    (icon (text "undo")))
+
+                (button
+                    ("title" "Redo")
+                    ("ui_ident" "redo")
+                    ("type" "button")
+                    (icon (text "redo")))))))
+
 ; random js
 (text "<script data-turbo-permanent=\"true\" id=\"init-script\">
     document.documentElement.addEventListener(\"turbo:load\", () => {
diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index 75a24ef..a9f6142 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -405,7 +405,7 @@
                 (text "{%- endif %}"))))
     (text "{% if community and show_community and community.id != config.town_square or question %}"))
 
-(text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0%}")
+(text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}")
 (div
     ("class" "media_gallery gap-2")
     (text "{% for upload in upload_ids %}")
@@ -677,6 +677,8 @@
             ("class" "no_p_margin")
             ("style" "font-weight: 500")
             (text "{{ question.content|markdown|safe }}"))
+        ; question drawings
+        (text "{{ self::post_media(upload_ids=question.drawings) }}")
         ; anonymous user ip thing
         ; this is only shown if the post author is anonymous AND we are a helper
         (text "{% if is_helper and owner.id == 0 %}")
@@ -693,7 +695,7 @@
         (div
             ("class" "flex gap-2 items-center justify-between"))))
 
-(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false) -%}")
+(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false, drawing_enabled=false) -%}")
 (div
     ("class" "card-nest")
     (div
@@ -707,6 +709,12 @@
         ("onsubmit" "create_question_from_form(event)")
         (div
             ("class" "flex flex-col gap-1")
+            ; carp canvas
+            (text "{% if drawing_enabled -%}")
+            (div ("ui_ident" "carp_canvas_field"))
+            (text "{%- endif %}")
+
+            ; form
             (label
                 ("for" "content")
                 (text "{{ text \"communities:label.content\" }}"))
@@ -718,25 +726,83 @@
                 ("required" "")
                 ("minlength" "2")
                 ("maxlength" "4096")))
-        (button
-            ("class" "primary")
-            (text "{{ text \"communities:action.create\" }}"))))
+        (div
+            ("class" "flex gap-2")
+            (button
+                ("class" "primary")
+                (text "{{ text \"communities:action.create\" }}"))
+
+            (text "{% if drawing_enabled -%}")
+            (button
+                ("class" "lowered")
+                ("ui_ident" "add_drawing")
+                ("onclick" "attach_drawing()")
+                ("type" "button")
+                (text "{{ text \"communities:action.draw\" }}"))
+
+            (button
+                ("class" "lowered red hidden")
+                ("ui_ident" "remove_drawing")
+                ("onclick" "remove_drawing()")
+                ("type" "button")
+                (text "{{ text \"communities:action.remove_drawing\" }}"))
+
+            (script
+                (text "globalThis.attach_drawing = () => {
+                    globalThis.gerald = trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]);
+                    globalThis.gerald.create_canvas();
+
+                    document.querySelector(\"[ui_ident=add_drawing]\").classList.add(\"hidden\");
+                    document.querySelector(\"[ui_ident=remove_drawing]\").classList.remove(\"hidden\");
+                }
+
+                globalThis.remove_drawing = async () => {
+                    if (
+                        !(await trigger(\"atto::confirm\", [
+                            \"Are you sure you would like to do this?\",
+                        ]))
+                    ) {
+                        return;
+                    }
+
+                    document.querySelector(\"[ui_ident=carp_canvas_field]\").innerHTML = \"\";
+                    globalThis.gerald = null;
+
+                    document.querySelector(\"[ui_ident=add_drawing]\").classList.remove(\"hidden\");
+                    document.querySelector(\"[ui_ident=remove_drawing]\").classList.add(\"hidden\");
+                }"))
+            (text "{%- endif %}"))))
 
 (script
-    (text "async function create_question_from_form(e) {
+    (text "globalThis.gerald = null;
+    async function create_question_from_form(e) {
         e.preventDefault();
         await trigger(\"atto::debounce\", [\"questions::create\"]);
-        fetch(\"/api/v1/questions\", {
-            method: \"POST\",
-            headers: {
-                \"Content-Type\": \"application/json\",
-            },
-            body: JSON.stringify({
+
+        // create body
+        const body = new FormData();
+
+        if (globalThis.gerald) {
+            body.append(\"drawing.carpgraph\", new Blob([new Uint8Array(globalThis.gerald.as_carp2())], {
+                type: \"application/octet-stream\"
+            }));
+        }
+
+
+        body.append(
+            \"body\",
+            JSON.stringify({
                 content: e.target.content.value,
                 receiver: \"{{ receiver }}\",
                 community: \"{{ community }}\",
                 is_global: \"{{ is_global }}\" == \"true\",
             }),
+        );
+
+        // ...
+        fetch(\"/api/v1/questions\", {
+            method: \"POST\",
+            body,
         })
             .then((res) => res.json())
             .then((res) => {
@@ -747,6 +813,10 @@
 
                 if (res.ok) {
                     e.target.reset();
+
+                    if (globalThis.gerald) {
+                        globalThis.gerald.clear();
+                    }
                 }
             });
     }"))
@@ -1928,7 +1998,7 @@
     (text "{%- endif %}")
 
     ; note listings
-    (text "{% for note in notes %}")
+    (text "{% for note in notes %} {% if not view_mode or note.title != \"journal.css\" -%}")
     (div
         ("class" "flex flex-row gap-1")
         (a
@@ -1958,6 +2028,6 @@
                     (icon (text "trash"))
                     (str (text "general:action.delete")))))
         (text "{%- endif %}"))
-    (text "{% endfor %}"))
+    (text "{%- endif %} {% endfor %}"))
 (text "{%- endif %}")
 (text "{%- endmacro %}")
diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp
index 267541a..54a5415 100644
--- a/crates/app/src/public/html/journals/app.lisp
+++ b/crates/app/src/public/html/journals/app.lisp
@@ -2,6 +2,27 @@
 (text "{% if journal -%}") (title (text "{{ journal.title }}")) (text "{% else %}") (title (text "Journals - {{ config.name }}")) (text "{%- endif %}")
 (link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}"))
 
+(style
+    (text "html, body {
+        overflow: hidden auto !important;
+    }
+
+    .sidebar {
+        position: sticky;
+        top: 42px;
+    }
+
+    @media screen and (max-width: 900px) {
+        .sidebar {
+            position: absolute;
+            top: unset;
+        }
+
+        body.sidebars_shown {
+            overflow: hidden !important;
+        }
+    }"))
+
 (text "{% if view_mode and journal and is_editor -%} {% if note -%}")
 ; redirect to note
 (meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}/{{ note.title }}"))
@@ -9,6 +30,11 @@
 ; redirect to journal homepage
 (meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}"))
 (text "{%- endif %} {%- endif %}")
+
+(text "{% if view_mode and journal -%}")
+; add journal css
+(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/api/v1/journals/{{ journal.id }}/journal.css?v=tetratto-{{ random_cache_breaker }}"))
+(text "{%- endif %}")
 (text "{% endblock %} {% block body %} {{ macros::nav(selected=\"journals\") }}")
 (text "{% if not view_mode -%}")
 (nav
@@ -63,17 +89,19 @@
                         ("href" "/api/v1/auth/user/find/{{ journal.owner }}")
                         (text "{{ components::avatar(username=journal.owner, selector_type=\"id\", size=\"18px\") }}"))
 
+                    (text "{% if (view_mode and owner) or not view_mode -%}")
                     (a
                         ("class" "flush")
-                        ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }}/index {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
+                        ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}")
                         (b (text "{{ journal.title }}")))
+                    (text "{%- endif %}")
 
                     (text "{% if note -%}")
                     (span (text "/"))
                     (b (text "{{ note.title }}"))
                     (text "{%- endif %}"))
 
-                (text "{% if user and user.id == journal.owner -%}")
+                (text "{% if user and user.id == journal.owner and (not note or note.title != \"journal.css\") -%}")
                 (div
                     ("class" "pillmenu")
                     (a
@@ -83,7 +111,7 @@
                         (icon (text "pencil")))
                     (a
                         ("class" "{% if view_mode -%}active{%- endif %}")
-                        ("href" "/@{{ user.username }}/{{ journal.title }}/{% if note -%} {{ note.title }} {%- else -%} index {%- endif %}")
+                        ("href" "/@{{ user.username }}/{{ journal.title }}{% if note -%} /{{ note.title }} {%- endif %}")
                         (icon (text "eye"))))
                 (text "{%- endif %}"))
             (text "{%- endif %}")
@@ -96,6 +124,7 @@
                 ("class" "card w-full flex flex-col gap-2")
                 (h3 (str (text "journals:label.welcome")))
                 (span (str (text "journals:label.select_a_journal")))
+                (span ("class" "mobile") (str (text "journals:label.mobile_click_my_journals")))
                 (button
                     ("onclick" "create_journal()")
                     (icon (text "plus"))
@@ -180,10 +209,36 @@
             ; import codemirror
             (script ("src" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.js") ("data-turbo-temporary" "true"))
             (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/display/placeholder.js") ("data-turbo-temporary" "true"))
-            (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true"))
             (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.css") ("data-turbo-temporary" "true"))
 
+            (text "{% if note.title == \"journal.css\" -%}")
+            ; css editor
+            (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/edit/closebrackets.js") ("data-turbo-temporary" "true"))
+            (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/css-hint.js") ("data-turbo-temporary" "true"))
+            (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.js") ("data-turbo-temporary" "true"))
+            (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/lint/css-lint.js") ("data-turbo-temporary" "true"))
+            (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/css/css.js") ("data-turbo-temporary" "true"))
+            (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.css") ("data-turbo-temporary" "true"))
+            (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/lint/lint.css") ("data-turbo-temporary" "true"))
+
+            (style
+                (text ".CodeMirror {
+                    font-family: monospace !important;
+                    font-size: 16px;
+                    border: solid 1px var(--color-super-lowered);
+                    border-radius: var(--radius);
+                }
+
+                .CodeMirror-line {
+                    padding-left: 5px !important;
+                }"))
+            (text "{% else %}")
+            ; markdown editor
+            (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true"))
+            (text "{%- endif %}")
+
             ; tab bar
+            (text "{% if note.title != \"journal.css\" -%}")
             (div
                 ("class" "pillmenu")
                 (a
@@ -198,6 +253,7 @@
                     ("data-tab-button" "preview")
                     ("data-turbo" "false")
                     (str (text "journals:label.preview_pane"))))
+            (text "{%- endif %}")
 
             ; tabs
             (div
@@ -220,11 +276,16 @@
             ; init codemirror
             (script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}"))
             (script
-                (text "setTimeout(() => {
+                (text "setTimeout(async () => {
+                    if (!document.getElementById(\"preview_tab\").shadowRoot) {
+                        document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" });
+                    }
+
                     globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), {
                         value: document.getElementById(\"editor_content\").innerHTML,
-                        mode: \"markdown\",
+                        mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\",
                         lineWrapping: true,
+                        lineNumbers: \"{{ note.title }}\" === \"journal.css\",
                         autoCloseBrackets: true,
                         autofocus: true,
                         viewportMargin: Number.POSITIVE_INFINITY,
@@ -232,7 +293,8 @@
                         highlightFormatting: false,
                         fencedCodeBlockHighlighting: false,
                         xml: false,
-                        smartIndent: false,
+                        smartIndent: true,
+                        indentUnit: 4,
                         placeholder: `# {{ note.title }}`,
                         extraKeys: {
                             Home: \"goLineLeft\",
@@ -243,6 +305,15 @@
                         },
                     });
 
+                    editor.on(\"keydown\", (cm, e) => {
+                        if (e.key.length > 1) {
+                            // ignore all keys that aren't a letter
+                            return;
+                        }
+
+                        CodeMirror.showHint(cm, CodeMirror.hint.css);
+                    });
+
                     document.querySelector(\"[data-tab-button=editor]\").addEventListener(\"click\", async (e) => {
                         e.preventDefault();
                         trigger(\"atto::hooks::tabs:switch\", [\"editor\"]);
@@ -262,7 +333,11 @@
                             })
                         ).text();
 
-                        document.getElementById(\"preview_tab\").innerHTML = res;
+                        const preview_token = window.crypto.randomUUID();
+                        document.getElementById(\"preview_tab\").shadowRoot.innerHTML = `${res}<style>
+                            @import url(\"/css/style.css\");
+                            @import url(\"/api/v1/journals/{{ journal.id }}/journal.css?v=preview-${preview_token}\");
+                        </style>`;
                         trigger(\"atto::hooks::tabs:switch\", [\"preview\"]);
                     });
                 }, 150);"))
@@ -272,7 +347,34 @@
                 ("class" "flex flex-col gap-2 card")
                 (text "{{ note.content|markdown|safe }}"))
 
-            (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
+            (div
+                ("class" "flex w-full justify-between gap-2")
+                (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}")))
+                (text "{% if user and user.id == owner.id -%}")
+                (button
+                    ("class" "small")
+                    ("onclick" "{% if journal.privacy == \"Public\" -%}
+                        trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
+                    {%- else -%}
+                        prompt_make_public();
+                        trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}'])
+                    {%- endif %}")
+                    (icon (text "share"))
+                    (str (text "general:label.share")))
+
+                (script
+                    (text "globalThis.prompt_make_public = async () => {
+                        if (
+                            !(await trigger(\"atto::confirm\", [
+                                \"Would you like to make this journal public? This is required for others to view this note.\",
+                            ]))
+                        ) {
+                            return;
+                        }
+
+                        change_journal_privacy({ target: { selectedOptions: [{ value: \"Public\" }] }, preventDefault: () => {} });
+                    }"))
+                (text "{%- endif %}"))
             (text "{%- endif %}")
             (text "{%- endif %}")))
     (style
@@ -332,7 +434,7 @@
                 },
                 body: JSON.stringify({
                     title,
-                    content: `# ${title}`,
+                    content: title === \"journal.css\" ? `/* ${title} */\\n` : `# ${title}`,
                     journal: \"{{ selected_journal }}\",
                 }),
             })
@@ -431,8 +533,8 @@
 
         globalThis.change_journal_privacy = async (e) => {
             e.preventDefault();
-            const selected = event.target.selectedOptions[0];
-            fetch(\"/api/v1/journals/{{ selected_journal }}/privacy\", {
+            const selected = e.target.selectedOptions[0];
+            fetch(\"/api/v1/journals/{% if journal -%} {{ journal.id }} {%- else -%} {{ selected_journal }} {%- endif %}/privacy\", {
                 method: \"POST\",
                 headers: {
                     \"Content-Type\": \"application/json\",
diff --git a/crates/app/src/public/html/profile/media.lisp b/crates/app/src/public/html/profile/media.lisp
index a276c90..a3888dc 100644
--- a/crates/app/src/public/html/profile/media.lisp
+++ b/crates/app/src/public/html/profile/media.lisp
@@ -1,7 +1,7 @@
 (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
 (div
     ("style" "display: contents")
-    (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
+    (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
 
 (text "{%- endif %} {{ macros::profile_nav(selected=\"media\") }}")
 (div
diff --git a/crates/app/src/public/html/profile/outbox.lisp b/crates/app/src/public/html/profile/outbox.lisp
index 316aa62..d77d314 100644
--- a/crates/app/src/public/html/profile/outbox.lisp
+++ b/crates/app/src/public/html/profile/outbox.lisp
@@ -1,7 +1,7 @@
 (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
 (div
     ("style" "display: contents")
-    (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
+    (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
 
 (text "{%- endif %} {{ macros::profile_nav(selected=\"outbox\") }}")
 (div
diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp
index 0c9d79a..bbdd6a5 100644
--- a/crates/app/src/public/html/profile/posts.lisp
+++ b/crates/app/src/public/html/profile/posts.lisp
@@ -1,7 +1,7 @@
 (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
 (div
     ("style" "display: contents")
-    (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
+    (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
 
 (text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
 (div
@@ -44,9 +44,10 @@
         ("ui_ident" "io_data_load")
         (div ("ui_ident" "io_data_marker"))))
 
+(text "{% set paged = user and user.settings.paged_timelines %}")
 (script
     (text "setTimeout(() => {
-        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
     });"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/html/profile/replies.lisp b/crates/app/src/public/html/profile/replies.lisp
index ff54816..4afe348 100644
--- a/crates/app/src/public/html/profile/replies.lisp
+++ b/crates/app/src/public/html/profile/replies.lisp
@@ -1,7 +1,7 @@
 (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
 (div
     ("style" "display: contents")
-    (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header) }}"))
+    (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}"))
 
 (text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}")
 (div
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 8be4836..31856fb 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -564,14 +564,14 @@
                                 (li
                                     (text "Use custom CSS on your profile"))
                                 (li
-                                    (text "Ability to use community emojis outside of
+                                    (text "Use community emojis outside of
                                     their community"))
                                 (li
-                                    (text "Ability to upload and use gif emojis"))
+                                    (text "Upload and use gif emojis"))
                                 (li
                                     (text "Create infinite stack timelines"))
                                 (li
-                                    (text "Ability to upload images to posts"))
+                                    (text "Upload images to posts"))
                                 (li
                                     (text "Save infinite post drafts"))
                                 (li
@@ -579,7 +579,7 @@
                                 (li
                                     (text "Ability to create forges"))
                                 (li
-                                    (text "Ability to create more than 1 app"))
+                                    (text "Create more than 1 app"))
                                 (li
                                     (text "Create up to 10 stack blocks"))
                                 (li
@@ -587,7 +587,9 @@
                                 (li
                                     (text "Increased proxied image size"))
                                 (li
-                                    (text "Create infinite journals")))
+                                    (text "Create infinite journals"))
+                                (li
+                                    (text "Create infinite notes in each journal")))
                             (a
                                 ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
                                 ("class" "button")
@@ -1374,6 +1376,14 @@
                         \"{{ profile.settings.allow_anonymous_questions }}\",
                         \"checkbox\",
                     ],
+                    [
+                        [
+                            \"enable_drawings\",
+                            \"Allow users to create drawings and submit them with questions\",
+                        ],
+                        \"{{ profile.settings.enable_drawings }}\",
+                        \"checkbox\",
+                    ],
                     [
                         [\"motivational_header\", \"Motivational header\"],
                         settings.motivational_header,
@@ -1401,6 +1411,11 @@
                         \"Hides dislikes on all posts. Users will also no longer be able to dislike your posts.\",
                         \"text\",
                     ],
+                    [
+                        [\"paged_timelines\", \"Make timelines paged instead of infinitely scrolled\"],
+                        \"{{ profile.settings.paged_timelines }}\",
+                        \"checkbox\",
+                    ],
                     [[], \"Fun\", \"title\"],
                     [
                         [\"disable_gpa_fun\", \"Disable GPA\"],
diff --git a/crates/app/src/public/html/stacks/feed.lisp b/crates/app/src/public/html/stacks/feed.lisp
index 5698065..5002856 100644
--- a/crates/app/src/public/html/stacks/feed.lisp
+++ b/crates/app/src/public/html/stacks/feed.lisp
@@ -83,9 +83,10 @@
                 ("ui_ident" "io_data_load")
                 (div ("ui_ident" "io_data_marker")))
 
+            (text "{% set paged = user and user.settings.paged_timelines %}")
             (script
                 (text "setTimeout(() => {
-                    trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+                    trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
                 });"))
             (text "{%- endif %}"))))
 
diff --git a/crates/app/src/public/html/timelines/all.lisp b/crates/app/src/public/html/timelines/all.lisp
index c38dd88..7cced78 100644
--- a/crates/app/src/public/html/timelines/all.lisp
+++ b/crates/app/src/public/html/timelines/all.lisp
@@ -33,9 +33,10 @@
         ("ui_ident" "io_data_load")
         (div ("ui_ident" "io_data_marker"))))
 
+(text "{% set paged = user and user.settings.paged_timelines %}")
 (script
     (text "setTimeout(() => {
-        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
     });"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/html/timelines/following.lisp b/crates/app/src/public/html/timelines/following.lisp
index b1759e4..ef23a55 100644
--- a/crates/app/src/public/html/timelines/following.lisp
+++ b/crates/app/src/public/html/timelines/following.lisp
@@ -11,9 +11,10 @@
         ("ui_ident" "io_data_load")
         (div ("ui_ident" "io_data_marker"))))
 
+(text "{% set paged = user and user.settings.paged_timelines %}")
 (script
     (text "setTimeout(() => {
-        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
     });"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/html/timelines/home.lisp b/crates/app/src/public/html/timelines/home.lisp
index e398615..5a5658b 100644
--- a/crates/app/src/public/html/timelines/home.lisp
+++ b/crates/app/src/public/html/timelines/home.lisp
@@ -31,9 +31,10 @@
         (div ("ui_ident" "io_data_marker")))
     (text "{%- endif %}"))
 
+(text "{% set paged = user and user.settings.paged_timelines %}")
 (script
     (text "setTimeout(() => {
-        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
     });"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/html/timelines/popular.lisp b/crates/app/src/public/html/timelines/popular.lisp
index 6d26f3d..d0223df 100644
--- a/crates/app/src/public/html/timelines/popular.lisp
+++ b/crates/app/src/public/html/timelines/popular.lisp
@@ -11,9 +11,10 @@
         ("ui_ident" "io_data_load")
         (div ("ui_ident" "io_data_marker"))))
 
+(text "{% set paged = user and user.settings.paged_timelines %}")
 (script
     (text "setTimeout(() => {
-        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1]);
+        trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
     });"))
 
 (text "{% endblock %}")
diff --git a/crates/app/src/public/html/timelines/swiss_army.lisp b/crates/app/src/public/html/timelines/swiss_army.lisp
index eb722c9..23243ce 100644
--- a/crates/app/src/public/html/timelines/swiss_army.lisp
+++ b/crates/app/src/public/html/timelines/swiss_army.lisp
@@ -30,3 +30,7 @@
         (str (text "chats:label.go_back")))
     (text "{%- endif %}"))
 (text "{%- endif %}")
+
+(text "{% if paginated -%}")
+(text "{{ components::pagination(page=page, items=list|length) }}")
+(text "{%- endif %}")
diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js
index 6c30428..a417a46 100644
--- a/crates/app/src/public/js/atto.js
+++ b/crates/app/src/public/js/atto.js
@@ -39,6 +39,7 @@ media_theme_pref();
     // init
     use("me", () => {});
     use("streams", () => {});
+    use("carp", () => {});
 
     // env
     self.DEBOUNCE = [];
@@ -1141,7 +1142,7 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
         },
     );
 
-    self.define("io_data_load", (_, tmpl, page) => {
+    self.define("io_data_load", (_, tmpl, page, paginated_mode = false) => {
         self.IO_DATA_MARKER = document.querySelector(
             "[ui_ident=io_data_marker]",
         );
@@ -1164,7 +1165,16 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
         self.IO_DATA_PAGE = page;
         self.IO_DATA_SEEN_IDS = [];
 
-        self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
+        if (!paginated_mode) {
+            self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER);
+        } else {
+            // immediately load first page
+            self.IO_DATA_TMPL = self.IO_DATA_TMPL.replace("&page=", "");
+            self.IO_DATA_TMPL += `&paginated=true&page=`;
+            self.io_load_data();
+        }
+
+        self.IO_PAGINATED = paginated_mode;
     });
 
     self.define("io_load_data", async () => {
diff --git a/crates/app/src/public/js/carp.js b/crates/app/src/public/js/carp.js
new file mode 100644
index 0000000..dbef3ed
--- /dev/null
+++ b/crates/app/src/public/js/carp.js
@@ -0,0 +1,624 @@
+(() => {
+    const self = reg_ns("carp");
+
+    const END_OF_HEADER = 0x1a;
+    const COLOR = 0x1b;
+    const SIZE = 0x2b;
+    const LINE = 0x3b;
+    const POINT = 0x4b;
+    const EOF = 0x1f;
+
+    function enc(s, as = "guess") {
+        if ((as === "guess" && typeof s === "number") || as === "u32") {
+            // encode u32
+            const view = new DataView(new ArrayBuffer(16));
+            view.setUint32(0, s);
+            return new Uint8Array(view.buffer).slice(0, 4);
+        }
+
+        if (as === "u16") {
+            // encode u16
+            const view = new DataView(new ArrayBuffer(16));
+            view.setUint16(0, s);
+            return new Uint8Array(view.buffer).slice(0, 2);
+        }
+
+        // encode string
+        const encoder = new TextEncoder();
+        return encoder.encode(s);
+    }
+
+    function dec(as, from) {
+        if (as === "u32") {
+            // decode u32
+            const view = new DataView(new Uint8Array(from).buffer);
+            return view.getUint32(0);
+        }
+
+        if (as === "u16") {
+            // decode u16
+            const view = new DataView(new Uint8Array(from).buffer);
+            return view.getUint16(0);
+        }
+
+        // decode string
+        const decoder = new TextDecoder();
+        return decoder.decode(from);
+    }
+
+    function lpad(size, input) {
+        if (input.length === size) {
+            return input;
+        }
+
+        for (let i = 0; i < size - (input.length - 1); i++) {
+            input = [0, ...input];
+        }
+
+        return input;
+    }
+
+    self.enc = enc;
+    self.dec = dec;
+    self.lpad = lpad;
+
+    self.CARPS = {};
+    self.define("new", function ({ $ }, bind_to, read_only = false) {
+        const canvas = new CarpCanvas(bind_to, read_only);
+        $.CARPS[bind_to.getAttribute("ui_ident")] = canvas;
+        return canvas;
+    });
+
+    class CarpCanvas {
+        #element; // HTMLElement
+        #ctx; // CanvasRenderingContext2D
+        #pos = { x: 0, y: 0 }; // Vec2
+
+        STROKE_SIZE = 2;
+        #stroke_size_old = 2;
+        COLOR = "#000000";
+        #color_old = "#000000";
+
+        COMMANDS = [];
+        HISTORY = [];
+        HISTORY_IDX = 0;
+        #cmd_store = [];
+        #undo_clear_future = false; // if we should clear to HISTORY_IDX on next draw
+
+        onedit;
+        read_only;
+
+        /// Create a new [`CarpCanvas`]
+        constructor(element, read_only) {
+            this.#element = element;
+            this.read_only = read_only;
+        }
+
+        /// Push #line_store to LINES
+        push_state() {
+            this.COMMANDS = [...this.COMMANDS, ...this.#cmd_store];
+            this.#cmd_store = [];
+
+            this.HISTORY.push(this.COMMANDS);
+            this.HISTORY_IDX += 1;
+
+            if (this.#undo_clear_future) {
+                this.HISTORY = this.HISTORY.slice(0, this.HISTORY_IDX);
+                this.#undo_clear_future = false;
+            }
+
+            if (this.onedit) {
+                this.onedit(this.as_string());
+            }
+        }
+
+        /// Read current position in history and draw it.
+        draw_from_history() {
+            this.COMMANDS = this.HISTORY[this.HISTORY_IDX];
+            const bytes = this.as_carp2();
+            this.from_bytes(bytes); // draw
+        }
+
+        /// Undo changes.
+        undo() {
+            if (this.HISTORY_IDX === 0) {
+                // cannot undo
+                return;
+            }
+
+            this.HISTORY_IDX -= 1;
+            this.draw_from_history();
+            this.#undo_clear_future = false;
+        }
+
+        /// Redo changes.
+        redo() {
+            if (this.HISTORY_IDX === this.HISTORY.length - 1) {
+                // cannot redo
+                return;
+            }
+
+            this.HISTORY_IDX += 1;
+            this.draw_from_history();
+        }
+
+        /// Create canvas and init context
+        async create_canvas() {
+            const canvas = document.createElement("canvas");
+
+            canvas.width = "300";
+            canvas.height = "200";
+
+            this.#ctx = canvas.getContext("2d");
+
+            if (!this.read_only) {
+                // desktop
+                canvas.addEventListener(
+                    "mousemove",
+                    (e) => {
+                        this.draw_event(e);
+                    },
+                    false,
+                );
+
+                canvas.addEventListener(
+                    "mouseup",
+                    (e) => {
+                        this.push_state();
+                    },
+                    false,
+                );
+
+                canvas.addEventListener(
+                    "mousedown",
+                    (e) => {
+                        this.#cmd_store.push({
+                            type: "Line",
+                            data: [],
+                        });
+
+                        this.move_event(e);
+                    },
+                    false,
+                );
+
+                canvas.addEventListener(
+                    "mouseenter",
+                    (e) => {
+                        this.move_event(e);
+                    },
+                    false,
+                );
+
+                // mobile
+                canvas.addEventListener(
+                    "touchmove",
+                    (e) => {
+                        e.preventDefault();
+
+                        e.clientX = e.changedTouches[0].clientX;
+                        e.clientY = e.changedTouches[0].clientY;
+
+                        this.draw_event(e, true);
+                    },
+                    false,
+                );
+
+                canvas.addEventListener(
+                    "touchstart",
+                    (e) => {
+                        e.preventDefault();
+
+                        e.clientX = e.changedTouches[0].clientX;
+                        e.clientY = e.changedTouches[0].clientY;
+
+                        this.#cmd_store.push({
+                            type: "Line",
+                            data: [],
+                        });
+
+                        this.move_event(e);
+                    },
+                    false,
+                );
+
+                canvas.addEventListener(
+                    "touchend",
+                    (e) => {
+                        e.preventDefault();
+
+                        e.clientX = e.changedTouches[0].clientX;
+                        e.clientY = e.changedTouches[0].clientY;
+
+                        this.push_state();
+                        this.move_event(e);
+                    },
+                    false,
+                );
+
+                // add controls
+                const controls_tmpl = document
+                    .getElementById("carp_canvas")
+                    .content.cloneNode(true);
+                this.#element.appendChild(controls_tmpl);
+
+                const canvas_loc = this.#element.querySelector(
+                    "[ui_ident=canvas_loc]",
+                );
+                canvas_loc.appendChild(canvas);
+
+                const color_picker = this.#element.querySelector(
+                    "[ui_ident=color_picker]",
+                );
+                color_picker.addEventListener("change", (e) => {
+                    this.set_old_color(this.COLOR);
+                    this.COLOR = e.target.value;
+                });
+
+                const stroke_range = this.#element.querySelector(
+                    "[ui_ident=stroke_range]",
+                );
+                stroke_range.addEventListener("change", (e) => {
+                    this.set_old_stroke_size(this.STROKE_SIZE);
+                    this.STROKE_SIZE = e.target.value;
+                });
+
+                const undo = this.#element.querySelector("[ui_ident=undo]");
+                undo.addEventListener("click", () => {
+                    this.undo();
+                });
+
+                const redo = this.#element.querySelector("[ui_ident=redo]");
+                redo.addEventListener("click", () => {
+                    this.redo();
+                });
+            }
+        }
+
+        /// Resize the canvas
+        resize(size) {
+            this.#ctx.canvas.width = size.x;
+            this.#ctx.canvas.height = size.y;
+        }
+
+        /// Clear the canvas
+        clear() {
+            const canvas = this.#ctx.canvas;
+            this.#ctx.clearRect(0, 0, canvas.width, canvas.height);
+        }
+
+        /// Set the old color
+        set_old_color(value) {
+            this.#color_old = value;
+        }
+
+        /// Set the old stroke_size
+        set_old_stroke_size(value) {
+            this.#stroke_size_old = value;
+        }
+
+        /// Update position (from event)
+        move_event(e) {
+            const rect = this.#ctx.canvas.getBoundingClientRect();
+
+            const x = e.clientX - rect.left;
+            const y = e.clientY - rect.top;
+
+            this.move({ x, y });
+        }
+
+        /// Update position
+        move(pos) {
+            this.#pos.x = pos.x;
+            this.#pos.y = pos.y;
+        }
+
+        /// Draw on the canvas (from event)
+        draw_event(e, mobile = false) {
+            if (e.buttons !== 1 && mobile === false) return;
+            const rect = this.#ctx.canvas.getBoundingClientRect();
+
+            const x = e.clientX - rect.left;
+            const y = e.clientY - rect.top;
+
+            this.draw({ x, y });
+        }
+
+        /// Draw on the canvas
+        draw(pos, skip_line_store = false) {
+            this.#ctx.beginPath();
+
+            this.#ctx.lineWidth = this.STROKE_SIZE;
+            this.#ctx.strokeStyle = this.COLOR;
+            this.#ctx.lineCap = "round";
+
+            this.#ctx.moveTo(this.#pos.x, this.#pos.y);
+            this.move(pos);
+            this.#ctx.lineTo(this.#pos.x, this.#pos.y);
+
+            if (!skip_line_store) {
+                // yes flooring the values will make the image SLIGHTLY different,
+                // but it also saves THOUSANDS of characters
+                const point = [
+                    Math.floor(this.#pos.x),
+                    Math.floor(this.#pos.y),
+                ];
+
+                if (this.#color_old !== this.COLOR) {
+                    this.#cmd_store.push({
+                        type: "Color",
+                        data: enc(this.COLOR.replace("#", "")),
+                    });
+                }
+
+                if (this.#stroke_size_old !== this.STROKE_SIZE) {
+                    this.#cmd_store.push({
+                        type: "Size",
+                        data: lpad(2, enc(this.STROKE_SIZE, "u16")), // u16
+                    });
+                }
+
+                this.#cmd_store.push({
+                    type: "Point",
+                    data: [
+                        // u32
+                        ...lpad(4, enc(point[0])),
+                        ...lpad(4, enc(point[1])),
+                    ],
+                });
+
+                if (this.#color_old !== this.COLOR) {
+                    // we've already seen it once, time to update it
+                    this.set_old_color(this.COLOR);
+                }
+
+                if (this.#stroke_size_old !== this.STROKE_SIZE) {
+                    this.set_old_stroke_size(this.STROKE_SIZE);
+                }
+            }
+
+            this.#ctx.stroke();
+        }
+
+        /// Create blob and get URL
+        as_blob() {
+            const blob = this.#ctx.canvas.toBlob();
+            return URL.createObjectURL(blob);
+        }
+
+        /// Create Carp2 representation of the graph
+        as_carp2() {
+            // most stuff should have an lpad of 4 to make sure it's a u32 (4 bytes)
+            const header = [
+                ...enc("CG"),
+                ...enc("02"),
+                ...lpad(4, enc(this.#ctx.canvas.width)),
+                ...lpad(4, enc(this.#ctx.canvas.height)),
+                END_OF_HEADER,
+            ];
+
+            // build commands
+            const commands = [];
+            commands.push(COLOR);
+            commands.push(...enc("000000"));
+            commands.push(SIZE);
+            commands.push(...lpad(4, enc(2)).slice(2));
+
+            for (const command of this.COMMANDS) {
+                // this is `impl Into<Vec<u8>> for Command`
+                switch (command.type) {
+                    case "Point":
+                        commands.push(POINT);
+                        break;
+
+                    case "Line":
+                        commands.push(LINE);
+                        break;
+
+                    case "Color":
+                        commands.push(COLOR);
+                        break;
+
+                    case "Size":
+                        commands.push(SIZE);
+                        break;
+                }
+
+                commands.push(...command.data);
+            }
+
+            // this is so fucking stupid the fact that arraybuffers send as a fucking
+            // concatenated string of the NUMBERS of the bytes is so stupid this is
+            // actually crazy what the fuck is this shit
+            //
+            // didn't expect i'd have to do this shit myself considering it's done
+            // for you with File prototypes from a file input
+            const bin = [...header, ...commands, EOF];
+            let bin_str = "";
+
+            for (const byte of bin) {
+                bin_str += String.fromCharCode(byte);
+            }
+
+            // return
+            return bin;
+        }
+
+        /// Export lines as string
+        as_string() {
+            return JSON.stringify(this.COMMANDS);
+        }
+
+        /// From an array of bytes
+        from_bytes(input) {
+            this.clear();
+
+            let idx = -1;
+            function next() {
+                idx += 1;
+                return [idx, input[idx]];
+            }
+
+            function select_bytes(count) {
+                // select_bytes! macro
+                const data = [];
+                let seen_bytes = 0;
+
+                let [_, byte] = next();
+                while (byte !== undefined) {
+                    seen_bytes += 1;
+                    data.push(byte);
+
+                    if (seen_bytes === count) {
+                        break;
+                    }
+
+                    [_, byte] = next();
+                }
+
+                return data;
+            }
+
+            // everything past this is just a reverse implementation of carp2.rs in js
+            const commands = [];
+            const dimensions = { x: 0, y: 0 };
+            let in_header = true;
+            let seen_point = false;
+            let byte_buffer = [];
+
+            let [i, byte] = next();
+            while (byte !== undefined) {
+                switch (byte) {
+                    case END_OF_HEADER:
+                        in_header = false;
+                        break;
+
+                    case COLOR:
+                        {
+                            const data = select_bytes(6);
+                            commands.push({
+                                type: "Color",
+                                data,
+                            });
+                            this.COLOR = `#${dec("string", new Uint8Array(data))}`;
+                        }
+                        break;
+
+                    case SIZE:
+                        {
+                            const data = select_bytes(2);
+                            commands.push({
+                                type: "Size",
+                                data,
+                            });
+                            this.STROKE_SIZE = dec("u16", data);
+                        }
+                        break;
+
+                    case POINT:
+                        {
+                            const data = select_bytes(8);
+                            commands.push({
+                                type: "Point",
+                                data,
+                            });
+
+                            const point = {
+                                x: dec("u32", data.slice(0, 4)),
+                                y: dec("u32", data.slice(4, 8)),
+                            };
+
+                            if (!seen_point) {
+                                // this is the FIRST POINT that has been seen...
+                                // we need to start drawing from here to avoid a line
+                                // from 0,0 to the point
+                                this.move(point);
+                                seen_point = true;
+                            }
+
+                            this.draw(point, true);
+                        }
+                        break;
+
+                    case LINE:
+                        // each line starts at a new place (probably)
+                        seen_point = false;
+                        break;
+
+                    case EOF:
+                        break;
+
+                    default:
+                        if (in_header) {
+                            if (0 <= i < 2) {
+                                // tag
+                            } else if (2 <= i < 4) {
+                                //version
+                            } else if (4 <= i < 8) {
+                                // width
+                                byte_buffer.push(byte);
+
+                                if (i === 7) {
+                                    dimensions.x = dec("u32", byte_buffer);
+                                    byte_buffer = [];
+                                }
+                            } else if (8 <= i < 12) {
+                                // height
+                                byte_buffer.push(byte);
+
+                                if (i === 7) {
+                                    dimensions.y = dec("u32", byte_buffer);
+                                    byte_buffer = [];
+                                    this.resize(dimensions); // update canvas
+                                }
+                            }
+                        } else {
+                            // misc byte
+                            console.log(`extraneous byte at ${i}`);
+                        }
+
+                        break;
+                }
+
+                // ...
+                [i, byte] = next();
+            }
+
+            return commands;
+        }
+
+        /// Download image as `.carpgraph`
+        download() {
+            const blob = new Blob([new Uint8Array(this.as_carp2())], {
+                type: "image/carpgraph",
+            });
+
+            const url = URL.createObjectURL(blob);
+
+            const anchor = document.createElement("a");
+            anchor.href = url;
+            anchor.setAttribute("download", "image.carpgraph");
+            document.body.appendChild(anchor);
+            anchor.click();
+            anchor.remove();
+
+            URL.revokeObjectURL(url);
+        }
+
+        /// Download image as `.carpgraph1`
+        download_json() {
+            const string = this.as_string();
+            const blob = new Blob([string], { type: "application/json" });
+            const url = URL.createObjectURL(blob);
+
+            const anchor = document.createElement("a");
+            anchor.href = url;
+            anchor.setAttribute("download", "image.carpgraph_json");
+            document.body.appendChild(anchor);
+            anchor.click();
+            anchor.remove();
+
+            URL.revokeObjectURL(url);
+        }
+    }
+})();
diff --git a/crates/app/src/public/js/loader.js b/crates/app/src/public/js/loader.js
index 558a9a7..fd1d1df 100644
--- a/crates/app/src/public/js/loader.js
+++ b/crates/app/src/public/js/loader.js
@@ -24,7 +24,7 @@ globalThis.ns = (ns) => {
 
     if (!res) {
         return console.error(
-            "namespace does not exist, please use one of the following:",
+            `namespace "${ns}" does not exist, please use one of the following:`,
             Object.keys(globalThis._app_base.ns_store),
         );
     }
diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs
index e062be1..e177db7 100644
--- a/crates/app/src/routes/api/v1/auth/images.rs
+++ b/crates/app/src/routes/api/v1/auth/images.rs
@@ -213,7 +213,7 @@ pub async fn upload_avatar_request(
     if mime == "image/gif" {
         // gif image, don't encode
         if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
-            return Json(Error::DataTooLong("gif".to_string()).into());
+            return Json(Error::FileTooLarge.into());
         }
 
         std::fs::write(&path, img.0).unwrap();
@@ -226,7 +226,7 @@ pub async fn upload_avatar_request(
 
     // check file size
     if img.0.len() > MAXIMUM_FILE_SIZE {
-        return Json(Error::DataTooLong("image".to_string()).into());
+        return Json(Error::FileTooLarge.into());
     }
 
     // upload image
@@ -314,7 +314,7 @@ pub async fn upload_banner_request(
     if mime == "image/gif" {
         // gif image, don't encode
         if img.0.len() > MAXIMUM_GIF_FILE_SIZE {
-            return Json(Error::DataTooLong("gif".to_string()).into());
+            return Json(Error::FileTooLarge.into());
         }
 
         std::fs::write(&path, img.0).unwrap();
@@ -327,7 +327,7 @@ pub async fn upload_banner_request(
 
     // check file size
     if img.0.len() > MAXIMUM_FILE_SIZE {
-        return Json(Error::DataTooLong("image".to_string()).into());
+        return Json(Error::FileTooLarge.into());
     }
 
     // upload image
diff --git a/crates/app/src/routes/api/v1/communities/images.rs b/crates/app/src/routes/api/v1/communities/images.rs
index 464dede..3ddee00 100644
--- a/crates/app/src/routes/api/v1/communities/images.rs
+++ b/crates/app/src/routes/api/v1/communities/images.rs
@@ -136,7 +136,7 @@ pub async fn upload_avatar_request(
 
     // check file size
     if img.0.len() > MAXIMUM_FILE_SIZE {
-        return Json(Error::DataTooLong("image".to_string()).into());
+        return Json(Error::FileTooLarge.into());
     }
 
     // upload image
@@ -191,7 +191,7 @@ pub async fn upload_banner_request(
 
     // check file size
     if img.0.len() > MAXIMUM_FILE_SIZE {
-        return Json(Error::DataTooLong("image".to_string()).into());
+        return Json(Error::FileTooLarge.into());
     }
 
     // upload image
diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs
index 81a1fae..7bf4bf3 100644
--- a/crates/app/src/routes/api/v1/communities/posts.rs
+++ b/crates/app/src/routes/api/v1/communities/posts.rs
@@ -133,7 +133,7 @@ pub async fn create_request(
     // check sizes
     for img in &images {
         if img.len() > MAXIMUM_FILE_SIZE {
-            return Json(Error::DataTooLong("image".to_string()).into());
+            return Json(Error::FileTooLarge.into());
         }
     }
 
diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs
index 270197f..5e9de0d 100644
--- a/crates/app/src/routes/api/v1/communities/questions.rs
+++ b/crates/app/src/routes/api/v1/communities/questions.rs
@@ -15,6 +15,7 @@ use tetratto_core::model::{
 };
 use crate::{
     get_user_from_token,
+    image::JsonMultipart,
     routes::{api::v1::CreateQuestion, pages::PaginatedQuery},
     State,
 };
@@ -23,7 +24,7 @@ pub async fn create_request(
     jar: CookieJar,
     headers: HeaderMap,
     Extension(data): Extension<State>,
-    Json(req): Json<CreateQuestion>,
+    JsonMultipart(drawings, req): JsonMultipart<CreateQuestion>,
 ) -> impl IntoResponse {
     let data = &(data.read().await).0;
     let user = get_user_from_token!(jar, data, oauth::AppScope::UserCreateQuestions);
@@ -70,7 +71,10 @@ pub async fn create_request(
         }
     }
 
-    match data.create_question(props).await {
+    match data
+        .create_question(props, drawings.iter().map(|x| x.to_vec()).collect())
+        .await
+    {
         Ok(id) => Json(ApiReturn {
             ok: true,
             message: "Question created".to_string(),
diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs
index 97f8c9b..19944c6 100644
--- a/crates/app/src/routes/api/v1/journals.rs
+++ b/crates/app/src/routes/api/v1/journals.rs
@@ -9,11 +9,14 @@ use crate::{
     routes::api::v1::{UpdateJournalPrivacy, CreateJournal, UpdateJournalTitle},
     State,
 };
-use tetratto_core::model::{
-    journals::{Journal, JournalPrivacyPermission},
-    oauth,
-    permissions::FinePermission,
-    ApiReturn, Error,
+use tetratto_core::{
+    database::NAME_REGEX,
+    model::{
+        journals::{Journal, JournalPrivacyPermission},
+        oauth,
+        permissions::FinePermission,
+        ApiReturn, Error,
+    },
 };
 
 pub async fn get_request(
@@ -46,6 +49,28 @@ pub async fn get_request(
     })
 }
 
+pub async fn get_css_request(
+    Path(id): Path<usize>,
+    Extension(data): Extension<State>,
+) -> impl IntoResponse {
+    let data = &(data.read().await).0;
+
+    let note = match data.get_note_by_journal_title(id, "journal.css").await {
+        Ok(x) => x,
+        Err(e) => {
+            return (
+                [("Content-Type", "text/css"), ("Cache-Control", "no-cache")],
+                format!("/* {e} */"),
+            );
+        }
+    };
+
+    (
+        [("Content-Type", "text/css"), ("Cache-Control", "no-cache")],
+        note.content,
+    )
+}
+
 pub async fn list_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
     let data = &(data.read().await).0;
     let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadJournals) {
@@ -99,7 +124,17 @@ pub async fn update_title_request(
         None => return Json(Error::NotAllowed.into()),
     };
 
-    props.title = props.title.replace(" ", "_");
+    props.title = props.title.replace(" ", "_").to_lowercase();
+
+    // check name
+    let regex = regex::RegexBuilder::new(NAME_REGEX)
+        .multi_line(true)
+        .build()
+        .unwrap();
+
+    if regex.captures(&props.title).is_some() {
+        return Json(Error::MiscError("This title contains invalid characters".to_string()).into());
+    }
 
     // make sure this title isn't already in use
     if data
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index f16b1ed..9217437 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -551,6 +551,7 @@ pub fn routes() -> Router {
         .route("/journals", post(journals::create_request))
         .route("/journals/{id}", get(journals::get_request))
         .route("/journals/{id}", delete(journals::delete_request))
+        .route("/journals/{id}/journal.css", get(journals::get_css_request))
         .route("/journals/{id}/title", post(journals::update_title_request))
         .route(
             "/journals/{id}/privacy",
diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs
index 45c4a74..faf1bec 100644
--- a/crates/app/src/routes/api/v1/notes.rs
+++ b/crates/app/src/routes/api/v1/notes.rs
@@ -10,12 +10,15 @@ use crate::{
     routes::api::v1::{CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteTitle},
     State,
 };
-use tetratto_core::model::{
-    journals::{JournalPrivacyPermission, Note},
-    oauth,
-    permissions::FinePermission,
-    uploads::CustomEmoji,
-    ApiReturn, Error,
+use tetratto_core::{
+    database::NAME_REGEX,
+    model::{
+        journals::{JournalPrivacyPermission, Note},
+        oauth,
+        permissions::FinePermission,
+        uploads::CustomEmoji,
+        ApiReturn, Error,
+    },
 };
 
 pub async fn get_request(
@@ -135,7 +138,17 @@ pub async fn update_title_request(
         Err(e) => return Json(e.into()),
     };
 
-    props.title = props.title.replace(" ", "_");
+    props.title = props.title.replace(" ", "_").to_lowercase();
+
+    // check name
+    let regex = regex::RegexBuilder::new(NAME_REGEX)
+        .multi_line(true)
+        .build()
+        .unwrap();
+
+    if regex.captures(&props.title).is_some() {
+        return Json(Error::MiscError("This title contains invalid characters".to_string()).into());
+    }
 
     // make sure this title isn't already in use
     if data
diff --git a/crates/app/src/routes/api/v1/uploads.rs b/crates/app/src/routes/api/v1/uploads.rs
index 8a6a8bb..0e7d6ab 100644
--- a/crates/app/src/routes/api/v1/uploads.rs
+++ b/crates/app/src/routes/api/v1/uploads.rs
@@ -4,7 +4,7 @@ use axum_extra::extract::CookieJar;
 use pathbufd::PathBufD;
 use crate::{get_user_from_token, State};
 use super::auth::images::read_image;
-use tetratto_core::model::{oauth, ApiReturn, Error};
+use tetratto_core::model::{carp::CarpGraph, oauth, uploads::MediaType, ApiReturn, Error};
 
 pub async fn get_request(
     Path(id): Path<usize>,
@@ -39,10 +39,17 @@ pub async fn get_request(
         ));
     }
 
-    Ok((
-        [("Content-Type", upload.what.mime())],
-        Body::from(read_image(path)),
-    ))
+    let bytes = read_image(path);
+
+    if upload.what == MediaType::Carpgraph {
+        // conver to svg and return
+        return Ok((
+            [("Content-Type", "image/svg+xml".to_string())],
+            Body::from(CarpGraph::from_bytes(bytes).to_svg()),
+        ));
+    }
+
+    Ok(([("Content-Type", upload.what.mime())], Body::from(bytes)))
 }
 
 pub async fn delete_request(
diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs
index f721eb3..4a450c5 100644
--- a/crates/app/src/routes/assets.rs
+++ b/crates/app/src/routes/assets.rs
@@ -18,3 +18,4 @@ serve_asset!(loader_js_request: LOADER_JS("text/javascript"));
 serve_asset!(atto_js_request: ATTO_JS("text/javascript"));
 serve_asset!(me_js_request: ME_JS("text/javascript"));
 serve_asset!(streams_js_request: STREAMS_JS("text/javascript"));
+serve_asset!(carp_js_request: CARP_JS("text/javascript"));
diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs
index 123f29a..de1b240 100644
--- a/crates/app/src/routes/mod.rs
+++ b/crates/app/src/routes/mod.rs
@@ -19,6 +19,7 @@ pub fn routes(config: &Config) -> Router {
         .route("/js/atto.js", get(assets::atto_js_request))
         .route("/js/me.js", get(assets::me_js_request))
         .route("/js/streams.js", get(assets::streams_js_request))
+        .route("/js/carp.js", get(assets::carp_js_request))
         .nest_service(
             "/public",
             get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),
diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs
index f631826..1f03dd7 100644
--- a/crates/app/src/routes/pages/journals.rs
+++ b/crates/app/src/routes/pages/journals.rs
@@ -116,7 +116,7 @@ pub async fn view_request(
     }
 
     // if we don't have a selected journal, we shouldn't be here probably
-    if selected_journal.is_empty() {
+    if selected_journal.is_empty() | (selected_note == "journal.css") {
         return Err(Html(
             render_error(Error::NotAllowed, &jar, &data, &user).await,
         ));
@@ -207,3 +207,81 @@ pub async fn view_request(
     // return
     Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
 }
+
+/// `/@{owner}/{journal}`
+pub async fn index_view_request(
+    jar: CookieJar,
+    Extension(data): Extension<State>,
+    Path((owner, selected_journal)): Path<(String, String)>,
+) -> impl IntoResponse {
+    let data = data.read().await;
+    let user = match get_user_from_token!(jar, data.0) {
+        Some(ua) => Some(ua),
+        None => None,
+    };
+
+    // get owner
+    let owner = match data.0.get_user_by_username(&owner).await {
+        Ok(ua) => ua,
+        Err(e) => {
+            return Err(Html(render_error(e, &jar, &data, &user).await));
+        }
+    };
+
+    check_user_blocked_or_private!(user, owner, data, jar);
+
+    // get journal and check privacy settings
+    let journal = match data
+        .0
+        .get_journal_by_owner_title(owner.id, &selected_journal)
+        .await
+    {
+        Ok(p) => p,
+        Err(e) => {
+            return Err(Html(render_error(e, &jar, &data, &user).await));
+        }
+    };
+
+    if journal.privacy == JournalPrivacyPermission::Private {
+        if let Some(ref user) = user {
+            if user.id != journal.owner {
+                return Err(Html(
+                    render_error(Error::NotAllowed, &jar, &data, &Some(user.to_owned())).await,
+                ));
+            }
+        } else {
+            return Err(Html(
+                render_error(Error::NotAllowed, &jar, &data, &user).await,
+            ));
+        }
+    }
+
+    // ...
+    let notes = match data.0.get_notes_by_journal(journal.id).await {
+        Ok(p) => Some(p),
+        Err(e) => {
+            return Err(Html(render_error(e, &jar, &data, &user).await));
+        }
+    };
+
+    let lang = get_lang!(jar, data.0);
+    let mut context = initial_context(&data.0.0.0, lang, &user).await;
+
+    if selected_journal.is_empty() {
+        context.insert("selected_journal", &0);
+    } else {
+        context.insert("selected_journal", &selected_journal);
+    }
+
+    context.insert("selected_note", &0);
+    context.insert("journal", &journal);
+
+    context.insert("owner", &owner);
+    context.insert("notes", &notes);
+
+    context.insert("view_mode", &true);
+    context.insert("is_editor", &false);
+
+    // return
+    Ok(Html(data.1.render("journals/app.html", &context).unwrap()))
+}
diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs
index 3ff3f0d..8b76292 100644
--- a/crates/app/src/routes/pages/misc.rs
+++ b/crates/app/src/routes/pages/misc.rs
@@ -576,6 +576,8 @@ pub struct TimelineQuery {
     pub user_id: usize,
     #[serde(default)]
     pub tag: String,
+    #[serde(default)]
+    pub paginated: bool,
 }
 
 /// `/_swiss_army_timeline`
@@ -697,6 +699,7 @@ pub async fn swiss_army_timeline_request(
 
     context.insert("list", &list);
     context.insert("page", &req.page);
+    context.insert("paginated", &req.paginated);
     Ok(Html(
         data.1
             .render("timelines/swiss_army.html", &context)
diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs
index 2177d94..2eaeca2 100644
--- a/crates/app/src/routes/pages/mod.rs
+++ b/crates/app/src/routes/pages/mod.rs
@@ -134,7 +134,7 @@ pub fn routes() -> Router {
         // journals
         .route("/journals", get(journals::redirect_request))
         .route("/journals/{journal}/{note}", get(journals::app_request))
-        .route("/@{owner}/{journal}", get(journals::view_request))
+        .route("/@{owner}/{journal}", get(journals::index_view_request))
         .route("/@{owner}/{journal}/{note}", get(journals::view_request))
 }
 
diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs
index ba07543..1c1bda2 100644
--- a/crates/core/src/database/auth.rs
+++ b/crates/core/src/database/auth.rs
@@ -216,7 +216,7 @@ impl DataManager {
                 &0_i32,
                 &(data.last_seen as i64),
                 &String::new(),
-                &"[]",
+                "[]",
                 &0_i32,
                 &0_i32,
                 &serde_json::to_string(&data.connections).unwrap(),
diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs
index 4602b6b..a4a0d00 100644
--- a/crates/core/src/database/journals.rs
+++ b/crates/core/src/database/journals.rs
@@ -1,9 +1,10 @@
 use oiseau::{cache::Cache, query_row};
 use crate::{
+    database::common::NAME_REGEX,
     model::{
         auth::User,
-        permissions::FinePermission,
         journals::{Journal, JournalPrivacyPermission},
+        permissions::FinePermission,
         Error, Result,
     },
 };
@@ -69,7 +70,7 @@ impl DataManager {
         Ok(res.unwrap())
     }
 
-    const MAXIMUM_FREE_JOURNALS: usize = 15;
+    const MAXIMUM_FREE_JOURNALS: usize = 5;
 
     /// Create a new journal in the database.
     ///
@@ -83,7 +84,19 @@ impl DataManager {
             return Err(Error::DataTooLong("title".to_string()));
         }
 
-        data.title = data.title.replace(" ", "_");
+        data.title = data.title.replace(" ", "_").to_lowercase();
+
+        // check name
+        let regex = regex::RegexBuilder::new(NAME_REGEX)
+            .multi_line(true)
+            .build()
+            .unwrap();
+
+        if regex.captures(&data.title).is_some() {
+            return Err(Error::MiscError(
+                "This title contains invalid characters".to_string(),
+            ));
+        }
 
         // make sure this title isn't already in use
         if self
diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs
index e56bc93..a00fde9 100644
--- a/crates/core/src/database/mod.rs
+++ b/crates/core/src/database/mod.rs
@@ -30,3 +30,4 @@ mod userblocks;
 mod userfollows;
 
 pub use drivers::DataManager;
+pub use common::NAME_REGEX;
diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs
index d46394b..ea7da45 100644
--- a/crates/core/src/database/notes.rs
+++ b/crates/core/src/database/notes.rs
@@ -1,4 +1,5 @@
 use oiseau::cache::Cache;
+use crate::database::common::NAME_REGEX;
 use crate::model::{auth::User, journals::Note, permissions::FinePermission, Error, Result};
 use crate::{auto_method, DataManager};
 use oiseau::{execute, get, params, query_row, query_rows, PostgresRow};
@@ -64,6 +65,8 @@ impl DataManager {
         Ok(res.unwrap())
     }
 
+    const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10;
+
     /// Create a new note in the database.
     ///
     /// # Arguments
@@ -82,7 +85,33 @@ impl DataManager {
             return Err(Error::DataTooLong("content".to_string()));
         }
 
-        data.title = data.title.replace(" ", "_");
+        data.title = data.title.replace(" ", "_").to_lowercase();
+
+        // check number of notes
+        let owner = self.get_user_by_id(data.owner).await?;
+
+        if !owner.permissions.check(FinePermission::SUPPORTER) {
+            let journals = self.get_notes_by_journal(data.owner).await?;
+
+            if journals.len() >= Self::MAXIMUM_FREE_NOTES_PER_JOURNAL {
+                return Err(Error::MiscError(
+                    "You already have the maximum number of notes you can have in this journal"
+                        .to_string(),
+                ));
+            }
+        }
+
+        // check name
+        let regex = regex::RegexBuilder::new(NAME_REGEX)
+            .multi_line(true)
+            .build()
+            .unwrap();
+
+        if regex.captures(&data.title).is_some() {
+            return Err(Error::MiscError(
+                "This title contains invalid characters".to_string(),
+            ));
+        }
 
         // make sure this title isn't already in use
         if self
diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs
index eeef882..72b4f5b 100644
--- a/crates/core/src/database/posts.rs
+++ b/crates/core/src/database/posts.rs
@@ -1952,6 +1952,15 @@ impl DataManager {
             self.delete_poll(y.poll_id, &user).await?;
         }
 
+        // delete question (if not global question)
+        if y.context.answering != 0 {
+            let question = self.get_question_by_id(y.context.answering).await?;
+
+            if !question.is_global {
+                self.delete_question(question.id, &user).await?;
+            }
+        }
+
         // return
         Ok(())
     }
@@ -2031,6 +2040,15 @@ impl DataManager {
             for upload in y.uploads {
                 self.delete_upload(upload).await?;
             }
+
+            // delete question (if not global question)
+            if y.context.answering != 0 {
+                let question = self.get_question_by_id(y.context.answering).await?;
+
+                if !question.is_global {
+                    self.delete_question(question.id, &user).await?;
+                }
+            }
         } else {
             // incr parent comment count
             if let Some(replying_to) = y.replying_to {
diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs
index d88fb44..0a6965c 100644
--- a/crates/core/src/database/questions.rs
+++ b/crates/core/src/database/questions.rs
@@ -2,6 +2,7 @@ use std::collections::HashMap;
 use oiseau::cache::Cache;
 use tetratto_shared::unix_epoch_timestamp;
 use crate::model::communities_permissions::CommunityPermission;
+use crate::model::uploads::{MediaType, MediaUpload};
 use crate::model::{
     Error, Result,
     communities::Question,
@@ -33,6 +34,7 @@ impl DataManager {
             // ...
             context: serde_json::from_str(&get!(x->10(String))).unwrap(),
             ip: get!(x->11(String)),
+            drawings: serde_json::from_str(&get!(x->12(String))).unwrap(),
         }
     }
 
@@ -333,13 +335,20 @@ impl DataManager {
         Ok(res.unwrap())
     }
 
+    const MAXIMUM_DRAWING_SIZE: usize = 32768; // 32 KiB
+
     /// Create a new question in the database.
     ///
     /// # Arguments
     /// * `data` - a mock [`Question`] object to insert
-    pub async fn create_question(&self, mut data: Question) -> Result<usize> {
+    pub async fn create_question(
+        &self,
+        mut data: Question,
+        drawings: Vec<Vec<u8>>,
+    ) -> Result<usize> {
         // check if we can post this
         if data.is_global {
+            // global
             if data.community > 0 {
                 // posting to community
                 data.receiver = 0;
@@ -370,6 +379,7 @@ impl DataManager {
                 }
             }
         } else {
+            // single
             let receiver = self.get_user_by_id(data.receiver).await?;
 
             if !receiver.settings.enable_questions {
@@ -380,6 +390,10 @@ impl DataManager {
                 return Err(Error::NotAllowed);
             }
 
+            if !receiver.settings.enable_drawings && drawings.len() > 0 {
+                return Err(Error::DrawingsDisabled);
+            }
+
             // check for ip block
             if self
                 .get_ipblock_by_initiator_receiver(receiver.id, &data.ip)
@@ -390,6 +404,28 @@ impl DataManager {
             }
         }
 
+        // create uploads
+        if drawings.len() > 2 {
+            return Err(Error::MiscError(
+                "Too many uploads. Please use a maximum of 2".to_string(),
+            ));
+        }
+
+        for drawing in &drawings {
+            // this is the initial iter to check sizes, we'll do uploads after
+            if drawing.len() > Self::MAXIMUM_DRAWING_SIZE {
+                return Err(Error::FileTooLarge);
+            }
+        }
+
+        for _ in 0..drawings.len() {
+            data.drawings.push(
+                self.create_upload(MediaUpload::new(MediaType::Carpgraph, data.owner))
+                    .await?
+                    .id,
+            );
+        }
+
         // ...
         let conn = match self.0.connect().await {
             Ok(c) => c,
@@ -398,7 +434,7 @@ impl DataManager {
 
         let res = execute!(
             &conn,
-            "INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
+            "INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
             params![
                 &(data.id as i64),
                 &(data.created as i64),
@@ -411,7 +447,8 @@ impl DataManager {
                 &0_i32,
                 &0_i32,
                 &serde_json::to_string(&data.context).unwrap(),
-                &data.ip
+                &data.ip,
+                &serde_json::to_string(&data.drawings).unwrap(),
             ]
         );
 
@@ -430,6 +467,23 @@ impl DataManager {
             .await?;
         }
 
+        // write to uploads
+        for (i, drawing_id) in data.drawings.iter().enumerate() {
+            let drawing = match drawings.get(i) {
+                Some(d) => d,
+                None => {
+                    self.delete_upload(*drawing_id).await?;
+                    continue;
+                }
+            };
+
+            let upload = self.get_upload_by_id(*drawing_id).await?;
+
+            if let Err(e) = std::fs::write(&upload.path(&self.0.0).to_string(), drawing.to_vec()) {
+                return Err(Error::MiscError(e.to_string()));
+            }
+        }
+
         // return
         Ok(data.id)
     }
@@ -495,6 +549,11 @@ impl DataManager {
             return Err(Error::DatabaseError(e.to_string()));
         }
 
+        // delete uploads
+        for upload in y.drawings {
+            self.delete_upload(upload).await?;
+        }
+
         // return
         Ok(())
     }
diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs
index 8c12f76..171c881 100644
--- a/crates/core/src/model/auth.rs
+++ b/crates/core/src/model/auth.rs
@@ -231,6 +231,12 @@ pub struct UserSettings {
     /// A list of strings the user has muted.
     #[serde(default)]
     pub muted: Vec<String>,
+    /// If timelines are paged instead of infinitely scrolled.
+    #[serde(default)]
+    pub paged_timelines: bool,
+    /// If drawings are enabled for questions sent to the user.
+    #[serde(default)]
+    pub enable_drawings: bool,
 }
 
 fn mime_avif() -> String {
@@ -332,7 +338,7 @@ impl User {
 
         // parse
         for char in input.chars() {
-            if (char == '\\') && !escape {
+            if ((char == '\\') | (char == '/')) && !escape {
                 escape = true;
                 continue;
             }
diff --git a/crates/core/src/model/carp.rs b/crates/core/src/model/carp.rs
new file mode 100644
index 0000000..40876fa
--- /dev/null
+++ b/crates/core/src/model/carp.rs
@@ -0,0 +1,285 @@
+use serde::{Serialize, Deserialize};
+
+/// Starting at the beginning of the file, the header details specific information
+/// about the file.
+///
+/// 1. `CG` tag (2 bytes)
+/// 2. version number (2 bytes)
+/// 3. width of graph (4 bytes)
+/// 4. height of graph (4 bytes)
+/// 5. `END_OF_HEADER`
+///
+/// The header has a total of 13 bytes. (12 of info, 1 of `END_OF_HEADER)
+///
+/// Everything after `END_OF_HEADER` should be another command and its parameters.
+pub const END_OF_HEADER: u8 = 0x1a;
+/// The color command marks the beginning of a hex-encoded color **string**.
+///
+/// The hastag character should **not** be included.
+pub const COLOR: u8 = 0x1b;
+/// The size command marks the beginning of a integer brush size.
+pub const SIZE: u8 = 0x2b;
+/// Marks the beginning of a new line.
+pub const LINE: u8 = 0x3b;
+/// A point marks the coordinates (relative to the previous `DELTA_ORIGIN`, or `(0, 0)`)
+/// in which a point should be drawn.
+///
+/// The size and color are that of the previous `COLOR` and `SIZE` commands.
+///
+/// Points are two `u32`s (or 8 bytes in length).
+pub const POINT: u8 = 0x4b;
+/// An end-of-file marker.
+pub const EOF: u8 = 0x1f;
+
+/// A type of [`Command`].
+#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
+#[repr(u8)]
+pub enum CommandType {
+    /// [`END_OF_HEADER`]
+    EndOfHeader = END_OF_HEADER,
+    /// [`COLOR`]
+    Color = COLOR,
+    /// [`SIZE`]
+    Size = SIZE,
+    /// [`LINE`]
+    Line = LINE,
+    /// [`POINT`]
+    Point = POINT,
+    /// [`EOF`]
+    Eof = EOF,
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+pub struct Command {
+    /// The type of the command.
+    pub r#type: CommandType,
+    /// Raw data as bytes.
+    pub data: Vec<u8>,
+}
+
+impl From<Command> for Vec<u8> {
+    fn from(val: Command) -> Self {
+        let mut d = val.data;
+        d.insert(0, val.r#type as u8);
+        d
+    }
+}
+
+/// A graph is CarpGraph's representation of an image. It's essentially just a
+/// reproducable series of commands which a renderer can traverse to reconstruct
+/// an image.
+#[derive(Serialize, Deserialize, Debug)]
+pub struct CarpGraph {
+    pub header: Vec<u8>,
+    pub dimensions: (u32, u32),
+    pub commands: Vec<Command>,
+}
+
+macro_rules! select_bytes {
+    ($count:literal, $from:ident) => {{
+        let mut data: Vec<u8> = Vec::new();
+        let mut seen_bytes = 0;
+
+        while let Some((_, byte)) = $from.next() {
+            seen_bytes += 1;
+            data.push(byte.to_owned());
+
+            if seen_bytes == $count {
+                // we only need <count> bytes, stop just before we eat the next byte
+                break;
+            }
+        }
+
+        data
+    }};
+}
+
+macro_rules! spread {
+    ($into:ident, $from:expr) => {
+        for byte in &$from {
+            $into.push(byte.to_owned())
+        }
+    };
+}
+
+impl CarpGraph {
+    pub fn to_bytes(&self) -> Vec<u8> {
+        let mut out: Vec<u8> = Vec::new();
+
+        // reconstruct header
+        spread!(out, self.header);
+        spread!(out, self.dimensions.0.to_be_bytes()); // width
+        spread!(out, self.dimensions.1.to_be_bytes()); // height
+        out.push(END_OF_HEADER);
+
+        // reconstruct commands
+        for command in &self.commands {
+            out.push(command.r#type as u8);
+            spread!(out, command.data);
+        }
+
+        // ...
+        out.push(EOF);
+        out
+    }
+
+    pub fn from_bytes(bytes: Vec<u8>) -> Self {
+        let mut header: Vec<u8> = Vec::new();
+        let mut dimensions: (u32, u32) = (0, 0);
+        let mut commands: Vec<Command> = Vec::new();
+
+        let mut in_header: bool = true;
+        let mut byte_buffer: Vec<u8> = Vec::new(); // storage for bytes which need to construct a bigger type (like `u32`)
+
+        let mut bytes_iter = bytes.iter().enumerate();
+        while let Some((i, byte)) = bytes_iter.next() {
+            let byte = byte.to_owned();
+            match byte {
+                END_OF_HEADER => in_header = false,
+                COLOR => {
+                    let data = select_bytes!(6, bytes_iter);
+                    commands.push(Command {
+                        r#type: CommandType::Color,
+                        data,
+                    });
+                }
+                SIZE => {
+                    let data = select_bytes!(2, bytes_iter);
+                    commands.push(Command {
+                        r#type: CommandType::Size,
+                        data,
+                    });
+                }
+                POINT => {
+                    let data = select_bytes!(8, bytes_iter);
+                    commands.push(Command {
+                        r#type: CommandType::Point,
+                        data,
+                    });
+                }
+                LINE => commands.push(Command {
+                    r#type: CommandType::Line,
+                    data: Vec::new(),
+                }),
+                EOF => break,
+                _ => {
+                    if in_header {
+                        if (0..2).contains(&i) {
+                            // tag
+                            header.push(byte);
+                        } else if (2..4).contains(&i) {
+                            // version
+                            header.push(byte);
+                        } else if (4..8).contains(&i) {
+                            // width
+                            byte_buffer.push(byte);
+
+                            if i == 7 {
+                                // end, construct from byte buffer
+                                let (bytes, _) = byte_buffer.split_at(size_of::<u32>());
+                                dimensions.0 = u32::from_be_bytes(bytes.try_into().unwrap());
+                                byte_buffer = Vec::new();
+                            }
+                        } else if (8..12).contains(&i) {
+                            // height
+                            byte_buffer.push(byte);
+
+                            if i == 11 {
+                                // end, construct from byte buffer
+                                let (bytes, _) = byte_buffer.split_at(size_of::<u32>());
+                                dimensions.1 = u32::from_be_bytes(bytes.try_into().unwrap());
+                                byte_buffer = Vec::new();
+                            }
+                        }
+                    } else {
+                        // misc byte
+                        println!("extraneous byte at {i}");
+                    }
+                }
+            }
+        }
+
+        Self {
+            header,
+            dimensions,
+            commands,
+        }
+    }
+
+    pub fn to_svg(&self) -> String {
+        let mut out: String = String::new();
+        out.push_str(&format!(
+            "<svg viewBox=\"0 0 {} {}\" xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" style=\"background: white; width: {}px; height: {}px\" class=\"carpgraph\">",
+            self.dimensions.0, self.dimensions.1, self.dimensions.0, self.dimensions.1, self.dimensions.0, self.dimensions.1
+        ));
+
+        // add lines
+        let mut stroke_size: u16 = 2;
+        let mut stroke_color: String = "000000".to_string();
+
+        let mut previous_x_y: Option<(u32, u32)> = None;
+        let mut line_path = String::new();
+
+        for command in &self.commands {
+            match command.r#type {
+                CommandType::Size => {
+                    let (bytes, _) = command.data.split_at(size_of::<u16>());
+                    stroke_size = u16::from_be_bytes(bytes.try_into().unwrap_or([0, 0]));
+                }
+                CommandType::Color => {
+                    stroke_color =
+                        String::from_utf8(command.data.to_owned()).unwrap_or("#000000".to_string())
+                }
+                CommandType::Line => {
+                    if !line_path.is_empty() {
+                        out.push_str(&format!(
+                            "<path d=\"{line_path}\" stroke=\"#{stroke_color}\" stroke-width=\"{stroke_size}\" />"
+                        ));
+                    }
+
+                    previous_x_y = None;
+                    line_path = String::new();
+                }
+                CommandType::Point => {
+                    let (x, y) = command.data.split_at(size_of::<u32>());
+                    let point = ({ u32::from_be_bytes(x.try_into().unwrap()) }, {
+                        u32::from_be_bytes(y.try_into().unwrap())
+                    });
+
+                    // add to path string
+                    line_path.push_str(&format!(
+                        " M{} {}{}",
+                        point.0,
+                        point.1,
+                        if let Some(pxy) = previous_x_y {
+                            // line to there
+                            format!(" L{} {}", pxy.0, pxy.1)
+                        } else {
+                            String::new()
+                        }
+                    ));
+
+                    previous_x_y = Some((point.0, point.1));
+
+                    // add circular point
+                    out.push_str(&format!(
+                        "<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"#{stroke_color}\" />",
+                        point.0,
+                        point.1,
+                        stroke_size / 2 // the size is technically the diameter of the circle
+                    ));
+                }
+                _ => unreachable!("never pushed to commands"),
+            }
+        }
+
+        if !line_path.is_empty() {
+            out.push_str(&format!(
+                "<path d=\"{line_path}\" stroke=\"#{stroke_color}\" stroke-width=\"{stroke_size}\" />"
+            ));
+        }
+
+        // return
+        format!("{out}</svg>")
+    }
+}
diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs
index 41508ff..7b1957f 100644
--- a/crates/core/src/model/communities.rs
+++ b/crates/core/src/model/communities.rs
@@ -345,6 +345,9 @@ pub struct Question {
     /// The IP of the question creator for IP blocking and identifying anonymous users.
     #[serde(default)]
     pub ip: String,
+    /// The IDs of all uploads which hold this question's drawings.
+    #[serde(default)]
+    pub drawings: Vec<usize>,
 }
 
 impl Question {
@@ -369,6 +372,7 @@ impl Question {
             dislikes: 0,
             context: QuestionContext::default(),
             ip,
+            drawings: Vec::new(),
         }
     }
 }
diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs
index c50ea7c..ed2da9e 100644
--- a/crates/core/src/model/mod.rs
+++ b/crates/core/src/model/mod.rs
@@ -1,6 +1,7 @@
 pub mod addr;
 pub mod apps;
 pub mod auth;
+pub mod carp;
 pub mod channels;
 pub mod communities;
 pub mod communities_permissions;
@@ -41,10 +42,12 @@ pub enum Error {
     AlreadyAuthenticated,
     DataTooLong(String),
     DataTooShort(String),
+    FileTooLarge,
     UsernameInUse,
     TitleInUse,
     QuestionsDisabled,
     RequiresSupporter,
+    DrawingsDisabled,
     Unknown,
 }
 
@@ -62,10 +65,12 @@ impl Display for Error {
             Self::AlreadyAuthenticated => "Already authenticated".to_string(),
             Self::DataTooLong(name) => format!("Given {name} is too long!"),
             Self::DataTooShort(name) => format!("Given {name} is too short!"),
+            Self::FileTooLarge => "Given file is too large".to_string(),
             Self::UsernameInUse => "Username in use".to_string(),
             Self::TitleInUse => "Title in use".to_string(),
             Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(),
             Self::RequiresSupporter => "Only site supporters can do this".to_string(),
+            Self::DrawingsDisabled => "You are not allowed to submit drawings there".to_string(),
             _ => format!("An unknown error as occurred: ({:?})", self),
         })
     }
diff --git a/crates/core/src/model/uploads.rs b/crates/core/src/model/uploads.rs
index d502697..35165c6 100644
--- a/crates/core/src/model/uploads.rs
+++ b/crates/core/src/model/uploads.rs
@@ -5,7 +5,7 @@ use crate::config::Config;
 use std::fs::{write, exists, remove_file};
 use super::{Error, Result};
 
-#[derive(Serialize, Deserialize)]
+#[derive(Serialize, Deserialize, PartialEq, Eq)]
 pub enum MediaType {
     #[serde(alias = "image/webp")]
     Webp,
@@ -17,6 +17,8 @@ pub enum MediaType {
     Jpg,
     #[serde(alias = "image/gif")]
     Gif,
+    #[serde(alias = "image/carpgraph")]
+    Carpgraph,
 }
 
 impl MediaType {
@@ -27,6 +29,7 @@ impl MediaType {
             Self::Png => "png",
             Self::Jpg => "jpg",
             Self::Gif => "gif",
+            Self::Carpgraph => "carpgraph",
         }
     }
 
diff --git a/sql_changes/questions_drawings.sql b/sql_changes/questions_drawings.sql
new file mode 100644
index 0000000..f45e50b
--- /dev/null
+++ b/sql_changes/questions_drawings.sql
@@ -0,0 +1,2 @@
+ALTER TABLE questions
+ADD COLUMN drawings TEXT NOT NULL DEFAULT '[]';