Skip to content

Commit f008b05

Browse files
author
Kevin Hopper
committed
F.6: Lemmy bundle — federated link aggregator
Stacked on F.5 (Pixelfed). First ActivityPub app on this track that isn't Mastodon-compatible — Lemmy has its own v3 REST API at /api/v3/, so this adds a fresh API shape to the bundle roster. Federation is community-scoped rather than user-scoped, which changes the moderation model slightly (admin block_instance + defederate carry heavier blast radius because they affect every subscribed community on that instance). Bundle (bundles/lemmy/): - manifest.json consent_required with EN/ES text covering federation reach, pict-rs cache growth (5-20 GB within weeks), community-scoped federation pulling heavy remote content, and legal responsibility for illegal content with defederation risk from major hubs. min_ram_mb=1000, recommended=2000 — Pi-friendly when Lemmy is the only federated bundle on the host. - docker-compose.yml dessalines/lemmy:0.19 + dessalines/lemmy-ui:0.19 + postgres:16-alpine + asonix/pictrs:0.5. Lemmy reads env vars but the compose entrypoint writes a /config/config.hjson file deterministically on each start from env — lemmy-ui proxies /api/v3/* to lemmy:8536, so a single Caddy federation site is enough. mem_limits: lemmy=768m, lemmy-ui=256m, postgres=512m, pictrs=512m. pict-rs tuning env vars (GIF max frames/dimensions, video codec) match current defaults from the Lemmy ops docs. - server/server.js 14 MCP tools per the federated-social verb taxonomy: lemmy_status, lemmy_list_communities, lemmy_follow_community, lemmy_unfollow_community, lemmy_post (title + url or body), lemmy_comment (threaded replies), lemmy_feed (Subscribed/Local/All + sort), lemmy_search, lemmy_block_user, lemmy_block_community (inline, rate-limited), lemmy_block_instance, lemmy_defederate (QUEUED, admin), lemmy_review_reports (read-only), lemmy_media_prune. resolveCommunity() and resolvePerson() helpers handle "name@server" → id resolution via the search API. All z.string() fields bounded. loadSharedDeps() pattern from F.1. Works with no env set and boots clean. - server/index.js stdio transport. - panel/lemmy.js + panel/routes.js Nest panel: status + local communities + hot posts. XSS-safe. API routes serve /api/lemmy/{status,communities,posts}. - skills/lemmy.md setup wizard (no CLI admin bootstrap — web wizard replaces the admin_pending placeholder), JWT login recipe, follow-community / post / comment / search workflows, moderation ladder, troubleshooting (federation, pict-rs, admin permissions). - scripts/post-install.sh waits for both lemmy + lemmy-ui health, verifies federation network, prints Caddy + setup wizard + JWT login next steps. - scripts/backup.sh pg_dump + pict-rs sled DB + media files tar. Note that federation identity lives in the database (instance private key) — restoring to a different hostname breaks federation. - package.json MCP + zod deps. Integrations with shipped F-series: - F.0 rate limiter content + moderation verbs wrapped via the shared token-bucket wrapper. Read-only status / review_reports uncapped. - F.0 hardware gate min/recommended RAM + disk per manifest schema. - F.1 federated-social category wiring already present. - No storage-translator involvement — pict-rs uses its own sled DB for metadata and on-disk files; S3-backing pict-rs is a 0.5+ feature we can wire via translator in a follow-up. Human-in-the-loop moderation: - Inline (rate-limited): lemmy_block_user, lemmy_block_community (user-scoped; hides content from the authenticated user's view). - Queued (operator confirms in Nest within 72h): lemmy_block_instance, lemmy_defederate. Admin-scoped; these affect every subscribed community on the target instance. Same moderation_actions table + Crow notification flow as F.1/F.4/F.5. API shape note: - Lemmy v3 uses `Authorization: Bearer <jwt>` (the older `?auth=` query param is still supported but deprecated). Helper code uses Bearer. - resolveCommunity() and resolvePerson() share a pattern with F.1/F.5's account resolution; waiting until F.7 Mastodon to decide whether to hoist into servers/shared/fediverse-resolvers.js. Registry / discovery surface: - registry/add-ons.json entry before developer-kit. - skills/superpowers.md trigger row (EN+ES: lemmy, link aggregator, reddit alternative, subscribe community, post link, fediverse discussion, upvote). - CLAUDE.md Skills Reference entry after pixelfed.md. Verified: - node --check on all JS files - bash -n on shell scripts - MCP server boots via createLemmyServer() with no env set - docker compose config parses with required env set - JSON parse on manifest, package, registry - npm run check passes Next: - F.7 Mastodon (flagship AP, 3 GB min — heaviest small-AP) - F.8 PeerTube (video, needs S3 + transcoding policy) - F.11 identity attestation, F.12 cross-app bridging
1 parent 30c3f13 commit f008b05

