chore: added skills and assets 6696471d
Steve · 2026-04-02 23:05 6 file(s) · +918 −0
assets/CommitMono-400-Italic.otf (added) +0 −0

Binary file — no preview.

assets/CommitMono-400-Regular.otf (added) +0 −0

Binary file — no preview.

assets/CommitMono-700-Italic.otf (added) +0 −0

Binary file — no preview.

assets/CommitMono-700-Regular.otf (added) +0 −0

Binary file — no preview.

skills/andromeda-stack/SKILL.md (added) +632 −0
1 +
---
2 +
name: Andromeda Stack
3 +
description: Scaffold a Rust CRUD web application with Axum, SQLite, Askama templates, andromeda-auth session auth, embedded static assets, and Docker deployment. Use when the user wants to build a new Rust web server with CRUD operations in the andromeda monorepo.
4 +
---
5 +
6 +
# Rust Andromeda Web App
7 +
8 +
## Overview
9 +
10 +
Scaffold and build Rust CRUD web applications in the andromeda workspace using Axum + SQLite + Askama templates + `andromeda-auth` for authentication. The result is a single binary web server with HTML pages, a JSON API, optional session or API key auth, and Docker deployment.
11 +
12 +
All apps live under `apps/` in the workspace and share dependencies via the root `Cargo.toml`. The `andromeda-auth` crate at `crates/auth/` provides shared authentication primitives.
13 +
14 +
## Project Structure
15 +
16 +
```
17 +
apps/app-name/
18 +
├── src/
19 +
│   ├── main.rs         # Entry point, starts the server
20 +
│   ├── server.rs       # Axum routes, middleware, handlers, static asset serving
21 +
│   ├── db.rs           # SQLite schema, CRUD functions, error types
22 +
│   └── auth.rs         # Session/cookie auth wrapper (uses andromeda-auth crate)
23 +
├── templates/          # Askama HTML templates
24 +
│   ├── base.html       # Base layout with blocks (title, content)
25 +
│   └── *.html          # Pages extend base.html
26 +
├── static/             # CSS, fonts, favicons (served via tower-http ServeDir)
27 +
│   └── styles.css
28 +
├── .env.example        # Environment variable reference
29 +
├── Dockerfile          # Multi-stage workspace build
30 +
└── docker-compose.yml  # Compose config with volume for DB persistence
31 +
```
32 +
33 +
## Dependencies (Cargo.toml)
34 +
35 +
Apps use workspace dependencies from the root `Cargo.toml`. Use `{ workspace = true }` for shared crates:
36 +
37 +
```toml
38 +
[package]
39 +
name = "app-name"
40 +
version = "0.1.0"
41 +
edition = "2024"
42 +
description = "Short app description"
43 +
license = "MIT"
44 +
repository = "https://github.com/stevedylandev/andromeda"
45 +
homepage = "https://github.com/stevedylandev/andromeda"
46 +
47 +
[dependencies]
48 +
axum = { workspace = true }
49 +
tokio = { workspace = true }
50 +
serde = { workspace = true }
51 +
serde_json = { workspace = true }
52 +
rusqlite = { workspace = true }
53 +
nanoid = { workspace = true }
54 +
rust-embed = { workspace = true }
55 +
dotenvy = { workspace = true }
56 +
subtle = { workspace = true }
57 +
rand = { workspace = true }
58 +
tracing = { workspace = true }
59 +
tracing-subscriber = { workspace = true }
60 +
andromeda-auth = { workspace = true }
61 +
askama = "0.15"
62 +
askama_web = { version = "0.15", features = ["axum-0.8"] }
63 +
```
64 +
65 +
After creating the app, register it in the workspace root `Cargo.toml` under `[workspace] members`.
66 +
67 +
Only add additional crates when the specific app requires them. Do NOT include TUI crates (ratatui, crossterm), CLI crates (clap), or HTTP client crates (reqwest) unless explicitly requested.
68 +
69 +
- `tracing` + `tracing-subscriber` — structured logging, always include
70 +
- `rand` — needed for session token generation when using session-based auth
71 +
- `tower-http` — for serving static files from disk via `ServeDir`
72 +
73 +
## Database Layer (db.rs)
74 +
75 +
Pattern: single-file module with `Arc<Mutex<Connection>>` for thread-safe SQLite access.
76 +
77 +
### Structure
78 +
79 +
```rust
80 +
use nanoid::nanoid;
81 +
use rusqlite::{Connection, params};
82 +
use serde::{Deserialize, Serialize};
83 +
use std::fmt;
84 +
use std::sync::{Arc, Mutex};
85 +
86 +
pub type Db = Arc<Mutex<Connection>>;
87 +
88 +
#[derive(Debug)]
89 +
pub enum DbError {
90 +
    Sqlite(rusqlite::Error),
91 +
    LockPoisoned,
92 +
}
93 +
94 +
impl fmt::Display for DbError {
95 +
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 +
        match self {
97 +
            DbError::Sqlite(e) => write!(f, "Database error: {}", e),
98 +
            DbError::LockPoisoned => write!(f, "Database lock poisoned"),
99 +
        }
100 +
    }
101 +
}
102 +
103 +
impl std::error::Error for DbError {}
104 +
105 +
impl From<rusqlite::Error> for DbError {
106 +
    fn from(e: rusqlite::Error) -> Self {
107 +
        DbError::Sqlite(e)
108 +
    }
109 +
}
110 +
```
111 +
112 +
### Key patterns
113 +
114 +
- **Model struct**: derive `Serialize, Deserialize`, all fields `pub`
115 +
- **ID generation**: `nanoid!(10)` for short unique IDs
116 +
- **DB path from env**: `std::env::var("APP_DB_PATH").unwrap_or_else(|_| "app.sqlite".to_string())`
117 +
- **init_db()**: opens connection, runs `CREATE TABLE IF NOT EXISTS`, returns `Arc<Mutex<Connection>>`
118 +
- **CRUD functions**: standalone functions that take `&Db` as first param
119 +
  - `create_*` — INSERT, return created model with `last_insert_rowid()`
