This file documents JS module organization, custom web components, and shared frontend utilities under `static/js/`. Claude Code auto-loads it whenever a file under this directory is read or edited.

## JS module organization (hard rules)

- All JS in `static/js/` is ES6 modules, one class per file, instantiated as `app` and reachable everywhere. The browser-side application root is `Application.js`.
- Javascript files must be in module (ES6) format and imported as module format. It must be as object oriented as possible and a file per class. There must always be a main application class called `Application`, instantiated as `app`, accessible everywhere.
- Never use JavaScript 3rd party frameworks unless specified.
- CDN scripts referenced from `base.html` (marked, highlight.js, emoji-picker-element) MUST use `defer` or `type="module"` - otherwise `wait_until="domcontentloaded"` in Playwright tests will time out.
- **Static asset URLs are boot-versioned for cache-busting.** Assets are served `public, immutable, max-age=31536000` (1 year, for the Lighthouse "efficient cache policy" score) while a restart still busts every browser. Never hardcode a bare `/static/...` href/src: templates wrap it in the `static_url(path)` Jinja global (`templating.py`) and runtime JS that builds an absolute static URL uses `assetUrl(path)` (`static/js/assetVersion.js`, reads `<meta name="asset-version">` rendered in `base.html`). Both emit a `/static/v<STATIC_VERSION>/...` path segment (`config.STATIC_VERSION` = `DEVPLACE_STATIC_VERSION` env or `int(time.time())` at process start), so a deploy = a restart = a new timestamp = a new URL for every asset. Both helpers no-op for non-`/static/` paths and for `/static/uploads/`, so they are safe to wrap around dynamic values.
  - **Path segment, never a query string - do NOT switch this to `?v=`.** The frontend is unbundled ES6 with ~130 relative imports (`./Http.js`) and zero absolute ones. A `?v=` only versions the entry `<script>`; its transitive relative imports would resolve to unversioned URLs and stay frozen under `immutable`, so a deploy would not propagate JS for a year. A path segment is inherited automatically by the entire module graph and by relative CSS `url()`.
  - **Serving:** `main.py` mounts a `CachedStaticFiles` at `/static/v{STATIC_VERSION}` (immutable 1y; `service-worker.js` -> `no-cache`) plus the plain `/static` mount as the unversioned fallback; nginx mirrors this with `location ~ ^/static/v\d+/` (immutable 1y) and a short `max-age=3600` on the unversioned `/static/`. `/static/uploads/` (user content, stable DB paths) is never versioned; `service-worker.js` keeps a stable unversioned URL with `no-cache` so its registration scope is stable and a new worker always deploys.
  - **Multi-worker:** the version is captured **once at launch** and shared via `DEVPLACE_STATIC_VERSION` (set in `make prod` = `$(date +%s)` and the Dockerfile's `sh -c` CMD) - otherwise two workers could compute timestamps a second apart and serve mismatched versioned mounts (a 404 on the other worker). Unset in dev (`--reload`) so each reload refreshes it.

## Custom web components (`static/js/components/`)

Self-contained, presentational UI is built as custom elements with the `dp-` prefix:
`dp-avatar`, `dp-code`, `dp-content`, `dp-title`, `dp-toast`, `dp-dialog`, `dp-context-menu`, `dp-upload`, `dp-lightbox`, `dp-chat`, `dp-quiz-player`, `dp-quiz-builder`.

`dp-quiz-player` (`AppQuizPlayer.js`) and `dp-quiz-builder` (`AppQuizBuilder.js`) both **adopt** server-rendered markup rather than building it (the `dp-chat` `mode="page"` pattern): the player intercepts each question's real `<form method="post">` through `Http.sendForm`, renders the graded result in place, shows a `Checking…` state while a `free_text` grade is in flight, ticks the remaining time from `Format.duration`, and advances to the next slide; the builder toggles the kind-specific field groups from the question-kind picker and submits the add-question form through `Http.sendForm`. Neither re-renders a prompt (the server already rendered it through `render_content`), neither opens a WebSocket, and neither implements a fetch wrapper, a poller or a dialog. Removing the JS leaves a fully working no-JS quiz.

**Prompt seeding on the shared Devii opener (`data-devii-prompt`).** `DeviiTerminal.bindTriggers` binds every `[data-devii-open]` element; it now reads `trigger.dataset.deviiPrompt` and passes it to `DeviiTerminal.open(prompt)`, which calls `this.element.open()` and then `devii-terminal.prefill(text)` (sets `this.input.value`, moves the caret to the end, focuses). **It never auto-sends** - the member reads the request and presses Enter, keeping the assistant's first action explicitly user-initiated. This is a platform-wide capability available to any page, not a quiz-only shim: add `data-devii-prompt="..."` beside `data-devii-open` and the terminal opens pre-filled.

`dp-chat` (`AppChat.js`) is the Slack-like DM chat widget that fully replaced the old page-controller trio `MessagesLayout.js`/`MessagesSocket.js`/`MessageSearch.js` (deleted) and `static/css/messages.css` (superseded by `static/css/chat.css`) on `/messages`. Unlike every other component here, it does NOT build its DOM from scratch on `connectedCallback` when server-rendered light-DOM children already exist (`mode="page"`) - it **adopts** the existing `.messages-list`/`.messages-main`/`.messages-thread`/`.message-bubble` markup `templates/messages.html` still renders (so no-JS and crawler fallback both keep working) and only builds a from-scratch skeleton when none is present (`mode="embed"`, a standalone `<dp-chat mode="embed" self-uid="..." with-uid="..." send-url="..." ws-url="...">` usable outside `/messages`). It owns its own `ChatSocket` instance, the conversation search dropdown (absorbed from the old `MessageSearch.js`, still built on the shared `ListNav` utility), consecutive-message grouping (`chat/MessageGrouping.js`), a working optimistic send with `.pending`/`.failed` (tap-to-retry) bubble states, and an opt-in (`ai-indicator` attribute) "Adjusted by AI" caption shown when a reconciled echo's `ai_processed` frame flag is true and the content changed from what was locally typed - never a diff/revert UI, the backend keeps no pre-correction copy to diff against. Presence needs no component code at all: the adopted markup keeps its `data-presence-uid` attributes, so the page-global `PresenceManager` (below) already drives it, and the conversation list it builds live uses the shared `Avatar.badgeElement(user)` rather than hand-rolled dot markup. In `mode="embed"`, where no page-global instance may exist, it constructs a root-scoped `PresenceManager` instead of re-implementing presence - `dp-chat` owns no presence logic of its own. The message body itself keeps rendering through `<dp-content>` (see "CLIENT-only now" below) - the redesign only removed the historical `no-copy` attribute on message bubbles so `dp-content`'s own existing `.content-copy-btn` doubles as the hover/focus-reveal action toolbar's Copy button (§6.3 of the design doc it was built from), instead of a second copy mechanism being hand-rolled.

`static/js/chat/ChatSocket.js` and `static/js/chat/MessageGrouping.js` are chat-scoped helpers used only by `AppChat.js` - `ChatSocket` is modeled on `PubSubClient.js`'s real exponential backoff (200ms doubling to a 5000ms cap, reset on a successful connect) rather than the flat-delay retry the deleted `MessagesSocket.js` used, and preserves the same `4013` "wrong worker" fast-retry special case. `MessageGrouping.shouldGroup(previous, current)` is the one pure predicate (<=300s gap, same sender) shared by both the initial-history grouping pass and the live-append path, so the two can never disagree. Neither file is a general-purpose "do not reimplement" utility for the rest of the app (see the out-of-scope note in the design document this shipped from) - they are not added to the do-not-reimplement list below; a future non-chat WebSocket feature should keep hand-rolling its own client (as `DeviiSocket.js`/`DeepsearchProgressSocket.js`/`SeoProgressSocket.js`/`AppDeepsearchChat.js`'s inline socket already do) rather than reaching into `chat/`.