13 files changed

Lines changed: 1275 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,7 @@ Add-on skills (activated when corresponding add-on is installed):
467467
- `matrix-dendrite.md` — Matrix homeserver on Dendrite: create/join/leave rooms, send messages, sync, invite users, federation health; appservice registration prep for F.12 bridges; :8448-vs-well-known either/or federation story
468468
- `funkwhale.md` — Funkwhale federated music pod: library listing, search, upload, follow remote channels/libraries, playlists, listening history, moderation (block_user/mute inline; block_domain/defederate queued), media prune; on-disk or S3 audio storage via storage-translators.funkwhale()
469469
- `pixelfed.md` — Pixelfed federated photo-sharing: post photos (upload+status), feed, search, follow, moderation (block_user/mute inline; block_domain/defederate/import_blocklist queued), admin reports, remote reporting, media prune; Mastodon-compatible REST API; on-disk or S3 media via storage-translators.pixelfed()
470+
- `lemmy.md` — Lemmy federated link aggregator: status, list/follow/unfollow communities, post (link + body), comment, feed (Subscribed/Local/All), search, moderation (block_user/block_community inline; block_instance/defederate queued), admin reports, pict-rs media prune; Lemmy v3 REST API; community-scoped federation
470471
- `calibre-server.md` — Calibre content server: search, browse, download ebooks via OPDS
471472
- `calibre-web.md` — Calibre-Web reader: search, shelves, reading status, download
472473
- `miniflux.md` — Miniflux RSS reader: subscribe feeds, read articles, star, mark read