120 +
  - `get_*_by_short_id` — SELECT, return `Result<Option<Model>, DbError>`
121 +
  - `get_all_*` — SELECT with ORDER BY id DESC
122 +
  - `delete_*_by_short_id` — DELETE, return `Result<bool, DbError>` (rows_affected > 0)
123 +
  - `update_*_by_short_id` — UPDATE then SELECT to return updated model
124 +
- **Error handling**: `QueryReturnedNoRows` maps to `Ok(None)`, not an error
125 +
126 +
## Server Layer (server.rs)
127 +
128 +
### Embedded assets with rust_embed
129 +
130 +
```rust
131 +
use rust_embed::Embed;
132 +
133 +
#[derive(Embed)]
134 +
#[folder = "assets/"]
135 +
struct Assets;
136 +
137 +
#[derive(Embed)]
138 +
#[folder = "static/"]
139 +
struct Static;
140 +
```
141 +
142 +
Serve with handlers that match on file path and return correct MIME types:
143 +
144 +
```rust
145 +
fn mime_from_path(path: &str) -> &'static str {
146 +
    match path.rsplit('.').next().unwrap_or("") {
147 +
        "css" => "text/css",
148 +
        "js" => "application/javascript",
149 +
        "html" => "text/html",
150 +
        "png" => "image/png",
151 +
        "ico" => "image/x-icon",
152 +
        "svg" => "image/svg+xml",
153 +
        "woff" | "woff2" => "font/woff2",
154 +
        "ttf" => "font/ttf",
155 +
        "otf" => "font/otf",
156 +
        "json" | "webmanifest" => "application/json",
157 +
        _ => "application/octet-stream",
158 +
    }
159 +
}
160 +
```
161 +
162 +
### App state
163 +
164 +
```rust
165 +
#[derive(Clone)]
166 +
struct AppState {
167 +
    db: Db,
168 +
    server_config: ServerConfig,
169 +
}
170 +
```
171 +
172 +
Add domain-specific fields as needed (e.g., a highlighter, cache, etc).
173 +
174 +
### Askama templates
175 +
176 +
```rust
177 +
use askama::Template;
178 +
use askama_web::WebTemplate;
179 +
180 +
#[derive(Template)]
181 +
#[template(path = "index.html")]
182 +
struct IndexTemplate;
183 +
184 +
// Templates with data:
185 +
#[derive(Template)]
186 +
#[template(path = "item.html")]
187 +
struct ItemTemplate {
188 +
    name: String,
189 +
    content: String,
190 +
}
191 +
```
192 +
193 +
Render with `WebTemplate(MyTemplate { ... })`.
194 +
195 +
### Route structure
196 +
197 +
Two sets of routes: **web routes** (HTML pages + form submissions) and **API routes** (JSON).
198 +
199 +
**Web routes:**
200 +
- `GET /` — index page (template)
201 +
- `GET /admin` — admin panel (template)
202 +
- `POST /items` — form submission, redirects on success
203 +
- `GET /items/{short_id}` — view single item (template)
204 +
205 +
**API routes:**
206 +
- `GET /api/items` — list all (JSON)
207 +
- `POST /api/items` — create (JSON body → 201 + JSON)
208 +
- `GET /api/items/{short_id}` — get one (JSON)
209 +
- `PUT /api/items/{short_id}` — update (JSON body → JSON)
210 +
- `DELETE /api/items/{short_id}` — delete (JSON)
211 +
212 +
**Static asset routes:**
213 +
- `GET /assets/{*path}` — embedded assets (favicons, fonts, images)
214 +
- `GET /static/{*path}` — embedded static files (CSS)
215 +
216 +
### Form deserialization
217 +
218 +
```rust
219 +
#[derive(Deserialize)]
220 +
struct CreateItemForm {
221 +
    name: String,
222 +
    content: String,
223 +
}
224 +
```
225 +
226 +
Use `Form(form): Form<CreateItemForm>` for HTML forms, `Json(body): Json<CreateItem>` for API.
227 +
228 +
### Error responses
229 +
230 +
- Web handlers return `Result<..., (StatusCode, Html<String>)>`
231 +
- API handlers return `Result<..., (StatusCode, Json<serde_json::Value>)>`
232 +
- Use `serde_json::json!({"error": "message"})` for API error bodies
233 +
234 +
## Authentication
235 +
236 +
The workspace provides a shared `andromeda-auth` crate at `crates/auth/` with these primitives:
237 +
238 +
```rust
239 +
// andromeda-auth public API
240 +
pub fn verify_password(input: &str, expected: &str) -> bool;
241 +
pub fn generate_session_token() -> String;
242 +
pub fn build_session_cookie(token: &str, secure: bool) -> String;
243 +
pub fn clear_session_cookie() -> String;
244 +
pub fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<String>;
245 +
```
246 +
247 +
Apps import `andromeda-auth` via workspace dependency and wrap it in a local `auth.rs` module.
248 +
249 +
### Session/cookie auth (for web-facing apps)
250 +
251 +
The standard pattern for apps that need login (e.g., jotts, parcels, feeds). Create a `src/auth.rs` that wraps the auth crate with app-specific session storage and an Axum extractor.
252 +
253 +
**Sessions table in db.rs:**
254 +
255 +
```sql
256 +
CREATE TABLE IF NOT EXISTS sessions (
257 +
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
258 +
    token      TEXT NOT NULL UNIQUE,
259 +
    expires_at TEXT NOT NULL
260 +
);
261 +
```
262 +
263 +
**Session DB functions in db.rs:**
264 +
265 +
```rust
266 +
pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> {
267 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
268 +
    conn.execute("INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)", params![token, expires_at])?;
269 +
    Ok(())
270 +
}
271 +
272 +
pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> {
273 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
274 +
    match conn.query_row("SELECT expires_at FROM sessions WHERE token = ?1", params![token], |row| row.get(0)) {
275 +
        Ok(val) => Ok(Some(val)),
276 +
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
277 +
        Err(e) => Err(DbError::Sqlite(e)),
278 +
    }
279 +
}
280 +
281 +
pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> {
282 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
283 +
    conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?;
284 +
    Ok(())
285 +
}
286 +
287 +
pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> {
288 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
289 +
    conn.execute("DELETE FROM sessions WHERE expires_at < datetime('now')", [])?;
290 +
    Ok(())
291 +
}
292 +
```
293 +
294 +
**auth.rs module — wraps andromeda-auth with session validation:**
295 +
296 +
```rust
297 +
use axum::{
298 +
    extract::{FromRef, FromRequestParts},
299 +
    http::request::Parts,
300 +
    response::{IntoResponse, Redirect, Response},
301 +
};
302 +
use std::sync::Arc;
303 +
304 +
use crate::AppState;
305 +
306 +
/// Axum extractor — guards routes behind login. Redirects to /login if invalid.
307 +
pub struct AuthSession;
308 +
309 +
impl<S> FromRequestParts<S> for AuthSession
310 +
where
311 +
    S: Send + Sync,
312 +
    Arc<AppState>: FromRef<S>,
313 +
{
314 +
    type Rejection = Response;
315 +
316 +
    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
317 +
        let state = Arc::<AppState>::from_ref(state);
318 +
        let token = andromeda_auth::extract_session_cookie(&parts.headers);
319 +
        if let Some(token) = token {
320 +
            if is_valid_session(&state, &token) {
321 +
                return Ok(AuthSession);
322 +
            }
323 +
        }
324 +
        Err(Redirect::to("/login").into_response())
325 +
    }
326 +
}
327 +
328 +
fn is_valid_session(state: &AppState, token: &str) -> bool {
329 +
    // Check DB for token expiry — return true if token exists and hasn't expired
330 +
    match crate::db::get_session_expiry(&state.db, token) {
331 +
        Ok(Some(expires_at)) => expires_at > chrono::Utc::now().to_rfc3339(),
332 +
        _ => false,
333 +
    }
334 +
}
335 +
```
336 +
337 +
**Usage in handlers** — use `andromeda_auth` functions directly for login/logout:
338 +
339 +
```rust
340 +
use andromeda_auth;
341 +
342 +
// POST /login — verify password, create session, set cookie
343 +
async fn post_login(State(state): State<Arc<AppState>>, Form(form): Form<LoginForm>) -> Response {
344 +
    if andromeda_auth::verify_password(&form.password, &state.app_password) {
345 +
        let token = andromeda_auth::generate_session_token();
346 +
        // Store token in DB with expiry...
347 +
        let cookie = andromeda_auth::build_session_cookie(&token, state.cookie_secure);
348 +
        // Set cookie header and redirect to /
349 +
    } else {
350 +
        // Redirect to /login?error=Invalid+password
351 +
    }
352 +
}
353 +
354 +
// GET /logout — clear session
355 +
async fn get_logout(headers: HeaderMap, State(state): State<Arc<AppState>>) -> Response {
356 +
    if let Some(token) = andromeda_auth::extract_session_cookie(&headers) {
357 +
        let _ = crate::db::delete_session(&state.db, &token);
358 +
    }
359 +
    let cookie = andromeda_auth::clear_session_cookie();
360 +
    // Set cookie header and redirect to /login
361 +
}
362 +
```
363 +
364 +
**Protect routes** — add `_session: auth::AuthSession` as a parameter:
365 +
366 +
```rust
367 +
async fn get_index(_session: auth::AuthSession, State(state): State<Arc<AppState>>) -> Response {
368 +
    // only reachable if session is valid
369 +
}
370 +
```
371 +
372 +
**AppState for session auth:**
373 +
374 +
```rust
375 +
pub struct AppState {
376 +
    pub db: Db,
377 +
    pub app_password: String,
378 +
    pub cookie_secure: bool,
379 +
}
380 +
```
381 +
382 +
### API key auth (alternative — for API-only apps)
383 +
384 +
For apps that don't need a login page (e.g., sipp), use API key middleware instead of session auth. This pattern does NOT use `andromeda-auth` — it's self-contained in `server.rs`:
385 +
386 +
```rust
387 +
#[derive(Clone)]
388 +
struct ServerConfig {
389 +
    api_key: Option<String>,
390 +
    auth_endpoints: HashSet<String>,
391 +
}
392 +
```
393 +
394 +
See `apps/sipp` for the full API key middleware pattern.
395 +
396 +
### Environment variables
397 +
398 +
Prefix app-specific env vars with the app name (e.g., `JOTTS_`, `SIPP_`). Shared vars like `HOST`, `PORT`, `COOKIE_SECURE` don't need a prefix:
399 +
400 +
| Variable | Purpose | Default |
401 +
|----------|---------|---------|
402 +
| `HOST` | Bind address | `127.0.0.1` (set to `0.0.0.0` in Docker) |
403 +
| `PORT` | Listen port | `3000` |
404 +
| `APP_DB_PATH` | SQLite file path | `app.sqlite` |
405 +
| `APP_PASSWORD` | Single password for session auth (web apps) | None |
406 +
| `COOKIE_SECURE` | Set `true` for HTTPS-only cookies | `false` |
407 +
| `APP_API_KEY` | API key for API-key auth pattern | None (auth disabled) |
408 +
| `APP_AUTH_ENDPOINTS` | Comma-separated endpoint names, "all", or "none" | `api_delete,api_list,api_update` |
409 +
410 +
## Templates (Askama)
411 +
412 +
HTML templates live in `templates/` and use Askama syntax. Key patterns:
413 +
414 +
- Link CSS via `/static/styles.css`
415 +
- Link assets via `/assets/filename`
416 +
- Include `<meta name="theme-color" content="#121113" />`
417 +
- Forms POST to web routes (not API routes)
418 +
- Use `{{ variable }}` for template interpolation
419 +
- Use `{{ variable|safe }}` for pre-rendered HTML (e.g., syntax highlighted content)
420 +
421 +
### Template inheritance
422 +
423 +
Use a `base.html` with block sections. All pages extend it:
424 +
425 +
**templates/base.html:**
426 +
```html
427 +
<!DOCTYPE html>
428 +
<html lang="en">
429 +
<head>
430 +
  <meta charset="UTF-8">
431 +
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
432 +
  <title>{% block title %}APP_NAME{% endblock %}</title>
433 +
  <meta name="theme-color" content="#121113" />
434 +
  <style>
435 +
    /* base styles here */
436 +
  </style>
437 +
</head>
438 +
<body>
439 +
  <div class="container">
440 +
    {% block content %}{% endblock %}
441 +
  </div>
442 +
</body>
443 +
</html>
444 +
```
445 +
446 +
**templates/index.html:**
447 +
```html
448 +
{% extends "base.html" %}
449 +
{% block title %}Items{% endblock %}
450 +
{% block content %}
451 +
  {% if let Some(error) = error %}
452 +
    <p class="error">{{ error }}</p>
453 +
  {% endif %}
454 +
  {% for item in items %}
455 +
    <div>{{ item.name }}</div>
456 +
  {% endfor %}
457 +
{% endblock %}
458 +
```
459 +
460 +
### Flash messages via query params
461 +
462 +
Pass transient error/success messages through redirects using query parameters. No session flash needed.
463 +
464 +
**Query param struct:**
465 +
```rust
466 +
#[derive(Deserialize, Default)]
467 +
pub struct FlashQuery {
468 +
    pub error: Option<String>,
469 +
}
470 +
```
471 +
472 +
**In handlers** — redirect with message:
473 +
```rust
474 +
Redirect::to("/items/add?error=Name+is+required.").into_response()
475 +
```
476 +
477 +
**In receiving handler** — extract and pass to template:
478 +
```rust
479 +
async fn get_add(Query(q): Query<FlashQuery>) -> Response {
480 +
    render(AddTemplate { error: q.error })
481 +
}
482 +
```
483 +
484 +
**In template** — conditionally render:
485 +
```html
486 +
{% if let Some(error) = error %}
487 +
  <p class="error">{{ error }}</p>
488 +
{% endif %}
489 +
```
490 +
491 +
## Logging (tracing)
492 +
493 +
Always initialize tracing in `main()` before anything else:
494 +
495 +
```rust
496 +
tracing_subscriber::fmt::init();
497 +
```
498 +
499 +
Use throughout the app:
500 +
- `tracing::error!("DB error: {}", e)` — unrecoverable failures
501 +
- `tracing::warn!("Non-critical issue: {}", e)` — degraded but functional
502 +
- `tracing::info!("Listening on {}", addr)` — startup/lifecycle events
503 +
504 +
## main.rs
505 +
506 +
Minimal — just loads env and starts the server:
507 +
508 +
```rust
509 +
mod auth;
510 +
mod db;
511 +
mod server;
512 +
513 +
#[tokio::main]
514 +
async fn main() {
515 +
    dotenvy::dotenv().ok();
516 +
    tracing_subscriber::fmt::init();
517 +
    let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
518 +
    let port: u16 = std::env::var("PORT")
519 +
        .ok()
520 +
        .and_then(|v| v.parse().ok())
521 +
        .unwrap_or(3000);
522 +
    server::run(host, port).await;
523 +
}
524 +
```
525 +
526 +
Keep main.rs minimal — all logic lives in `server.rs`, `db.rs`, and `auth.rs`.
527 +
528 +
## Dockerfile
529 +
530 +
Multi-stage workspace build. Must be built from the repo root with `docker build -f apps/APP_NAME/Dockerfile .`:
531 +
532 +
```dockerfile
533 +
# Build from repo root: docker build -t APP_NAME -f apps/APP_NAME/Dockerfile .
534 +
FROM rust:1-slim-bookworm AS builder
535 +
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
536 +
WORKDIR /app
537 +
538 +
# Copy workspace manifests for dependency caching
539 +
COPY Cargo.toml Cargo.lock .
540 +
COPY crates/auth/Cargo.toml crates/auth/
541 +
# Copy all app Cargo.tomls (needed for workspace resolution)
542 +
COPY apps/sipp/Cargo.toml apps/sipp/
543 +
COPY apps/feeds/Cargo.toml apps/feeds/
544 +
COPY apps/parcels/Cargo.toml apps/parcels/
545 +
COPY apps/jotts/Cargo.toml apps/jotts/
546 +
COPY apps/og/Cargo.toml apps/og/
547 +
COPY apps/shrink/Cargo.toml apps/shrink/
548 +
COPY apps/APP_NAME/Cargo.toml apps/APP_NAME/
549 +
550 +
# Create stubs for dependency caching
551 +
RUN mkdir -p crates/auth/src && echo '' > crates/auth/src/lib.rs \
552 +
    && for app in sipp feeds parcels jotts og shrink APP_NAME; do \
553 +
         mkdir -p apps/$app/src && echo 'fn main() {}' > apps/$app/src/main.rs; \
554 +
       done
555 +
556 +
RUN cargo build --release -p APP_NAME
557 +
558 +
# Copy real source
559 +
COPY crates/auth/src crates/auth/src
560 +
COPY apps/APP_NAME/src apps/APP_NAME/src
561 +
COPY apps/APP_NAME/static apps/APP_NAME/static
562 +
COPY apps/APP_NAME/templates apps/APP_NAME/templates
563 +
564 +
RUN touch apps/APP_NAME/src/*.rs crates/auth/src/*.rs && cargo build --release -p APP_NAME
565 +
566 +
FROM debian:bookworm-slim
567 +
COPY --from=builder /app/target/release/APP_NAME /usr/local/bin/APP_NAME
568 +
WORKDIR /data
569 +
EXPOSE 3000
570 +
ENV HOST=0.0.0.0
571 +
ENV PORT=3000
572 +
CMD ["APP_NAME"]
573 +
```
574 +
575 +
Replace all `APP_NAME` with the actual binary/package name. If the app makes HTTPS requests (e.g., uses reqwest), add `RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*` to the final stage.
576 +
577 +
## docker-compose.yml
578 +
579 +
```yaml
580 +
services:
581 +
  app:
582 +
    build:
583 +
      context: ../..
584 +
      dockerfile: apps/APP_NAME/Dockerfile
585 +
    ports:
586 +
      - "${PORT:-3000}:${PORT:-3000}"
587 +
    environment:
588 +
      - APP_PASSWORD=${APP_PASSWORD:-changeme}
589 +
      - APP_DB_PATH=/data/APP_NAME.sqlite
590 +
      - COOKIE_SECURE=false
591 +
      - HOST=0.0.0.0
592 +
      - PORT=${PORT:-3000}
593 +
    volumes:
594 +
      - app-data:/data
595 +
    restart: unless-stopped
596 +
597 +
volumes:
598 +
  app-data:
599 +
```
600 +
601 +
Key points:
602 +
- `context: ../..` builds from the workspace root
603 +
- Named volume persists the SQLite database across container restarts
604 +
- ENV vars use app-specific prefixes (e.g., `JOTTS_PASSWORD`, `SIPP_API_KEY`)
605 +
606 +
## .env.example
607 +
608 +
Always create one with all configurable env vars and sensible comments.
609 +
610 +
## Checklist
611 +
612 +
When scaffolding a new app with this pattern:
613 +
614 +
1. Create `apps/app-name/` with `cargo init`
615 +
2. Register in workspace root `Cargo.toml` under `[workspace] members`
616 +
3. Set up `Cargo.toml` with workspace dependencies + `andromeda-auth`
617 +
4. Write `db.rs` — schema, model struct, CRUD functions, session table if needed
618 +
5. Write `auth.rs` — wrap `andromeda-auth` with `AuthSession` extractor (if auth needed)
619 +
6. Write `server.rs` — config, state, templates, handlers, routes
620 +
7. Write `main.rs` — minimal entry point
621 +
8. Create `templates/` with at least a `base.html` and index page
622 +
9. Create `static/styles.css`
623 +
10. Create `.env.example`
624 +
11. Create `Dockerfile` (workspace-aware multi-stage) and `docker-compose.yml`
625 +
12. Test: `cargo run -p app-name`, verify routes work
626 +
627 +
## What NOT to include
628 +
629 +
- No external CSS frameworks unless specified 
630 +
- No ORMs — use raw rusqlite
631 +
- No connection pools — `Arc<Mutex<Connection>>` is sufficient for SQLite
632 +
- No async database drivers — rusqlite is synchronous and that's fine
skills/darkmatter-styles/SKILL.md (added) +286 −0
1 +
---
2 +
name: darkmatter-styles
3 +
description: Use when building any web UI for Steve - applies his personal dark aesthetic with Commit Mono font, #121113 background, white borders, minimal layout, and max-width centered content. Use for new pages, components, or when asked to match his existing style.
4 +
---
5 +
6 +
# Darkmatter Styles
7 +
8 +
## Overview
9 +
10 +
Steve's personal web aesthetic: dark, minimal, monospace. No frameworks, no decorative flourishes. Everything is functional and stark.
11 +
12 +
## Core Palette
13 +
14 +
| Token | Value | Usage |
15 +
|-------|-------|-------|
16 +
| Background | `#121113` | All surfaces — html, inputs, buttons, textarea |
17 +
| Foreground | `#ffffff` | All text and borders |
18 +
| Border | `1px solid white` | Inputs, buttons, textarea |
19 +
| Gray Dark | `#1e1c1f` | Code block backgrounds |
20 +
| Gray Mid | `#333` | Dividers, list item borders, section separators |
21 +
| Gray Light | `#555` | Tertiary borders (blockquote borders) |
22 +
23 +
**No accent colors, no gradients.** Background, white, and grays only.
24 +
25 +
### Visual Hierarchy via Opacity (NOT gray color values)
26 +
27 +
Use opacity on white text instead of gray hex colors for secondary/tertiary text:
28 +
29 +
| Level | Opacity | Usage |
30 +
|-------|---------|-------|
31 +
| Primary | 1.0 | Headings, body text, links |
32 +
| Secondary | 0.7 | Labels, form labels, blockquotes |
33 +
| Tertiary | 0.5 | Nav links dimmed, table headers, dates, metadata, empty states |
34 +
| Muted | 0.3 | Null/placeholder values |
35 +
| Error | 0.8 | Error messages |
36 +
37 +
**Do NOT use `color: #888` for secondary text.** Always use `opacity` on white text instead.
38 +
39 +
## Typography
40 +
41 +
- **Font:** `"Commit Mono"` (self-hosted .otf), fallback `monospace, sans-serif`
42 +
- Applied globally via `* { font-family: ... }`
43 +
- **Body font-size:** 14px
44 +
- **Line-height:** 1.6
45 +
46 +
### Font Size Scale
47 +
48 +
| Size | Usage |
49 +
|------|-------|
50 +
| 28px | Site logo/title (bold, uppercase) |
51 +
| 18px | Markdown h1 |
52 +
| 16px | Markdown h2, note/item titles, primary labels |
53 +
| 15px | Markdown h3 |
54 +
| 14px | Body text, inputs, buttons, markdown h4-h6 |
55 +
| 13px | Inline code, error messages |
56 +
| 12px | Nav links, form labels, metadata, dates, table headers, action links |
57 +
58 +
### Font Face Declarations
59 +
60 +
```css
61 +
@font-face {
62 +
  font-family: "Commit Mono";
63 +
  src: url("/static/fonts/CommitMono-400-Regular.otf") format("opentype");
64 +
  font-weight: 400;
65 +
  font-style: normal;
66 +
}
67 +
68 +
@font-face {
69 +
  font-family: "Commit Mono";
70 +
  src: url("/static/fonts/CommitMono-700-Regular.otf") format("opentype");
71 +
  font-weight: 700;
72 +
  font-style: normal;
73 +
}
74 +
```
75 +
76 +
## Base Reset
77 +
78 +
```css
79 +
* {
80 +
  padding: 0;
81 +
  margin: 0;
82 +
  box-sizing: border-box;
83 +
  font-family: "Commit Mono", monospace, sans-serif;
84 +
  scrollbar-width: none;
85 +
  -ms-overflow-style: none;
86 +
}
87 +
88 +
html {
89 +
  background: #121113;
90 +
  color: #ffffff;
91 +
  font-size: 14px;
92 +
  line-height: 1.6;
93 +
}
94 +
95 +
html::-webkit-scrollbar {
96 +
  display: none;
97 +
}
98 +
```
99 +
100 +
## Layout
101 +
102 +
Single-column, centered, max 700px wide. No top body padding — top spacing comes from header `margin-top`:
103 +
104 +
```css
105 +
body {
106 +
  display: flex;
107 +
  flex-direction: column;
108 +
  justify-content: start;
109 +
  align-items: start;
110 +
  gap: 1.5rem;
111 +
  min-height: 100vh;
112 +
  max-width: 700px;
113 +
  margin: auto;
114 +
  padding: 0 1rem;
115 +
}
116 +
117 +
@media (max-width: 480px) {
118 +
  body {
119 +
    padding: 1rem;
120 +
    gap: 1rem;
121 +
  }
122 +
}
123 +
```
124 +
125 +
## Header
126 +
127 +
The header uses a border-bottom separator and `margin-top: 2rem` for top spacing. The site title/logo is **always uppercase**, 28px bold:
128 +
129 +
```css
130 +
.header {
131 +
  display: flex;
132 +
  flex-direction: column;
133 +
  gap: 0.5rem;
134 +
  width: 100%;
135 +
  margin-top: 2rem;
136 +
  border-bottom: 1px solid #333;
137 +
  padding-bottom: 1rem;
138 +
}
139 +
140 +
.logo {
141 +
  font-size: 28px;
142 +
  font-weight: 700;
143 +
  text-decoration: none;
144 +
  text-transform: uppercase;
145 +
}
146 +
```
147 +
148 +
## Navigation Links
149 +
150 +
Compact gap, small font:
151 +
152 +
```css
153 +
.links {
154 +
  display: flex;
155 +
  align-items: center;
156 +
  gap: 0.75rem;
157 +
  font-size: 12px;
158 +
}
159 +
```
160 +
161 +
## Interactive Elements
162 +
163 +
All inputs, textareas, and buttons match the background — they blend into the surface with only a white border. **No border-radius**, padding uses `0.4rem 0.75rem`:
164 +
165 +
```css
166 +
input, textarea {
167 +
  background: #121113;
168 +
  color: #ffffff;
169 +
  border: 1px solid white;
170 +
  padding: 0.4rem 0.75rem;
171 +
  font-size: 14px;
172 +
  width: 100%;
173 +
  border-radius: 0;
174 +
}
175 +
176 +
textarea {
177 +
  min-height: 400px;
178 +
  resize: vertical;
179 +
}
180 +
181 +
button {
182 +
  background: #121113;
183 +
  color: #ffffff;
184 +
  padding: 0.4rem 0.75rem;
185 +
  border: 1px solid white;
186 +
  cursor: pointer;
187 +
  width: fit-content;
188 +
  font-size: 14px;
189 +
  border-radius: 0;
190 +
}
191 +
192 +
button:hover, a:hover {
193 +
  opacity: 0.7;
194 +
}
195 +
196 +
a {
197 +
  color: #ffffff;
198 +
  text-decoration: none;
199 +
}
200 +
```
201 +
202 +
## Labels
203 +
204 +
```css
205 +
label {
206 +
  font-size: 12px;
207 +
  opacity: 0.7;
208 +
}
209 +
```
210 +
211 +
## Errors
212 +
213 +
Use a left border accent, not a full box border:
214 +
215 +
```css
216 +
.error {
217 +
  color: #ffffff;
218 +
  border-left: 2px solid #ffffff;
219 +
  padding-left: 0.5rem;
220 +
  font-size: 13px;
221 +
  opacity: 0.8;
222 +
}
223 +
```
224 +
225 +
## List Items
226 +
227 +
Vertical stacking with bottom borders as dividers, 16px title size:
228 +
229 +
```css
230 +
.item-list {
231 +
  display: flex;
232 +
  flex-direction: column;
233 +
  width: 100%;
234 +
}
235 +
236 +
.item {
237 +
  display: flex;
238 +
  flex-direction: column;
239 +
  gap: 0.25rem;
240 +
  padding: 0.75rem 0;
241 +
  border-bottom: 1px solid #333;
242 +
}
243 +
244 +
.item:hover {
245 +
  opacity: 0.7;
246 +
}
247 +
248 +
.item-title {
249 +
  font-size: 16px;
250 +
}
251 +
252 +
.item-meta {
253 +
  font-size: 12px;
254 +
  opacity: 0.5;
255 +
}
256 +
```
257 +
258 +
## Table Headers
259 +
260 +
Uppercase, dimmed, lightweight:
261 +
262 +
```css
263 +
th {
264 +
  opacity: 0.5;
265 +
  font-weight: 400;
266 +
  font-size: 12px;
267 +
  text-transform: uppercase;
268 +
}
269 +
```
270 +
271 +
## Meta Tags
272 +
273 +
Always include:
274 +
```html
275 +
<meta name="theme-color" content="#121113" />
276 +
```
277 +
278 +
## What NOT to Do
279 +
280 +
- No `border-radius` — keep all corners sharp (explicitly set `border-radius: 0` on inputs/buttons)
281 +
- No box shadows or drop shadows
282 +
- No color other than `#121113`, `#ffffff`, and the gray tones (`#1e1c1f`, `#333`, `#555`)
283 +
- **No `color: #888`** — use `opacity` on white text for visual hierarchy instead
284 +
- No external font CDNs — fonts are self-hosted
285 +
- No utility frameworks (no Tailwind, no Bootstrap)
286 +
- No decorative elements, icons, or emojis