Conventions:

- Each extends `Component` (`static/js/components/Component.js`), a thin `HTMLElement` base with `attr(name, fallback)`, `boolAttr(name)`, `intAttr(name, fallback)` helpers.
- **Light DOM only** (no `attachShadow`), so the site's global CSS applies - matching the existing `devii-*` elements. `dp-dialog`/`dp-context-menu` reuse the existing `.dialog-*`/`.context-menu*` styles in `components.css`; `dp-toast` styles are in `components.css` (`.dp-toast-host`/`.dp-toast`).
- Each file self-registers via `customElements.define` at the bottom. `components/index.js` imports them all and is imported by `Application.js`, so every element is defined on every page (including `/docs`).
- The behavioural singletons are created once in `Application.js` and exposed as `app.dialog`, `app.contextMenu`, `app.toast`. The `dp-dialog`/`dp-context-menu` elements keep the exact pre-existing `Dialog`/`ContextMenu` APIs (`confirm`/`prompt`/`alert`/`open`, `attach`/`open`/`close`) so existing callers (`ProjectFiles`, `ApiKeyManager`, `ModalManager`, `PushManager`, `ApiTester`) are unchanged. `Avatar`/`CodeBlock`/`Toast`/`ContentRenderer` remain as static/service modules (widely imported); the matching elements (`dp-avatar`, `dp-code`, `dp-content`) reuse them. `dp-content` wraps the shared `contentRenderer` engine that also powers the `data-render` attribute (driven by `ContentEnhancer`) - the engine stays a reusable service, the element is its component face. `dp-content` captures its raw markdown source before `contentRenderer.applyTo` overwrites `textContent`, then mounts a hover/focus reveal `.content-copy-btn` in its top-right corner that copies that original source to the clipboard (mirroring `CodeBlock.copyButton`); CSS lives beside `.code-copy-btn` in `docs.css`.
- **`dp-content` / `dp-title` are CLIENT-only now (live contexts).** `dp-content` (`AppContent.js`) and its inline companion `dp-title` (`AppTitle.js`, `contentRenderer.renderInline`) render markdown/emoji/media (and, for title, inline-only with an inline-tag allowlist) on the client from `textContent`. **They are no longer used for server-rendered content/titles** - those moved to the backend `render_content`/`render_title` globals (see the root CLAUDE.md "Content rendering pipeline" section) for SEO. The components remain defined and are used only where content is generated client-side after load: `dp-content` in the DeepSearch chat (`AppDeepsearchChat.js`) and the planning report (`PlanningGenerator.js` -> `admin_issues_planning.html`). Keep them for those live cases and future use; do NOT wrap server-known content/titles in them. Both are now used ONLY for live/client-generated content (chat, planning report, dynamically inserted bubbles); server-rendered content/titles use the backend `render_content`/`render_title` globals instead. The components are intentionally kept for those live contexts and future use.
- **What is NOT a component (by design):** partial-bound controllers (`ReactionBar`, `VoteManager`, `BookmarkManager`, `PollManager`, `CommentManager`, etc.) enhance server-rendered `_*.html` markup rather than render standalone UI; pure utilities (`Http`, `DomUtils`, `FormManager`, `Poller`, `JobPoller`, `OptimisticAction`) have no UI. These stay plain ES6 modules. The four optimistic engagement controllers extend the shared `OptimisticAction` base - see "Shared frontend utilities" below. Self-contained presentational widgets become components; partial-bound controllers (votes, reactions, comments) and pure utilities stay plain modules because they enhance server-rendered markup rather than render standalone UI.
- Documented in the public docs **Components** section (`templates/docs/component-*.html`, registered under `SECTION_COMPONENTS` in the `routers/docs` package). Each page has a `data-render` markdown block (with example markup HTML-escaped as `&lt;dp-...&gt;`, since `data-render` re-parses `textContent` and would otherwise instantiate the example) plus a live demo in a separate block whose `<script type="module">` imports its own component module (it runs before `Application.js`).
- **`dp-lightbox` (`AppLightbox.js`, `app.lightbox`, `static/css/lightbox.css`)** is the single image lightbox. It is **attribute-wired, not call-wired**: the singleton installs ONE delegated `document` click listener and opens any clicked `img[data-lightbox]` (showing `data-full` when present, else the image's own src; `alt` as caption; ignored when the image sits inside an `<a>`). To make a thumbnail open it, just add `data-lightbox` - never attach a click handler. Attachment thumbnails carry `data-lightbox`+`data-full` (`_attachment_display.html`); markdown content images are marked centrally in `ContentEnhancer.initContentRenderer` (one place covers every `data-render` surface), so embedded images are always clickable. The old hand-rolled `.attachment-lightbox` markup/CSS was dead (never wired) and has been removed.
- `dp-upload` (the single file-upload button used everywhere) replaced the old `AttachmentUploader`; see "The upload button (`dp-upload`)" below.

## Shared frontend utilities (do not re-implement these)

A small set of plain ES6 modules under `static/js/` own the cross-cutting patterns so feature code stays tiny. Reach for these instead of hand-rolling a loop, a fetch, or a click handler. `Http` (`static/js/Http.js`) is the single fetch helper (`getJson`, `sendForm`, and `send` which throws `error.message` on non-2xx or a `200 {ok:false}` body); every live-update loop uses `Poller` (`new Poller(fn, intervalMs, {pauseHidden})`); async-job status polls use `JobPoller.run(statusUrl, {onDone, onFailed, onTimeout})` (`ProjectForker`, `ZipDownloader`); click-to-POST engagement controllers (`VoteManager`/`ReactionBar`/`BookmarkManager`/`PollManager`) extend `OptimisticAction` and call `this.submit(url, params, errorTarget, render)`. Floating windows (container terminals and Devii) extend `FloatingWindow`. **Scroll restoration is `ScrollMemory`** (`static/js/ScrollMemory.js`, `app.scrollMemory`): per-tab (sessionStorage) positions keyed by exact `path+search`, restored ONLY on `back_forward`/`reload` navigations or a click on `a.back-link`/`[data-scroll-back]`/breadcrumb/previous-trail-URL links, applied via a layout-stable rAF loop that aborts on user input; it sets `history.scrollRestoration = "manual"` site-wide and upgrades query-less back-link hrefs to the exact previous URL - mark any "back to X" anchor with the `back-link` class and never hand-roll scroll persistence.

Detail on each utility:

- **`Http` (`static/js/Http.js`, global `window.Http`).** The single HTTP helper. `getJson(url)` (GET -> JSON, throws on non-2xx); `sendForm(url, params)` (POST form-encoded, follows the `/auth/login` redirect via `Http.toLogin()`, throws a bare status on failure, returns JSON); `send(url, params)` (POST form-encoded that throws `data.error.message` on `!ok` **or** a 200 body with `ok:false` - the manager-style error the container/admin UIs surface in a toast); `postJson`/`postForm`/`toLogin`. Container files (`ContainerManager`, `ContainerList`, `ContainerInstance`, `ContainerTerminal`), `ServiceMonitor`, and `ProjectFiles` all route through it - none re-implement `fetch`.
  `Http.suspend()` is the named "we are resolving this elsewhere, do not let the caller render an error" idiom (a promise that never settles). It replaced three inline `new Promise(() => {})` copies and is what `toLogin()` and the terms gate both return.
- **`TermsGate` (`static/js/TermsGate.js`, `app.termsGate`).** The single client-side handler for the terms-acceptance refusal. `Http` calls `Http._gate(data, options, retry)` on **every** POST helper (`sendForm`, `send`, `postJson`); when the error payload carries `code: "terms_acceptance_required"` it hands off to `app.termsGate.intercept(error, retry)`, which shows one dialog (Accept and continue / Not now, with the Terms, Guidelines and Privacy links), POSTs `/auth/accept-terms` on accept, and then **re-runs the original request** so the click the user made actually happens. Declining returns `Http.suspend()`.
  Load-bearing details: the handoff runs **before** the `options.silent` check, because the four `OptimisticAction` controllers (vote/react/bookmark/poll) pass `silent: true` and would otherwise swallow a blocking gate into a 1.5s "Error" flash; `options.termsRetry` bounds the retry to exactly one pass; and `confirm()`/`accept()` are each deduped by a stored promise so N concurrent gated requests produce one dialog and one acceptance POST. `dp-upload` bypasses `Http` (it needs `FormData`), so it checks `app.termsGate.matches(data)` itself - any other raw-`fetch` caller must do the same. Never add a per-caller terms check: the backend contract lives in `routers/auth/terms.py` `TERMS_ACCEPTANCE_CODE` and is documented in `devplacepy/services/moderation/CLAUDE.md`.
- **`Poller` (`static/js/Poller.js`).** `new Poller(fn, intervalMs, { immediate = true, pauseHidden = false })` runs `fn` on an interval with `start()`/`stop()`/`tick()`; `tick()` swallows errors so one failed poll never kills the loop, and `pauseHidden` skips the tick while `document.hidden`. Used by every live-update loop: `CounterManager` (30s, `pauseHidden`), `ContainerManager` (3s), `ContainerList` (4s), `AiUsageMonitor`, `ServiceMonitor`, and `ContainerInstance`'s detail (4s) + logs (3s). Store the `Poller`, not a raw interval id.
- **`JobPoller` (`static/js/JobPoller.js`).** `JobPoller.run(statusUrl, { onDone, onFailed, onTimeout, intervalMs = 1500, maxAttempts = 200 })` returns a Promise; it polls `Http.getJson(statusUrl)`, swallows transient fetch errors, and fires the matching callback on `status === "done"|"failed"` or timeout. This is the one place the async-job status-poll lives - `ProjectForker` and `ZipDownloader` both call it with their own navigate/download/toast callbacks.
- **`OptimisticAction` (`static/js/OptimisticAction.js`).** Base with one method, `submit(url, params, errorTarget, render)`: `Http.sendForm` -> `render(result)` on success -> `console.error` + (when `errorTarget` is given) `Toast.flash(errorTarget, "Error", 1500)` on failure. `VoteManager`/`ReactionBar`/`BookmarkManager`/`PollManager` `extend` it and call `this.submit(...)` for their POST, **keeping their own event wiring** (so `ReactionBar`'s palette toggle and `PollManager`'s multi-action handlers and `VoteManager`'s per-button `stopPropagation` are untouched). Pass `errorTarget` only where the old code toasted (`VoteManager`); the others pass `null` to keep their console-only behaviour.
- **`EmojiPickerElement` (`static/js/EmojiPickerElement.js`).** The single wrapper around the vendored `emoji-picker-element`: `EmojiPickerElement.load()` lazily imports the vendor module once (shared promise, failures swallowed) and `EmojiPickerElement.create(onSelect)` returns a configured `<emoji-picker>` (data source `static/vendor/emoji-picker-element/data.json`) that calls `onSelect(unicode)` on `emoji-click`. Both consumers use it: `EmojiPicker` (insert at cursor in a textarea) and `ReactionBar` (react with any emoji). Never build an `<emoji-picker>`, re-import the vendor module, or repeat the data-source path elsewhere.
- **`FloatingWindow` / `WindowManager` (`static/js/components/`).** The draggable window base and shared z-order manager - documented in the Container manager section; both the container terminals and the Devii terminal extend `FloatingWindow`.
- **`PresenceManager` (`static/js/PresenceManager.js`, `app.presence`) and `Avatar` (`static/js/Avatar.js`).** The single online-status renderer and the single avatar-markup builder. `PresenceManager` makes ONE subscription to `public.presence.roster` and drives EVERY `[data-presence-uid]` element from the pushed online uid set - there is no client-side clock and no expiry timer, so nothing can disagree with the feed's Online now panel. `Avatar.badgeElement(user)` builds the `.avatar-badge` + `.presence-dot` + award-badge trio (the JS twin of `templates/_presence_dot.html`) and is used by `OnlineUsers` and `dp-chat`. Never re-derive online status from a timestamp in feature code, and never hand-build a presence dot.
- **`ScrollMemory` (`static/js/ScrollMemory.js`, `app.scrollMemory`).** Site-wide, per-tab scroll restoration - the fix for the "back to feed jumps to top" class of bugs. It sets `history.scrollRestoration = "manual"` once (never rely on the browser's auto-restore, which fires before late-loading content settles and never applies to normal link navigations), so ALL scroll restoration flows through this one module. State lives in `sessionStorage` (per-tab by definition, exactly the required scope): a position map keyed by exact `pathname + search` (hash ignored; saved by a throttled passive scroll listener plus a final write on `pagehide`/hidden `visibilitychange`, pruned to 50 entries / 60 min), a visited-URL trail (capped at 20), and a one-shot click-intent flag (30s validity, consumed on every load).
  **When it restores** - only when the situation is genuinely "going back": (1) navigation type `back_forward` (browser back without bfcache; with bfcache - `pageshow` `persisted` - the frozen page already has its scroll, so it only re-syncs the trail and clears the intent flag), (2) `reload`, (3) a same-origin click on `a.back-link` / `a[data-scroll-back]` / a breadcrumb link / any link whose target equals the trail's previous URL, which stamps the intent flag the next load matches. A fresh visit (topnav, address bar, redirect after POST) never restores, and a URL with a `#fragment` always wins over restoration.
  **How it restores reliably**: a `requestAnimationFrame` loop re-applies the target position (clamped to the current `scrollHeight`, `behavior: "instant"` so the global `html { scroll-behavior: smooth }` never animates it) until the document height has been stable at the target for 10 frames or a 4s deadline passes, and aborts instantly on the first `wheel`/`touchstart`/`keydown`/`pointerdown` so it never fights the user. It also **upgrades bare back-links on load**: when the previous trail URL has the same pathname as a query-less `a.back-link`/`[data-scroll-back]` href (post page's `/feed` vs the `/feed?tab=recent&before=...` the user actually came from), the href is rewritten to the exact previous URL so tab/topic/cursor AND scroll survive the round trip. Give any new "back to X" anchor the `back-link` class (or `data-scroll-back`) and it participates automatically - never hand-roll `scrollTo` persistence per page. Guarded by `tests/e2e/feed.py::test_feed_scroll_restored_via_back_link` / `_via_browser_back` / `_not_restored_on_fresh_visit`.
- **On-screen keyboard reflow (mobile).** `FloatingWindow` listens on `window.visualViewport` `resize`/`scroll` and, while fullscreen/maximized, sets the window inline `height`/`top` to the visual viewport (the area NOT covered by the phone keyboard) instead of letting the keyboard push the window off-screen; it also toggles the `fw-keyboard-visible` class whose CSS drops the fixed `bottom` so the inline size wins. The Devii terminal mirrors this in its own `_setState` / `_onVisualViewport2` (`devii-keyboard-visible`) and `_ensureInputVisible` keeps the focused `.devii-input` pinned just above the keyboard; `ContainerTerminal._onResize` re-fits xterm against the shrunken viewport and `scrollToBottom`s. `AppContextMenu.open` clamps into the visual-viewport rect (`offsetLeft/Top` + `width/height`) so Paste stays on-screen above the keyboard, and the menu no longer closes on a visualViewport-only height change (keyboard appearing) - only a true `window.innerWidth` change closes it. All paths fall back to `window.inner*` when `visualViewport` is absent, so desktop and old browsers are untouched.

## Clickable avatars and usernames

Every avatar and username in the UI links to the user's profile page. Use the `_avatar_link.html` and `_user_link.html` include components:

```html
{% set _user = item.author %}
{% set _size = 32 %}
{% set _size_class = "sm" %}
{% include "_avatar_link.html" %}

<a href="/profile/{{ user['username'] }}" class="post-author-link">{{ user['username'] }}</a>
```

The include files expect: `_user` (dict), `_size` (pixels), `_size_class` ("sm"|"md"|"lg").

Affected templates: `base.html`, `feed.html`, `post.html`, `profile.html`, `messages.html`, `notifications.html`.

For clickable avatars/usernames, reuse `templates/_avatar_link.html` and `templates/_user_link.html` via `{% include %}` with `_user`/`_size`/`_size_class` locals.

## Image upload

When a user uploads an image during post creation, the markdown `![](/static/uploads/{filename})` is appended to the post content. The ContentRenderer then renders it as an `<img>`. All URLs are relative.

```python
content += f"\n\n![](/static/uploads/{image_filename})"
```

File validation: max 5MB, allowed extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`.

## Attachments and media

`attachments.py` is the single store for files referenced via `attachment_uids` (posts, comments, projects, gists, messages, issues). One gate, `is_extension_allowed(ext)`, decides what may be uploaded and is used by both `routers/uploads.py` and `store_attachment()` - do not reintroduce a second check. Its semantics: when the admin `allowed_file_types` setting is non-empty it is the **authoritative** allowlist (mime auto-detected via `mimetypes` for extensions outside the built-in `ALLOWED_UPLOAD_TYPES` map); when empty it falls back to the built-in map, which includes images plus the browser-playable video formats `.mp4/.webm/.ogv/.mov/.m4v`. (Before this, `allowed_file_types` could only *narrow* the hardcoded list, never add to it - which is why adding a video extension there had no effect.)

**Ingesting a file from a URL.** `store_attachment_from_url(url, user_uid, filename=None)` (async, in `attachments.py`) is the remote counterpart to `store_attachment`: it downloads the URL on the server through `fetch_remote_file()` - SSRF-guarded (`_guard_public_url` resolves the host and refuses private/loopback/reserved/multicast addresses, mirroring the Devii fetch guard) and size-capped (streams, aborting once `_get_max_upload_bytes()` is exceeded) - resolves a filename from the URL path or the response `Content-Type` (`MIME_TO_EXT`), then calls `store_attachment()` so the bytes land in the **exact same** pipeline (validation, thumbnailing, DB row). It raises `RemoteFetchError(message, status)` which the route maps to an HTTP status. It is exposed at `POST /uploads/upload-url` (`UploadUrlForm{url, filename?}`, `require_user_api`) and as the Devii catalog action `attach_url` (handler `http`, `requires_auth=True`); both return the same record as `/uploads/upload`. The returned `uid` binds to a resource the same way as any upload - via `attachment_uids` at create/edit time - so attaching a remote image is just `attach_url` then `create_post`/`create_project`/etc. with that uid. Do not re-download remote files in a router; reuse this helper so the guard and size cap stay in one place.

`_row_to_attachment()` / `store_attachment()` expose `is_image` and `is_video` (derived from the mime prefix). The shared partial `templates/_attachment_display.html` branches image -> `<img>`, video -> `<video controls preload="metadata" class="gallery-video">`, else download link; rendering through this one partial is what makes video work across every feature at once.

**Every caller MUST bind `attachments` before including the partial** - `{% set attachments = item.get('attachments', []) %}` or `{% with attachments=... %}`. The partial iterates the bare name `attachments`, so a caller that only guards on `{% if item.attachments %}` and includes without binding renders the gallery from whatever `attachments` happens to be in the surrounding page context. This is not theoretical: `_post_card.html` did exactly that, so **feed and profile cards silently rendered an empty gallery for every post that had an image**, and on a project page (where `project_detail.html` sets `attachments` at template scope for the project's own files) a devlog card would have rendered the *project's* attachments as if they were the post's. Guarded by `tests/e2e/feed.py::test_feed_card_shows_the_post_image`.

**A lone attachment is a hero, not a chip.** When the gallery holds exactly one item the partial adds a `single` class, and `attachments.css` widens that item to the full content column (`max-height: 480px`, `object-fit: contain`, no hover scale) instead of the 240x200 chip a multi-item gallery uses. **The `single` branch must serve `att['url']`, never `thumbnail_url`** - a thumbnail is 200px on its longest side, so blowing it up to the column width renders visibly blurry. That is the whole reason the src is a conditional rather than "thumbnail when one exists". Because the partial is shared, this applies everywhere at once: post cards, post detail, comments, gists, projects and chat bubbles. Animated GIFs never had a thumbnail to begin with (`THUMBNAIL_EXTENSIONS` excludes `.gif`, so animation survives), which means they already took the original-file path and simply render larger now. `AttachmentOut` (`schemas.py`) carries both flags - add new display keys there too or JSON drops them.

Media is served **inline** (not forced-download) for known-safe types only. The set `INLINE_MEDIA_EXTENSIONS` in `main.py` (`UploadStaticFiles`) and the matching `map $uri $upload_disposition` in `nginx/nginx.conf.template` must stay in sync: images/video/audio -> `inline` (so `<video>` plays and seeks via Range), everything else -> `attachment`. SVG is deliberately excluded from both (stored-XSS defense). `ContentRenderer.js` embeds direct video URLs typed into content via `videoExtRe`, mirroring its image handling.

### The upload button (`dp-upload`)

Every file-upload UI is the one custom element `dp-upload` (`static/js/components/AppUpload.js`); it replaced `AttachmentUploader.js` (deleted). It shows a clean button with a count badge + removable filename chips (no thumbnail grid), and runs in one of three `mode`s:

- `attachment` (default, the 7 forms via `_attachment_form.html` / inline in `_comment_form.html` and `messages.html`): uploads each file to `/uploads/upload` on select and keeps the hidden `<input name="attachment_uids">` the routers already link - **server contract unchanged**. Limits come from `max-size`/`max-files`/`allowed-types` attributes (fed by the Jinja globals).
- `direct` (the project file browser, `project_files.html`): uploads to a custom `endpoint` with `field-name` plus a settable `extraFields` (e.g. `{path}`) and emits `dp-upload:uploaded` / `dp-upload:done` / `dp-upload:error`. `ProjectFiles.uploadTo(dir)` sets `extraFields` then calls `widget.open()`, and refreshes the tree on `done`.
- `field` (create-post image, `feed.html`): wraps a real `<input type="file" name="image">` that submits with the form - no AJAX, inline-image flow unchanged.

**Paste-to-attach is the opt-in `paste` boolean attribute**, not a per-form handler. With it set, `dp-upload` binds ONE `paste` listener on its `closest("form")` and routes the clipboard's image files through the same `handleFiles` path as the picker and the drop target, so validation, limits, the terms gate and the hidden `attachment_uids` field are shared - never re-implement a clipboard reader in a page controller. It is opt-in because a form may hold several upload buttons (`projects.html` has cover + logo beside the attachment one) and a form-wide default would attach one pasted image to all of them; mark exactly the button that owns the form's content attachments. Set on `_attachment_form.html` (so every form including it - post composer, post edit, gists, projects, issues, screenshots - inherits it), `_comment_form.html`, `messages.html`, and the `mode="embed"` skeleton `AppChat` builds. Non-image clipboard payloads fall through untouched, so pasting text still types.

The component validates size/type/count and reports errors via `app.toast`. CSS is `.dp-upload-*` in `components.css`; the old `.attachment-upload-*` upload-widget styles were removed, but the `.attachment-gallery`/`.attachment-lightbox` display styles (for already-saved attachments) remain.

## ReportDialog (`ReportDialog.js`, `app.reportDialog`)

One class, one dialog, every surface. It delegates a document-level click on `[data-report-type]` (emitted by `_report_button.html`), opens the single `#report-dialog` overlay with the standard `.visible` toggle, and submits through `Http.sendForm` to `/reports/{target_type}/{target_uid}`. The toast repeats the published response window returned by the endpoint, so the acknowledgement the user sees is the one the server actually committed to. It carries no reason list of its own - the options are server-rendered from the `REPORT_REASONS` Jinja global, so a client can never offer a reason the API would reject.