bundles/lemmy/docker-compose.yml

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Lemmy — federated link aggregator + discussion platform.
2+
#
3+
# Four-container bundle: lemmy (backend, Rust) + lemmy-ui (frontend) +
4+
# postgres + pict-rs (image hosting). lemmy + lemmy-ui on crow-federation
5+
# (Caddy reverse-proxies to lemmy-ui:1234); DB + pict-rs on default.
6+
#
7+
# Lemmy-UI serves the SPA AND proxies /api/v3/* to lemmy:8536 internally,
8+
# so a single Caddy federation site is enough.
9+
#
10+
# Data:
11+
# ~/.crow/lemmy/postgres/ Postgres data dir
12+
# ~/.crow/lemmy/pictrs/ pict-rs sled db + media files
13+
# ~/.crow/lemmy/lemmy.hjson Generated once at install time; not volume-mounted
14+
# because lemmy reads env-var overrides too.
15+
#
16+
# Images: dessalines/lemmy:0.19 and dessalines/lemmy-ui:0.19 (floats
17+
# within 0.19.x — verify latest + CVE feed at implementation time per the
18+
# plan's image-tag policy).
19+
20+
networks:
21+
crow-federation:
22+
external: true
23+
default:
24+
25+
services:
26+
postgres:
27+
image: postgres:16-alpine
28+
container_name: crow-lemmy-postgres
29+
networks:
30+
- default
31+
environment:
32+
POSTGRES_USER: lemmy
33+
POSTGRES_PASSWORD: ${LEMMY_DB_PASSWORD}
34+
POSTGRES_DB: lemmy
35+
volumes:
36+
- ${LEMMY_DATA_DIR:-~/.crow/lemmy}/postgres:/var/lib/postgresql/data
37+
init: true
38+
mem_limit: 512m
39+
restart: unless-stopped
40+
healthcheck:
41+
test: ["CMD-SHELL", "pg_isready -U lemmy"]
42+
interval: 10s
43+
timeout: 5s
44+
retries: 10
45+
start_period: 20s
46+
47+
pictrs:
48+
image: asonix/pictrs:0.5
49+
container_name: crow-lemmy-pictrs
50+
user: "991:991"
51+
networks:
52+
- default
53+
environment:
54+
PICTRS__SERVER__API_KEY: ${LEMMY_PICTRS_API_KEY}
55+
PICTRS__MEDIA__VIDEO_CODEC: vp9
56+
PICTRS__MEDIA__GIF__MAX_WIDTH: "256"
57+
PICTRS__MEDIA__GIF__MAX_HEIGHT: "256"
58+
PICTRS__MEDIA__GIF__MAX_AREA: "65536"
59+
PICTRS__MEDIA__GIF__MAX_FRAME_COUNT: "400"
60+
RUST_LOG: warn,tracing=warn
61+
RUST_BACKTRACE: full
62+
volumes:
63+
- ${LEMMY_DATA_DIR:-~/.crow/lemmy}/pictrs:/mnt
64+
init: true
65+
mem_limit: 512m
66+
restart: unless-stopped
67+
68+
lemmy:
69+
image: dessalines/lemmy:0.19
70+
container_name: crow-lemmy
71+
hostname: lemmy
72+
networks:
73+
- default
74+
- crow-federation
75+
depends_on:
76+
postgres:
77+
condition: service_healthy
78+
environment:
79+
RUST_LOG: "warn"
80+
RUST_BACKTRACE: "full"
81+
LEMMY_DATABASE_URL: "postgres://lemmy:${LEMMY_DB_PASSWORD}@postgres:5432/lemmy"
82+
LEMMY_CONFIG_LOCATION: /config/config.hjson
83+
volumes:
84+
- ./config:/config:ro
85+
entrypoint:
86+
- sh
87+
- -c
88+
- |
89+
mkdir -p /config
90+
cat > /config/config.hjson <<HJSON
91+
{
92+
hostname: "${LEMMY_HOSTNAME}"
93+
bind: "0.0.0.0"
94+
port: 8536
95+
tls_enabled: false
96+
database: {
97+
host: postgres
98+
port: 5432
99+
database: lemmy
100+
user: lemmy
101+
password: "${LEMMY_DB_PASSWORD}"
102+
}
103+
pictrs: {
104+
url: "http://pictrs:8080/"
105+
api_key: "${LEMMY_PICTRS_API_KEY}"
106+
}
107+
federation: {
108+
enabled: ${LEMMY_FEDERATION_ENABLED:-true}
109+
debug: false
110+
}
111+
setup: {
112+
admin_username: "admin_pending"
113+
admin_password: "change_me_via_web_setup"
114+
site_name: "Crow Lemmy"
115+
}
116+
captcha: { enabled: true, difficulty: medium }
117+
opentelemetry_url: null
118+
}
119+
HJSON
120+
exec /app/lemmy
121+
init: true
122+
mem_limit: 768m
123+
restart: unless-stopped
124+
healthcheck:
125+
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8536/api/v3/site >/dev/null 2>&1 || exit 1"]
126+
interval: 30s
127+
timeout: 10s
128+
retries: 10
129+
start_period: 60s
130+
131+
lemmy-ui:
132+
image: dessalines/lemmy-ui:0.19
133+
container_name: crow-lemmy-ui
134+
networks:
135+
- default
136+
- crow-federation
137+
depends_on:
138+
lemmy:
139+
condition: service_healthy
140+
environment:
141+
LEMMY_UI_LEMMY_INTERNAL_HOST: "lemmy:8536"
142+
LEMMY_UI_LEMMY_EXTERNAL_HOST: ${LEMMY_HOSTNAME}
143+
LEMMY_UI_HTTPS: "true"
144+
init: true
145+
mem_limit: 256m
146+
restart: unless-stopped
147+
healthcheck:
148+
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:1234/ >/dev/null 2>&1 || exit 1"]
149+
interval: 30s
150+
timeout: 10s
151+
retries: 10
152+
start_period: 60s

