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 scrollToBottoms. 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:

{% 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.

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 modes:

  • 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.

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.