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 asappand reachable everywhere. The browser-side application root isApplication.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 asapp, accessible everywhere. - Never use JavaScript 3rd party frameworks unless specified.
- CDN scripts referenced from
base.html(marked, highlight.js, emoji-picker-element) MUST usedeferortype="module"- otherwisewait_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 thestatic_url(path)Jinja global (templating.py) and runtime JS that builds an absolute static URL usesassetUrl(path)(static/js/assetVersion.js, reads<meta name="asset-version">rendered inbase.html). Both emit a/static/v<STATIC_VERSION>/...path segment (config.STATIC_VERSION=DEVPLACE_STATIC_VERSIONenv orint(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 underimmutable, so a deploy would not propagate JS for a year. A path segment is inherited automatically by the entire module graph and by relative CSSurl(). - Serving:
main.pymounts aCachedStaticFilesat/static/v{STATIC_VERSION}(immutable 1y;service-worker.js->no-cache) plus the plain/staticmount as the unversioned fallback; nginx mirrors this withlocation ~ ^/static/v\d+/(immutable 1y) and a shortmax-age=3600on the unversioned/static/./static/uploads/(user content, stable DB paths) is never versioned;service-worker.jskeeps a stable unversioned URL withno-cacheso 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 inmake prod=$(date +%s)and the Dockerfile'ssh -cCMD) - 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.
- Path segment, never a query string - do NOT switch this to
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 thinHTMLElementbase withattr(name, fallback),boolAttr(name),intAttr(name, fallback)helpers. - Light DOM only (no
attachShadow), so the site's global CSS applies - matching the existingdevii-*elements.dp-dialog/dp-context-menureuse the existing.dialog-*/.context-menu*styles incomponents.css;dp-toaststyles are incomponents.css(.dp-toast-host/.dp-toast). - Each file self-registers via
customElements.defineat the bottom.components/index.jsimports them all and is imported byApplication.js, so every element is defined on every page (including/docs). - The behavioural singletons are created once in
Application.jsand exposed asapp.dialog,app.contextMenu,app.toast. Thedp-dialog/dp-context-menuelements keep the exact pre-existingDialog/ContextMenuAPIs (confirm/prompt/alert/open,attach/open/close) so existing callers (ProjectFiles,ApiKeyManager,ModalManager,PushManager,ApiTester) are unchanged.Avatar/CodeBlock/Toast/ContentRendererremain as static/service modules (widely imported); the matching elements (dp-avatar,dp-code,dp-content) reuse them.dp-contentwraps the sharedcontentRendererengine that also powers thedata-renderattribute (driven byContentEnhancer) - the engine stays a reusable service, the element is its component face.dp-contentcaptures its raw markdown source beforecontentRenderer.applyTooverwritestextContent, then mounts a hover/focus reveal.content-copy-btnin its top-right corner that copies that original source to the clipboard (mirroringCodeBlock.copyButton); CSS lives beside.code-copy-btnindocs.css. dp-content/dp-titleare CLIENT-only now (live contexts).dp-content(AppContent.js) and its inline companiondp-title(AppTitle.js,contentRenderer.renderInline) render markdown/emoji/media (and, for title, inline-only with an inline-tag allowlist) on the client fromtextContent. They are no longer used for server-rendered content/titles - those moved to the backendrender_content/render_titleglobals (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-contentin 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 backendrender_content/render_titleglobals 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_*.htmlmarkup 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 sharedOptimisticActionbase - 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 underSECTION_COMPONENTSin therouters/docspackage). Each page has adata-rendermarkdown block (with example markup HTML-escaped as<dp-...>, sincedata-renderre-parsestextContentand would otherwise instantiate the example) plus a live demo in a separate block whose<script type="module">imports its own component module (it runs beforeApplication.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 delegateddocumentclick listener and opens any clickedimg[data-lightbox](showingdata-fullwhen present, else the image's own src;altas caption; ignored when the image sits inside an<a>). To make a thumbnail open it, just adddata-lightbox- never attach a click handler. Attachment thumbnails carrydata-lightbox+data-full(_attachment_display.html); markdown content images are marked centrally inContentEnhancer.initContentRenderer(one place covers everydata-rendersurface), so embedded images are always clickable. The old hand-rolled.attachment-lightboxmarkup/CSS was dead (never wired) and has been removed.dp-upload(the single file-upload button used everywhere) replaced the oldAttachmentUploader; 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, globalwindow.Http). The single HTTP helper.getJson(url)(GET -> JSON, throws on non-2xx);sendForm(url, params)(POST form-encoded, follows the/auth/loginredirect viaHttp.toLogin(), throws a bare status on failure, returns JSON);send(url, params)(POST form-encoded that throwsdata.error.messageon!okor a 200 body withok:false- the manager-style error the container/admin UIs surface in a toast);postJson/postForm/toLogin. Container files (ContainerManager,ContainerList,ContainerInstance,ContainerTerminal),ServiceMonitor, andProjectFilesall route through it - none re-implementfetch.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 inlinenew Promise(() => {})copies and is whattoLogin()and the terms gate both return.TermsGate(static/js/TermsGate.js,app.termsGate). The single client-side handler for the terms-acceptance refusal.HttpcallsHttp._gate(data, options, retry)on every POST helper (sendForm,send,postJson); when the error payload carriescode: "terms_acceptance_required"it hands off toapp.termsGate.intercept(error, retry), which shows one dialog (Accept and continue / Not now, with the Terms, Guidelines and Privacy links), POSTs/auth/accept-termson accept, and then re-runs the original request so the click the user made actually happens. Declining returnsHttp.suspend(). Load-bearing details: the handoff runs before theoptions.silentcheck, because the fourOptimisticActioncontrollers (vote/react/bookmark/poll) passsilent: trueand would otherwise swallow a blocking gate into a 1.5s "Error" flash;options.termsRetrybounds the retry to exactly one pass; andconfirm()/accept()are each deduped by a stored promise so N concurrent gated requests produce one dialog and one acceptance POST.dp-uploadbypassesHttp(it needsFormData), so it checksapp.termsGate.matches(data)itself - any other raw-fetchcaller must do the same. Never add a per-caller terms check: the backend contract lives inrouters/auth/terms.pyTERMS_ACCEPTANCE_CODEand is documented indevplacepy/services/moderation/CLAUDE.md.Poller(static/js/Poller.js).new Poller(fn, intervalMs, { immediate = true, pauseHidden = false })runsfnon an interval withstart()/stop()/tick();tick()swallows errors so one failed poll never kills the loop, andpauseHiddenskips the tick whiledocument.hidden. Used by every live-update loop:CounterManager(30s,pauseHidden),ContainerManager(3s),ContainerList(4s),AiUsageMonitor,ServiceMonitor, andContainerInstance's detail (4s) + logs (3s). Store thePoller, not a raw interval id.JobPoller(static/js/JobPoller.js).JobPoller.run(statusUrl, { onDone, onFailed, onTimeout, intervalMs = 1500, maxAttempts = 200 })returns a Promise; it pollsHttp.getJson(statusUrl), swallows transient fetch errors, and fires the matching callback onstatus === "done"|"failed"or timeout. This is the one place the async-job status-poll lives -ProjectForkerandZipDownloaderboth 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+ (whenerrorTargetis given)Toast.flash(errorTarget, "Error", 1500)on failure.VoteManager/ReactionBar/BookmarkManager/PollManagerextendit and callthis.submit(...)for their POST, keeping their own event wiring (soReactionBar's palette toggle andPollManager's multi-action handlers andVoteManager's per-buttonstopPropagationare untouched). PasserrorTargetonly where the old code toasted (VoteManager); the others passnullto keep their console-only behaviour.EmojiPickerElement(static/js/EmojiPickerElement.js). The single wrapper around the vendoredemoji-picker-element:EmojiPickerElement.load()lazily imports the vendor module once (shared promise, failures swallowed) andEmojiPickerElement.create(onSelect)returns a configured<emoji-picker>(data sourcestatic/vendor/emoji-picker-element/data.json) that callsonSelect(unicode)onemoji-click. Both consumers use it:EmojiPicker(insert at cursor in a textarea) andReactionBar(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 extendFloatingWindow.PresenceManager(static/js/PresenceManager.js,app.presence) andAvatar(static/js/Avatar.js). The single online-status renderer and the single avatar-markup builder.PresenceManagermakes ONE subscription topublic.presence.rosterand 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 oftemplates/_presence_dot.html) and is used byOnlineUsersanddp-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 setshistory.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 insessionStorage(per-tab by definition, exactly the required scope): a position map keyed by exactpathname + search(hash ignored; saved by a throttled passive scroll listener plus a final write onpagehide/hiddenvisibilitychange, 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 typeback_forward(browser back without bfcache; with bfcache -pageshowpersisted- 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 ona.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#fragmentalways wins over restoration. How it restores reliably: arequestAnimationFrameloop re-applies the target position (clamped to the currentscrollHeight,behavior: "instant"so the globalhtml { 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 firstwheel/touchstart/keydown/pointerdownso 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-lessa.back-link/[data-scroll-back]href (post page's/feedvs 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 theback-linkclass (ordata-scroll-back) and it participates automatically - never hand-rollscrollTopersistence per page. Guarded bytests/e2e/feed.py::test_feed_scroll_restored_via_back_link/_via_browser_back/_not_restored_on_fresh_visit.- On-screen keyboard reflow (mobile).
FloatingWindowlistens onwindow.visualViewportresize/scrolland, while fullscreen/maximized, sets the window inlineheight/topto the visual viewport (the area NOT covered by the phone keyboard) instead of letting the keyboard push the window off-screen; it also toggles thefw-keyboard-visibleclass whose CSS drops the fixedbottomso the inline size wins. The Devii terminal mirrors this in its own_setState/_onVisualViewport2(devii-keyboard-visible) and_ensureInputVisiblekeeps the focused.devii-inputpinned just above the keyboard;ContainerTerminal._onResizere-fits xterm against the shrunken viewport andscrollToBottoms.AppContextMenu.openclamps 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 truewindow.innerWidthchange closes it. All paths fall back towindow.inner*whenvisualViewportis 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  is appended to the post content. The ContentRenderer then renders it as an <img>. All URLs are relative.
content += f"\n\n"
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.htmlandmessages.html): uploads each file to/uploads/uploadon select and keeps the hidden<input name="attachment_uids">the routers already link - server contract unchanged. Limits come frommax-size/max-files/allowed-typesattributes (fed by the Jinja globals).direct(the project file browser,project_files.html): uploads to a customendpointwithfield-nameplus a settableextraFields(e.g.{path}) and emitsdp-upload:uploaded/dp-upload:done/dp-upload:error.ProjectFiles.uploadTo(dir)setsextraFieldsthen callswidget.open(), and refreshes the tree ondone.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.