bundles/lemmy/manifest.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"id": "lemmy",
3+
"name": "Lemmy",
4+
"version": "1.0.0",
5+
"description": "Federated link aggregator and discussion platform over ActivityPub — Reddit-alternative on the fediverse. Posts, comments, communities, votes; remote Lemmy/Mastodon/Kbin users can follow your communities and comment cross-server.",
6+
"type": "bundle",
7+
"author": "Crow",
8+
"category": "federated-social",
9+
"tags": ["lemmy", "activitypub", "fediverse", "link-aggregator", "discussion", "reddit-alt"],
10+
"icon": "message-circle",
11+
"docker": { "composefile": "docker-compose.yml" },
12+
"server": {
13+
"command": "node",
14+
"args": ["server/index.js"],
15+
"envKeys": ["LEMMY_URL", "LEMMY_JWT", "LEMMY_HOSTNAME"]
16+
},
17+
"panel": "panel/lemmy.js",
18+
"panelRoutes": "panel/routes.js",
19+
"skills": ["skills/lemmy.md"],
20+
"consent_required": true,
21+
"install_consent_messages": {
22+
"en": "Lemmy joins the public fediverse over ActivityPub — your instance becomes publicly addressable at the domain you configure, any community you host or post you publish can be replicated to federated servers (other Lemmy instances, Kbin, Mastodon, Friendica) and cannot be fully recalled. Lemmy's pict-rs image cache grows with federated content: 5-20 GB within weeks is typical on an active instance. Lemmy is hardware-gated to refuse install on hosts with <1 GB effective RAM after committed bundles; warns below 4 GB total. Because Lemmy's federation is community-scoped rather than user-scoped, a single large community can pull heavy content from many remote instances — monitor disk. Hosting illegal content (CSAM, credible threats) is your legal responsibility — major hubs (lemmy.world, lemmy.ml) may defederate your instance if reports go unaddressed.",
23+
"es": "Lemmy se une al fediverso público vía ActivityPub — tu instancia será direccionable en el dominio que configures, cualquier comunidad que hospedes o publicación que hagas puede replicarse a servidores federados (otras instancias de Lemmy, Kbin, Mastodon, Friendica) y no puede recuperarse completamente. El caché de imágenes pict-rs de Lemmy crece con el contenido federado: 5-20 GB en semanas es típico en una instancia activa. Lemmy está limitado por hardware: rechazado en hosts con <1 GB de RAM efectiva tras paquetes comprometidos; advierte bajo 4 GB totales. Como la federación de Lemmy es por comunidad (no por usuario), una sola comunidad grande puede traer contenido pesado de muchas instancias remotas — monitoriza el disco. Hospedar contenido ilegal (CSAM, amenazas creíbles) es tu responsabilidad legal — los hubs principales (lemmy.world, lemmy.ml) pueden dejar de federarse con tu instancia si los reportes no son atendidos."
24+
},
25+
"requires": {
26+
"env": ["LEMMY_HOSTNAME", "LEMMY_DB_PASSWORD", "LEMMY_PICTRS_API_KEY"],
27+
"bundles": ["caddy"],
28+
"min_ram_mb": 1000,
29+
"recommended_ram_mb": 2000,
30+
"min_disk_mb": 5000,
31+
"recommended_disk_mb": 50000
32+
},
33+
"env_vars": [
34+
{ "name": "LEMMY_HOSTNAME", "description": "Public domain (subdomain; path-mounts break ActivityPub).", "required": true },
35+
{ "name": "LEMMY_DB_PASSWORD", "description": "Password for the bundled Postgres role.", "required": true, "secret": true },
36+
{ "name": "LEMMY_PICTRS_API_KEY", "description": "Shared secret Lemmy uses to talk to pict-rs (any random 32+ char string).", "required": true, "secret": true },
37+
{ "name": "LEMMY_JWT", "description": "Admin login JWT (obtain via POST /api/v3/user/login after registering the admin via the web UI).", "required": false, "secret": true },
38+
{ "name": "LEMMY_URL", "description": "Internal URL the MCP server uses to reach Lemmy's API (default http://lemmy:8536 over crow-federation).", "default": "http://lemmy:8536", "required": false },
39+
{ "name": "LEMMY_OPEN_REGISTRATION", "description": "Allow new user signups (true/false). Default false — opens registration only after moderation tooling is configured.", "default": "false", "required": false },
40+
{ "name": "LEMMY_FEDERATION_ENABLED", "description": "Whether to federate with other ActivityPub servers (true/false).", "default": "true", "required": false }
41+
],
42+
"ports": [],
43+
"webUI": null,
44+
"notes": "Four containers (lemmy + lemmy-ui + postgres + pict-rs). Expose via caddy_add_federation_site { domain: LEMMY_HOSTNAME, upstream: 'lemmy-ui:1234', profile: 'activitypub' }. The UI proxies /api/v3/ to lemmy:8536 internally. Initial admin registers via the /signup page during the first-boot setup wizard (no CLI bootstrap needed)."
45+
}

