Files
pad/web
xarmian cd2bf7f977 feat(attachments): claim markdown and plain text for an in-app text renderer
Adds the third preview predicate GitHub #1169 needs, and widens the
surface-renderer union onto the 'text' slot PLAN-2393 reserved.

`canPreviewAsText` is deliberately NOT an edit to either existing
predicate. `canOpenInViewer` (DR-16) asks what the in-app IMAGE viewer
may decode; `canBrowserPreview` (DR-5) asks what the BROWSER may be
handed in a new tab, where for `text/markdown` the honest answer stays
no because the browser downloads it. Widening DR-5 to reach #1169 would
route markdown to the Open-in-new-tab action and reproduce the exact
download-instead-of-render behaviour the issue reports. The new question
— do WE fetch the bytes and render them ourselves — is the only one
#1169 asks, and it gets its own predicate. `canBrowserPreview` and its
tests are untouched, which is the signal that nothing widened.

MIME-exact, never by category: the server's `CategoryText` CONTAINS the
force-download bucket (`text/html`, `text/javascript`,
`application/javascript` — `internal/attachments/mime.go`), so a
category test would admit exactly the types PLAN-2393 DR-6 forbids
inlining. An allowlist excludes them by construction.

Deliberately outside the server mirror: `inlineSafe` in mime.go declares
itself the mirror of `VIEWER_MIMES` + `BROWSER_PREVIEW_MIMES`, the set
the server may send `Content-Disposition: inline`. This set must never
join it — we never ask the browser to inline these bytes, we `fetch()`
them (which a download disposition does not impede) and render sanitized
HTML ourselves. That is what lets `text/markdown` preview in-app while
still being served as an attachment.

`isMarkdownAttachment` consults the FILENAME, and that is the path that
actually fires rather than belt-and-braces: an uploaded `.md` is stored
as `text/plain`. `ValidateUpload` sniffs the bytes with
`http.DetectContentType`, which answers `text/plain` for prose, and
returns the SNIFFED entry; the extension is used only to REJECT a
mismatch, and `.md` → `text/markdown` shares `CategoryText`, so nothing
rejects. Measured: `ValidateUpload([]byte("# Heading\n..."),
"preview.md")` returns `mime="text/plain"`. The MIME check stays first
(an explicitly-typed row is honoured whatever it is named); the
extension fallback is GATED on the MIME already being in the set, so a
filename can never widen what previews. Server side filed as BUG-2841.

Set is smaller than what we could render: csv/tsv/json/xml/yaml/toml are
allowlisted uploads and all left out, because each has an obviously
better rendering this unit does not build, and shipping them raw now
would make that rendering a regression later. Tests pin the exclusions
with that reason.

`TEXT_PREVIEW_MAX_BYTES` carries its receipt: measured over 25 repo
`*.md` files (p50 3.6 KB, max 82 KB) and 63 workspace doc bodies (p50
5.0 KB, max 23 KB); 1 MiB is ~12x the largest observed, sits under the
25 MiB upload bound so it has a live range, and errs toward rendering.
The comment states the limit honestly — the cap always bounds what is
RENDERED, but it saves the TRANSFER only when the size is known before
the load.

One existing assertion changed meaning rather than breaking: the
renderer test asserted `text/plain` has no renderer. It now asserts
'text'. That is the contract change, made explicitly and annotated. Two
force-download assertions are labelled as regression guards rather than
evidence — they pass against origin/main too.

Refs IDEA-2712
2026-09-01 03:44:02 +00:00
..
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00

Pad Web UI

SvelteKit 2 + Svelte 5 frontend for Pad, compiled to static files and embedded into the Go binary.

Development

npm install
npm run dev          # Dev server at localhost:5173 (proxies API to localhost:7777)
npm run build        # Production build to build/
npm run check        # Type checking with svelte-check

When developing, run the Go backend separately with make dev from the project root.

Building for Production

Do not build in isolation. Always use make build from the project root — this builds the web frontend, then compiles the Go binary with the build output embedded via //go:embed.

Stack

  • Svelte 5 with runes ($state, $derived, $effect)
  • SvelteKit 2 with adapter-static (SPA mode)
  • Tiptap block editor with markdown round-trip
  • svelte-dnd-action for drag-and-drop in board/list views
  • SSE for real-time updates
  • TypeScript throughout

Structure

src/
  routes/                    SvelteKit pages
    +layout.svelte           App shell (sidebar + main)
    +page.svelte             Landing/redirect
    [workspace]/
      +page.svelte           Dashboard (collections, phases, activity)
      +layout.svelte         SSE connection per workspace
      [collection]/
        +page.svelte         Collection view (board/list)
      [collection]/[item]/
        +page.svelte         Item detail + editor
      conventions/            Purpose-built conventions page
      playbooks/              Purpose-built playbooks page
      settings/               Workspace settings
  lib/
    api/client.ts            HTTP API client
    components/
      layout/                Sidebar, navigation
      editor/                Tiptap editor, raw markdown editor
      fields/                FieldEditor, relation picker
      items/                 ItemCard, ItemDetail
      collections/           BoardView, ListView
      common/                StatusBadge, badges, modals
      search/                CommandPalette
    stores/                  Svelte 5 reactive stores
      workspace.svelte.ts    Workspace state
      collections.svelte.ts  Collection + item state
      ui.svelte.ts           Sidebar, mobile state
    types/index.ts           TypeScript types and constants
  app.css                    Global styles and design tokens