# DevPlace Documentation

Complete developer documentation for `https://devplace.net`.

## Contents

- [Overview](#doc-index)
- [Getting started](#doc-getting-started)
- [Devii Assistant](#doc-devii)
- [Media gallery](#doc-media-gallery)
- [Notification settings](#doc-notification-settings)
- [SEO Diagnostics](#doc-tools-seo)
- [DeepSearch](#doc-tools-deepsearch)
- [Claude Code setup](#doc-claude)
- [Manual](#doc-claude-manual)
- [Subagents](#doc-claude-agents)
- [Commands](#doc-claude-commands)
- [Workflows](#doc-claude-workflows)
- [Components overview](#doc-components)
- [dp-avatar](#doc-component-dp-avatar)
- [dp-code](#doc-component-dp-code)
- [dp-content](#doc-component-dp-content)
- [dp-upload](#doc-component-dp-upload)
- [dp-toast](#doc-component-dp-toast)
- [dp-dialog](#doc-component-dp-dialog)
- [dp-context-menu](#doc-component-dp-context-menu)
- [dp-lightbox](#doc-component-dp-lightbox)
- [devii-terminal](#doc-component-devii-terminal)
- [devii-avatar](#doc-component-devii-avatar)
- [emoji-picker](#doc-component-emoji-picker)
- [Design system overview](#doc-styles)
- [Colors](#doc-styles-colors)
- [Layout](#doc-styles-layout)
- [Responsiveness](#doc-styles-responsiveness)
- [Consistency rules](#doc-styles-consistency)
- [Authentication](#doc-authentication)
- [Conventions & Errors](#doc-conventions)
- [Authentication](#doc-auth)
- [Search & Lookups](#doc-lookups)
- [Votes, Reactions, Bookmarks & Polls](#doc-social-actions)
- [Posts, Comments, Projects, Gists & News](#doc-content)
- [Profiles & Social Graph](#doc-profiles)
- [Messaging](#doc-messaging)
- [Notifications](#doc-notifications)
- [Uploads](#doc-uploads)
- [Project Filesystem](#doc-project-files)
- [Tools (SEO Diagnostics)](#doc-tools)
- [Web Push](#doc-push)
- [Bug Reports](#doc-bugs)

---

<a id="doc-index"></a>
# DevPlace Documentation

DevPlace is a server-rendered social network for developers with a clean HTTP surface you
can automate. Anything you can do in the browser you can also do from a script.

> Tip: use the **search box** in the sidebar, or the [docs search](/docs/search.html) page,
> to find anything across these pages instantly.

## Choose your path

**Use the site.** Learn what DevPlace offers and let the assistant do the work:
[Devii Assistant](#doc-devii), [Media gallery](#doc-media-gallery),
[Notification settings](#doc-notification-settings).

**Build on the API.** Authenticate, learn the shared conventions, then browse the
reference: [Authentication](#doc-authentication),
[Conventions and Errors](#doc-conventions). Every reference page lists its endpoints
with copy-paste cURL, JavaScript, and Python examples and an interactive panel that runs the
call with your own API key pre-filled.

**Contribute to DevPlace.** Go from a fresh clone to a running instance and your first
change: [Getting started](#doc-getting-started).

## Machine-readable schema

An OpenAPI schema and two interactive explorers are generated from the backend:

- [Swagger UI](/swagger) - try endpoints from an auto-generated console.
- [ReDoc](/redoc) - a clean, readable reference rendering.
- [OpenAPI JSON](/openapi.json) - the raw schema for codegen and tooling.

## Download

Grab the entire documentation as a single file:

- [Single-page HTML](/docs/download.html) - one self-contained, offline-ready file.
- [Markdown](/docs/download.md) - the complete docs as one Markdown document.

## Base URL

Every example in these docs uses your current host:

```
https://devplace.net
```

---

<a id="doc-getting-started"></a>
# Getting started

A single path from a fresh clone to a running instance and your first change. If you
only want to call the API, jump to [Authentication](#doc-authentication) and the
[API reference](#doc-conventions) instead - this page is for working on DevPlace
itself.

## Run it locally

DevPlace is a Python package. Install it editable, then start the reloading dev server:

```bash
make install      # pip install -e .
make dev          # uvicorn --reload on port 10500
```

Open `http://localhost:10500`. The first account you register becomes the administrator.
`make prod` runs the two-worker production server on the same port.

## Repository layout

```
devplacepy/        the application package
  main.py          mounts routers, middleware, startup/shutdown
  routers/         one directory per URL domain (mirrors the endpoint tree)
  templates/       Jinja2 pages; static/ holds CSS and ES6 modules
  database.py      SQLite via dataset; query and batch helpers
  models.py        Pydantic Form models   schemas.py  *Out JSON models
  services/        background + async-job services, Devii, containers
tests/             unit / api / e2e, mirroring the route or source path
```

## How a feature is shaped

Every feature is **one data source with four faces**: the same route handler is rendered
as HTML, served as JSON, called by the Devii assistant as a tool, and described in the API
docs. The usual failure is changing one face and forgetting a connected one. Work in this
order so nothing is dropped:

1. **Data** - query helpers in `database.py`, a `Form` model in `models.py`, an `*Out`
   model in `schemas.py`.
2. **Server** - the handler in `routers/`, with the right guard (`get_current_user` for
   public reads, `require_user`, `require_admin`); return both faces via
   `respond(request, template, ctx, model=XOut)`.
3. **View** - extend `base.html`; one ES6 class per file under `static/js/`.
4. **Agent and docs** - add a Devii tool when a user could ask for the action, an
   `endpoint()` entry in `docs_api.py`, and update `README.md` and `AGENTS.md`.

## Validate before you finish

Never declare work done with a broken import or a validation error:

```bash
python -c "from devplacepy.main import app"   # must import clean
hawk .                                         # Python, JS, CSS, templates
```

Tests live in
`tests/` and run with `make test-unit`, `make test-api`, and `make test-e2e`.

## Read next

- [Conventions and Errors](#doc-conventions) - the rules every endpoint shares.
- [Components overview](#doc-components) and the [design system](#doc-styles) -
  the frontend building blocks and styling rules.

---

<a id="doc-devii"></a>
<div class="devii-doc-hero-mark">🤖</div>
    <h1>Devii - your AI teammate on DevPlace</h1>
    <p>Devii builds and runs things for you on this platform, just by chatting. Ask it to write a client,
        ship a complete bot, automate your day, or operate your account - in plain language.</p>
    <div class="devii-doc-hero-actions">
        <a href="/devii/" class="btn btn-primary btn-lg" data-devii-open><span class="icon">🤖</span> Launch Devii</a>
        <span class="devii-doc-hero-note">Available to everyone. Guests get a free sandbox; sign in to let Devii work on your own account.</span>
    </div>
</div>

<div class="docs-content" data-render>
## What Devii does for you

Describe what you want and Devii produces working results - code, automations, and actions on
the platform - then verifies them for you.

- **Generate API clients** in any language, wired to your account.
- **Build complete bots** that post, comment, vote, and reply on this community.
- **Automate your presence** with scheduled tasks that run for you.
- **Operate your account by chat** - post, comment, follow, message, manage gists and projects.
- **Work on your project files** - read, write, and edit a project's filesystem line by line.
- **Call any external API** - integrate web services with full HTTP requests (GET, POST, and more).
- **Attach files from the internet** - hand Devii a URL and it stores it as an attachment on your post or project.
- **Customize the site for yourself** - apply your own CSS and JavaScript, scoped to a page or globally.
- **Invent your own tools** - describe a tool in plain language and Devii adds it to its toolset.
- **Guide you live on screen** - highlight elements, show tips, scroll, and walk you through the site.
- **Answer platform questions** and turn the [documentation](#doc-index) into ready-to-run examples.

## Generate a client in any language

Name the part of the platform you want and your language. Devii returns a complete, runnable
client wired to your account with your API key (see [Authentication](#doc-authentication)).

> Write me a Python client for my feed and notifications, with functions to post and to mark
> notifications read.

> Generate a typed TypeScript SDK for posts, comments, and reactions, with examples.

> Give me a single-file Go client that creates a gist and prints its URL.

## Build a complete bot, on the fly

Describe the bot and the language. Devii writes the whole thing - main loop, authentication,
error handling - ready to drop in and run.

> Build a complete Node.js bot that watches for mentions of me and replies with a friendly
> answer, polling every minute.

> Write a Rust bot that posts my latest GitHub release to the feed whenever a new one appears.

> Create a Python bot that reads the news feed each morning, picks the top story, and publishes
> a short summary post - with a daily schedule so it runs by itself.

Ask for any language: Python, TypeScript, Go, Rust, Java, C#, Ruby, PHP, Bash - Devii adapts the
client and the bot to the one you name.

## Automate your day

Devii can schedule work and run it for you on a cadence you choose.

> Every weekday at 09:00, summarize my unread notifications and send me a direct message with
> the highlights.

> Once an hour, check for new comments on my latest post and thank each new commenter.

## Operate your account by conversation

Anything you can do on the site, you can ask Devii to do - and it confirms the result.

> Post a gist titled "Quicksort in Rust" with the code below, then follow the three most active
> authors on the leaderboard.

> Find the post about WebSockets, leave a thoughtful comment, and upvote it.

## Pull in files and call external services

Devii can reach the internet for you and bring the results back onto the platform.

> Attach this image to my project: https://example.com/diagram.png

> Create a post titled "Release banner" with this picture attached:
> https://example.com/banner.jpg

> Call the JSON API at https://api.example.com/v1/generate with a POST, send my prompt as the
> body, and show me what it returns.

## A guide that points at the screen

Devii can see the page you are on and guide you through it live - highlighting buttons, leaving
short tips, scrolling to the right place, and moving you between pages.

> Show me how to create a gist. Walk me through it step by step on screen.

> Take me to my notifications and point out how to mark them all read.

## Your built-in docs helper

Not sure how an endpoint works? Ask in the terminal on any documentation page.

> How do I paginate the feed, and what does the next cursor look like? Show me a working example.

## Try it now

Open the terminal and type anything - Devii plans, acts, verifies, and shows its work as it
goes. Signed-in users get a persistent session that follows them across tabs and devices; guests
get an instant sandbox.
</div>

<div class="devii-doc-cta">
    <a href="/devii/" class="btn btn-primary btn-lg" data-devii-open><span class="icon">🤖</span> Launch Devii</a>

---

<a id="doc-media-gallery"></a>
# Media gallery

Every image, video, and file you share on DevPlace is collected in one place: the **Media** tab on
your profile. Whatever you attach to a post, project, gist, comment, direct message, bug report, or
news item shows up here automatically, newest first.

## Where to find it

Open any profile and choose the **Media** tab, or go straight to
`/profile/YOUR_USERNAME?tab=media`. The gallery is public: like the Posts, Projects, and Gists
tabs, anyone can browse a member's media.

## Viewing media

The gallery is a responsive grid of thumbnails, from a wide multi-column layout on the
desktop down to a single column on a phone.

- **Images** show as thumbnails. Click one to open it full size in the lightbox; click the
  backdrop, press `Escape`, or use the close button to dismiss it.
- **Videos** play inline with standard controls.
- **Other files** (PDFs, archives, code, audio) appear as a labelled card showing the file name and
  size, and download when clicked.

Each tile links back to the post, project, or gist the media belongs to. Long galleries are split
into numbered pages.

## Deleting your own media

Hover (or tap) a tile you uploaded and a delete button appears. Confirm, and the item is removed
from your Media tab and from anywhere it was shown, including the original post or project. You can
only delete media you uploaded yourself.

You can also ask the Devii assistant to do it, for example:

> Delete the screenshot I attached to my last post.

Devii shows you exactly which file it will remove and asks you to confirm before deleting.
</div>

<div class="devii-doc-cta">

---

<a id="doc-notification-settings"></a>
# Notification settings

DevPlace notifies you when something involves you: a comment on your post, a reply to your comment, a
mention, an upvote on your work, a new follower, a direct message, a badge, a level-up, or an update
on a bug you filed. You decide which reach you, and how.

Each notification type is delivered on two independent channels:

- **In-app** - the notification appears on DevPlace (the bell in the top navigation and the
  `/notifications` page).
- **Push** - a native web push notification is sent to the devices where you enabled push.

The channels are independent: you can keep a type in-app but silence its push, or the reverse.

## Where to find it

Open your profile and choose the **Notifications** tab, or go straight to
`/profile/YOUR_USERNAME?tab=notifications`. The tab is private: only you (and administrators) can see or
change your settings.

Each notification type is one row with two checkboxes, **In-app** and **Push**. Ticking or unticking a
box saves immediately - there is no separate save button.

## What each type covers

| Type | Fires when |
|------|------------|
| Comments | someone comments on your post |
| Replies | someone replies to your comment |
| Mentions | someone mentions you with `@username` |
| Upvotes | someone `++`'d your post, comment, project, or gist |
| Followers | someone starts following you |
| Direct messages | someone sends you a message |
| Badges | you earn a badge |
| Level-ups | you reach a new level |
| Bug tracker | there is an update on a bug report you filed |

## Defaults

Every type and channel is **on** until you turn it off, so notifications work out of the box. A type
you have never changed follows the platform default; once you tick or untick a box, your choice is
remembered and no longer follows later changes to the default.

Use **Reset to defaults** at the bottom of the tab to clear all of your choices and return to the
platform defaults.

## Turning push on

Toggling **Push** for a type only takes effect once you have enabled push notifications on the device
with the bell-with-slash button in the top navigation or on the `/notifications` page. Until then,
push has nowhere to be delivered. See [Push notifications](#doc-push) for the device-side setup.

## Ask Devii

You can change these settings in plain language through the Devii assistant:

> Turn off push notifications for upvotes.

> Stop notifying me about new followers entirely.

Devii reads your current settings, makes the change, and can reset everything to the defaults (it asks
you to confirm a reset first, since that clears all of your choices).
</div>

<div class="devii-doc-cta">

---

<a id="doc-tools-seo"></a>
# SEO Diagnostics

SEO Diagnostics audits a web page or an entire sitemap against a broad battery of checks, far wider
than a single Core Web Vitals or rich-results test. It loads each page in a real headless browser,
inspects the rendered DOM, measures performance, and grades the result. Progress streams live while
the audit runs, and a full categorised report is produced at the end.

Open it from the **Tools** menu, or go straight to `/tools/seo`. It is public: you do not need an
account.

## Running an audit

1. Paste a URL (for a single page) or a `sitemap.xml` URL.
2. Choose **Single URL** or **Sitemap**. In sitemap mode, set how many pages to crawl (up to 50).
3. Press **Run audit**. Progress appears immediately: each page is loaded, then every check runs.
4. When the audit finishes, the score, grade, per-category breakdown and every individual check
   with its recommendation are shown. A link opens the full standalone report.

You can run one audit at a time. Targets that resolve to private or local addresses are refused.

## What gets checked

The auditor groups its findings into categories:

- **Crawlability and indexing** - HTTP status, redirect chains, HTTPS and HSTS, canonical tags,
  meta-robots and `X-Robots-Tag`, `robots.txt`, XML sitemap, URL hygiene, and mixed content.
- **On-page meta and content** - title and meta description (presence and length), single H1 and
  heading hierarchy, content depth, language, character encoding, viewport, and favicon.
- **Links** - internal and external link profile, descriptive anchor text.
- **Structured data** - JSON-LD validity, recognised schema types and their required properties,
  microdata and RDFa detection.
- **Social cards** - Open Graph and Twitter Card tags for rich link previews.
- **Performance and Core Web Vitals** - Largest Contentful Paint, Cumulative Layout Shift, First
  Contentful Paint, Time To First Byte, page weight, request count, DOM size, compression, caching,
  image optimisation, and console errors.
- **Mobile and accessibility** - responsive layout (no horizontal overflow), tap-target sizing,
  image alt coverage, and form labels.
- **Security** - the common security response headers and TLS.
- **AI and LLM-search readiness** - whether your primary content is in the initial HTML (server
  rendered) rather than JavaScript-only, presence of an `llms.txt`, and semantic HTML landmarks.

Each check reports a status (pass, warn, fail or info), its severity, the observed value, and a
recommendation when something can be improved. The overall score is a severity-weighted pass rate,
shown as a 0-100 number and an A to F grade, with a subscore per category.

## Scoring

Failing a critical check (for example a non-200 status or HTTPS) costs far more than a low-severity
warning. Informational checks never affect the score. A score of 90 or above is an A.

## Ask Devii

You can also run an audit in plain language through the Devii assistant:

> Run SEO diagnostics on https://example.com and tell me the score.

Devii queues the audit, polls it, and reports the score, grade and a link to the full report.

## Programmatic access

The same audit is available over the API: `POST /tools/seo/run` to queue, `GET /tools/seo/{uid}`
to poll, and `GET /tools/seo/{uid}/report` for the full report (HTML or JSON). See the
[Tools (SEO Diagnostics)](#doc-tools) API group for request and response shapes.
</div>

<div class="devii-doc-cta">
    <a href="/tools/seo" class="sidebar-link">Open SEO Diagnostics</a>

---

<a id="doc-tools-deepsearch"></a>
# DeepSearch

DeepSearch is a multi-agent deep web researcher. Given a single research question it plans a set of
diverse web search queries, crawls and reads the most relevant sources, indexes everything into a
private vector collection for that run, then runs a chain of agents (summarizer, critic, linker) to
produce a grounded, cited report with a confidence score, source diversity and an explicit list of
open gaps. Progress streams live while it works, and afterwards you can chat with the gathered
evidence.

Open it from the **Tools** menu, or go straight to `/tools/deepsearch`. It is public: you do not
need an account.

## Running a research job

1. Type a focused research question.
2. Set the **depth** (1-4) and the maximum number of **pages** to crawl (up to 30).
3. Press **Research**. Progress appears immediately: query planning, web search, crawling each
   source, indexing, then the analysis agents.
4. You can **pause**, **resume** or **cancel** a run at any time.
5. When it finishes, open the report to read the summary, findings, gaps and sources, and to chat
   with the research.

You can run one job at a time. Targets that resolve to private or local addresses are refused, and
every fetched URL (including redirects) is checked.

## How it works

- **Query planning** expands your question into several complementary searches.
- **Crawling** fetches each candidate first with a plain HTTP client, falling back to a headless
  browser for JavaScript-heavy pages. Identical content is de-duplicated, and a cross-session URL
  cache avoids re-fetching pages seen by earlier runs.
- **Indexing** splits each page into overlapping chunks, embeds them through the AI gateway (with a
  local embedding fallback when the gateway is unavailable), and stores them in a per-session
  ChromaDB collection.
- **Analysis** runs the summarizer, critic and linker agents to synthesise findings, surface gaps,
  and score overall confidence. The score combines confidence, source diversity and coverage.

## Chatting with the research

Every finished session has a chat pane. Answers are grounded **only** in the sources captured during
that run, using hybrid retrieval (vector similarity plus keyword/BM25 ranking) over the session
collection, and every claim is cited back to a source.

## Exporting

A finished report can be downloaded as **Markdown**, **JSON** or **PDF** from the report page.

## Ask Devii

You can also run research in plain language through the Devii assistant:

> Run a deep search on the history of the transistor and summarise the findings.

Devii queues the job, polls it, and reports the score, confidence and a link to the report.

## Programmatic access

The same research is available over the API: `POST /tools/deepsearch/run` to queue,
`GET /tools/deepsearch/{uid}` to poll, and `GET /tools/deepsearch/{uid}/session` for the full report
(HTML or JSON). See the [Tools](#doc-tools) API group for request and response shapes.
</div>

<div class="devii-doc-cta">
    <a href="/tools/deepsearch" class="sidebar-link">Open DeepSearch</a>

---

<a id="doc-claude"></a>
# Claude Code setup

DevPlace ships a complete, version-controlled setup for [Claude Code](https://claude.com/claude-code),
Anthropic's command-line coding agent. Everything lives under the `.claude/`
directory in the repository root, so it is shared with every contributor through
git and applies the moment the repository is opened in Claude Code.

This setup enforces the platform's ten quality dimensions and extends them into
feature work, all expressed in Claude Code's own primitives.

## What is in `.claude/`

| Path | Primitive | Purpose |
|------|-----------|---------|
| `.claude/agents/*.md` | Subagents | Ten single-dimension reviewers, one per quality dimension. See [Subagents](#doc-claude-agents). |
| `.claude/commands/*.md` | Slash commands | Lifecycle commands for understanding, building, verifying, testing, and operating. See [Commands](#doc-claude-commands). |
| `.claude/workflows/*.js` | Workflows | Deterministic multi-agent scripts for auditing and for building features. See [Workflows](#doc-claude-workflows). |
| `.claude/settings.local.json` | Settings | Local permission configuration. |

## Three layers of orchestration

The setup offers the same capability at three levels of control, from most
interactive to most deterministic.

1. **Subagents** are the building blocks. Each enforces exactly one quality
   dimension (security, audit coverage, documentation, and so on) and can be
   invoked on its own or spawned by anything else.
2. **Commands** are model-driven slash commands. Claude reads the command and acts
   in the current conversation. They cover the everyday lifecycle (understand, build,
   verify, test, operate) and orchestration (`/maintenance`). Best for a quick,
   interactive step with a human in the loop.
3. **Workflows** are script-driven orchestration. A JavaScript file in
   `.claude/workflows/` runs the same agents in a fixed, reproducible order, with
   structured output and adversarial verification of every finding. Best for a
   repeatable audit or a feature build.

## Quick reference

- Run one dimension: mention the subagent, for example
  `@agent-security-maintainer review the routers`.
- Orient on an area: `/explain <area>`, `/trace <route>`.
- Check or fix the whole fleet: `/maintenance`, `/maintenance fix`,
  `/maintenance check changed`, `/maintenance fix security,audit`.
- Verified audit of all dimensions: `/fleet`. Review the current diff: `/review`.
- Build a feature end to end: `/feature add bookmarks to gists`.
- Scaffold one route, a Devii tool, or an async job:
  `/endpoint`, `/devii-tool`, `/job-service`.
- Lighter build recipes: `/docs-page`, `/audit-event`, `/service`.
- Verify and run: `/serve`, `/screenshot <path>`, `/api-test`, `/validate`, `/test`.

New to the setup? The [Manual](#doc-claude-manual) is the task-oriented guide:
how to add a feature, change existing code, fix a bug, verify, test, and review,
step by step. The remaining pages are the full reference for the subagents, the
commands, and the workflows.

---

<a id="doc-claude-manual"></a>
# Manual

This is the hands-on manual for the [Claude Code setup](#doc-claude). It shows
how to actually get work done with the [subagents](#doc-claude-agents),
[commands](#doc-claude-commands), and [workflows](#doc-claude-workflows):
adding features, changing existing code, fixing bugs, verifying, testing, reviewing,
and operating the app. Read the three reference pages for the full catalogue; read
this page to learn the day-to-day flow.

## Mental model

The setup gives you the same capability at three levels of control. Reach for the
lowest level that fits the task.

| Level | Primitive | Reach for it when |
|-------|-----------|-------------------|
| Command | `/name` (single context) | A discrete step: orient, validate, run, scaffold one thing. |
| Workflow | `/name` (deterministic multi-agent script) | A repeatable, verified pipeline: build a feature, audit the diff. |
| Subagent | `@agent-<name>` | One quality dimension, ad hoc, on a slice of code. |

Two rules the whole environment honors, so you never have to ask:

- **No command, workflow, or subagent performs a git write.** Committing is always
  your manual step.
- **Nothing runs the test suite except the `/test` command**, which exists precisely
  so that running tests is an explicit choice you make.

## The core loop

Almost every change follows the same five beats. The rest of this manual is variations
on it.

1. **Orient** - `/explain <area>` or `/trace <route>` to learn the area and its fan-out.
2. **Build** - a build workflow (`/feature`, `/endpoint`, ...) or a build command, or hand edits.
3. **Run** - `/serve`, then `/screenshot` and `/api-test` to see it work.
4. **Check** - `/validate` for static checks, `/test` for the relevant tier.
5. **Review** - `/review` over the diff before you commit.

## Before you start

```
make install      # editable install (first time)
/serve            # start the dev server on port 10500 and confirm health
```

`/serve` launches `make dev` in the background and uses `mole` to confirm the server
answers. Leave it running; the verify commands target it.

## Recipe: add a feature

A feature in DevPlace fans out across nine layers (form model, output schema, data
helpers, route, view, Devii tool, API docs, SEO, tests). The `/feature` workflow
drives all of them, then audits and tests itself.

```
/explain the gists area                      # optional: understand the pattern first
/feature add a bookmark button to gists, owner-scoped, with a profile tab
```

What `/feature` does, in order: maps the area and the closest existing feature,
plans a per-layer change list, implements it coherently in the repo, runs the
validator and an import check, audits the result in parallel
(`fanout-maintainer` + `security-maintainer` + `docs-maintainer`), fixes the gaps,
and writes the missing integration tests. When it finishes:

```
/screenshot /gists          # see the new UI rendered, verified by falcon vision
/api-test the gist bookmark endpoints
/test api                   # run the API tier
/review                     # verified diff review before committing
```

## Recipe: add one endpoint, one Devii tool, or one async job

When you need just one route or capability rather than a whole feature, use the
narrower build workflows. Each understands the closest existing pattern, implements
across the touchpoints, verifies, and writes a test.

```
/endpoint POST /gists/{slug}/star to star a gist
/devii-tool a tool to list the current user's bookmarks
/job-service render a project to a PDF and offer it as a download
```

For the lighter, single-file recipes, use the build commands instead:

```
/docs-page bookmarks "Bookmarks" General      # a new prose docs page
/audit-event gist.bookmark for the new POST route
/service a digest service that emails weekly activity
```

## Recipe: update or change existing code

When you are modifying something that already exists rather than adding new surface:

1. `/trace <route or feature>` - see every layer it touches, so you change all of them
   together (the cardinal failure mode here is updating one layer and forgetting a
   connected one).
2. Make the edit - by hand, or describe it to Claude in the conversation.
3. `/validate` - confirm the validator and the app import still pass and no prose
   em-dash slipped in.
4. `/screenshot` and `/api-test` if it touched the UI or an endpoint.
5. `/review` - the diff is checked across every dimension with adversarial
   verification before you commit.

> Keep changes coherent across the fan-out. If you change a route's returned JSON,
> update its `*Out` schema, its template, its API docs entry, and its Devii action in
> the same change. `/review` will flag a half-migrated change, but it is cheaper to
> get it right the first time. `/trace` tells you the full set.

## Recipe: fix a bug

```
/explain <the failing area>          # understand the code path
/test tests/api/<area>/<file>.py     # reproduce with the focused test, if one exists
```

Fix the root cause (not the symptom), then:

```
/validate
/test <the same target>              # confirm green
/review
```

Never weaken a test to make it pass. If a test exposes a real defect, the fix goes in
the code, not the test - the `test-maintainer` and the `/test` command both enforce
this.

## Verifying your work

Verification is mandatory before you call anything done. Use the layer that matches
what you changed.

| You changed | Verify with |
|-------------|-------------|
| Any source file | `/validate` (validator + app import + em-dash scan) |
| An HTTP endpoint | `/api-test <endpoints>` against the running server |
| A page, layout, component, or responsive rule | `/screenshot <path>` (Playwright capture + falcon vision) |
| Behavior covered by tests | `/test <tier or path>` |
| A whole change set | `/review` (diff review, all dimensions, verified) |

`/validate` is fast and should run after every edit. `/screenshot` and `/api-test`
need `/serve` running first. `/test` is the only thing that runs the suite, and only
because you asked.

## Reviewing and maintaining

| Goal | Use |
|------|-----|
| Review the current uncommitted diff before committing | `/review` |
| A full, verified audit of the whole codebase | `/fleet` |
| Fan the ten dimensions out interactively (check or fix) | `/maintenance`, `/maintenance fix`, `/maintenance check changed` |
| One dimension, ad hoc | `@agent-security-maintainer ...`, `@agent-docs-maintainer ...` |

`/review` and `/fleet` adversarially verify every finding (a second agent tries to
refute it) before reporting, so they are low-noise. `/maintenance fix` applies fixes
serially in canonical order so two agents never edit the same file at once.

## Understanding the codebase

| Goal | Use |
|------|-----|
| Architecture, data flow, and gotchas for an area | `/explain <area>` |
| Where every layer of a route or feature lives, and what is missing | `/trace <route>` |
| A deep read of one quality dimension | `@agent-<dimension>-maintainer` in report mode |

These are read-only; they change nothing.

## Operating the app

| Goal | Use |
|------|-----|
| Start the dev server and confirm it is healthy | `/serve` |
| Capture and describe a page | `/screenshot <path>` |
| Exercise endpoints from a JSON spec | `/api-test <endpoints>` |
| Manage roles, news, attachments, Devii quota, zips, forks, containers | `/cli <args>` |

`/cli` confirms any destructive action (clear, prune) with you before running it,
since it acts on the live database.

## What the environment enforces

Every build command, workflow, and subagent is bound to the project rules, so the
output already conforms:

- No comments or docstrings in source (only the file header and required tool
  docstrings); the `retoor <retoor@molodetz.nl>` header on new files.
- No em-dash characters in authored prose; full Python type hints; `pathlib` over
  `os`; Pydantic input with explicit max lengths.
- Reuse of the shared helpers (`templating.templates`, the `database.py` batch
  helpers, `respond`, the avatar and user partials, and the frontend `Http` /
  `Poller` / `JobPoller` / `OptimisticAction` / `FloatingWindow`).
- The right auth guard on every mutating route; soft deletes; owner-or-admin delete
  authorization; DD/MM/YYYY dates.
- The validator and the app import must pass before a build step finishes.

It will not commit, will not run tests outside `/test`, and will not weaken a guard or a
test to make a finding disappear.

## Troubleshooting

| Symptom | Cause and fix |
|---------|---------------|
| `/screenshot` or `/api-test` says the server is down | Run `/serve` first; if the port moved, `/serve` scans 10500-10510. |
| A build workflow reports unfixed audit gaps | The fix would have degraded behavior or broken a consumer; read the stated reason and resolve it by hand, then `/validate`. |
| `/review` flags a finding you believe is wrong | It already survived an adversarial refutation; re-read the cited `file:line` with its context before dismissing it. |
| A workflow run seems stuck | Run `/workflows` to watch live progress, pause, or stop it. |
| You want only some dimensions | `/maintenance fix security,audit`, or call a single `@agent-...-maintainer`. |

## Cheat sheet

| Task | Entry point |
|------|-------------|
| Understand an area | `/explain` |
| Map a feature's layers | `/trace` |
| Build a full feature | `/feature` |
| Add one route / Devii tool / job | `/endpoint` / `/devii-tool` / `/job-service` |
| Add a docs page / audit event / service | `/docs-page` / `/audit-event` / `/service` |
| Start the server | `/serve` |
| Verify a page / endpoint | `/screenshot` / `/api-test` |
| Static checks | `/validate` |
| Run tests | `/test` |
| Review the diff | `/review` |
| Audit the whole repo | `/fleet` |
| Fix one dimension across the repo | `/maintenance fix` |
| Management CLI | `/cli` |

---

<a id="doc-claude-agents"></a>
# Subagents

The ten files in `.claude/agents/` are Claude Code **project subagents**. Each is a
Markdown file with YAML frontmatter (`name`, `description`, `tools`, `model`) and a
body that is the subagent's full system prompt. Each enforces exactly one quality
dimension and nothing else, and each carries the same accuracy doctrine: confirm
every finding against the source, actively disprove false positives, cross-reference
every consumer before changing anything, and never reduce functionality to satisfy a
rule.

## The fleet

| Subagent | Dimension |
|----------|-----------|
| `security-maintainer` | Authorization on every route, private-resource gating, read-only file guards, input validation, XSS controls. |
| `audit-maintainer` | Every state-changing action emits an audit record; denials and failures carry the right result; the event catalogue is complete. |
| `devii-maintainer` | The Devii assistant can do everything a role allows over REST, and exposes only the tools that role may call. |
| `docs-maintainer` | CLAUDE.md, AGENTS.md, README, the API docs, and the prose pages agree with the source, with correct role gating. |
| `fanout-maintainer` | A feature is wired through every layer: form model, output schema, response, Devii tool, API docs, SEO, docs. |
| `dry-maintainer` | Shared helpers are reused instead of duplicated logic re-implemented. |
| `style-maintainer` | Naming, headers, typing, and formatting follow the project rules, applied with context so intentional patterns are left alone. |
| `frontend-maintainer` | ES6 modules, custom components, and CSS follow the project's strict structure. |
| `seo-maintainer` | Public pages carry the right search metadata and appear in the sitemap. |
| `test-maintainer` | Routes without an integration test get one written. It never runs the suite. |

## Modes

Each subagent operates in one of two modes, chosen by how it is invoked.

- **Report** (default): record findings only, change nothing.
- **Fix**: apply a minimal root-cause fix per the doctrine, then run the project
  validator (`hawk .`) and confirm the build still imports.

A subagent never runs the test suite and never performs a git write.

## Invoking a subagent

Mention the subagent by its `name`, or let Claude delegate to it automatically based
on its `description`:

```
@agent-security-maintainer check every POST in routers/ has a guard
@agent-docs-maintainer report doc drift in the bugs router (report only)
```

Each subagent's `model` is set to `inherit`, so it runs on the model the session is
using. To run the whole fleet at once, use the
[`/maintenance` command or the `/fleet` workflow](#doc-claude-workflows).

---

<a id="doc-claude-commands"></a>
# Commands

The files in `.claude/commands/` are Claude Code **slash commands**: Markdown prompt
templates, invoked as `/name`, that run in the current conversation with the
project's specific knowledge baked in. They are lighter than the
[workflows](#doc-claude-workflows) (single context, no multi-agent fan-out) and
cover the everyday development lifecycle: understand, build, verify, test, and
operate.

Arguments after the command name are available to the prompt; for example
`/explain the bugs router` passes "the bugs router" as the subject.

## Understand and navigate

| Command | What it does |
|---------|--------------|
| `/explain <area>` | Reads the relevant AGENTS.md section and the code, then summarizes the architecture, data flow, invariants, and entry points. Read-only. |
| `/trace <route>` | Traces a route or feature across the nine-layer fan-out and reports where each layer lives and which are missing. Read-only. |

## Verify and test

| Command | What it does |
|---------|--------------|
| `/validate` | Runs the mandatory pre-completion check on changed files: the validator, the app import, and an em-dash scan. Never runs the suite. |
| `/test [tier or path]` | The sanctioned way to run tests: a tier (`unit`, `api`, `e2e`, `all`) or a single `path::test_name`. The subagents and workflows never run tests; this command is how you ask. |

## Build recipes

These are the lighter scaffolds that complement the build
[workflows](#doc-claude-workflows) (`/feature`, `/endpoint`, `/devii-tool`,
`/job-service`).

| Command | What it does |
|---------|--------------|
| `/docs-page <slug> <title>` | Scaffolds a new prose docs page: the template plus the `pages.py` registration, then validates it. |
| `/audit-event <key>` | Adds an audit event end to end: the `events.md` key, the `category_for` mapping, and the recorder call at the mutation. |
| `/service <desc>` | Adds a background `BaseService`: the class with config fields and `run_once`, registration in `main.py`, and docs. |

## Operate and verify the UI

These drive the local `rclaude` toolset against a running dev server.

| Command | What it does |
|---------|--------------|
| `/serve` | Starts the dev server in the background and confirms it is healthy on port 10500 (via `mole`). |
| `/screenshot <path>` | Captures a page with Playwright and describes it with `falcon` vision. The required visual check for any UI change. |
| `/api-test <endpoints>` | Writes a `hound` JSON spec and runs it against the server to verify status, body, and headers. |
| `/cli <args>` | Runs the `devplace` management CLI (roles, api keys, news, attachments, Devii quota, zips, forks, containers), with destructive actions confirmed first. |

## Maintenance

| Command | What it does |
|---------|--------------|
| `/maintenance [check\|fix] [changed] [subset]` | Fans the ten [subagents](#doc-claude-agents) out across every quality dimension. Check runs in parallel; fix runs serially in canonical order. |

## A typical loop

A common cycle while building looks like this:

1. `/explain` or `/trace` to orient on the area.
2. A build workflow (`/feature`, `/endpoint`) or build command for the change.
3. `/serve`, then `/screenshot` and `/api-test` to verify it runs and renders.
4. `/validate` for the static checks, then `/test` for the relevant tier.
5. `/review` (a workflow) before committing.

Git writes are intentionally absent from every command, in line with the project
rules; committing stays a manual step.

---

<a id="doc-claude-workflows"></a>
# Workflows

This page documents the deterministic orchestration layer of the
[Claude Code setup](#doc-claude): the workflow scripts that run the
[subagents](#doc-claude-agents) as fixed, reproducible pipelines. For the
lighter, single-context slash commands (including `/maintenance`), see
[Commands](#doc-claude-commands).

## Workflows

The files in `.claude/workflows/` are **deterministic** orchestration scripts. Each
runs as a fixed pipeline of subagents with structured output, in the background, and
is invoked by its file name as a slash command. Run `/workflows` at any time to watch
live progress, pause, or stop a run.

| Command | Type | What it does |
|---------|------|--------------|
| `/fleet` | read-only | Runs all ten dimensions in parallel, then adversarially verifies every finding against the source before reporting it. The high-confidence, low-false-positive audit. |
| `/review` | read-only | Reviews the current `git diff` across every dimension with the same adversarial verification. A pre-commit gate. |
| `/feature` | build | Adds a feature across the full fan-out: understand the area, plan the layers, implement, audit completeness and security, fix gaps, and write tests. |
| `/endpoint` | build | Scaffolds one new route across every touchpoint (form model, output schema, guarded handler, mount, template, Devii action, API docs, SEO) and writes its test. |
| `/devii-tool` | build | Adds a Devii capability with auth flags matched to the route guard, dispatcher wiring, docs, and a test, then verifies role gating and confirmation. |
| `/job-service` | build | Scaffolds an async job service in the zip and fork pattern: the service class, enqueue, status, and download routes, the output schema, Devii tools, the frontend poller, and docs. |

### Read-only versus build workflows

The two read-only workflows (`/fleet`, `/review`) fan subagents out freely, since
nothing is written. The build workflows (`/feature`, `/endpoint`, `/devii-tool`,
`/job-service`) implement coherently in a single pass so that interdependent files
stay in agreement, then fan out again for a parallel audit, then fix gaps. They edit
files directly in the working tree, run the project validator and an import check
when done, and never run the test suite or commit.

### Adversarial verification

The read-only workflows do something the simple `/maintenance` command does not:
each candidate finding is handed to a second, independent subagent instance whose
only task is to **refute** it against the source. A finding is reported only if it
survives that refutation. This directly enforces the project rule that a wrong
finding is worse than a missed one.

### Passing input

Build workflows take a plain description as their argument:

```
/feature add a bookmark button to gists, owner-scoped, with a profile tab
/endpoint POST /gists/{slug}/star to star a gist
/devii-tool a tool to list the current user's bookmarks
/job-service render a project to a PDF and offer it as a download
```

The read-only workflows take no required input; `/review` accepts an optional base
git ref to diff against.

## Where to start

For a routine change, run `/review` before committing. For a new capability, run the
matching build workflow and review its output. For a periodic deep audit, run
`/fleet`. All of them reuse the same ten subagents, so the quality bar is identical
no matter which entry point you choose.

---

<a id="doc-components"></a>
# Components overview

DevPlace ships a set of reusable **custom HTML web components** (custom elements). They
are plain ES6 modules under `static/js/components/`, registered once through
`components/index.js` (imported by `Application.js`, which loads on every page), so each element
is available everywhere, including these documentation pages.

## Conventions

- **Prefix.** Project components use the `dp-` prefix (`dp-avatar`, `dp-code`, `dp-toast`,
  `dp-dialog`, `dp-context-menu`). The assistant ships its own `devii-` elements, and the
  emoji picker is a vendored third-party element.
- **Light DOM.** Components render into the light DOM (no shadow root) so the site's global CSS
  applies to them directly, matching the existing `devii-*` elements.
- **Base class.** Most extend `Component` (`static/js/components/Component.js`), a thin
  `HTMLElement` subclass with attribute helpers (`attr`, `boolAttr`, `intAttr`).
- **Singletons.** Behavioural singletons (`dp-dialog`, `dp-context-menu`, `dp-toast`) are
  created once by `Application.js` and reachable as `app.dialog`, `app.contextMenu`, and
  `app.toast`.

## Catalog

| Component | Purpose |
|---|---|
| [dp-avatar](#doc-component-dp-avatar) | User avatar image from a username seed. |
| [dp-code](#doc-component-dp-code) | Syntax-highlighted code block with copy button. |
| [dp-content](#doc-component-dp-content) | Render markdown/emoji/media as safe sanitised HTML. |
| [dp-upload](#doc-component-dp-upload) | Clean file upload button with count and removable chips. |
| [dp-toast](#doc-component-dp-toast) | Transient corner notifications. |
| [dp-dialog](#doc-component-dp-dialog) | Promise-based confirm, prompt, and alert modal. |
| [dp-context-menu](#doc-component-dp-context-menu) | Right-click / long-press context menu. |
| [dp-lightbox](#doc-component-dp-lightbox) | Full-screen image viewer opened by clicking a thumbnail. |
| [devii-terminal](#doc-component-devii-terminal) | The Devii assistant terminal window. |
| [devii-avatar](#doc-component-devii-avatar) | The Devii animated avatar character. |
| [emoji-picker](#doc-component-emoji-picker) | Vendored emoji picker element. |

## A note on scope

Components are the self-contained, presentational UI pieces. The rest of the frontend is
plain ES6 modules: page controllers that enhance server-rendered partials (votes,
reactions, comments, polls, which share the `OptimisticAction` base) and small utilities (`Http`
for fetch, `Poller` and `JobPoller` for live and job-status polling, plus DOM helpers). Those are
not custom elements because they operate on server-side markup rather than rendering
standalone UI.

---

<a id="doc-component-dp-avatar"></a>
# dp-avatar

Renders a circular avatar image from a username seed via the local Multiavatar
service at `/avatar/multiavatar/{seed}`. A thin element wrapper around the `Avatar` helper.

Source: `static/js/components/AppAvatar.js`.

## Attributes

| Attribute | Type | Default | Description |
|---|---|---|---|
| `username` | string | (required) | Seed used to generate and label the avatar. |
| `size` | integer | `24` | Width and height in pixels. |

Both attributes are observed: changing them re-renders the avatar.

## Usage

```html
&lt;dp-avatar username="alice_test" size="64"&gt;&lt;/dp-avatar&gt;
```

No JavaScript is required - the element renders itself on connection.
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example</div>
    <div class="component-demo-stage">
        <dp-avatar username="alice_test" size="32"></dp-avatar>
        <dp-avatar username="bob_test" size="48"></dp-avatar>
        <dp-avatar username="devplace" size="64"></dp-avatar>
    </div>
</div>
<script type="module">
    import "/static/v1781400935/js/components/AppAvatar.js";
</script>

---

<a id="doc-component-dp-code"></a>
# dp-code

A syntax-highlighted code block with a copy button and optional line numbers, wrapping the
`CodeBlock` helper (highlight.js plus the copy/line-number affordances).

Source: `static/js/components/AppCode.js`.

## Attributes

| Attribute | Type | Default | Description |
|---|---|---|---|
| `language` | string | (auto) | Sets `language-{value}` on the inner `<code>` for highlighting. |
| `line-numbers` | boolean | off | When present, renders a line-number gutter. |

## Usage

Provide a `<pre><code>` child, or plain text and the element wraps it:

```html
&lt;dp-code language="python" line-numbers&gt;
&lt;pre&gt;&lt;code&gt;def hello():
    return "world"&lt;/code&gt;&lt;/pre&gt;
&lt;/dp-code&gt;
```

On connection the element enhances its content once: highlighting, a copy button, and (when
`line-numbers` is set) a gutter.
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example</div>
    <dp-code language="javascript" line-numbers><pre><code>export class Greeter {
    greet(name) {
        return `Hello, ${name}`;
    }
}</code></pre></dp-code>
</div>
<script type="module">
    import "/static/v1781400935/js/components/AppCode.js";
</script>

---

<a id="doc-component-dp-content"></a>
# dp-content

Renders user-supplied text as safe rich content: emoji shortcodes, GitHub-flavored markdown,
`DOMPurify` sanitisation, then media embeds (images, video, YouTube), mentions, and
autolinks. It exposes the shared `contentRenderer` engine (`static/js/ContentRenderer.js`),
the same pipeline used by the site's `data-render` attribute, as a custom element.

Source: `static/js/components/AppContent.js`.

## Behaviour

- On connection the element reads its own text, renders it once, and replaces its contents with
  the sanitised HTML; code blocks are highlighted.
- Sanitisation is fail-closed: rendering throws if `DOMPurify` is unavailable rather than
  emitting unsanitised HTML.
- Equivalent to `data-render` on a server-rendered element, but as a self-contained element you
  drop in directly.

## Usage

Put markdown (or plain text) as the element's text. Escape any literal HTML you do not want
treated as live markup:

```html
&lt;dp-content&gt;
# Hello
Visit **DevPlace** :rocket: and watch https://youtu.be/dQw4w9WgXcQ
&lt;/dp-content&gt;
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example</div>
    <dp-content>## Markdown, rendered live

This is **bold**, this is _italic_, and here is a shortcode :rocket: :fire:.

- a list item
- another with `inline code`
- a mention of @alice_test and a link to https://example.com

```js
const answer = 42;
```
</dp-content>
</div>
<script type="module">
    import "/static/v1781400935/js/components/AppContent.js";
</script>

---

<a id="doc-component-dp-upload"></a>
# dp-upload

A tunable file-upload button: a compact button with a selected-count badge and a row of
removable filename chips, no thumbnail grid. It replaces the native `<input type="file">`
and the legacy attachment uploader everywhere in the app.

Source: `static/js/components/AppUpload.js`.

## Modes

Set with the `mode` attribute:

| Mode | Behaviour |
|---|---|
| `attachment` (default) | On select, each file is uploaded to `endpoint` immediately; the returned UIDs are kept in a hidden `<input name="attachment_uids">` that submits with the surrounding form. |
| `direct` | Each file is uploaded to `endpoint` immediately with `field-name` plus any `extraFields`; emits events instead of writing a form field. Used for direct-to-app uploads (e.g. the project file browser). |
| `field` | No AJAX. Wraps a real `<input type="file" name="{field-name}">` whose files submit with the form (e.g. the create-post inline image). |

## Attributes

| Attribute | Default | Description |
|---|---|---|
| `mode` | `attachment` | `attachment`, `direct`, or `field`. |
| `label` | (none) | Optional button text shown beside the icon. |
| `multiple` | off | Allow selecting more than one file. |
| `directory` | off | Allow selecting a whole directory (`webkitdirectory`). |
| `accept` | (none) | Native accept filter, e.g. `image/*`. |
| `allowed-types` | (none) | Comma list of permitted extensions, e.g. `.jpg,.png`. |
| `max-size` | `10` | Maximum size per file, in MB. |
| `max-files` | `10` | Maximum number of files. |
| `endpoint` | `/uploads/upload` | Upload URL (attachment / direct). |
| `name` | `attachment_uids` | Hidden field name for collected UIDs (attachment mode). |
| `field-name` | `file` | File field name in upload requests / native field mode. |

## Methods and properties

| Member | Description |
|---|---|
| `open()` | Open the file picker programmatically. |
| `clear()` | Remove all selected files. |
| `extraFields` | Object of extra form fields sent with each upload (direct mode), e.g. `{ path }`. |

## Events

`dp-upload:uploaded` (per file, detail `{file, result}`), `dp-upload:error` (detail
`{file, message}`), `dp-upload:done` (direct mode, after a batch), `dp-upload:change`
(detail `{count}`).

## Usage

```html
&lt;dp-upload multiple max-size="10" max-files="5" allowed-types=".jpg,.png,.pdf"&gt;&lt;/dp-upload&gt;
&lt;dp-upload mode="field" field-name="image" accept="image/*" label="Add image"&gt;&lt;/dp-upload&gt;
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example - select files to see the count and chips</div>
    <dp-upload mode="field" field-name="demo" multiple label="Choose files"></dp-upload>
</div>
<script type="module">
    import "/static/v1781400935/js/components/AppUpload.js";
</script>

---

<a id="doc-component-dp-toast"></a>
# dp-toast

A transient notification host. It stacks short messages in the bottom-right corner and fades
each one out. `Application.js` creates a single instance, reachable as `app.toast`.

Source: `static/js/components/AppToast.js`.

## Methods

| Method | Description |
|---|---|
| `show(message, options)` | Display `message`. Options: `type` (`info`, `success`, `error`; default `info`) and `ms` (lifetime in milliseconds, default `3000`). Returns the toast element. |

## Usage

```html
&lt;dp-toast&gt;&lt;/dp-toast&gt;
```

```javascript
app.toast.show("Saved", { type: "success" });
app.toast.show("Something went wrong", { type: "error", ms: 5000 });
```

The element renders nothing until `show` is called.
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example</div>
    <div class="component-demo-stage">
        <button type="button" class="btn btn-secondary" data-toast="info">Info</button>
        <button type="button" class="btn btn-primary" data-toast="success">Success</button>
        <button type="button" class="btn btn-danger" data-toast="error">Error</button>
    </div>
    <dp-toast id="toast-demo"></dp-toast>
</div>
<script type="module">
    import "/static/v1781400935/js/components/AppToast.js";
    const toast = document.getElementById("toast-demo");
    const messages = { info: "Heads up", success: "Saved", error: "Something went wrong" };
    document.querySelectorAll("[data-toast]").forEach((btn) => {
        btn.addEventListener("click", () => {
            const type = btn.getAttribute("data-toast");
            toast.show(messages[type], { type });
        });
    });
</script>

---

<a id="doc-component-dp-dialog"></a>
# dp-dialog

A promise-based modal for confirmations, prompts, and alerts, replacing the browser's blocking
`confirm`/`prompt`/`alert`. `Application.js` creates a single instance reachable as `app.dialog`.

Source: `static/js/components/AppDialog.js`.

## Methods

Each returns a Promise resolving when the user responds.

| Method | Resolves with |
|---|---|
| `confirm(options)` | `true` if confirmed, `false` if cancelled or dismissed. |
| `prompt(options)` | the entered string, or `null` if cancelled. |
| `alert(options)` | `undefined` once acknowledged. |

`options`: `title`, `message`, `confirmLabel`, `cancelLabel`, `danger` (red confirm button),
and for `prompt`: `label`, `value`, `placeholder`.

## Usage

```html
&lt;dp-dialog&gt;&lt;/dp-dialog&gt;
```

```javascript
const ok = await app.dialog.confirm({
    title: "Delete post",
    message: "This cannot be undone.",
    danger: true,
});
if (ok) {
    const name = await app.dialog.prompt({ label: "New name", value: "untitled" });
}
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example</div>
    <div class="component-demo-stage">
        <button type="button" class="btn btn-primary" id="dlg-confirm">Confirm</button>
        <button type="button" class="btn btn-secondary" id="dlg-prompt">Prompt</button>
        <button type="button" class="btn btn-secondary" id="dlg-alert">Alert</button>
        <span id="dlg-result" class="text-muted"></span>
    </div>
    <dp-dialog id="dialog-demo"></dp-dialog>
</div>
<script type="module">
    import "/static/v1781400935/js/components/AppDialog.js";
    const dialog = document.getElementById("dialog-demo");
    const result = document.getElementById("dlg-result");
    document.getElementById("dlg-confirm").addEventListener("click", async () => {
        const ok = await dialog.confirm({ title: "Confirm", message: "Proceed with this action?" });
        result.textContent = `confirm returned: ${ok}`;
    });
    document.getElementById("dlg-prompt").addEventListener("click", async () => {
        const value = await dialog.prompt({ title: "Rename", label: "New name", value: "untitled" });
        result.textContent = `prompt returned: ${JSON.stringify(value)}`;
    });
    document.getElementById("dlg-alert").addEventListener("click", async () => {
        await dialog.alert({ title: "Notice", message: "This is an alert." });
        result.textContent = "alert acknowledged";
    });
</script>

---

<a id="doc-component-dp-context-menu"></a>
# dp-context-menu

A custom right-click (and long-press on touch) context menu. A single instance is created by
`Application.js` and reachable as `app.contextMenu`.

Source: `static/js/components/AppContextMenu.js`.

## Methods

| Method | Description |
|---|---|
| `attach(host, builder)` | Bind to an element. `builder(event)` returns the item array to show, or an empty array to suppress. Handles desktop right-click and a 500ms touch long-press. |
| `open(x, y, items)` | Open the menu at viewport coordinates with the given items. |
| `close()` | Hide the menu. |

Each item: `{ label, onSelect, icon?, danger?, disabled?, separator? }`. Use `{ separator: true }`
for a divider. The menu repositions to stay within the viewport and auto-closes on selection,
outside click, scroll, resize, or Escape.

## Usage

```html
&lt;dp-context-menu&gt;&lt;/dp-context-menu&gt;
```

```javascript
app.contextMenu.attach(myElement, (event) => [
    { label: "Open", onSelect: () => open() },
    { separator: true },
    { label: "Delete", danger: true, onSelect: () => remove() },
]);
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example - right-click (or long-press) the box</div>
    <div class="component-demo-stage">
        <div id="ctx-target" class="card" style="padding:1.5rem;cursor:context-menu;user-select:none;">
            Right-click here
        </div>
        <span id="ctx-result" class="text-muted"></span>
    </div>
    <dp-context-menu id="ctx-menu"></dp-context-menu>
</div>
<script type="module">
    import "/static/v1781400935/js/components/AppContextMenu.js";
    const menu = document.getElementById("ctx-menu");
    const target = document.getElementById("ctx-target");
    const result = document.getElementById("ctx-result");
    menu.attach(target, () => [
        { label: "Open", icon: "\u{1F4C2}", onSelect: () => { result.textContent = "Open selected"; } },
        { label: "Rename", icon: "\u{270F}", onSelect: () => { result.textContent = "Rename selected"; } },
        { separator: true },
        { label: "Delete", icon: "\u{1F5D1}", danger: true, onSelect: () => { result.textContent = "Delete selected"; } },
    ]);
</script>

---

<a id="doc-component-dp-lightbox"></a>
# dp-lightbox

A full-screen image viewer opened by clicking a thumbnail. `Application.js` creates a single
instance, reachable as `app.lightbox`. Every image marked with the `data-lightbox` attribute
opens in the same overlay, so thumbnails behave identically across the site.

Source: `static/js/components/AppLightbox.js`.

## The `data-lightbox` contract

Mark any thumbnail with `data-lightbox` to make it clickable. The viewer shows the value of
`data-full` when present (a distinct full-resolution URL), otherwise the image's own source. The
image `alt` is reused as the caption.

```html
&lt;img src="thumb.jpg" data-lightbox data-full="full.jpg" alt="A diagram"&gt;
```

Images rendered from markdown content (`data-render`) are marked automatically, so embedded
images are clickable with no extra markup.

## Methods

| Method | Behaviour |
|---|---|
| `open(src, { alt })` | Open the overlay showing `src`, with optional caption `alt`. |
| `close()` | Close the overlay. |

The overlay also closes on the close button, a click outside the image, or the `Escape` key.

```javascript
app.lightbox.open("/static/img/diagram.png", { alt: "Architecture" });
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example</div>
    <div class="component-demo-stage">
        <img src="/avatar/multiavatar/devplace?size=96" width="96" height="96" data-lightbox data-full="/avatar/multiavatar/devplace?size=512" alt="Sample thumbnail" style="border-radius: var(--radius); border: 1px solid var(--border);">
        <span class="text-muted">Click the thumbnail to open the lightbox.</span>
    </div>

---

<a id="doc-component-devii-terminal"></a>
# devii-terminal

The draggable terminal window for the Devii assistant. It connects to `/devii/ws`, renders
markdown replies, keeps input history, and supports closed, normal, maximized, and fullscreen
states with geometry persisted to `localStorage`. The `DeviiTerminal` controller (`app.devii`)
creates it and appends it to the page, so you do not place it by hand.

It extends the shared `FloatingWindow` base (`static/js/components/FloatingWindow.js`), reusing the
container terminals' drag, resize, geometry-persistence, and window chrome, and adds its own
launcher button, closed state, and socket/markdown/history.

Source: `static/js/devii/devii-terminal.js`.

## Attributes

| Attribute | Type | Description |
|---|---|---|
| `open` | boolean | Open the terminal on connection. |
| `avatar` | string | Id or selector of an associated `devii-avatar` element. |

## Methods and properties

| Member | Description |
|---|---|
| `open()` / `close()` / `toggle()` | Control visibility. |
| `state` | `closed`, `normal`, `maximized`, or `fullscreen`. |
| `geometry` | `{ width, height, left, top }` window placement. |

## Usage

Any element marked `data-devii-open` opens the terminal (the controller delegates these clicks),
so you rarely instantiate it directly:

```html
&lt;button data-devii-open&gt;Ask Devii&lt;/button&gt;
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example</div>
    <div class="component-demo-stage">
        <button type="button" class="btn btn-primary" data-devii-open>Open Devii terminal</button>
    </div>

---

<a id="doc-component-devii-avatar"></a>
# devii-avatar

The animated assistant avatar (a vendored md-clippy character). It is a controller element: it
renders no markup of its own but drives the on-screen character, which can speak, animate, and move.
The `DeviiTerminal` controller creates one as `#devii` and links it to the terminal.

Source: `static/js/devii/devii-avatar.js`.

## Attributes

| Attribute | Type | Default | Description |
|---|---|---|---|
| `character` | string | `Clippy` | Which character to display. |
| `tts` | boolean | off | Enable text-to-speech. |
| `autoshow` | boolean | off | Show the character on connection. |

## Methods

| Method | Description |
|---|---|
| `execute(action, args)` | Run an avatar action: `show`, `hide`, `speak`, `play_animation`, `random_animation`, `move_to`, `gesture_at`, `stop`, `switch_character`, `list_animations`, `list_characters`, `get_viewport`. |
| `ask(text)` | Send a prompt to the connected assistant. |

## Usage

```html
&lt;devii-avatar character="Clippy" autoshow&gt;&lt;/devii-avatar&gt;
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example - shows the avatar and plays an animation</div>
    <div class="component-demo-stage">
        <button type="button" class="btn btn-primary" id="avatar-demo-btn">Show avatar</button>
        <span id="avatar-demo-result" class="text-muted"></span>
    </div>
</div>
<script type="module">
    const result = document.getElementById("avatar-demo-result");
    document.getElementById("avatar-demo-btn").addEventListener("click", async () => {
        const avatar = document.querySelector("devii-avatar");
        if (!avatar || typeof avatar.execute !== "function") {
            result.textContent = "Avatar is not available on this page.";
            return;
        }
        try {
            await avatar.execute("show");
            await avatar.execute("random_animation");
            result.textContent = "Played a random animation.";
        } catch (error) {
            result.textContent = "Avatar could not start.";
        }
    });
</script>

---

<a id="doc-component-emoji-picker"></a>
# emoji-picker

A vendored third-party emoji picker (`emoji-picker-element`) with category browsing, search, and
skin-tone selection. DevPlace loads it globally and wires it to comment forms through the
`EmojiPicker` wrapper, targeting textareas marked `.emoji-picker-target`.

Source: `static/vendor/emoji-picker-element/`.

## Attributes

| Attribute | Description |
|---|---|
| `locale` | Language for emoji labels (for example `en`). |
| `skin-tone-emoji` | Default skin-tone emoji. |
| `emoji-version` | Restrict to a Unicode emoji version. |

## Events

| Event | Detail |
|---|---|
| `emoji-click` | `{ unicode, emoji, ... }` for the chosen emoji (primary event). |
| `skin-tone-change` | The selected skin tone. |

## Usage

```html
&lt;emoji-picker&gt;&lt;/emoji-picker&gt;
```

```javascript
picker.addEventListener("emoji-click", (event) => {
    insert(event.detail.unicode);
});
```
</div>

<div class="component-demo">
    <div class="component-demo-title">Live example - pick an emoji</div>
    <div class="component-demo-stage">
        <emoji-picker></emoji-picker>
        <span id="emoji-demo-result" class="text-muted">No emoji selected yet.</span>
    </div>
</div>
<script type="module">
    import "/static/v1781400935/vendor/emoji-picker-element/index.js";
    const result = document.getElementById("emoji-demo-result");
    document.querySelector(".component-demo emoji-picker").addEventListener("emoji-click", (event) => {
        result.textContent = `Selected: ${event.detail.unicode}`;
    });
</script>

---

<a id="doc-styles"></a>
# Design system

DevPlace has one visual language: hand-written vanilla CSS driven by design tokens, with no preprocessor, no CSS-in-JS, and no utility framework. This section is the canonical reference for how the interface looks and is structured, and states the rules every page must follow.

> The feed / posts page is the reference implementation. When two pages disagree, the feed page wins and the other gets fixed. The pages below describe the system; the [Consistency rules](#doc-styles-consistency) page turns it into hard requirements.

## The four pages

| Page | What it covers |
|------|----------------|
| [Colors](#doc-styles-colors) | The palette, every token and what it is for, what not to do, and the vision behind a dark, single-accent theme. |
| [Layout](#doc-styles-layout) | The page shell, the canonical multi-column and single-column layouts, and which compositions are acceptable. |
| [Responsiveness](#doc-styles-responsiveness) | The goal, the standard breakpoint ladder, and the exact techniques used to reach it. |
| [Consistency rules](#doc-styles-consistency) | Hard, non-negotiable structural rules for the header, footer, breadcrumb, and content root, taken from the posts page. |

## Principles

- **Tokens over literals.** Every colour, space, radius, and shadow is a CSS custom property defined once in `variables.css`. Stylesheets reference the token, never a raw hex or pixel value.
- **One layout per page.** A page has a single top-level layout container inside the shared content root; it never nests competing wrappers or re-declares the maximum width.
- **Mobile is not an afterthought.** Multi-column layouts collapse to one fluid column, touch targets grow, and nothing scrolls horizontally by accident.
- **Structure is shared, content is per page.** The header, breadcrumb, content root, and footer come from `base.html`. A page supplies only its own content and its own stylesheet.

---

<a id="doc-styles-colors"></a>
# Colors

The palette is defined once, as CSS custom properties on `:root` in `static/css/variables.css`. Every stylesheet references those tokens; no file hardcodes a hex value, so changing the theme means editing one file.

## The vision

DevPlace is kept open for hours, often at night. The theme is therefore **dark, low-glare, and deliberately quiet**: deep violet-black backgrounds, soft lavender text, and a *single* warm accent.

- **One accent, used sparingly.** A single orange (`--accent`, `#ff6b35`) marks what matters: the primary action, the active navigation item, links, and the current vote. Because nothing else competes for it, the accent always means "act on this". A second strong colour used decoratively breaks it.
- **Backgrounds layer by elevation.** A near-black page sits behind slightly lighter cards, behind a lighter hover state. Depth comes from these few background steps plus a soft shadow, not from borders alone.
- **Text is a three-step hierarchy.** Primary for content, secondary for supporting text, muted for metadata. There is no fourth shade.
- **Colour with meaning is reserved.** Status colours (success, warning, danger, info) and topic colours carry semantics. They never "brighten up" a layout.

## Background and surface

Layered from the page backdrop up to interactive surfaces.

| Token | Value | Use it for | Do not |
|-------|-------|-----------|--------|
| `--bg-primary` | `#0f0a1a` | The page backdrop (`body`). | Put cards or inputs directly on it without a surface token. |
| `--bg-secondary` | `#1a1028` | The top nav, dropdowns, the mobile panel, demo frames. | Use as a card body in the content area. |
| `--bg-card` | `#221436` | Cards, panels, the standard content surface. | Use for the page backdrop. |
| `--bg-card-hover` | `#2a1a42` | Hover state of cards, list rows, and ghost buttons. | Use as a resting background. |
| `--bg-input` | `#1a1028` | Inputs, textareas, selects. | Use as a content card surface. |
| `--bg-modal` | `#1a1028` | Modal card body. | Use outside modals. |
| `--bg-gradient` | violet gradient | Large hero / landing surfaces only. | Apply to small components. |

## Accent

| Token | Value | Use it for | Do not |
|-------|-------|-----------|--------|
| `--accent` | `#ff6b35` | The single primary action, active nav link, links, the voted star, focus border. | A second decorative colour, large fills, or more than one primary action per view. |
| `--accent-hover` | `#e55a2b` | Hover state of accent surfaces and links. | Resting state. |
| `--accent-light` | `rgba(255,107,53,.1)` | The tinted background behind an *active* nav item or self-highlight row. | Body text (too low contrast). |

## Text

A strict three-step hierarchy. Never introduce a fourth text shade.

| Token | Value | Use it for |
|-------|-------|-----------|
| `--text-primary` | `#f0e8f8` | Headings and body content. |
| `--text-secondary` | `#b8a8d0` | Supporting copy, labels, secondary buttons. |
| `--text-muted` | `#7a6a90` | Timestamps, counts, hints, metadata. |

## Borders, radius, and shadow

| Token | Value | Use it for |
|-------|-------|-----------|
| `--border` | `#2e2040` | The default hairline on cards, inputs, dividers. |
| `--border-light` | `#3a2a50` | Hover/emphasis borders. |
| `--radius` | `8px` | Buttons, inputs, small controls. |
| `--radius-lg` | `12px` | Cards and panels. |
| `--radius-xl` | `16px` | Large feature surfaces. |
| `--shadow-sm` / `--shadow` / `--shadow-lg` | (shadows) | Card hover, raised panels, modals respectively. |

## Status colours (semantic only)

These encode meaning and must only be used to convey it.

| Token | Value | Means |
|-------|-------|-------|
| `--success` | `#4caf50` | Success, healthy, online. |
| `--warning` | `#ff9800` | Caution; also the colour of a cast vote star. |
| `--danger` | `#e53935` | Errors, destructive actions, the create-post button. |
| `--info` | `#42a5f5` | Neutral information. |

## Topic colours (badges only)

Used exclusively for topic badges (`.badge-devlog`, `.badge-showcase`, …) and the matching sidebar accents. Do not reuse them as general UI colours.

| Token | Value | Topic |
|-------|-------|-------|
| `--topic-devlog` | `#7c4dff` | Devlog |
| `--topic-showcase` | `#00bfa5` | Showcase |
| `--topic-question` | `#448aff` | Question |
| `--topic-rant` | `#ff5252` | Rant |
| `--topic-fun` | `#ffab00` | Fun |
| `--topic-signals` | `#00bcd4` | Signals |

## Rules

1. **Never hardcode a colour.** Use the token: `color: var(--text-secondary)`, never `color: #b8a8d0`. If a value is missing, add a token to `variables.css` rather than inlining a hex.
2. **One accent per view.** Exactly one primary action should carry `--accent`. Everything else is `--btn-secondary`/ghost.
3. **Status and topic colours are semantic.** Do not use `--danger` for a non-destructive button or a topic colour as a background flourish.
4. **Respect the text hierarchy.** Three shades only: primary, secondary, muted.
5. **Surfaces step up, never sideways.** Page → card → card-hover. Do not place a card on `--bg-primary` without a surface token, and do not use `--bg-secondary` as a content card.

The reference below is rendered live from the tokens, so it always reflects the current theme.

</div>

<style>
.sg-swatch-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.75rem; }
.sg-swatch { display: flex; align-items: center; gap: 0.625rem; padding: 0.5rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); }
.sg-chip { width: 40px; height: 40px; border-radius: var(--radius); border: 1px solid var(--border-light); flex-shrink: 0; }
.sg-swatch-info { display: flex; flex-direction: column; min-width: 0; }
.sg-swatch-name { font-family: var(--font-mono); font-size: 0.75rem; color: var(--text-primary); }
.sg-swatch-hex { font-size: 0.6875rem; color: var(--text-muted); }
.sg-row { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
.sg-note { font-size: 0.8125rem; color: var(--text-muted); margin-top: 0.5rem; }
</style>

<div class="component-demo">
    <div class="component-demo-title">Live palette</div>
    <div class="sg-swatch-grid">
        <div class="sg-swatch"><span class="sg-chip" style="background: var(--bg-primary)"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-primary</span><span class="sg-swatch-hex">#0f0a1a</span></span></div>
        <div class="sg-swatch"><span class="sg-chip" style="background: var(--bg-secondary)"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-secondary</span><span class="sg-swatch-hex">#1a1028</span></span></div>
        <div class="sg-swatch"><span class="sg-chip" style="background: var(--bg-card)"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-card</span><span class="sg-swatch-hex">#221436</span></span></div>
        <div class="sg-swatch"><span class="sg-chip" style="background: var(--bg-card-hover)"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-card-hover</span><span class="sg-swatch-hex">#2a1a42</span></span></div>
        <div class="sg-swatch"><span class="sg-chip" style="background: var(--accent)"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--accent</span><span class="sg-swatch-hex">#ff6b35</span></span></div>
        <div class="sg-swatch"><span class="sg-chip" style="background: var(--accent-hover)"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--accent-hover</span><span class="sg-swatch-hex">#e55a2b</span></span></div>
        <div class="sg-swatch"><span class="sg-chip" style="background: var(--text-primary)"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--text-primary</span><span class="sg-swatch-hex">#f0e8f8</span></span></div>
        <div class="sg-swatch"><span class="sg-chip" style="background: var(--text-secondary)"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--text-secondary</span><span class="sg-swatch-hex">#b8a8d0</span></span></div>
        <div class="sg-swatch"><span class="sg-chip" style="background: var(--text-muted)"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--text-muted</span><span class="sg-swatch-hex">#7a6a90</span></span></div>
        <div class="sg-swatch"><span class="sg-chip" style="background: var(--border)"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--border</span><span class="sg-swatch-hex">#2e2040</span></span></div>
        <div class="sg-swatch"><span class="sg-chip" style="background: var(--success)"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--success</span><span class="sg-swatch-hex">#4caf50</span></span></div>
        <div class="sg-swatch"><span class="sg-chip" style="background: var(--warning)"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--warning</span><span class="sg-swatch-hex">#ff9800</span></span></div>
        <div class="sg-swatch"><span class="sg-chip" style="background: var(--danger)"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--danger</span><span class="sg-swatch-hex">#e53935</span></span></div>
        <div class="sg-swatch"><span class="sg-chip" style="background: var(--info)"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--info</span><span class="sg-swatch-hex">#42a5f5</span></span></div>
    </div>
</div>

<div class="component-demo">
    <div class="component-demo-title">Topic badges (the only place topic colours appear)</div>
    <div class="sg-row">
        <span class="badge badge-devlog">Devlog</span>
        <span class="badge badge-showcase">Showcase</span>
        <span class="badge badge-question">Question</span>
        <span class="badge badge-rant">Rant</span>
        <span class="badge badge-fun">Fun</span>
        <span class="badge badge-signals">Signals</span>
    </div>
</div>

<div class="component-demo">
    <div class="component-demo-title">One accent per view: correct vs misuse</div>
    <div class="sg-row">
        <button type="button" class="btn btn-primary">Primary action</button>
        <button type="button" class="btn btn-secondary">Secondary</button>
        <button type="button" class="btn btn-ghost">Ghost</button>
    </div>
    <p class="sg-note">Correct: a single accent-filled primary action, with supporting actions de-emphasised.</p>
    <div class="sg-row" style="margin-top: 0.75rem;">
        <button type="button" class="btn btn-primary">Save</button>
        <button type="button" class="btn btn-primary">Publish</button>
        <button type="button" class="btn btn-primary">Share</button>
    </div>
    <p class="sg-note">Misuse: three accent buttons compete, so none reads as the primary action.</p>

---

<a id="doc-styles-layout"></a>
# Layout

Layout uses CSS Grid and Flexbox: one shared page shell with a small set of approved content layouts inside it. New pages pick one of these rather than inventing a structure.

## The page shell

Every page is wrapped by `base.html` in the same four-part shell, from top to bottom:

1. **Fixed top nav** (`.topnav`, height `--nav-height` = `56px`, `position: fixed`, `z-index: 1000`). Always on screen, never re-implemented per page.
2. **Breadcrumb** (`.breadcrumb`, optional but expected). It renders only when the page supplies two or more crumbs. It carries `padding-top: calc(var(--nav-height) + 0.5rem)`, which pushes content clear of the fixed nav; a page that omits breadcrumbs slides under the header. See [Consistency rules](#doc-styles-consistency).
3. **Content root** (`<main class="page">`, `max-width: var(--max-content)` = `1200px`, centred, `padding: 1rem`). All page content lives inside this one element.
4. **Footer** (`.site-footer`), rendered from the `footer` block.

```text
+-----------------------------------------------+  .topnav (fixed, 56px)
|  Dev[Place]   Home Posts News ...   bell user |
+-----------------------------------------------+
|  Home / Feed                                  |  .breadcrumb (clears the nav)
|  +-----------------------------------------+  |
|  |              <main class="page">        |  |  max 1200px, centred
|  |   [ one approved layout goes here ]     |  |
|  +-----------------------------------------+  |
|                  site footer                   |
+-----------------------------------------------+
```

A page never re-declares `max-width: 1200px`, never adds a second centred wrapper, and never rebuilds the nav or footer. It supplies exactly one layout container inside `.page`.

## Approved layouts

### 1. Three-column app layout (the default)

The feed / posts page: a fixed-width left sidebar, a fluid main column, and a fixed-width right rail.

```css
.feed-layout {
    display: grid;
    grid-template-columns: var(--sidebar-width) 1fr 280px; /* 240px | fluid | 280px */
    gap: 1.5rem;
    align-items: start;
}
@media (max-width: 1024px) {
    .feed-layout { grid-template-columns: 1fr; } /* collapse to one column */
    .feed-right  { display: none; }              /* drop the right rail */
}
```

```text
&lt;div class="feed-layout"&gt;
  &lt;aside class="sidebar-card"&gt; ... &lt;/aside&gt;   left: navigation/filters (240px)
  &lt;div class="feed-main"&gt; ... &lt;/div&gt;          centre: primary content (fluid)
  &lt;aside class="feed-right"&gt; ... &lt;/aside&gt;     right: stats/context (280px, sticky)
&lt;/div&gt;
```

Rules for this layout: the two side columns are `<aside>`; the centre column must set `min-width: 0` so long content (code, URLs) cannot blow out the grid; the right rail is `position: sticky; top: calc(var(--nav-height) + 1rem)`; at `1024px` it collapses to a single fluid column and the right rail is hidden.

### 2. Three-column with two equal rails

The leaderboard uses `280px 1fr 280px` with both `<aside>` columns sticky. Same collapse behaviour at `1024px`.

### 3. Single-column list

A vertical stack of cards in a flex column, used inside the main column or on its own:

```css
.feed-posts { display: flex; flex-direction: column; gap: 1rem; }
```

### 4. Card grid

Auto-filling responsive grid for galleries such as projects:

```css
.projects-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
    gap: 1rem;
}
```

## Cards

A card is the standard content surface: `--bg-card` background, a `--border` hairline, `--radius-lg` corners, and `1rem` padding. The shared `.card` class encodes that; `.post-card` / `.project-card` use the same recipe. On hover a card lifts its border to `--border-light` and adds `--shadow-sm`, without changing its background.

## Spacing

Use the spacing scale, not arbitrary values. Card padding is `1rem`; the gap between cards is `1rem`; the gap between layout columns is `1.5rem`.

| Token | Value |
|-------|-------|
| `--space-xs` | `0.25rem` |
| `--space-sm` | `0.375rem` |
| `--space-base` | `0.5rem` |
| `--space-md` | `0.75rem` |
| `--space-lg` | `1rem` |
| `--space-xl` | `1.25rem` |
| `--space-2xl` | `1.5rem` |

## Acceptable and not

- **Acceptable:** one layout container inside `.page`; CSS Grid for columns; `<aside>` for side columns; `min-width: 0` on the fluid column; sticky rails; cards built from tokens.
- **Not acceptable:** a second `max-width`/centred wrapper inside `.page`; absolute positioning to fake columns; fixed pixel heights on content; a side column that does not collapse on small screens; a main column without `min-width: 0` (it will overflow on long code lines).

</div>

<style>
.sg-layout { display: grid; grid-template-columns: 90px 1fr 110px; gap: 0.75rem; align-items: start; }
.sg-box { border: 1px solid var(--border-light); border-radius: var(--radius); background: var(--bg-card); padding: 0.625rem; color: var(--text-secondary); font-size: 0.75rem; text-align: center; }
.sg-box-main { min-height: 96px; display: flex; align-items: center; justify-content: center; }
.sg-box-accent { border-color: var(--accent); color: var(--accent); }
.sg-stack { display: flex; flex-direction: column; gap: 0.625rem; }
.sg-tag { font-family: var(--font-mono); font-size: 0.6875rem; color: var(--text-muted); display: block; margin-top: 0.25rem; }
@media (max-width: 600px) {
    .sg-layout { grid-template-columns: 1fr; }
    .sg-box-right { display: none; }
}
</style>

<div class="component-demo">
    <div class="component-demo-title">Three-column app layout (resize the window below 600px to see it collapse, mirroring the real 1024px rule)</div>
    <div class="sg-layout">
        <div class="sg-box">Sidebar<span class="sg-tag">&lt;aside&gt; 240px</span></div>
        <div class="sg-box sg-box-main sg-box-accent">Main content<span class="sg-tag">1fr, min-width: 0</span></div>
        <div class="sg-box sg-box-right">Right rail<span class="sg-tag">280px, sticky</span></div>
    </div>
</div>

<div class="component-demo">
    <div class="component-demo-title">Single-column card list</div>
    <div class="sg-stack">
        <div class="card">A card. <code>--bg-card</code>, <code>--border</code>, <code>--radius-lg</code>, padding <code>1rem</code>.</div>
        <div class="card">Another card. Stacked with a <code>1rem</code> gap.</div>
        <div class="card">A third card.</div>
    </div>

---

<a id="doc-styles-responsiveness"></a>
# Responsiveness

The interface works from a 320px phone to a wide desktop without a separate mobile site. The same HTML reflows; only the CSS changes per breakpoint.

## The goal

- **One readable column on small screens.** Multi-column layouts collapse to a single fluid column; secondary rails are dropped, not squeezed.
- **No accidental horizontal scroll.** Long content (code, URLs, tables) is contained, never pushing the page wider than the viewport.
- **Comfortable touch targets.** On touch devices every interactive element is at least `44 x 44px`.
- **No iOS zoom-on-focus.** Form controls are at least `16px` on touch devices so Safari does not zoom when they are focused.
- **Respect device safe areas.** Floating controls keep clear of notches and home indicators with `env(safe-area-inset-*)`.

The viewport is declared once in `base.html` and must not be changed:

```html
&lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt;
```

## The standard breakpoint ladder

Use these four breakpoints. They are `max-width` (desktop-first refinements) and the ones the shared shell already targets, so a page that uses them stays in step with the nav, breadcrumb, and footer.

| Breakpoint | What changes |
|-----------|--------------|
| `1024px` | Multi-column layouts collapse to one column; the right rail is hidden; the top nav links collapse into the hamburger menu; the page padding tightens. |
| `768px` | Horizontal tab strips (feed tabs, project tabs) switch to a single scrolling row; labels shrink. |
| `480px` | Compact paddings; secondary actions hidden; post/detail typography steps down; `grid-2col` becomes one column; floating buttons honour safe-area insets. |
| `360px` | Headers allowed to wrap; timestamps drop to their own line; the comment avatar is hidden so the input keeps room. |

> A handful of one-off breakpoints (960, 900, 760, 720, 640, …) exist in older stylesheets, the exact inconsistency this section exists to remove. **Do not add new ad-hoc breakpoints**: reach for `1024 / 768 / 480 / 360` first, and only introduce another value if a specific component genuinely breaks between two of them, with a comment saying why.

## How it is achieved

**Collapse a grid to one column.** The whole mobile story for a multi-column page:

```css
@media (max-width: 1024px) {
    .feed-layout { grid-template-columns: 1fr; }
    .feed-right  { display: none; }
}
```

**Keep the fluid column from overflowing.** A grid/flex child does not shrink below its content unless told to. Every fluid main column sets:

```css
.feed-main, .leaderboard-main { min-width: 0; }
```

**Let tab strips scroll instead of wrapping or overflowing:**

```css
@media (max-width: 768px) {
    .feed-nav { overflow-x: auto; flex-wrap: nowrap; -webkit-overflow-scrolling: touch; }
    .feed-nav-btn { flex-shrink: 0; }
}
```

**Guarantee touch targets and prevent zoom.** One shared rule in `base.css` covers every page, so pages do not repeat it:

```css
@media (hover: none) and (pointer: coarse) {
    .btn, .topnav-link, .sidebar-link, .post-action-btn, .profile-tab { min-height: 44px; }
    .topnav-icon, .emoji-toggle-btn { min-width: 44px; min-height: 44px; }
    .modal-card input, .modal-card textarea, .modal-card select { font-size: 16px; }
}
```

**Keep floating controls off the notch / home bar:**

```css
@media (max-width: 480px) {
    .feed-fab {
        bottom: calc(1rem + env(safe-area-inset-bottom));
        right:  calc(1rem + env(safe-area-inset-right));
    }
}
```

## Rules

1. **Use the four standard breakpoints** (`1024 / 768 / 480 / 360`); do not invent new ones without a written reason.
2. **Every fluid grid/flex column sets `min-width: 0`.** This single line prevents almost all horizontal-overflow bugs.
3. **Collapse, do not cram.** Below `1024px` a page is one fluid column; drop side rails with `display: none` rather than shrinking them to slivers.
4. **Never override the viewport meta or the shared touch-target rule.** They are global; rely on them.
5. **Test the small end.** The hard cases are `360px` (wrapping) and long unbroken strings inside a card.

</div>

<style>
.sg-resp { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.625rem; }
.sg-resp .sg-cell { border: 1px solid var(--border-light); border-radius: var(--radius); background: var(--bg-card); padding: 0.75rem; font-size: 0.8125rem; color: var(--text-secondary); text-align: center; }
.sg-resp-note { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.625rem; }
@media (max-width: 768px) {
    .sg-resp { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 480px) {
    .sg-resp { grid-template-columns: 1fr; }
}
</style>

<div class="component-demo">
    <div class="component-demo-title">Live reflow (three columns to two to one as the viewport narrows)</div>
    <div class="sg-resp">
        <div class="sg-cell">Item one</div>
        <div class="sg-cell">Item two</div>
        <div class="sg-cell">Item three</div>
        <div class="sg-cell">Item four</div>
        <div class="sg-cell">Item five</div>
        <div class="sg-cell">Item six</div>
    </div>
    <p class="sg-resp-note">This grid uses the standard 768px and 480px breakpoints. Narrow the browser to watch it step from three columns to two to one.</p>

---

<a id="doc-styles-consistency"></a>
# Consistency rules

These are hard, structural rules, not suggestions. They are derived from the **posts / feed page**, the reference implementation and the most structurally and visually correct page in the application. Other pages are refactored to match it; new pages follow these rules from the start.

The header, breadcrumb, content root, and footer are *shared infrastructure*. A page supplies its content and its stylesheet; it never rebuilds the frame.

## The reference skeleton

This is the posts page reduced to its structure. A content page looks like this and nothing else:

```html
{% extends "base.html" %}

{% block extra_head %}
&lt;link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}"&gt;
{% endblock %}

{% block content %}
&lt;h1 class="sr-only"&gt;Feed&lt;/h1&gt;

&lt;div class="feed-layout"&gt;            {# exactly one layout container #}
    &lt;aside class="sidebar-card"&gt; ... &lt;/aside&gt;
    &lt;div class="feed-main"&gt; ... &lt;/div&gt;
    &lt;aside class="feed-right"&gt; ... &lt;/aside&gt;
&lt;/div&gt;
{% endblock %}
```

The header, breadcrumb, `<main class="page">` wrapper, and footer are all supplied by `base.html`. The template above never appears to contain them, and never should.

## Hard rules

### 1. Extend the base template

Every page is `{% extends "base.html" %}`. Page CSS is added only through `{% block extra_head %}`; page JS only through `{% block extra_js %}`. A page never includes its own `<html>`, `<head>`, or `<body>`, and never links `variables.css` / `base.css` / `components.css` (the base already does).

### 2. The header is shared, never re-implemented

The top nav is `base.html`'s `.topnav`. Pages do not build their own header or navigation bar; the active link is derived from the request path inside `base.html`. A page-level toolbar (tabs, filters) lives *inside the content*, like the feed's `.feed-nav` strip, below the breadcrumb, never as a second site header.

### 3. Breadcrumbs are mandatory (this is the most-broken rule)

A page **must** pass at least two breadcrumbs (Home plus the current page) through its SEO context:

```python
seo_ctx = list_page_seo(
    request,
    title="Projects",
    breadcrumbs=[{"name": "Home", "url": "/feed"}, {"name": "Projects", "url": "/projects"}],
)
```

This is not only for SEO. The `.breadcrumb` element carries `padding-top: calc(var(--nav-height) + 0.5rem)`, the padding that **pushes content clear of the fixed top nav**. A page with no breadcrumbs (or only one) has its first content hidden behind the header. Always supply two or more.

### 4. All content lives in the single content root

`base.html` already wraps the `content` block in `<main class="page">` (`max-width: 1200px`, centred, `padding: 1rem`). Therefore a page:

- puts everything inside one top-level layout container (a grid like `.feed-layout`, or a single column);
- never adds a second centred / `max-width` wrapper inside `.page`;
- never re-declares the maximum content width.

See [Layout](#doc-styles-layout) for the approved layout containers.

### 5. Multi-column structure is grid, and it collapses

Side columns are `<aside>`; the fluid centre column sets `min-width: 0`. The grid collapses to a single fluid column at `1024px` and the right rail is hidden. See [Responsiveness](#doc-styles-responsiveness).

### 6. One H1 per page

Each page has exactly one `<h1>`. When the heading is visually redundant (the feed's title), keep it for accessibility with `class="sr-only"` rather than removing it.

### 7. The footer is shared

The footer is `base.html`'s `.site-footer`, rendered from the `footer` block. Pages do not build their own footer. Override the `footer` block only with a deliberate reason (for example, a full-bleed tool page) and document why.

### 8. Cards and surfaces use tokens

Content sits on cards built from tokens: `--bg-card`, a `--border` hairline, `--radius-lg`, `1rem` padding (the shared `.card` recipe). Colours, spacing, radii, and shadows are always tokens, never literals. See [Colors](#doc-styles-colors).

## Page author checklist

- [ ] `{% extends "base.html" %}`; CSS via `extra_head`, JS via `extra_js`.
- [ ] Two or more breadcrumbs passed in the SEO context (content clears the nav).
- [ ] Exactly one layout container inside `.page`; no second `max-width` wrapper.
- [ ] Multi-column = grid with `<aside>` rails, fluid column has `min-width: 0`, collapses at `1024px`.
- [ ] One `<h1>` (use `.sr-only` if visually redundant).
- [ ] No custom header or footer; both come from `base.html`.
- [ ] Only tokens for colour, spacing, radius, and shadow.
- [ ] Standard breakpoints only (`1024 / 768 / 480 / 360`).

</div>

<style>
.sg-anatomy { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.sg-anatomy-row { padding: 0.625rem 0.875rem; font-size: 0.8125rem; border-bottom: 1px solid var(--border); }
.sg-anatomy-row:last-child { border-bottom: none; }
.sg-anatomy-nav { background: var(--bg-secondary); color: var(--text-primary); font-weight: 600; }
.sg-anatomy-crumb { background: var(--bg-primary); color: var(--text-muted); font-size: 0.75rem; }
.sg-anatomy-main { background: var(--bg-card); color: var(--text-secondary); padding-top: 1rem; padding-bottom: 1rem; }
.sg-anatomy-foot { background: var(--bg-primary); color: var(--text-muted); text-align: center; font-size: 0.75rem; }
.sg-anatomy code { font-size: 0.75rem; color: var(--accent); }
.sg-anatomy-tag { float: right; font-family: var(--font-mono); font-size: 0.6875rem; color: var(--text-muted); }
</style>

<div class="component-demo">
    <div class="component-demo-title">Anatomy of a page (what base.html owns vs what the page supplies)</div>
    <div class="sg-anatomy">
        <div class="sg-anatomy-row sg-anatomy-nav">Dev<span style="color: var(--accent)">Place</span> &middot; Home Posts News Gists Projects<span class="sg-anatomy-tag">base.html: .topnav (fixed)</span></div>
        <div class="sg-anatomy-row sg-anatomy-crumb">Home / Projects<span class="sg-anatomy-tag">base.html: .breadcrumb (clears the nav)</span></div>
        <div class="sg-anatomy-row sg-anatomy-main">Your content, inside one layout container.<span class="sg-anatomy-tag">page: block content, inside .page</span></div>
        <div class="sg-anatomy-row sg-anatomy-foot">DevPlace &middot; footer links<span class="sg-anatomy-tag">base.html: .site-footer</span></div>
    </div>

---

<a id="doc-authentication"></a>
# Authentication

DevPlace accepts four interchangeable authentication methods. The website uses the
session cookie; the other three authenticate **any request** - any page or action -
without a browser login, ideal for scripts and automation. See
[Conventions & Errors](#doc-conventions) for the base URL, request bodies,
content negotiation, pagination, and status codes that every endpoint shares.


> You are not logged in. Examples below use placeholders. [Log in](/auth/login) and
> reload this page to see ready-to-copy examples with your own API key.


Your API key is a UUID shown on your [profile page](/profile/YOUR_USERNAME).
You can regenerate it there at any time; the previous key stops working immediately.

The interactive panels throughout the [API reference](#doc-index) pre-fill your API
key, so you can run user-level calls directly from these pages. Vote, reaction, bookmark, and
poll endpoints require an `X-Requested-With: fetch` header to return JSON instead of a
redirect; the panels and generated snippets add it automatically.

## 1. Session cookie

The website signs you in with a `session` cookie after you log in. Browsers send it
automatically - nothing to configure.

## 2. X-API-KEY header

Send your API key in the `X-API-KEY` header:

```bash
curl -H "X-API-KEY: YOUR_API_KEY" \
  https://devplace.net/notifications
```

## 3. Bearer token

The same API key also works as a Bearer token, supported out of the box by many HTTP clients:

```bash
curl -H "Authorization: Bearer YOUR_API_KEY" \
  https://devplace.net/notifications
```

## 4. HTTP Basic

Authenticate with your username (or email) and password. `curl -u` base64-encodes
the credentials for you:

```bash
curl -u YOUR_USERNAME:YOUR_PASSWORD \
  https://devplace.net/notifications
```

This sends an `Authorization: Basic base64(username:password)` header. You can also
build the header yourself:

```bash
curl -H "Authorization: Basic $(printf '%s' 'YOUR_USERNAME:YOUR_PASSWORD' | base64)" \
  https://devplace.net/notifications
```

## Performing actions

The same methods work on POST actions. For example, follow another user:

```bash
curl -X POST -H "X-API-KEY: YOUR_API_KEY" \
  https://devplace.net/follow/SOME_USERNAME
```

Create a post:

```bash
curl -X POST -H "X-API-KEY: YOUR_API_KEY" \
  -d "topic=devlog" -d "title=Hello" -d "content=Posted from a script" \
  https://devplace.net/posts/create
```

## Errors

Invalid credentials make protected endpoints respond with `401 Unauthorized`. Requests
with no credentials are treated as anonymous, and protected actions redirect to the login
page as in the browser. The full status-code and error-shape reference lives in
[Conventions & Errors](#doc-conventions).

---

<a id="doc-conventions"></a>
# Conventions & Errors

Shared rules that apply to every endpoint in this reference.

## Base URL

Every example uses your current host:

```
https://devplace.net
```

## Authentication

Each endpoint is tagged **public**, **user**, or **admin**. Authenticate user and admin
endpoints with any of the four methods in [Authentication](#doc-authentication): the
`session` cookie, an `X-API-KEY` header, a `Bearer` token, or HTTP Basic credentials. The
interactive panels on this site pre-fill your own API key, so you can run user-level calls
immediately.

## Request bodies

POST endpoints accept `application/x-www-form-urlencoded` form fields (the same fields the
website submits). File uploads use `multipart/form-data`. A small number of endpoints accept
a JSON body; those are noted explicitly.

## HTML or JSON (content negotiation)

**Every** endpoint that renders a page or returns a redirect can also answer in JSON - the
website keeps working exactly as before, and automation gets structured data from the same
URLs. A request is served JSON when it sends either of:

- `Accept: application/json`
- `Content-Type: application/json`

A normal browser navigation (`Accept: text/html`) always receives HTML, so nothing existing
changes. Responses are defined by Pydantic models, so each page returns the same data the
template renders.

**Page reads** (GET) return the page payload as a JSON object (lists include a `next_cursor`
for pagination; detail pages embed author, comments, reactions, poll, and attachments).

**Actions** (the form POSTs: create / edit / delete / follow / send / mark-read …) return a
uniform envelope instead of a `302` redirect:

```json
{ "ok": true, "redirect": "/posts/abc-my-post", "data": { "uid": "…", "slug": "…", "url": "…" } }
```

`data` carries the created/affected resource where applicable, or `null`. Cookies (e.g. the
session set on login/signup) are still set on JSON responses.

**Errors** are JSON too when JSON is requested:

```json
{ "error": { "status": 404, "message": "Not found" } }
```

Validation failures return `422` with `{ "error": "validation", "fields": { "field": ["msg"] } }`.
Unauthenticated JSON requests to a protected endpoint return `401` (browsers are redirected to
the login page instead); non-admins calling an admin endpoint get `403`.

## Trying it here

Every endpoint below has a live panel. Pick the response format (**JSON** by default, or
**HTML** where the endpoint negotiates) and the panel sets the matching `Accept` header on the
request and the generated cURL/JavaScript/Python snippets. The **Expected** tab always shows the
modeled response shape; the **Live response** tab shows the real result after you press
**Send request**.

## AJAX responses (legacy shape)

The [Votes, Reactions, Bookmarks & Polls](#doc-social-actions) endpoints predate the
envelope and keep their original flat JSON shapes (e.g. `{ "saved": true }`). They return JSON
when the request carries an `X-Requested-With: fetch` header (a subset of the rule above);
without it they `302` redirect, mirroring the browser flow. The interactive panels send the
header for you.

## Pagination

Most list endpoints page with an opaque cursor. Pass the `before` query parameter set to the
`created_at` (or `synced_at`) value of the last item you received to fetch the next page; the
JSON payload returns a `next_cursor` to use as the next `before`. This covers the feed, the
post/project/gist/news lists, notifications, and saved bookmarks.

The follower and following lists are the exception: `GET /profile/{username}/followers` and
`GET /profile/{username}/following` use classic page-based pagination via the `page` query
parameter (25 per page), not a cursor.

## Identifiers

Posts, projects, gists, and news articles accept either their slug or their bare UUID in the
path. Slugs embed the first eight characters of the UUID.

## Dates

All dates rendered to users are `DD/MM/YYYY`. Timestamps in stored records are ISO-8601 UTC.

## Status codes

| Code | Meaning |
|------|---------|
| `200` | Success (JSON or HTML) |
| `201` | Resource created (uploads) |
| `302` | Redirect (browser-style success for form posts) |
| `400` | Invalid request body or parameters |
| `401` | Credentials supplied but invalid |
| `403` | Authenticated but not allowed |
| `404` | Resource not found |
| `413` | Upload exceeds the configured size limit (see [Uploads](#doc-uploads)) |
| `415` | Upload file type not allowed (see [Uploads](#doc-uploads)) |
| `422` | Form/body validation failed (JSON clients) |
| `429` | Rate limit exceeded (see Rate limiting) |
| `503` | Maintenance mode |

## Rate limiting

Mutating requests (`POST`/`PUT`/`DELETE`/`PATCH`) are rate limited per client IP over a
rolling window; reads are not limited. The limit and window are configurable by an
administrator (defaults: 60 requests per 60 seconds). When you exceed the limit you receive a
`429` whose `Retry-After` header gives the number of seconds to wait before retrying. The
OpenAI gateway (`/openai/...`) is exempt.

## Troubleshooting

**I get HTML back instead of JSON.** Send `Accept: application/json` (or
`Content-Type: application/json` on a body). A request is only served JSON when it asks for it
and does not also accept `text/html`; a normal browser navigation always gets HTML.
`X-Requested-With: fetch` is **not** a general JSON switch - it only applies to the legacy
engagement actions (votes, reactions, bookmarks, polls).

**An action returns `302` instead of the JSON envelope.** Same cause: the request did not ask
for JSON. Add the `Accept: application/json` header and the action returns
`{ "ok": true, "redirect": "...", "data": {...} }` instead of redirecting.

**A protected endpoint redirects me to the login page.** Browser-style (HTML) requests to a
`user`/`admin` endpoint without valid credentials are redirected to login; the same request
with `Accept: application/json` returns `401` instead. Non-admins calling an `admin` endpoint
get `403` (JSON) or a redirect to the feed (HTML).

**An upload is rejected with `413` or `415`.** `413` means the file exceeds the configured
size limit; `415` means the file type is not in the allowed list. Both limits are set by an
administrator.

---

<a id="doc-auth"></a>
# Authentication

Create an account, sign in, recover your password, and log out. These are the only endpoints
that set or clear the `session` cookie; every other request authenticates with the methods
described in [Authentication](#doc-authentication). The shared rules (content
negotiation, pagination, status codes) live in [Conventions and Errors](#doc-conventions).

## Page vs. action

The GET endpoints render HTML sign-up, login, and password-reset forms; they also return the
page data as JSON when requested with `Accept: application/json` (including `page` to
distinguish the form type).

The POST endpoints are **actions**: they accept form fields, set or clear the `session` cookie,
and return a `302` redirect (or the JSON envelope for JSON callers).

**Sign-up requires a valid `g-recaptcha-response`** when reCAPTCHA is enabled. Use the JSON
envelope to see validation errors as `{ "error": "validation", "fields": {...} }`.

### `GET /auth/signup` - Sign up page

Render the registration form. Returns an HTML page.

*Minimal role:* Public

**Sample response**

```json
{
  "page": "string",
  "next_url": "/path",
  "registration_closed": false,
  "sent": false,
  "token": "string",
  "errors": []
}
```

### `POST /auth/signup` - Sign up

Create a new account. Sets the session cookie on success.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | form | string | yes | Username, 3-20 characters. |
| `password` | form | string | yes | Password, 6+ characters. |
| `confirm_password` | form | string | yes | Must match password. |
| `g-recaptcha-response` | form | string | no | reCAPTCHA token when enabled. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/feed",
  "data": {
    "username": "alice"
  }
}
```

### `GET /auth/login` - Log in page

Render the login form. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `next` | query | string | no | Redirect target after login. |

**Sample response**

```json
{
  "page": "string",
  "next_url": "/path",
  "registration_closed": false,
  "sent": false,
  "token": "string",
  "errors": []
}
```

### `POST /auth/login` - Log in

Authenticate with username and password. Sets the session cookie.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | form | string | yes | Your username. |
| `password` | form | string | yes | Your password. |
| `next` | form | string | no | Redirect target after login. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/feed",
  "data": null
}
```

### `GET /auth/forgot-password` - Forgot password page

Render the forgot-password form. Returns an HTML page.

*Minimal role:* Public

**Sample response**

```json
{
  "page": "string",
  "next_url": "/path",
  "registration_closed": false,
  "sent": false,
  "token": "string",
  "errors": []
}
```

### `POST /auth/forgot-password` - Request password reset

Send a password-reset email with a one-time link.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `email` | form | string | yes | Your registered email. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/auth/forgot-password?sent=1",
  "data": null
}
```

### `GET /auth/reset-password/{token}` - Reset password page

Render the password-reset form (only valid with a one-time token). Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `token` | path | string | yes | The one-time reset token from the email. |

**Sample response**

```json
{
  "page": "string",
  "next_url": "/path",
  "registration_closed": false,
  "sent": false,
  "token": "string",
  "errors": []
}
```

### `POST /auth/reset-password/{token}` - Reset password

Set a new password using a one-time reset token.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `token` | path | string | yes | The one-time reset token from the email. |
| `password` | form | string | yes | New password, 6+ characters. |
| `confirm_password` | form | string | yes | Must match password. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/auth/login",
  "data": null
}
```

### `GET /auth/logout` - Log out

Clear the session cookie and redirect to the landing page.

*Minimal role:* Public

**Sample response**

```json
{
  "ok": true,
  "redirect": "/",
  "data": null
}
```

---

<a id="doc-lookups"></a>
# Search & Lookups

Type-ahead lookups that power mentions and the message composer. Both return JSON and accept
a single `q` query parameter. These feed [Messaging](#doc-messaging) (the recipient
composer) and [Profiles & Social Graph](#doc-profiles) (mentions and user pages).

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `GET /profile/search` - Search users

Find up to ten users whose username matches a query.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `q` | query | string | yes | Partial username to match. |

**Sample response**

```json
{
  "results": [
    {
      "uid": "8f14e45f-...",
      "username": "alice_test"
    }
  ]
}
```

### `GET /messages/search` - Search message recipients

Like user search, but excludes yourself; used by the message composer.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `q` | query | string | yes | Partial username to match. |

**Sample response**

```json
{
  "results": [
    {
      "uid": "0cc175b9-...",
      "username": "bob_test"
    }
  ]
}
```

---

<a id="doc-social-actions"></a>
# Votes, Reactions, Bookmarks & Polls

Lightweight engagement actions. The POST endpoints here are **toggles** - sending the same
action again removes it. They return JSON when called with `X-Requested-With: fetch` (sent
automatically by the panels below); the [Conventions & Errors](#doc-conventions) page
explains that header rule and the response envelope.

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `POST /votes/{target_type}/{target_uid}` - Cast or toggle a vote

Upvote or downvote a target. Re-sending the same value removes the vote.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `target_type` | path | enum | yes | Type of content being voted on. Allowed: post, comment, gist, project. |
| `target_uid` | path | string | yes | UID of the target. |
| `value` | form | enum | yes | 1 to upvote, -1 to downvote. Allowed: 1, -1. |

**Sample response**

```json
{
  "net": 3,
  "up": 4,
  "down": 1,
  "value": 1
}
```

### `POST /reactions/{target_type}/{target_uid}` - Toggle an emoji reaction

Add or remove an emoji reaction on a target.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `target_type` | path | enum | yes | Type of content being reacted to. Allowed: post, comment, gist, project. |
| `target_uid` | path | string | yes | UID of the target. |
| `emoji` | form | enum | yes | One of the allowed reaction emoji. Allowed: 👍, ❤️, 🚀, 🎉, 😂, 👀, 🔥, 🤯. |

**Sample response**

```json
{
  "counts": {
    "\ud83d\udc4d": 2
  },
  "mine": [
    "\ud83d\udc4d"
  ]
}
```

### `POST /bookmarks/{target_type}/{target_uid}` - Toggle a bookmark

Save or unsave a target to your bookmarks.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `target_type` | path | enum | yes | Type of content to bookmark. Allowed: post, gist, project, news. |
| `target_uid` | path | string | yes | UID of the target. |

**Sample response**

```json
{
  "saved": true
}
```

### `GET /bookmarks/saved` - View saved bookmarks

Render your saved content. Returns an HTML page.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `before` | query | string | no | Pagination cursor (created_at of the last item). |

> Bookmarks target posts, projects, gists, and news; see [Posts, Comments, Projects, Gists & News](#doc-content).

**Sample response**

```json
{
  "items": [
    {
      "target_type": "string",
      "target_uid": "UID",
      "type_label": "string",
      "title": "Title",
      "url": "/path",
      "time_ago": "2 hours ago"
    }
  ],
  "next_cursor": "2026-01-01T00:00:00+00:00"
}
```

### `POST /polls/{poll_uid}/vote` - Vote in a poll

Cast, change, or clear your vote on a poll option.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `poll_uid` | path | string | yes | UID of the poll. |
| `option_uid` | form | string | yes | UID of the chosen option. |

> You hold at most one vote per poll, and only your latest vote counts. Voting a different option replaces your previous choice; voting your current option again removes the vote.

**Sample response**

```json
{
  "question": "Best editor?",
  "options": [
    {
      "uid": "OPTION_UID",
      "label": "Vim",
      "votes": 5
    }
  ],
  "total": 5,
  "voted": "OPTION_UID"
}
```

---

<a id="doc-content"></a>
# Posts, Comments, Projects, Gists & News

The core content types. Read endpoints render HTML pages; write endpoints accept form fields
and redirect to the new or updated resource. List fields such as `attachment_uids` are
repeated form keys - upload files first via [Uploads](#doc-uploads) and pass the returned
uids here. Engage with this content through [Votes, Reactions, Bookmarks & Polls](#doc-social-actions).

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `GET /` - Home

The home page. Guests see the marketing splash; authenticated users see a personalized home (welcome, feed shortcut, latest posts, news). It no longer redirects to /feed. The latest-posts section shows at most two posts per author.

*Minimal role:* Public

**Sample response**

```json
{
  "is_authenticated": false,
  "user_post_count": 0,
  "landing_articles": [
    {
      "uid": "UID",
      "slug": "slug",
      "title": "Title",
      "description": "text",
      "url": "/path",
      "source_name": "string",
      "grade": 0,
      "synced_at": "2026-01-01T00:00:00+00:00",
      "time_ago": "2 hours ago",
      "image_url": "/path"
    }
  ],
  "landing_posts": [
    {
      "post": null,
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "role": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "time_ago": "2 hours ago",
      "comment_count": 0,
      "stars": 0,
      "slug": "slug"
    }
  ]
}
```

### `GET /feed` - Browse the feed

The main post feed. Returns an HTML page. Each page shows at most two posts per author.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `tab` | query | enum | no | Feed selector. Allowed: all, trending, following. |
| `topic` | query | enum | no | Filter by topic. Allowed: devlog, showcase, question, rant, fun, random, signals. |
| `search` | query | string | no | Search post title and content. |
| `before` | query | string | no | Pagination cursor. |

**Sample response**

```json
{
  "posts": [
    {
      "post": {
        "uid": "UID",
        "slug": "slug",
        "user_uid": "UID",
        "title": "Title",
        "content": "text",
        "topic": "random",
        "stars": 0,
        "image": "string",
        "created_at": "2026-01-01T00:00:00+00:00",
        "updated_at": "2026-01-01T00:00:00+00:00"
      },
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "role": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "time_ago": "2 hours ago",
      "my_vote": 0,
      "comment_count": 0,
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00"
        }
      ],
      "recent_comments": [
        {
          "comment": {
            "uid": "UID",
            "user_uid": "UID",
            "content": "text",
            "parent_uid": "UID",
            "target_type": "string",
            "target_uid": "UID",
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "author": {
            "uid": "UID",
            "username": "username",
            "avatar_seed": "string",
            "role": "string",
            "bio": "text",
            "location": "string",
            "git_link": "string",
            "website": "string",
            "level": 0,
            "xp": 0,
            "stars": 0,
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "time_ago": "2 hours ago",
          "votes": {
            "up": 0,
            "down": 0
          },
          "my_vote": 0,
          "children": [],
          "attachments": [
            {
              "uid": "UID",
              "filename": "string",
              "url": "/path",
              "size": 0,
              "is_image": false,
              "is_video": false,
              "mime_type": "string",
              "created_at": "2026-01-01T00:00:00+00:00"
            }
          ],
          "reactions": {
            "counts": {},
            "mine": []
          }
        }
      ],
      "reactions": {
        "counts": {},
        "mine": []
      },
      "bookmarked": false,
      "poll": {
        "uid": "UID",
        "question": "string",
        "options": [
          {
            "uid": "UID",
            "label": "string",
            "count": 0,
            "votes": 0,
            "pct": 0
          }
        ],
        "total": 0,
        "my_choice": "string",
        "voted": "string"
      }
    }
  ],
  "current_tab": "string",
  "current_topic": "random",
  "search": "string",
  "next_cursor": "2026-01-01T00:00:00+00:00",
  "total_members": 0,
  "posts_today": 0,
  "total_projects": 0,
  "total_gists": 0,
  "top_authors": [
    {
      "uid": "UID",
      "username": "username",
      "avatar_seed": "string",
      "role": "string",
      "bio": "text",
      "location": "string",
      "git_link": "string",
      "website": "string",
      "level": 0,
      "xp": 0,
      "stars": 0,
      "created_at": "2026-01-01T00:00:00+00:00"
    }
  ],
  "daily_topic": null
}
```

### `POST /posts/create` - Create a post

Publish a post, optionally with a poll. Redirects to the new post.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `content` | form | textarea | yes | Body, 10-125000 characters. |
| `title` | form | string | no | Optional title, up to 500 characters. |
| `topic` | form | enum | no | Post topic. Allowed: devlog, showcase, question, rant, fun, random, signals. |
| `project_uid` | form | string | no | Attach to a project. |
| `poll_question` | form | string | no | Optional poll question. |
| `poll_options` | form | string | no | Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options). |

> Returns a `302` redirect to `/posts/{slug}` on success.

**Sample response**

```json
{
  "ok": true,
  "redirect": "/posts/POST_SLUG",
  "data": {
    "uid": "POST_UID",
    "slug": "POST_SLUG",
    "url": "/posts/POST_SLUG"
  }
}
```

### `GET /posts/{post_slug}` - View a post

Render a post with comments. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `post_slug` | path | string | yes | Slug or UID of the post. |

**Sample response**

```json
{
  "post": {
    "uid": "UID",
    "slug": "slug",
    "user_uid": "UID",
    "title": "Title",
    "content": "text",
    "topic": "random",
    "stars": 0,
    "image": "string",
    "created_at": "2026-01-01T00:00:00+00:00",
    "updated_at": "2026-01-01T00:00:00+00:00"
  },
  "author": {
    "uid": "UID",
    "username": "username",
    "avatar_seed": "string",
    "role": "string",
    "bio": "text",
    "location": "string",
    "git_link": "string",
    "website": "string",
    "level": 0,
    "xp": 0,
    "stars": 0,
    "created_at": "2026-01-01T00:00:00+00:00"
  },
  "is_owner": false,
  "star_count": 0,
  "my_vote": 0,
  "time_ago": "2 hours ago",
  "comments": [
    {
      "comment": {
        "uid": "UID",
        "user_uid": "UID",
        "content": "text",
        "parent_uid": "UID",
        "target_type": "string",
        "target_uid": "UID",
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "role": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "time_ago": "2 hours ago",
      "votes": {
        "up": 0,
        "down": 0
      },
      "my_vote": 0,
      "children": [],
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00"
        }
      ],
      "reactions": {
        "counts": {},
        "mine": []
      }
    }
  ],
  "attachments": [
    {
      "uid": "UID",
      "filename": "string",
      "url": "/path",
      "size": 0,
      "is_image": false,
      "is_video": false,
      "mime_type": "string",
      "created_at": "2026-01-01T00:00:00+00:00"
    }
  ],
  "reactions": {
    "counts": {},
    "mine": []
  },
  "bookmarked": false,
  "poll": {
    "uid": "UID",
    "question": "string",
    "options": [
      {
        "uid": "UID",
        "label": "string",
        "count": 0,
        "votes": 0,
        "pct": 0
      }
    ],
    "total": 0,
    "my_choice": "string",
    "voted": "string"
  },
  "comment_count": 0,
  "related_posts": [
    {
      "post": {
        "uid": "UID",
        "slug": "slug",
        "user_uid": "UID",
        "title": "Title",
        "content": "text",
        "topic": "random",
        "stars": 0,
        "image": "string",
        "created_at": "2026-01-01T00:00:00+00:00",
        "updated_at": "2026-01-01T00:00:00+00:00"
      },
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "role": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "time_ago": "2 hours ago",
      "my_vote": 0,
      "comment_count": 0,
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00"
        }
      ],
      "recent_comments": [
        {
          "comment": {
            "uid": "UID",
            "user_uid": "UID",
            "content": "text",
            "parent_uid": "UID",
            "target_type": "string",
            "target_uid": "UID",
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "author": {
            "uid": "UID",
            "username": "username",
            "avatar_seed": "string",
            "role": "string",
            "bio": "text",
            "location": "string",
            "git_link": "string",
            "website": "string",
            "level": 0,
            "xp": 0,
            "stars": 0,
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "time_ago": "2 hours ago",
          "votes": {
            "up": 0,
            "down": 0
          },
          "my_vote": 0,
          "children": [],
          "attachments": [
            {
              "uid": "UID",
              "filename": "string",
              "url": "/path",
              "size": 0,
              "is_image": false,
              "is_video": false,
              "mime_type": "string",
              "created_at": "2026-01-01T00:00:00+00:00"
            }
          ],
          "reactions": {
            "counts": {},
            "mine": []
          }
        }
      ],
      "reactions": {
        "counts": {},
        "mine": []
      },
      "bookmarked": false,
      "poll": {
        "uid": "UID",
        "question": "string",
        "options": [
          {
            "uid": "UID",
            "label": "string",
            "count": 0,
            "votes": 0,
            "pct": 0
          }
        ],
        "total": 0,
        "my_choice": "string",
        "voted": "string"
      }
    }
  ],
  "topics": [
    "random"
  ]
}
```

### `POST /posts/edit/{post_slug}` - Edit a post

Update a post you own, optionally adding a poll if it has none.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `post_slug` | path | string | yes | Slug or UID of the post. |
| `content` | form | textarea | yes | Body, 10-125000 characters. |
| `title` | form | string | no | Optional title. |
| `topic` | form | enum | no | Post topic. Allowed: devlog, showcase, question, rant, fun, random, signals. |
| `poll_question` | form | string | no | Optional poll question. Adds a poll only when the post has none. |
| `poll_options` | form | string | no | Repeat the field for each poll option, or send a single newline- or comma-separated string (2-6 options). |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/posts/POST_SLUG",
  "data": {
    "uid": "POST_UID",
    "slug": "POST_SLUG",
    "url": "/posts/POST_SLUG"
  }
}
```

### `POST /posts/delete/{post_slug}` - Delete a post

Delete a post you own; administrators may delete any user's post. Soft-deleted (hidden everywhere but restorable from admin trash) and cascades its comments and votes.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `post_slug` | path | string | yes | Slug or UID of the post. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/feed",
  "data": null
}
```

### `POST /comments/create` - Create a comment

Comment on any commentable target. Supports nested replies.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `content` | form | textarea | yes | Body, 3-1000 characters. |
| `target_uid` | form | string | no | UID of the target (or use post_uid). |
| `post_uid` | form | string | no | Convenience alias for a post target. |
| `target_type` | form | enum | no | Type of the target. Allowed: post, project, news, bug, gist. |
| `parent_uid` | form | string | no | Parent comment UID for a reply. |

> Either `target_uid` or `post_uid` is required.

**Sample response**

```json
{
  "ok": true,
  "redirect": "/posts/POST_SLUG#comment-COMMENT_UID",
  "data": {
    "uid": "COMMENT_UID",
    "url": "/posts/POST_SLUG#comment-COMMENT_UID"
  }
}
```

### `POST /comments/delete/{comment_uid}` - Delete a comment

Delete a comment you own; administrators may delete any user's comment. Soft-deleted (hidden everywhere but restorable from admin trash).

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `comment_uid` | path | string | yes | UID of the comment. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/posts/POST_SLUG",
  "data": null
}
```

### `GET /projects` - Browse projects

List projects. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `tab` | query | enum | no | Sort selector. Allowed: recent, popular, released. |
| `search` | query | string | no | Search title and description. |
| `project_type` | query | enum | no | Filter by type. Allowed: game, game_asset, software, mobile_app, website. |
| `before` | query | string | no | Pagination cursor. |

**Sample response**

```json
{
  "projects": [
    {
      "uid": "UID",
      "slug": "slug",
      "user_uid": "UID",
      "title": "Title",
      "description": "text",
      "project_type": "string",
      "status": "published",
      "platforms": null,
      "stars": 0,
      "is_private": false,
      "read_only": false,
      "release_date": "string",
      "demo_date": "string",
      "created_at": "2026-01-01T00:00:00+00:00",
      "updated_at": "2026-01-01T00:00:00+00:00",
      "author_name": "string",
      "my_vote": 0
    }
  ],
  "current_tab": "string",
  "search": "string",
  "project_type": "string",
  "total_count": 0,
  "next_cursor": "2026-01-01T00:00:00+00:00",
  "total_members": 0,
  "top_authors": [
    {
      "uid": "UID",
      "username": "username",
      "avatar_seed": "string",
      "role": "string",
      "bio": "text",
      "location": "string",
      "git_link": "string",
      "website": "string",
      "level": 0,
      "xp": 0,
      "stars": 0,
      "created_at": "2026-01-01T00:00:00+00:00"
    }
  ]
}
```

### `GET /projects/{project_slug}` - View a project

Render a project with comments. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Slug or UID of the project. |

**Sample response**

```json
{
  "project": {
    "uid": "UID",
    "slug": "slug",
    "user_uid": "UID",
    "title": "Title",
    "description": "text",
    "project_type": "string",
    "status": "published",
    "platforms": null,
    "stars": 0,
    "is_private": false,
    "read_only": false,
    "release_date": "string",
    "demo_date": "string",
    "created_at": "2026-01-01T00:00:00+00:00",
    "updated_at": "2026-01-01T00:00:00+00:00"
  },
  "author": {
    "uid": "UID",
    "username": "username",
    "avatar_seed": "string",
    "role": "string",
    "bio": "text",
    "location": "string",
    "git_link": "string",
    "website": "string",
    "level": 0,
    "xp": 0,
    "stars": 0,
    "created_at": "2026-01-01T00:00:00+00:00"
  },
  "is_owner": false,
  "star_count": 0,
  "my_vote": 0,
  "time_ago": "2 hours ago",
  "comments": [
    {
      "comment": {
        "uid": "UID",
        "user_uid": "UID",
        "content": "text",
        "parent_uid": "UID",
        "target_type": "string",
        "target_uid": "UID",
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "role": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "time_ago": "2 hours ago",
      "votes": {
        "up": 0,
        "down": 0
      },
      "my_vote": 0,
      "children": [],
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00"
        }
      ],
      "reactions": {
        "counts": {},
        "mine": []
      }
    }
  ],
  "attachments": [
    {
      "uid": "UID",
      "filename": "string",
      "url": "/path",
      "size": 0,
      "is_image": false,
      "is_video": false,
      "mime_type": "string",
      "created_at": "2026-01-01T00:00:00+00:00"
    }
  ],
  "reactions": {
    "counts": {},
    "mine": []
  },
  "bookmarked": false,
  "platforms": null,
  "is_private": false,
  "read_only": false,
  "forked_from": {},
  "fork_count": 0
}
```

### `POST /projects/create` - Create a project

Publish a project. Redirects to the new project.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `title` | form | string | yes | Title, 1-200 characters. |
| `description` | form | textarea | yes | Description, 1-5000 characters. |
| `project_type` | form | enum | no | Project type. Allowed: game, game_asset, software, mobile_app, website. |
| `status` | form | string | no | Free-form status label. |
| `platforms` | form | string | no | Comma-separated platforms. |
| `release_date` | form | string | no | Optional release date in DD/MM/YYYY format. |
| `demo_date` | form | string | no | Optional demo date in DD/MM/YYYY format. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG",
  "data": {
    "uid": "PROJECT_UID",
    "slug": "PROJECT_SLUG",
    "url": "/projects/PROJECT_SLUG"
  }
}
```

### `POST /projects/edit/{project_slug}` - Edit a project

Update an owned project. Redirects to the project.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `title` | form | string | yes | Title, 1-200 characters. |
| `description` | form | textarea | yes | Description, 1-5000 characters. |
| `project_type` | form | enum | no | Project type. Allowed: game, game_asset, software, mobile_app, website. |
| `status` | form | string | no | Free-form status label. |
| `platforms` | form | string | no | Comma-separated platforms. |
| `release_date` | form | string | no | Optional release date in DD/MM/YYYY format. |
| `demo_date` | form | string | no | Optional demo date in DD/MM/YYYY format. |

### `POST /projects/delete/{project_slug}` - Delete a project

Delete a project you own; administrators may delete any user's project. Soft-deleted with all of its files (hidden everywhere but restorable from admin trash).

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Slug or UID of the project. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects",
  "data": null
}
```

### `POST /projects/{project_slug}/private` - Set project visibility

Mark a project you own private (only you and administrators can see it) or public. Send value=1 for private, value=0 for public.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Slug or UID of the project. |
| `value` | form | boolean | yes | 1 to make the project private, 0 to make it public. |

### `POST /projects/{project_slug}/readonly` - Set project read-only

Mark a project you own read-only so all of its files become immutable (no writes, edits, moves, deletes, or uploads succeed), or writable again. Send value=1 for read-only, value=0 for writable.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Slug or UID of the project. |
| `value` | form | boolean | yes | 1 to make the project read-only, 0 to make it writable. |

### `POST /projects/{project_slug}/zip` - Queue a project zip

Start a background job that archives the whole project. Returns the job uid and status URL to poll.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Slug or UID of the project. |

**Sample response**

```json
{
  "uid": "ZIP_JOB_UID",
  "status_url": "/zips/ZIP_JOB_UID"
}
```

### `GET /zips/{uid}` - Zip job status

Poll a zip job. While pending or running download_url is null; once done it points at the archive.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Zip job uid returned when the job was queued. |

**Sample response**

```json
{
  "uid": "ZIP_JOB_UID",
  "kind": "zip",
  "status": "done",
  "preferred_name": "my-project",
  "download_url": "/zips/ZIP_JOB_UID/download",
  "error": null,
  "bytes_in": 20480,
  "bytes_out": 8192,
  "item_count": 12,
  "file_count": 10,
  "dir_count": 2,
  "created_at": "2026-06-09T10:00:00+00:00",
  "completed_at": "2026-06-09T10:00:03+00:00"
}
```

### `GET /zips/{uid}/download` - Download a zip archive

Stream the finished archive as application/zip. Each access extends the retention window.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Zip job uid of a finished job. |

### `POST /projects/{project_slug}/fork` - Queue a project fork

Start a background job that copies the whole project into a new project owned by you. Returns the job uid and status URL to poll.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Slug or UID of the project to fork. |
| `title` | form | string | yes | Title for the new forked project. |

**Sample response**

```json
{
  "uid": "FORK_JOB_UID",
  "status_url": "/forks/FORK_JOB_UID"
}
```

### `GET /forks/{uid}` - Fork job status

Poll a fork job. While pending or running project_url is null; once done it points at the new project.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Fork job uid returned when the job was queued. |

**Sample response**

```json
{
  "uid": "FORK_JOB_UID",
  "kind": "fork",
  "status": "done",
  "preferred_name": "My Fork",
  "project_uid": "NEW_PROJECT_UID",
  "project_url": "/projects/new-project-slug",
  "source_project_uid": "SOURCE_PROJECT_UID",
  "error": null,
  "item_count": 12,
  "created_at": "2026-06-09T10:00:00+00:00",
  "completed_at": "2026-06-09T10:00:05+00:00"
}
```

### `GET /gists` - Browse gists

List code gists. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `language` | query | enum | no | Filter by language. Allowed: python, javascript, typescript, html, css, c, cpp, java, go, rust, sql, bash, json, markdown, plaintext. |
| `user_uid` | query | string | no | Filter by author UID. |
| `search` | query | string | no | Search gist title and description. |
| `before` | query | string | no | Pagination cursor. |

**Sample response**

```json
{
  "gists": [
    {
      "gist": {
        "uid": "UID",
        "slug": "slug",
        "user_uid": "UID",
        "title": "Title",
        "description": "text",
        "source_code": "string",
        "language": "python",
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "updated_at": "2026-01-01T00:00:00+00:00"
      },
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "role": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "time_ago": "2 hours ago",
      "my_vote": 0,
      "comment_count": 0
    }
  ],
  "total_count": 0,
  "next_cursor": "2026-01-01T00:00:00+00:00",
  "current_language": "python",
  "search": "string",
  "languages": [
    [
      "python"
    ]
  ],
  "gist_language_codes": [
    "python"
  ]
}
```

### `GET /gists/{gist_slug}` - View a gist

Render a gist with comments. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `gist_slug` | path | string | yes | Slug or UID of the gist. |

**Sample response**

```json
{
  "gist": {
    "uid": "UID",
    "slug": "slug",
    "user_uid": "UID",
    "title": "Title",
    "description": "text",
    "source_code": "string",
    "language": "python",
    "stars": 0,
    "created_at": "2026-01-01T00:00:00+00:00",
    "updated_at": "2026-01-01T00:00:00+00:00"
  },
  "author": {
    "uid": "UID",
    "username": "username",
    "avatar_seed": "string",
    "role": "string",
    "bio": "text",
    "location": "string",
    "git_link": "string",
    "website": "string",
    "level": 0,
    "xp": 0,
    "stars": 0,
    "created_at": "2026-01-01T00:00:00+00:00"
  },
  "is_owner": false,
  "star_count": 0,
  "my_vote": 0,
  "time_ago": "2 hours ago",
  "comments": [
    {
      "comment": {
        "uid": "UID",
        "user_uid": "UID",
        "content": "text",
        "parent_uid": "UID",
        "target_type": "string",
        "target_uid": "UID",
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "role": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "time_ago": "2 hours ago",
      "votes": {
        "up": 0,
        "down": 0
      },
      "my_vote": 0,
      "children": [],
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00"
        }
      ],
      "reactions": {
        "counts": {},
        "mine": []
      }
    }
  ],
  "attachments": [
    {
      "uid": "UID",
      "filename": "string",
      "url": "/path",
      "size": 0,
      "is_image": false,
      "is_video": false,
      "mime_type": "string",
      "created_at": "2026-01-01T00:00:00+00:00"
    }
  ],
  "reactions": {
    "counts": {},
    "mine": []
  },
  "bookmarked": false
}
```

### `POST /gists/create` - Create a gist

Publish a code snippet. Redirects to the new gist.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `title` | form | string | yes | Title, 1-200 characters. |
| `source_code` | form | textarea | yes | Source, 1-400000 characters. |
| `language` | form | enum | no | Syntax language. Allowed: python, javascript, typescript, html, css, c, cpp, java, go, rust, sql, bash, json, markdown, plaintext. |
| `description` | form | string | no | Optional description. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/gists/GIST_SLUG",
  "data": {
    "uid": "GIST_UID",
    "slug": "GIST_SLUG",
    "url": "/gists/GIST_SLUG"
  }
}
```

### `POST /gists/edit/{gist_slug}` - Edit a gist

Update a gist you own.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `gist_slug` | path | string | yes | Slug or UID of the gist. |
| `title` | form | string | yes | Title, 1-200 characters. |
| `source_code` | form | textarea | yes | Source, 1-400000 characters. |
| `language` | form | enum | no | Syntax language. Allowed: python, javascript, typescript, html, css, c, cpp, java, go, rust, sql, bash, json, markdown, plaintext. |
| `description` | form | string | no | Optional description. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/gists/GIST_SLUG",
  "data": {
    "uid": "GIST_UID",
    "slug": "GIST_SLUG",
    "url": "/gists/GIST_SLUG"
  }
}
```

### `POST /gists/delete/{gist_slug}` - Delete a gist

Delete a gist you own; administrators may delete any user's gist. Soft-deleted (hidden everywhere but restorable from admin trash).

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `gist_slug` | path | string | yes | Slug or UID of the gist. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/gists",
  "data": null
}
```

### `GET /news` - Browse news

Curated developer news. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `before` | query | string | no | Pagination cursor (synced_at). |

**Sample response**

```json
{
  "articles": [
    {
      "article": {
        "uid": "UID",
        "slug": "slug",
        "title": "Title",
        "description": "text",
        "content": "text",
        "url": "/path",
        "source_name": "string",
        "author": "string",
        "grade": 0,
        "status": "published",
        "image_url": "/path",
        "article_published": "string",
        "created_at": "2026-01-01T00:00:00+00:00",
        "synced_at": "2026-01-01T00:00:00+00:00"
      },
      "time_ago": "2 hours ago",
      "image_url": "/path",
      "grade": 0
    }
  ],
  "next_cursor": "2026-01-01T00:00:00+00:00"
}
```

### `GET /news/{news_slug}` - View a news article

Render a news article with comments. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `news_slug` | path | string | yes | Slug or UID of the article. |

**Sample response**

```json
{
  "article": {
    "uid": "UID",
    "slug": "slug",
    "title": "Title",
    "description": "text",
    "content": "text",
    "url": "/path",
    "source_name": "string",
    "author": "string",
    "grade": 0,
    "status": "published",
    "image_url": "/path",
    "article_published": "string",
    "created_at": "2026-01-01T00:00:00+00:00",
    "synced_at": "2026-01-01T00:00:00+00:00"
  },
  "canonical_slug": "slug",
  "image_url": "/path",
  "grade": 0,
  "time_ago": "2 hours ago",
  "comments": [
    {
      "comment": {
        "uid": "UID",
        "user_uid": "UID",
        "content": "text",
        "parent_uid": "UID",
        "target_type": "string",
        "target_uid": "UID",
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "role": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "time_ago": "2 hours ago",
      "votes": {
        "up": 0,
        "down": 0
      },
      "my_vote": 0,
      "children": [],
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00"
        }
      ],
      "reactions": {
        "counts": {},
        "mine": []
      }
    }
  ],
  "bookmarked": false
}
```

---

<a id="doc-profiles"></a>
# Profiles & Social Graph

Profile data, the follow graph, the leaderboard, and avatar generation. Find users with
[Search & Lookups](#doc-lookups); follows generate entries in [Notifications](#doc-notifications).

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `GET /profile/{username}` - View a profile

Render a user profile. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Target username. |
| `tab` | query | enum | no | Profile tab. Allowed: posts, activity, followers, following, media. |

**Sample response**

```json
{
  "profile_user": {
    "uid": "UID",
    "username": "username",
    "avatar_seed": "string",
    "role": "string",
    "bio": "text",
    "location": "string",
    "git_link": "string",
    "website": "string",
    "level": 0,
    "xp": 0,
    "stars": 0,
    "created_at": "2026-01-01T00:00:00+00:00"
  },
  "posts": [
    {
      "post": {
        "uid": "UID",
        "slug": "slug",
        "user_uid": "UID",
        "title": "Title",
        "content": "text",
        "topic": "random",
        "stars": 0,
        "image": "string",
        "created_at": "2026-01-01T00:00:00+00:00",
        "updated_at": "2026-01-01T00:00:00+00:00"
      },
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "role": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "time_ago": "2 hours ago",
      "my_vote": 0,
      "comment_count": 0,
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00"
        }
      ],
      "recent_comments": [
        {
          "comment": {
            "uid": "UID",
            "user_uid": "UID",
            "content": "text",
            "parent_uid": "UID",
            "target_type": "string",
            "target_uid": "UID",
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "author": {
            "uid": "UID",
            "username": "username",
            "avatar_seed": "string",
            "role": "string",
            "bio": "text",
            "location": "string",
            "git_link": "string",
            "website": "string",
            "level": 0,
            "xp": 0,
            "stars": 0,
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "time_ago": "2 hours ago",
          "votes": {
            "up": 0,
            "down": 0
          },
          "my_vote": 0,
          "children": [],
          "attachments": [
            {
              "uid": "UID",
              "filename": "string",
              "url": "/path",
              "size": 0,
              "is_image": false,
              "is_video": false,
              "mime_type": "string",
              "created_at": "2026-01-01T00:00:00+00:00"
            }
          ],
          "reactions": {
            "counts": {},
            "mine": []
          }
        }
      ],
      "reactions": {
        "counts": {},
        "mine": []
      },
      "bookmarked": false,
      "poll": {
        "uid": "UID",
        "question": "string",
        "options": [
          {
            "uid": "UID",
            "label": "string",
            "count": 0,
            "votes": 0,
            "pct": 0
          }
        ],
        "total": 0,
        "my_choice": "string",
        "voted": "string"
      }
    }
  ],
  "badges": [
    {
      "name": "string",
      "created_at": "2026-01-01T00:00:00+00:00"
    }
  ],
  "projects": [
    {
      "uid": "UID",
      "slug": "slug",
      "user_uid": "UID",
      "title": "Title",
      "description": "text",
      "project_type": "string",
      "status": "published",
      "platforms": null,
      "stars": 0,
      "is_private": false,
      "read_only": false,
      "release_date": "string",
      "demo_date": "string",
      "created_at": "2026-01-01T00:00:00+00:00",
      "updated_at": "2026-01-01T00:00:00+00:00"
    }
  ],
  "gists": [
    {
      "gist": {
        "uid": "UID",
        "slug": "slug",
        "user_uid": "UID",
        "title": "Title",
        "description": "text",
        "source_code": "string",
        "language": "python",
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00",
        "updated_at": "2026-01-01T00:00:00+00:00"
      },
      "author": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "role": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "time_ago": "2 hours ago",
      "my_vote": 0,
      "comment_count": 0
    }
  ],
  "current_tab": "string",
  "posts_count": 0,
  "is_following": false,
  "is_owner": false,
  "can_view_api_key": false,
  "api_key": "string",
  "can_manage_customization": false,
  "cust_disable_global": false,
  "cust_disable_pagetype": false,
  "ai_quota": {},
  "activities": [
    null
  ],
  "rank": 0,
  "heatmap": null,
  "heatmap_months": null,
  "streak": null,
  "people": [
    null
  ],
  "follow_pagination": null,
  "followers_count": 0,
  "following_count": 0,
  "viewer_is_admin": false,
  "media": [
    {
      "uid": "UID",
      "original_filename": "string",
      "file_size": 0,
      "mime_type": "string",
      "url": "/path",
      "thumbnail_url": "/path",
      "has_thumbnail": false,
      "is_image": false,
      "is_video": false,
      "target_type": "string",
      "target_uid": "UID",
      "target_url": "/path",
      "created_at": "2026-01-01T00:00:00+00:00"
    }
  ],
  "media_pagination": null,
  "notification_prefs": [
    null
  ]
}
```

### `POST /profile/update` - Update your profile

Update your own bio and links.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `bio` | form | textarea | no | Bio, up to 500 characters. |
| `location` | form | string | no | Location, up to 200 characters. |
| `git_link` | form | string | no | Git profile URL. |
| `website` | form | string | no | Personal website URL. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/YOUR_USERNAME",
  "data": null
}
```

### `POST /profile/regenerate-api-key` - Regenerate your API key

Issue a new API key and invalidate the current one.

*Minimal role:* Member

> > Running this invalidates the key these documentation panels use. Do it from your [profile page](/profile/YOUR_USERNAME) instead, then reload these docs.

**Sample response**

```json
{
  "api_key": "NEW_UUID"
}
```

### `POST /profile/{username}/customization/global` - Toggle site-wide customizations

Show or suppress your site-wide custom CSS and JS without deleting it. Admins may target any user.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Profile owner. Must be yourself unless you are an admin. |
| `value` | form | boolean | no | 1 to show your site-wide customizations, 0 to suppress them. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/YOUR_USERNAME",
  "data": {
    "url": "/profile/YOUR_USERNAME",
    "cust_disable_global": true
  }
}
```

### `POST /profile/{username}/customization/pagetype` - Toggle per-page customizations

Show or suppress your per-page custom CSS and JS without deleting it. Admins may target any user.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Profile owner. Must be yourself unless you are an admin. |
| `value` | form | boolean | no | 1 to show your per-page customizations, 0 to suppress them. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/YOUR_USERNAME",
  "data": {
    "url": "/profile/YOUR_USERNAME",
    "cust_disable_pagetype": true
  }
}
```

### `POST /profile/{username}/notifications` - Toggle a notification preference

Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, bug.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Profile owner. Must be yourself unless you are an admin. |
| `notification_type` | form | string | yes | One of: comment, reply, mention, vote, follow, message, badge, level, bug. |
| `channel` | form | string | yes | Either in_app or push. |
| `value` | form | boolean | no | 1 to deliver this notification on this channel, 0 to suppress it. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/YOUR_USERNAME?tab=notifications",
  "data": {
    "notification_type": "vote",
    "channel": "push",
    "value": false
  }
}
```

### `POST /profile/{username}/notifications/reset` - Reset notification preferences

Clear all of a user's notification overrides so every type falls back to the platform default. Admins may target any user.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Profile owner. Must be yourself unless you are an admin. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/YOUR_USERNAME?tab=notifications"
}
```

### `POST /media/{uid}/delete` - Delete media

Remove one of your uploaded media attachments. It disappears from your profile Media tab and from any post, project, gist, or other place it was attached. You can delete media you uploaded; administrators may remove any user's media.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Attachment uid, taken from the Media tab response. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/YOUR_USERNAME?tab=media"
}
```

### `POST /follow/{username}` - Follow a user

Follow another user. Idempotent.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Username to follow. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/bob_test",
  "data": null
}
```

### `POST /follow/unfollow/{username}` - Unfollow a user

Stop following a user.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Username to unfollow. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/profile/bob_test",
  "data": null
}
```

### `GET /profile/{username}/followers` - List followers

List the users who follow a profile, 25 per page. Returns JSON.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Target username. |
| `page` | query | integer | no | Page number, 25 per page. |

**Sample response**

```json
{
  "username": "YOUR_USERNAME",
  "mode": "followers",
  "count": 2,
  "page": 1,
  "total_pages": 1,
  "followers": [
    {
      "uid": "UUID",
      "username": "bob_test",
      "bio": "Building things.",
      "is_following": false
    }
  ]
}
```

### `GET /profile/{username}/following` - List following

List the users a profile follows, 25 per page. Returns JSON.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `username` | path | string | yes | Target username. |
| `page` | query | integer | no | Page number, 25 per page. |

**Sample response**

```json
{
  "username": "YOUR_USERNAME",
  "mode": "following",
  "count": 1,
  "page": 1,
  "total_pages": 1,
  "following": [
    {
      "uid": "UUID",
      "username": "alice_test",
      "bio": "",
      "is_following": true
    }
  ]
}
```

### `GET /leaderboard` - View the leaderboard

Top contributors by stars. Returns an HTML page.

*Minimal role:* Public

**Sample response**

```json
{
  "entries": [
    {
      "uid": "UID",
      "username": "username",
      "avatar_seed": "string",
      "stars": 0,
      "level": 0,
      "rank": 0
    }
  ],
  "user_rank": 0,
  "total_members": 0,
  "posts_today": 0,
  "total_projects": 0,
  "total_gists": 0,
  "top_authors": [
    {
      "uid": "UID",
      "username": "username",
      "avatar_seed": "string",
      "role": "string",
      "bio": "text",
      "location": "string",
      "git_link": "string",
      "website": "string",
      "level": 0,
      "xp": 0,
      "stars": 0,
      "created_at": "2026-01-01T00:00:00+00:00"
    }
  ],
  "featured_news": [
    {
      "uid": "UID",
      "slug": "slug",
      "title": "Title",
      "description": "text",
      "content": "text",
      "url": "/path",
      "source_name": "string",
      "author": "string",
      "grade": 0,
      "status": "published",
      "image_url": "/path",
      "article_published": "string",
      "created_at": "2026-01-01T00:00:00+00:00",
      "synced_at": "2026-01-01T00:00:00+00:00"
    }
  ]
}
```

### `GET /avatar/{style}/{seed}` - Generate an avatar

Deterministic SVG avatar for a seed. Returns an image.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `style` | path | enum | yes | Avatar style. Allowed: multiavatar. |
| `seed` | path | string | yes | Seed string, usually a username. |
| `size` | query | int | no | Pixel size. |

---

<a id="doc-messaging"></a>
# Messaging

Direct messages between users. The inbox renders HTML; sending uses form fields. Look up
recipients with [Search & Lookups](#doc-lookups) and attach files via [Uploads](#doc-uploads).

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `GET /messages` - Open the inbox

Render conversations. Returns an HTML page.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `with_uid` | query | string | no | Open a specific conversation by user UID. |
| `search` | query | string | no | Jump to a conversation by username. |

**Sample response**

```json
{
  "conversations": [
    {
      "other_user": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "role": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "last_message": "string",
      "last_message_at": "string",
      "unread": false
    }
  ],
  "messages": [
    {
      "message": {
        "uid": "UID",
        "sender_uid": "UID",
        "receiver_uid": "UID",
        "content": "text",
        "read": false,
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "sender": {
        "uid": "UID",
        "username": "username",
        "avatar_seed": "string",
        "role": "string",
        "bio": "text",
        "location": "string",
        "git_link": "string",
        "website": "string",
        "level": 0,
        "xp": 0,
        "stars": 0,
        "created_at": "2026-01-01T00:00:00+00:00"
      },
      "is_mine": false,
      "time_ago": "2 hours ago",
      "attachments": [
        {
          "uid": "UID",
          "filename": "string",
          "url": "/path",
          "size": 0,
          "is_image": false,
          "is_video": false,
          "mime_type": "string",
          "created_at": "2026-01-01T00:00:00+00:00"
        }
      ]
    }
  ],
  "other_user": {
    "uid": "UID",
    "username": "username",
    "avatar_seed": "string",
    "role": "string",
    "bio": "text",
    "location": "string",
    "git_link": "string",
    "website": "string",
    "level": 0,
    "xp": 0,
    "stars": 0,
    "created_at": "2026-01-01T00:00:00+00:00"
  },
  "current_conversation": "string",
  "search": "string"
}
```

### `POST /messages/send` - Send a message

Send a direct message to a user.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `content` | form | textarea | yes | Body, 1-2000 characters. |
| `receiver_uid` | form | string | yes | Recipient user UID. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/messages?with_uid=RECEIVER_UID",
  "data": {
    "uid": "MESSAGE_UID"
  }
}
```

---

<a id="doc-notifications"></a>
# Notifications

Read your notification feed and mark items read. The unread counts endpoint backs the badges
in the navigation bar. Deliver these to the browser with [Web Push](#doc-push).

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `GET /notifications` - View notifications

Render your notifications. Returns an HTML page.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `before` | query | string | no | Pagination cursor. |

**Sample response**

```json
{
  "notification_groups": [
    {
      "label": "string",
      "entries": [
        {
          "notification": {
            "uid": "UID",
            "type": "string",
            "message": "string",
            "read": false,
            "related_uid": "UID",
            "target_url": "/path",
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "actor": {
            "uid": "UID",
            "username": "username",
            "avatar_seed": "string",
            "role": "string",
            "bio": "text",
            "location": "string",
            "git_link": "string",
            "website": "string",
            "level": 0,
            "xp": 0,
            "stars": 0,
            "created_at": "2026-01-01T00:00:00+00:00"
          },
          "time_ago": "2 hours ago"
        }
      ]
    }
  ],
  "next_cursor": "2026-01-01T00:00:00+00:00"
}
```

### `GET /notifications/counts` - Unread counts

Unread notification and message counts.

*Minimal role:* Public

> Guests receive `{ "notifications": 0, "messages": 0 }` instead of an error, so the navigation badge works before login.

**Sample response**

```json
{
  "notifications": 2,
  "messages": 1
}
```

### `GET /notifications/open/{notification_uid}` - Open a notification

Mark a notification read and redirect to its target.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `notification_uid` | path | string | yes | UID of the notification. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/posts/POST_SLUG#comment-COMMENT_UID",
  "data": null
}
```

### `POST /notifications/mark-read/{notification_uid}` - Mark one read

Mark a single notification as read.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `notification_uid` | path | string | yes | UID of the notification. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/notifications",
  "data": null
}
```

### `POST /notifications/mark-all-read` - Mark all read

Mark every notification as read.

*Minimal role:* Member

**Sample response**

```json
{
  "ok": true,
  "redirect": "/notifications",
  "data": null
}
```

---

<a id="doc-uploads"></a>
# Uploads

Attachment storage. Upload a file - or hand the server a public URL to fetch - to receive an
attachment record, then reference its `uid` in an `attachment_uids` field when creating a post,
comment, project, gist, message, or bug - see
[Posts, Comments, Projects, Gists & News](#doc-content). Images and videos embed and
play inline once posted; other types render as download links. The record's `is_image` and
`is_video` flags indicate how the file is displayed.

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `POST /uploads/upload` - Upload a file

Store a file and return its attachment record.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `file` | form | file | yes | The file to upload. |

> Allowed file types and the size limit are configured by administrators. Images and common video formats (mp4, webm, ogv, mov, m4v) are accepted by default.

> Returns `201` on success, `413` if too large, `415` if the type is not allowed.

**Sample response**

```json
{
  "uid": "ATTACHMENT_UID",
  "filename": "clip.mp4",
  "url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.mp4",
  "size": 20480,
  "is_image": false,
  "is_video": true,
  "mime_type": "video/mp4"
}
```

### `POST /uploads/upload-url` - Attach a file from a URL

Download a public URL on the server and store it as an attachment.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `url` | form | string | yes | Public http(s) URL of the file to download and attach. |
| `filename` | form | string | no | Optional filename with an allowed extension, used when the URL has no clear name. |

> The server fetches the URL (SSRF-guarded, size-capped) and stores the bytes through the same pipeline as a direct upload; the response is identical to Upload a file.

> The file type is taken from the URL path or the response Content-Type. Returns `201` on success, `413` if too large, `415` if the type cannot be resolved to an allowed type, `400` for an unreachable or private address.

**Sample response**

```json
{
  "uid": "ATTACHMENT_UID",
  "filename": "photo.png",
  "url": "/static/uploads/attachments/ab/cd/ATTACHMENT_UID.png",
  "size": 20480,
  "is_image": true,
  "is_video": false,
  "mime_type": "image/png"
}
```

### `DELETE /uploads/delete/{attachment_uid}` - Delete an attachment

Delete an attachment you own; administrators may delete any user's attachment. Soft-deleted (hidden everywhere but restorable; garbage-collected later).

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `attachment_uid` | path | string | yes | UID of the attachment. |

**Sample response**

```json
{
  "status": "deleted"
}
```

---

<a id="doc-project-files"></a>
# Project Filesystem

Each project carries a full virtual filesystem - directories and files - so a project can hold a
complete software project. Reading is public (anyone can browse a project's tree); creating,
editing, uploading, moving and deleting require the project owner. Text files are editable inline;
binary files are uploaded and served from `/static/uploads/project_files/...`.

Paths are relative POSIX paths inside the project (for example `src/main.py`). Parent directories
are created automatically on write, upload and mkdir. Paths containing `..`, null bytes or empty
segments are rejected.

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, status codes); see [Authentication](#doc-authentication) for the four ways to
sign requests.

### `GET /projects/{project_slug}/files` - List a project's files

Return the flat list of files and directories in a project.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |

**Sample response**

```json
{
  "project": {
    "uid": "PROJECT_UID",
    "slug": "PROJECT_SLUG"
  },
  "files": [
    {
      "path": "src/main.py",
      "name": "main.py",
      "type": "file",
      "is_binary": false,
      "size": 42
    }
  ],
  "is_owner": false
}
```

### `GET /projects/{project_slug}/files/raw` - Read a project file

Return one file's metadata and (for text files) its content.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | query | string | yes | Relative file path inside the project. |

**Sample response**

```json
{
  "path": "src/main.py",
  "name": "main.py",
  "type": "file",
  "is_binary": false,
  "mime_type": "text/plain",
  "size": 42,
  "url": null,
  "content": "print('hello')\n"
}
```

### `POST /projects/{project_slug}/files/write` - Write a text file

Create or overwrite a text file; parent directories are created automatically.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | form | string | yes | Relative file path. |
| `content` | form | textarea | yes | Full file content (max 400000 chars). |

> Owner only; non-owners get `403`. Invalid paths return `400`.

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "src/main.py",
    "type": "file"
  }
}
```

### `GET /projects/{project_slug}/files/lines` - Read a line range

Read a 1-indexed inclusive line range of a text file. Returns lines plus total_lines for targeting edits.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | query | string | yes | Relative file path. |
| `start` | query | integer | no | First line, 1-indexed (default 1). |
| `end` | query | integer | no | Last line inclusive; omit or -1 for end of file. |

> Text files only; binary, directory, or missing paths return `404`.

**Sample response**

```json
{
  "path": "src/main.py",
  "start": 1,
  "end": 2,
  "total_lines": 2,
  "lines": [
    "import os",
    "print(os.getcwd())"
  ],
  "content": "import os\nprint(os.getcwd())"
}
```

### `POST /projects/{project_slug}/files/replace-lines` - Replace a line range

Replace lines start..end (inclusive) with new content; empty content deletes the range. Leaves the rest of the file untouched.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | form | string | yes | Relative file path. |
| `start` | form | integer | yes | First line to replace (1-indexed). |
| `end` | form | integer | yes | Last line to replace (inclusive). |
| `content` | form | textarea | no | Replacement text (empty deletes the range). |

> Owner only. The preferred way to edit a large file; avoids rewriting the whole file.

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "src/main.py",
    "type": "file"
  }
}
```

### `POST /projects/{project_slug}/files/insert-lines` - Insert lines

Insert content before a 1-indexed line. Use at=1 to prepend and at=total_lines+1 to append.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | form | string | yes | Relative file path. |
| `at` | form | integer | yes | Insert before this 1-indexed line. |
| `content` | form | textarea | yes | Text to insert. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "src/main.py",
    "type": "file"
  }
}
```

### `POST /projects/{project_slug}/files/delete-lines` - Delete a line range

Delete lines start..end (inclusive) from a text file.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | form | string | yes | Relative file path. |
| `start` | form | integer | yes | First line to delete (1-indexed). |
| `end` | form | integer | yes | Last line to delete (inclusive). |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "src/main.py",
    "type": "file"
  }
}
```

### `POST /projects/{project_slug}/files/append` - Append to a file

Append content as new lines at the end of a text file; grow a large file across calls without resending it.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | form | string | yes | Relative file path. |
| `content` | form | textarea | yes | Text to append. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "log.txt",
    "type": "file"
  }
}
```

### `POST /projects/{project_slug}/files/upload` - Upload a file into a project

Upload a file into a directory (parents created); text decodes to an editable file, otherwise stored as binary.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `file` | form | file | yes | The file to upload. |
| `path` | form | string | no | Target directory, empty for the root. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "assets/logo.png",
    "type": "file",
    "is_binary": true
  }
}
```

### `POST /projects/{project_slug}/files/mkdir` - Create a directory

Create a directory and any missing parents.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | form | string | yes | Relative directory path. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "src/components",
    "type": "dir"
  }
}
```

### `POST /projects/{project_slug}/files/move` - Move or rename

Move or rename a file or directory (and its descendants).

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `from_path` | form | string | yes | Existing path. |
| `to_path` | form | string | yes | New path. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "src/new.py"
  }
}
```

### `POST /projects/{project_slug}/files/delete` - Delete a file or directory

Delete a file, or a directory and everything under it. Project owner or an administrator; soft-deleted (restorable from admin trash). Blocked while the project is read-only.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | form | string | yes | Relative path to delete. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/projects/PROJECT_SLUG/files",
  "data": {
    "path": "src/old.py"
  }
}
```

### `POST /projects/{project_slug}/files/zip` - Queue a zip of files

Archive the whole tree, or a subtree via the path query. Returns the job uid and status URL to poll with /zips/{uid}.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `project_slug` | path | string | yes | Project slug or uid. |
| `path` | query | string | no | Relative file or directory to archive; empty for the whole project. |

**Sample response**

```json
{
  "uid": "ZIP_JOB_UID",
  "status_url": "/zips/ZIP_JOB_UID"
}
```

---

<a id="doc-tools"></a>
# Tools: SEO Diagnostics

A public auditor that crawls a URL or sitemap with a headless browser and runs a broad battery of
technical, on-page, structured-data, Core Web Vitals, accessibility and AI-readiness checks. It runs
as a background job; poll the status URL (or watch the websocket) until `status` is `done`, then read
the full report.

Every endpoint follows the shared [Conventions & Errors](#doc-conventions). These are
**capability URLs**: the job `uid` is an unguessable identifier, so anyone holding it can read the
status and report.

### `POST /tools/seo/run` - Queue an SEO audit

Start a background SEO audit of a URL or sitemap. Returns the job uid plus status and websocket URLs.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `url` | form | string | yes | Page URL or sitemap.xml URL to audit. |
| `mode` | form | enum | no | 'url' (single page) or 'sitemap' (crawl). Allowed: url, sitemap. |
| `max_pages` | form | integer | no | Max pages to crawl in sitemap mode (1-50). |

**Sample response**

```json
{
  "uid": "SEO_JOB_UID",
  "status_url": "/tools/seo/SEO_JOB_UID",
  "ws_url": "/tools/seo/SEO_JOB_UID/ws"
}
```

### `GET /tools/seo/{uid}` - SEO audit status

Poll an SEO audit. Once done, score, grade and report_url are populated.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | SEO job uid returned when the audit was queued. |

**Sample response**

```json
{
  "uid": "SEO_JOB_UID",
  "kind": "seo",
  "status": "done",
  "target": "https://example.com",
  "mode": "url",
  "ws_url": "/tools/seo/SEO_JOB_UID/ws",
  "report_url": "/tools/seo/SEO_JOB_UID/report",
  "score": 82,
  "grade": "B",
  "page_count": 1,
  "error": null,
  "created_at": "2026-06-14T10:00:00+00:00",
  "completed_at": "2026-06-14T10:00:18+00:00"
}
```

### `GET /tools/seo/{uid}/report` - SEO audit report

Full categorised report: overall score, per-category subscores, and every check with its recommendation. Negotiates HTML or JSON.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | SEO job uid of a finished audit. |

**Sample response**

```json
{
  "uid": "SEO_JOB_UID",
  "status": "done",
  "target": "https://example.com",
  "score": 82,
  "grade": "B",
  "page_count": 1,
  "counts": {
    "pass": 40,
    "warn": 8,
    "fail": 3,
    "info": 5,
    "skip": 0
  },
  "categories": {
    "crawlability": {
      "score": 90,
      "pass": 9,
      "warn": 1,
      "fail": 0
    }
  },
  "pages": [
    {
      "url": "https://example.com",
      "status": 200,
      "score": 82,
      "grade": "B"
    }
  ],
  "checks": [
    {
      "id": "meta.title_present",
      "category": "meta",
      "title": "Title tag",
      "status": "pass",
      "severity": "high",
      "value": "Example Domain",
      "recommendation": "",
      "url": "https://example.com"
    }
  ],
  "site": {
    "robots": {
      "status": 200
    },
    "sitemap": {
      "status": 200,
      "url_count": 12
    }
  },
  "generated_at": "2026-06-14T10:00:18+00:00"
}
```

### `GET /tools/seo/{uid}/screenshot/{index}` - SEO audit page screenshot

Stream the rendered screenshot (image/png) captured for the audited page at the given zero-based index.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | SEO job uid of a finished audit. |
| `index` | path | integer | yes | Zero-based index of the audited page. |

### `POST /tools/deepsearch/run` - Queue a DeepSearch research job

Start a multi-agent deep web research job. Returns the job uid plus status and websocket URLs.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `query` | form | string | yes | The research question to investigate. |
| `depth` | form | integer | no | Research depth (1-4). |
| `max_pages` | form | integer | no | Maximum sources to crawl (1-30). |

**Sample response**

```json
{
  "uid": "DEEPSEARCH_JOB_UID",
  "status_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID",
  "ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/ws"
}
```

### `GET /tools/deepsearch/{uid}` - DeepSearch status

Poll a DeepSearch job. Once done, score, confidence and session_url are populated.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | DeepSearch job uid returned when the run was queued. |

**Sample response**

```json
{
  "uid": "DEEPSEARCH_JOB_UID",
  "kind": "deepsearch",
  "status": "done",
  "query": "history of the transistor",
  "depth": 2,
  "max_pages": 12,
  "ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/ws",
  "chat_ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/chat",
  "session_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/session",
  "score": 78,
  "confidence": 0.72,
  "source_diversity": 0.64,
  "page_count": 11,
  "chunk_count": 240,
  "error": null,
  "created_at": "2026-06-14T10:00:00+00:00",
  "completed_at": "2026-06-14T10:01:40+00:00"
}
```

### `GET /tools/deepsearch/{uid}/session` - DeepSearch report

Full cited research report: summary, findings, gaps, sources and metrics. Negotiates HTML or JSON.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | DeepSearch job uid of a finished run. |

**Sample response**

```json
{
  "uid": "DEEPSEARCH_JOB_UID",
  "status": "done",
  "query": "history of the transistor",
  "score": 78,
  "confidence": 0.72,
  "source_diversity": 0.64,
  "page_count": 11,
  "chunk_count": 240,
  "summary": "The transistor was invented at Bell Labs in 1947...",
  "findings": [
    {
      "title": "Invention",
      "detail": "...",
      "confidence": 0.8,
      "citations": [
        1
      ]
    }
  ],
  "gaps": [
    "Limited coverage of later MOSFET developments."
  ],
  "sources": [
    {
      "url": "https://example.com",
      "title": "Example",
      "source": "httpx"
    }
  ],
  "chat_ws_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/chat",
  "export_md_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.md",
  "export_json_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.json",
  "export_pdf_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.pdf"
}
```

---

<a id="doc-push"></a>
# Web Push

Browser push notifications via the Web Push protocol. Fetch the public VAPID key, then
register a `PushSubscription` obtained from the browser's `PushManager`.

There is no server-side unsubscribe endpoint: unsubscription is handled entirely in the
browser by calling `PushManager.unsubscribe()` on the subscription. The server stops delivering
to a subscription once its push endpoint reports it as gone. These mirror the in-app
[Notifications](#doc-notifications) feed.

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `GET /push.json` - Get the public key

Return the VAPID public key for subscribing.

*Minimal role:* Public

**Sample response**

```json
{
  "publicKey": "BASE64_VAPID_KEY"
}
```

### `POST /push.json` - Register a subscription

Register a browser push subscription. Sends a welcome notification.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `endpoint` | json | string | yes | Subscription endpoint URL. |
| `keys` | json | string | yes | Subscription keys object. |

> The body must be JSON: `{"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}}`.

**Sample response**

```json
{
  "registered": true
}
```

---

<a id="doc-bugs"></a>
# Bug Reports

The bug tracker is a full integration with a Gitea repository. The listing and detail views read
issues straight from Gitea with their live status, and a report you file is first rewritten by the
internal AI service into a consistent ticket, then posted to Gitea as an issue. The original
reporter is notified when a developer replies or the status changes, and a comment posted here is
pushed to Gitea and attributed to your account.

Every endpoint follows the shared [Conventions & Errors](#doc-conventions) (auth, content
negotiation, pagination, status codes); see [Authentication](#doc-authentication) for the
four ways to sign requests.

### `GET /bugs` - List bug tickets

Render the bug board from Gitea, paginated and filterable by state.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `state` | query | string | no | Filter: open (default), closed, or all. |
| `page` | query | integer | no | 1-based page. |

**Sample response**

```json
{
  "bugs": [
    {
      "number": 0,
      "title": "Title",
      "state": "string",
      "html_url": "/path",
      "comments_count": 0,
      "created_at": "2026-01-01T00:00:00+00:00",
      "updated_at": "2026-01-01T00:00:00+00:00",
      "author_username": "username",
      "author_uid": "UID",
      "author_avatar_seed": "string",
      "is_local_author": false
    }
  ],
  "pagination": null,
  "state": "string",
  "configured": false,
  "error_message": "string"
}
```

### `POST /bugs/create` - Report a bug

Enqueue a bug report. It is enhanced by AI and filed on the tracker.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `title` | form | string | yes | Title, 1-200 characters. |
| `description` | form | textarea | yes | Description, 1-5000 characters. |

> Returns a job uid and status_url. Poll the status_url until status is done to get the bug number.

**Sample response**

```json
{
  "uid": "JOB_UID",
  "status_url": "/bugs/jobs/JOB_UID"
}
```

### `GET /bugs/jobs/{uid}` - Bug filing job status

Poll the filing job; the result carries the new bug number and url.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `uid` | path | string | yes | Job uid. |

**Sample response**

```json
{
  "uid": "JOB_UID",
  "kind": "bug_create",
  "status": "done",
  "number": 42,
  "bug_url": "/bugs/42",
  "enhanced": true,
  "error": null,
  "created_at": "2026-06-12T09:00:00+00:00",
  "completed_at": "2026-06-12T09:00:03+00:00"
}
```

### `GET /bugs/{number}` - View a bug ticket

Render a Gitea issue and its comments. Returns an HTML page.

*Minimal role:* Public

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `number` | path | integer | yes | Bug number. |

**Sample response**

```json
{
  "bug": {
    "number": 0,
    "title": "Title",
    "state": "string",
    "html_url": "/path",
    "comments_count": 0,
    "created_at": "2026-01-01T00:00:00+00:00",
    "updated_at": "2026-01-01T00:00:00+00:00",
    "author_username": "username",
    "author_uid": "UID",
    "author_avatar_seed": "string",
    "is_local_author": false
  },
  "body": "string",
  "comments": [
    {
      "id": 0,
      "body": "string",
      "html_url": "/path",
      "created_at": "2026-01-01T00:00:00+00:00",
      "author_username": "username",
      "author_uid": "UID",
      "author_avatar_seed": "string",
      "is_local_author": false
    }
  ],
  "can_comment": false,
  "viewer_is_admin": false
}
```

### `POST /bugs/{number}/comment` - Comment on a bug

Post a comment to the Gitea issue, attributed to the current user.

*Minimal role:* Member

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `number` | path | integer | yes | Bug number. |
| `body` | form | textarea | yes | Comment, 1-5000 characters. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/bugs/12",
  "data": {
    "comment_id": 1
  }
}
```

### `POST /bugs/{number}/status` - Change a bug status

Open or close the Gitea issue. Admin only.

*Minimal role:* Admin

**Parameters**

| Name | In | Type | Required | Description |
|------|----|------|----------|-------------|
| `number` | path | integer | yes | Bug number. |
| `status` | form | string | yes | New status: open or closed. |

**Sample response**

```json
{
  "ok": true,
  "redirect": "/bugs/12",
  "data": {
    "state": "closed"
  }
}
```

---