bundles/lemmy/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "crow-lemmy",
3+
"version": "1.0.0",
4+
"description": "Lemmy (federated link aggregator) MCP server — posts, comments, communities, votes, moderation",
5+
"type": "module",
6+
"main": "server/index.js",
7+
"dependencies": {
8+
"@modelcontextprotocol/sdk": "^1.12.0",
9+
"zod": "^3.24.0"
10+
}
11+
}

bundles/lemmy/panel/lemmy.js

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* Crow's Nest Panel — Lemmy: instance status + subscribed communities + hot posts.
3+
* XSS-safe (textContent / createElement only).
4+
*/
5+
6+
export default {
7+
id: "lemmy",
8+
name: "Lemmy",
9+
icon: "message-circle",
10+
route: "/dashboard/lemmy",
11+
navOrder: 76,
12+
category: "federated-social",
13+
14+
async handler(req, res, { layout }) {
15+
const content = `
16+
<style>${styles()}</style>
17+
<div class="lm-panel">
18+
<h1>Lemmy <span class="lm-subtitle">federated link aggregator</span></h1>
19+
20+
<div class="lm-section">
21+
<h3>Status</h3>
22+
<div id="lm-status" class="lm-status"><div class="np-loading">Loading…</div></div>
23+
</div>
24+
25+
<div class="lm-section">
26+
<h3>Local Communities</h3>
27+
<div id="lm-communities" class="lm-communities"><div class="np-loading">Loading…</div></div>
28+
</div>
29+
30+
<div class="lm-section">
31+
<h3>Hot Posts (local)</h3>
32+
<div id="lm-posts" class="lm-posts"><div class="np-loading">Loading…</div></div>
33+
</div>
34+
35+
<div class="lm-section lm-notes">
36+
<h3>Notes</h3>
37+
<ul>
38+
<li>Lemmy federation is community-scoped. A single large federated community can pull heavy content; monitor disk.</li>
39+
<li>Moderation reports from all federated instances land in your admin queue. Review regularly.</li>
40+
<li>pict-rs cache grows with federated image content. Tune retention via <code>lemmy_media_prune</code>.</li>
41+
</ul>
42+
</div>
43+
</div>
44+
<script>${script()}</script>
45+
`;
46+
res.send(layout({ title: "Lemmy", content }));
47+
},
48+
};
49+
50+
function script() {
51+
return `
52+
function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
53+
function row(label, value) {
54+
const r = document.createElement('div'); r.className = 'lm-row';
55+
const b = document.createElement('b'); b.textContent = label;
56+
const s = document.createElement('span'); s.textContent = value == null ? '—' : String(value);
57+
r.appendChild(b); r.appendChild(s); return r;
58+
}
59+
function err(msg) { const d = document.createElement('div'); d.className = 'np-error'; d.textContent = msg; return d; }
60+
61+
async function loadStatus() {
62+
const el = document.getElementById('lm-status'); clear(el);
63+
try {
64+
const res = await fetch('/api/lemmy/status'); const d = await res.json();
65+
if (d.error) { el.appendChild(err(d.error)); return; }
66+
const card = document.createElement('div'); card.className = 'lm-card';
67+
card.appendChild(row('Site name', d.site_name || '(unset)'));
68+
card.appendChild(row('Hostname', d.hostname || '—'));
69+
card.appendChild(row('Version', d.version || '—'));
70+
card.appendChild(row('Users', d.users ?? '—'));
71+
card.appendChild(row('Posts', d.posts ?? '—'));
72+
card.appendChild(row('Communities', d.communities ?? '—'));
73+
card.appendChild(row('Federation', d.federation_enabled ? 'enabled' : 'disabled'));
74+
card.appendChild(row('Registration', d.registration_mode || '—'));
75+
card.appendChild(row('Authenticated', d.my_user || '(no JWT)'));
76+
el.appendChild(card);
77+
} catch (e) { el.appendChild(err('Cannot reach Lemmy.')); }
78+
}
79+
80+
async function loadCommunities() {
81+
const el = document.getElementById('lm-communities'); clear(el);
82+
try {
83+
const res = await fetch('/api/lemmy/communities'); const d = await res.json();
84+
if (d.error) { el.appendChild(err(d.error)); return; }
85+
if (!d.communities || d.communities.length === 0) {
86+
const i = document.createElement('div'); i.className = 'np-idle';
87+
i.textContent = 'No local communities yet. Create one in the web UI.';
88+
el.appendChild(i); return;
89+
}
90+
for (const c of d.communities) {
91+
const li = document.createElement('div'); li.className = 'lm-community';
92+
const t = document.createElement('b'); t.textContent = c.title || c.name;
93+
li.appendChild(t);
94+
const meta = document.createElement('div'); meta.className = 'lm-community-meta';
95+
meta.textContent = '!' + c.name + ' · ' + (c.subscribers || 0) + ' subs · ' + (c.posts || 0) + ' posts';
96+
li.appendChild(meta);
97+
el.appendChild(li);
98+
}
99+
} catch (e) { el.appendChild(err('Cannot load communities: ' + e.message)); }
100+
}
101+
102+
async function loadPosts() {
103+
const el = document.getElementById('lm-posts'); clear(el);
104+
try {
105+
const res = await fetch('/api/lemmy/posts'); const d = await res.json();
106+
if (d.error) { el.appendChild(err(d.error)); return; }
107+
if (!d.posts || d.posts.length === 0) {
108+
const i = document.createElement('div'); i.className = 'np-idle';
109+
i.textContent = 'No posts yet.';
110+
el.appendChild(i); return;
111+
}
112+
for (const p of d.posts) {
113+
const c = document.createElement('div'); c.className = 'lm-post';
114+
const t = document.createElement('b'); t.textContent = p.name;
115+
c.appendChild(t);
116+
const meta = document.createElement('div'); meta.className = 'lm-post-meta';
117+
meta.textContent = '!' + (p.community || '?') + ' · ' + (p.score || 0) + ' pts · ' + (p.comments || 0) + ' comments · ' + (p.creator || '?');
118+
c.appendChild(meta);
119+
el.appendChild(c);
120+
}
121+
} catch (e) { el.appendChild(err('Cannot load posts: ' + e.message)); }
122+
}
123+
124+
loadStatus();
125+
loadCommunities();
126+
loadPosts();
127+
`;
128+
}
129+
130+
function styles() {
131+
return `
132+
.lm-panel h1 { margin: 0 0 1rem; font-size: 1.5rem; }
133+
.lm-subtitle { font-size: 0.85rem; color: var(--crow-text-muted); font-weight: 400; margin-left: .5rem; }
134+
.lm-section { margin-bottom: 1.8rem; }
135+
.lm-section h3 { font-size: 0.8rem; color: var(--crow-text-muted); text-transform: uppercase;
136+
letter-spacing: 0.05em; margin: 0 0 0.7rem; }
137+
.lm-card { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border);
138+
border-radius: 10px; padding: 1rem; }
139+
.lm-row { display: flex; justify-content: space-between; padding: .25rem 0; font-size: .9rem; color: var(--crow-text-primary); }
140+
.lm-row b { color: var(--crow-text-muted); font-weight: 500; min-width: 160px; }
141+
.lm-community, .lm-post { background: var(--crow-bg-elevated); border: 1px solid var(--crow-border);
142+
border-radius: 8px; padding: .6rem .9rem; margin-bottom: .4rem; }
143+
.lm-community b, .lm-post b { color: var(--crow-text-primary); font-size: .9rem; }
144+
.lm-community-meta, .lm-post-meta { font-size: .75rem; color: var(--crow-text-muted); margin-top: .2rem; font-family: ui-monospace, monospace; }
145+
.lm-notes ul { margin: 0; padding-left: 1.2rem; color: var(--crow-text-secondary); font-size: .88rem; }
146+
.lm-notes li { margin-bottom: .3rem; }
147+
.lm-notes code { font-family: ui-monospace, monospace; background: var(--crow-bg);
148+
padding: 1px 4px; border-radius: 3px; font-size: .8em; }
149+
.np-idle, .np-loading { color: var(--crow-text-muted); font-size: 0.9rem; padding: 1rem;
150+
background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; }
151+
.np-error { color: #ef4444; font-size: 0.9rem; padding: 1rem;
152+
background: var(--crow-bg-elevated); border-radius: 10px; text-align: center; }
153+
`;
154+
}

0 commit comments

Comments
 (0)