mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
c491a4dfcd8abdae6d3c4937b6cc9e03bfb7221a
25 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
74589e98bb |
fix(attachments): make the live chip a button, not a link (TASK-2424)
From the orchestrator's pass on TASK-2424. The chip opens the options panel — it does not navigate — but it was still an <a href download> that only intercepted primary click. Middle-click and aux-click therefore opened or downloaded the file straight past the panel, which is the exact accidental download the panel exists to prevent, and screen readers announced a link for a control that opens a menu. A button has no URL to activate, so the bypass cannot exist rather than being intercepted, and it matches the strip's file tile — the same control, now with the same semantics. renderHTML stays an <a download>: that is the clipboard / read-only shape, where a link is honest and there is no panel to open. Deleted chips now use `disabled` rather than a dropped href: a disabled button receives no click or keydown at all, where the anchor's handlers still ran and had to swallow events. It is also blurred explicitly first, since a chip the user is focused on when another surface deletes the row would otherwise strand focus on an inert element. The keydown suppression moved ahead of the deleted bail so a stray Enter can never reach ProseMirror's keymap and split the paragraph the chip sits in. .file-chip carries the button reset explicitly (font: inherit), since the UA's 13.33px Arial would otherwise shrink the live chip away from the read-only one. |
||
|
|
6f8105b01d |
refactor(attachments): consolidate icon helpers onto an SVG set (TASK-2417)
Replaces the three independent emoji icon helpers on the live attachment surfaces with one mapper and one monochrome SVG icon set (PLAN-2392 DR-3, DR-3a, DR-3b). - display.ts: categoryIcon -> iconForAttachment(mime, filename), returning an icon identifier rather than an emoji. MIME first, filename extension second, generic file last -- never a question mark. isImage and formatBytes keep their signatures; StorageTab imports all three. - attachments/icons/: one currentColor-driven icon per format family, with TWO render paths over one path table -- AttachmentIcon.svelte for Svelte call sites, iconSvg() for the editor chip, which builds DOM imperatively and cannot mount a component. - attachment-chip.ts: iconForMime, iconForFilename and its local formatBytes deleted. The call site keeps its hide-zero/unknown-size conditional; the shared formatter renders "0 B" and does not grow a mode (DR-3b). - mime-families.json: the shared MIME -> family map, inside the web root because vitest cannot read outside it. A Go test asserts the server upload allowlist is fully covered by it (and carries no strays), so the two lists cannot drift silently; the web test covers one representative MIME per family plus the unknown-MIME and no-extension cases. CopyItemDialog and markdown/attachments.ts are deliberately untouched. Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC |
||
|
|
f994509289 |
feat(web): card anatomy — Chip status/priority, card tokens, violet ring, lane accents (TASK-2293) (#1023)
* feat(web): card anatomy per the refresh mock — Chip status/priority, card tokens, violet selection ring, lane accents (TASK-2293) PLAN-2290 Phase 3, PR A. ItemCard (shared by Board/List/starred/tags/roles): - Skin: --card-bg/--card-border/--radius-lg/--shadow-card; hover = border-strong (no transform — svelte-dnd-action owns card transforms); .focused becomes the mock's violet ring + glow (e2e asserts the CLASS, which is unchanged). - Anatomy: ref stays top-left; star moves to the right cluster before the kebab (ONE auto margin on the star — competing autos split the gap). - Status/priority render as Chip primitives (tinted pills; status keeps click-cycle + pulse via Chip props; labels Title Case, no more uppercase). - Tags become purple-tinted pills; leading separator before parent chip dropped (chips separate visually). - Dead CSS removed (meta-status family, status-pulse keyframes). Lane accents: columnAccentClassFor (shareView — shared with the public fork by construction) gains col-open for open/new/todo/planned; BoardView + PublicBoardView underline it --status-blue. Default underline unchanged for custom vocabularies. Gates: svelte-check 0 errors, 488 tests; board+pane screenshots verified in both themes. * fix(web): consolidate BoardView lane accents onto shared mapper + AA chip text in light theme Codex findings on #1023: (1) BoardView had its OWN columnCssClass — a fifth parallel status-color-ish map, so only public boards got col-open; it now delegates to shareView.columnAccentClassFor (in-app and public boards can't drift, and custom terminal lanes now read as done in-app too). (2) New --chip-text-mix token (100% dark / 72% light) darkens chip text on light surfaces — all chip colors verified >=5.8:1 on white (computed). * refactor(web): columnAccentClassFor moves to $lib/utils/fieldColors (Codex — dependency direction); shareView re-exports |
||
|
|
01a94d93a8 |
feat(web): Menu/MenuItem primitive — 3 menus migrated, escape-stack + portal + pointerdown dismissal (TASK-2292) (#1022)
* feat(web): Menu/MenuItem primitive — escape-stack ESC, portal mode, pointerdown outside-click (TASK-2292) PLAN-2290 Phase 2, PR 4 (final primitive). New shared machinery: - lib/components/common/Menu.svelte — anchored + portal modes (portal = fixed coords + flip/clamp, escapes card content-visibility containment), instance-scoped POINTERDOWN outside-click (structurally removes the BUG-2281 stopPropagation-on-rows detach workaround), ESC via the shared escapeStack at new priority menu=40 (one ESC closes menu before pane/drawer), roving keyboard nav, focus-in/focus-return, BottomSheet swap at 768px, --bg-raised panel skin. - lib/components/common/MenuItem.svelte — icon/hint/danger/menuitemradio rows. - lib/utils/clickOutside.ts + lib/utils/portalAction.ts — extracted from the hand-copied per-menu versions. - app.css: --bg-raised token (dark = tertiary; light = white). Migrated: ItemActionsMenu (portal mode, entire hand-rolled machinery deleted), QuickActionsMenu (anchored + sheetOnMobile, EmojiPicker exemption via exempt(), BUG-2281 workarounds removed), TopBar user menu (desktop + mobile branches deduped into one snippet; gains aria-haspopup/expanded + keyboard nav it never had). E2E locators updated to accessible-name form. Documented leave-alones: TopBar workspace-overflow menu (it IS a dndzone — conditional mount / focus-steal / pointerdown-close each break drag-reorder; in-file comments), LaneActionsMenu drill-down + WorkspaceSwitcher (Phase 3 / later). Gates: svelte-check 0 errors, 488 tests, make check green; runtime-verified via Playwright: user menu roving nav (ArrowDown x2 -> Admin), ESC closes via stack, kebab portal placement + edge-aware rows. * fix(web): Menu review fixes — form focus hand-off, drag suppression, scroll-close without refocus, resize close Codex findings on #1022: (1) QuickActions create-form now receives focus when it swaps in (the focused MenuItem unmounts on the flip); (2) clickOutside gains suppress() and TopBar's user menu passes isDragging||dragArmed so pill drags can't slam it shut (parity with the old drag guard); (3) portal scroll/resize dismissal calls onclose() directly — no trigger refocus fighting the user's scroll (parity with the old returnFocus=false); (4) resize now also closes portal menus (stale fixed coords). |
||
|
|
6422324edd |
feat(web): Button primitive + dark text-on-fill AA — 95 sites migrated (TASK-2292) (#1020)
* feat(web): Button primitive + dark text-on-fill AA fix; migrate 95 button sites (TASK-2292) PLAN-2290 Phase 2, PR 2. lib/components/common/Button.svelte — variants primary (filled --accent-primary-strong #7c4ff0, the violet band where white text passes AA 4.96:1 while staying >=3:1 vs surface — pays off the PR #1018 deferral) / secondary / ghost / danger (red tint, AA both themes); size sm/md; full attr passthrough (type=submit preserved at form sites). 95 usages across 14 files migrated (settings, conventions, playbooks x2, workspace home, console suite, modals, comment composer, EmptyState); dead scoped .btn* CSS deleted (net -291 lines). Deliberate leave-alones per file: ItemDetail action-bar strip (Phase 4 owns the pane), anchors styled as buttons, segmented controls, icon-only buttons, dashed low-emphasis affordances. Gates: svelte-check 0 errors (dead-selector warnings down 8->7), 488 web tests, make check green; screenshots reviewed both themes. * fix(web): Button class-prop merge + danger-solid variant for final confirms Codex findings on #1020: (1) caller-supplied class no longer clobbers the primitive's classes — class is destructured and merged, rest spread moved first; (2) new danger-solid variant (filled --accent-red-strong #dc2626, white text 4.83:1 AA both themes) restores destructive emphasis on the two final-confirm flows that had gone pale (OpenChildrenDialog override, conventions delete Confirm); entry-level destructive buttons keep the tint. |
||
|
|
a335033415 |
feat(web): Chip primitive + canonical fieldColors util — 48 badge sites migrated (TASK-2292) (#1019)
* feat(web): Chip primitive + canonical fieldColors util; migrate 48 badge sites (TASK-2292) PLAN-2290 Phase 2, PR 1. Extracts the first shared primitives: - lib/utils/fieldColors.ts — ONE statusColor/priorityColor (+ hasCanonicalStatus, formatFieldLabel), replacing four drifted implementations (ItemCard, fields/FieldEditor, CommandPalette, workspace home); shareView.ts re-exports it so public shares stay in lockstep. Deliberate unifications: open/new/todo/ planned -> --status-blue (was text-secondary on cards); active -> green (was cyan in palette/home); draft -> muted (was blue); rejected/cancelled/wontfix -> gray; priority medium -> text-secondary. - lib/components/common/Chip.svelte — tinted-pill primitive per the refresh mock (color-mix tint via new --chip-alpha token, colored text, dot/size/ onclick/pulse props); svelte-autofixer clean. - 48 badge usages across 15 files migrated to Chip; scoped .badge CSS deleted (net -355 lines). Deliberate leave-alones: GraphToolbar count bubble + filter toggles, stat tiles, timeline rail markers, avatars. Gates: svelte-check 0 errors, 488 web tests, make check green; board/settings screenshots verified in both themes. * fix(web): Chip button variant always preventDefaults (never navigates a parent <a>) Codex finding on #1019: an onclick Chip inside a link card would activate the link after the callback. preventDefault always (a chip is never a link); propagation intentionally continues so click-outside closers work — callers in interactive cards stopPropagation per the house pattern. |
||
|
|
841a2cb4ea |
feat(web): violet retheme — accent-primary alias, neutral scale, card tokens, radius, AA text (TASK-2291) (#1018)
* feat(web): violet retheme — accent-primary, neutral scale, card tokens, radius, AA text (TASK-2291) PLAN-2290 Phase 1, PR B. Values-only retheme in app.css + theme-color meta: - --accent-primary #9268f8 dark / #7c3aed light; --accent-blue aliased to it (~95% of its 462 sites are brand usage; categorical sites moved to --status-blue in PR A and stay blue). Dark value chosen by contrast math: AA as link text (4.85:1) while improving white-on-fill from 2.75 to 3.78 (>=3:1 UI threshold; full text-on-fill AA lands with the Phase 2 Button primitive via --text-on-accent). - Violet-biased neutral scale both themes; light mode inverts to off-white canvas (#f5f5f9) with white surfaces per the mock. - Muted/secondary text re-picked: >=5:1 on every bg token in both themes (closes TASK-2262 C9 app-wide). - --border-strong/--card-bg/--card-border/--shadow-card defined both themes (consumed from Phase 3). - Radius scale 6/4/8 -> 8/5/12; light-mode danger tuned to #dc2626 (4.83:1). - theme-color meta #4a9eff -> #8b5cf6. Verified: make check green; screenshots on 4 surfaces x 2 themes reviewed; contrast ratios computed for all text-token pairs. * fix(web): violet PWA branding (manifest/icon) + pin light accents in print block Codex review findings on #1018: manifest theme_color/background and icon.svg still carried the blue brand; print block now pins light-theme accents so dark-theme printing doesn't put the bright violet on white paper. Dark button text-on-fill AA is explicitly deferred to the Phase 2 Button primitive (tracked in TASK-2292). * fix(web): regenerate apple-touch-icon.png from the violet icon.svg (180x180) * fix(web): regenerate remaining brand rasters from violet icon.svg favicon-16/32, favicon.ico (single PNG-encoded 48px entry), icon-192 (was 0 bytes), icon-512, padicon.png (OG image, 701x701) all regenerated from the canonical icon.svg 'P' mark — the old rasters were a blue calendar design inconsistent with the linked SVG. site.webmanifest colors updated to the violet scheme. |
||
|
|
563371ee9b |
feat(web): define missing token families + zero-change drift sweep (TASK-2291) (#1017)
PLAN-2290 Phase 1, PR A. Defines --accent-red, --status-blue, --text-on-accent, --shadow-sm/md/lg, --modal-shadow, --scrim in app.css (values matching the long-standing inline fallbacks), then mechanically sweeps: - var(--accent-red, #hex) fallback forms collapsed (52+4+1 sites); 3 bare var(--accent-red) sites that previously resolved to NOTHING now render - phantom var(--color-danger, #dc2626) repointed to --accent-red - bare #ef4444/#dc2626 danger literals -> var(--accent-red) (57 files); #c0392b/#e53e3e/#dc2626 outliers unify to #ef4444 (deliberate) - shadow fallback forms collapsed to the now-defined tokens - 10 categorical literally-blue sites (status maps, burndown chart, info badge) repointed --accent-blue -> --status-blue so PR B's violet accent flip won't drag status colors Verified: svelte-check 0 errors, 488 web tests, make check green; Playwright before/after pixel diff on 4 surfaces x 2 themes — identical except the sidebar build-id string. |
||
|
|
b76abdd66b |
fix(web): full ARIA-modal isolation for the mobile detail pane (TASK-2131) (#1007)
* feat(web): full ARIA-modal isolation for the mobile detail pane (TASK-2131) Follow-up to TASK-2122. The mobile full-screen detail-pane overlay had a JS focus trap + an inert list column, but the app-shell chrome behind it stayed in the a11y tree and the pane was still just an <aside>. Complete the modal: - The mobile `.item-pane` becomes role="dialog" aria-modal="true"; the desktop split stays a bare <aside> (complementary landmark, non-modal). - MobileContextBar + BottomNav (rendered in the workspace +layout, ABOVE the pane host) are marked `inert` while a mobile overlay is up, so they leave the focus order AND the screen-reader tree. A JS trap can't constrain an SR virtual cursor and aria-modal is unevenly honored, so the background chrome must physically drop out. The chrome is a layout sibling the host can't reach by prop, so PaneHost hoists "a mobile overlay is active" into a small ref-counted store (paneOverlay.svelte.ts) the layout reads — one-way writer/reader split per CONVE-1688. Ref-counted so an overlapping route-change remount can't clear the signal early. The layout carries `inert` on display:contents wrappers (cascades to the fixed chrome, adds no box). Verified in a real browser (Playwright): mobile → dialog role + aria-modal + inert descendants unfocusable; desktop → bare aside + chrome interactive. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): exclude the pane's dialog role from foreign-modal guards (TASK-2131) The new mobile role="dialog" on the pane collided with three places that treat ANY [role="dialog"] as a *foreign* modal that owns its own ESC / focus — regressions the pane's <aside>-not-a-dialog invariant had been silently relying on: - paneFocus.ts PANE_EXEMPT_SURFACE_SELECTOR: any in-pane element matched closest('[role="dialog"]') → the whole pane read as an "exempt surface", killing the mobile Tab trap and confusing the focus-follows classifier. - The collection + item-page ESC guards querySelector('[role="dialog"]') → the pane matched itself → ESC was swallowed instead of closing/popping the pane on mobile. Fix: exclude the pane via [role="dialog"]:not(.item-pane) at all three sites (a genuinely nested dialog/menu opened FROM the pane still matches). Adds inExemptSurface unit coverage for the pane-not-exempt case. Caught by the independent Codex review pass. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): complete mobile modal isolation — banners + print (TASK-2131) Two more members of the same [role="dialog"] collision class, from the independent Codex pass: - Print: app.css @media print hides [role="dialog"] to strip overlays. The mobile pane now matches, so printing at <=768px with the pane open dropped the whole item from the printout. Exclude via :not(.item-pane) — the pane is the content being printed, not a transient overlay. - Banners: VerifyEmailBanner + ConnectBanner rendered OUTSIDE the inert wrappers, so their controls (Resend / Connect) stayed reachable by an SR virtual cursor behind the aria-modal pane — the same gap the inert of MobileContextBar/BottomNav closes. Fold them into the top inert wrapper so ALL app-shell siblings behind the overlay leave the a11y tree; only the pane (in children()) stays interactive. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
6d08b72f5d |
fix(web): thin horizontal scrollbars — set ::-webkit-scrollbar height (BUG-2127 follow-up) (#950)
The global `::-webkit-scrollbar` rule set `width: 6px` but no `height`. `width` only sizes vertical scrollbars; a horizontal scrollbar's thickness is `height`, which was unset — so horizontal scrollbars (notably the board-view lane track) fell back to the chunky ~15px browser default while every vertical scrollbar was a thin 6px. That "weirdly large horizontal scrollbar" is the real defect PR #946 papered over by making the lanes shrink-to-fill so horizontal overflow never occurred. Add `height: 6px` so horizontal and vertical scrollbars match. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
9664cd6ee5 |
feat(web): shared native-dialog Modal primitive + migrate form modals (TASK-2023) (#881)
* feat(web): shared native-dialog Modal primitive + migrate form modals (TASK-2023) * fix(web): hide closed native-dialog Modal (specificity vs UA rule) — Codex P1 |
||
|
|
5c491b78b5 |
feat(web): mobile bottom navigation (PLAN-1694) (#692)
* feat(web): mobile bottom navigation + quick-capture (PLAN-1694)
Add a mobile-only persistent bottom navigation bar that coexists with the
existing TopBar. Five slots: Dashboard, Search, Quick-capture (center +),
Activity, More. The "More" slot opens a BottomSheet with the sidebar's
overflow destinations + collections; the center action opens a quick-capture
sheet that creates an item via the existing API.
- New shared nav source (lib/nav/destinations.ts) consumed by both the
desktop Sidebar and the new BottomNav/More sheet so the two can't drift;
Sidebar refactored to derive active-state from it (lossless).
- BottomNav mounts in the workspace layout (workspace-only), shows only on
mobile (uiStore.isMobile, ≤768px), and toggles body.has-bottom-nav so
app.css reflows .main-content above the fixed bar.
- iOS: app.html viewport-fit=cover + env(safe-area-inset-bottom) padding.
- z-index 40 (above TopBar/sidebar, below sheets).
Header retirement + full "You" (workspace/account) consolidation are
deliberately deferred to a human design checkpoint (DR-3).
Verify: web npm run check (0 errors) + npm run build (✓). Codex review loop
clean (1 P2 fixed: quick-capture re-defaults a stale collection slug).
Refs PLAN-1694 (TASK-1695, TASK-1696, TASK-1697, TASK-1698); parent ROADM-28.
* feat(web): retire mobile header in-workspace, add You sheet + contextual bar (PLAN-1694)
Complete the mobile nav vision on top of the bottom bar: inside a workspace
the global mobile header is gone, replaced by the BottomNav + a "You" sheet +
a contextual back/title bar on detail screens.
- BottomNav 5th slot More → You (👤). New YouSheet composes the workspace
switcher, the nav overflow, and the full account menu carried over from the
retired TopBar (Workspaces/Settings/Billing/Admin/theme/Resources/Connect/
Sign out) so nothing is lost.
- Root layout gates <TopBar mobile /> on !inWorkspace (page.params.workspace),
keeping it for the non-workspace picker/home which has no BottomNav; the
.app-layout top offset now applies only via body.has-mobile-topbar.
- New MobileContextBar: fixed back+title bar shown only on detail screens
(path depth ≥2); body.has-context-bar reflows .main-content top. Root/tab
screens render full-height (reclaimed space).
Codex consensus + review clean (no P1). P2s fixed: safe in-app-history check
via afterNavigate (deep-link fallback to parent URL), humanized title fallback
for pages that don't wire titleStore, and the You sheet closes on navigation
so a workspace switch dismisses it.
Verify: web npm run check (0 errors) + npm run build (✓).
Refs PLAN-1694 (TASK-1699, TASK-1700); parent ROADM-28.
* feat(web): redesign mobile nav into Workspace + You docked sheets (PLAN-1694)
Address design feedback: the previous "You" sheet reused common/BottomSheet
(covered the nav bar) and embedded the old WorkspaceSwitcher (plain, unadapted).
Replace it with two purpose-built surfaces that dock ABOVE the nav.
- DockedSheet (new): bottom sheet anchored above the bottom nav — backdrop
stops at the nav's top edge so the bar stays visible + tappable and the
active slot stays lit. ~2/3 height, grab handle, slide-up, swipe-down /
tap-out / Escape to dismiss. No more full-screen overlay covering the nav.
- Bottom-nav slot 1 Dashboard → Workspace (icon = current workspace avatar),
opening WorkspaceSheet: a designed switcher card (inline-expand list) +
Navigate tile grid + Collections.
- "You" slot → YouSheet, now account-only: profile header, theme toggle,
account Settings / Workspaces / Billing / Admin / Connect / Resources /
Sign out. Search / + / Activity unchanged.
- New avatar util (avatarColor/avatarInitial) for workspace + user avatars.
Splits the overstuffed single sheet into two focused surfaces (where-am-I vs
me) and removes the reused WorkspaceSwitcher/BottomSheet. Codex review CLEAN.
Verify: web npm run check (0 errors) + npm run build (✓).
Refs PLAN-1694 (TASK-1701); parent ROADM-28.
* feat(web): toggle Workspace/You sheets on repeat nav tap (PLAN-1694)
Tapping the Workspace or You nav slot a second time now closes its open
sheet (tap to open, tap again to close). Opening one still closes the other.
The docked backdrop stops above the nav, so the slot button stays tappable
while its sheet is open.
Refs PLAN-1694 (TASK-1701).
* feat(web): dock mobile search to match the nav sheets (PLAN-1694)
The CommandPalette was the last mobile surface that didn't match — a
full-screen takeover (square, no handle, hid the nav). Give its mobile
presentation the same docked treatment as the Workspace/You sheets;
desktop ⌘K is untouched.
- Mobile: dock the palette above the bottom nav (backdrop stops at the nav's
top edge so the bar stays visible), rounded top, grab handle with
swipe-down-to-dismiss, tap-outside to close, no X. Lift above the nav only
when it's present (:global(body.has-bottom-nav)); flush to bottom on the
non-workspace picker. Keyboard handling preserved: input stays at the sheet
top, .results is the sole scroll area, min-height keeps the input clear of
the keyboard when empty.
- BottomNav: Search slot toggles (tap to open, tap again to close) and lights
up while open; the three surfaces (Workspace / You / Search) are now
mutually exclusive.
Codex review CLEAN. Verify: web npm run check (0 errors) + build (✓).
Note: on-screen-keyboard feel should get an on-device smoke test.
Refs PLAN-1694 (TASK-1701); parent ROADM-28.
|
||
|
|
3bf1b60365 |
feat(attachments): editor image crop with aspect presets (TASK-880) (#298)
Adds a drag-to-crop modal on top of the AttachmentImage toolbar
introduced in TASK-879. The /transform endpoint already accepted
the "crop" operation shape from TASK-879 — this PR wires the editor
UI plus the supporting tests.
Editor:
- attachment-crop-modal.ts (new): pure-DOM crop modal in the same
style as the existing image lightbox. Returns a Promise that
resolves to the crop rect in ORIGINAL-IMAGE pixel coordinates
when the user clicks Apply, or null on cancel / dismiss /
image-load failure.
- Image fits to a centered <dialog> via flex layout; backdrop
click and Esc both cancel cleanly.
- Crop rectangle starts at 80% of the image, centered. Body is
a "move" handle; four corner handles resize.
- Aspect presets: Free, 1:1, 4:3, 16:9. Preset clicks snap the
current rect to the new ratio while preserving its center;
subsequent corner drags clamp to the locked ratio.
- Pointer events (touch + mouse for free) with setPointerCapture
so drag continues even if the cursor leaves the handle.
- Coordinate translation: rect in preview-pixel space →
naturalWidth / offsetWidth scale → original-image pixel
space. Result is clamped to natural bounds so a fractional-
rounding overrun doesn't push the rect off-image.
- attachment-image.ts: extracts swapNodeUuid() helper from
runRotate so runCrop can share the setNodeMarkup +
invalidate-old-metadata flow. The toolbar gains a fourth
button (⌶ Crop…) that opens the modal pointed at the original
variant. Per-format gating (refreshToolbarState) treats the
crop button identically to the rotate trio — both go through
/transform, so a libvips-only format (e.g. WebP on the pure-Go
build) disables the whole toolbar with the same explanatory
tooltip.
- app.css: full styling for the crop modal — header with aspect
toolbar, image stage with shadow-cutout overlay around the
crop rect, four corner handles, footer with Cancel + Apply.
Uses the existing CSS-variable palette so light/dark mode
track automatically.
Server tests (3 new):
- TestTransform_CropProducesNewBlobAtRectDimensions: end-to-end
PNG crop, verify the response dimensions AND that the served
bytes decode at the same dimensions (guards against an
encode-pipeline off-by-one).
- TestTransform_CropClipsToImageBounds: rect that extends past
the image boundary clips rather than 400ing — the editor's
rounding can produce rect+1px past natural width/height in
rare fractional-scale cases, and the processor's Crop
intersects with image bounds for exactly this reason.
- TestTransform_CropRejectsBadRect: missing rect, zero width,
negative xy, rect entirely outside → 400.
Parent: PLAN-866. Closes the editor-side image-tools track on top of
TASK-878 (Processor) and TASK-879 (rotate / transform endpoint).
|
||
|
|
f93b0ee4ce |
feat(attachments): server-side rotate transform + editor toolbar (TASK-879) (#297)
* feat(attachments): server-side rotate transform + editor toolbar (TASK-879)
Adds the POST /transform endpoint and the editor's rotate toolbar
on top of TASK-878's Processor abstraction. Rotation produces a NEW
content-addressed attachment row; the editor swaps the AttachmentImage
node's UUID via setNodeMarkup and the original ages into orphan GC.
Server (internal/server/handlers_attachments_transform.go):
POST /api/v1/workspaces/{slug}/attachments/{id}/transform with
body {operation, ...params}. Phase 1 wires the "rotate" branch
(degrees: 90 | 180 | 270 only — pixel-exact reorderings, no
resampling, matches what the editor emits). The "crop" branch
is parsed and validated but the transform path is wired in
TASK-880; defining the wire format here keeps both PRs aligned.
Auth: editor+ on the workspace. Cross-workspace and deleted-parent
probes return 404 (not 403) so the new endpoint can't become a
side-channel for ID enumeration. Unsupported MIME → 415; oversized
image → 413; bad params → 400; missing processor → 503. Output
format follows the same PNG-stays-PNG / else-JPEG policy as the
thumbnail pipeline so derived blobs deduplicate cleanly.
Tests (10): rotate 90 swaps WxH, rotate 180 keeps WxH, bad degrees
→ 400, unknown op → 400, non-existent attachment → 404, cross-
workspace → 404, no processor → 503, derived row has fresh hash +
inherits workspace/uploader/item, served bytes decode at the new
dimensions, deleted-parent → 404.
Web client (web/src/lib/api/client.ts + types):
api.attachments.transform(slug, id, payload) hits the new endpoint
with a discriminated AttachmentTransformRequest type. New
api.server.capabilities() reads the public capability profile
added in TASK-878. Both surface PadApiError on failure so the
editor can show actionable messages.
Editor:
- attachment-metadata.ts (new): shared HEAD-probe cache extracted
from attachment-chip.ts so AttachmentImage's toolbar can probe
the image's MIME with the same zero-extra-network-cost
deduplication. Adds mimeToFormat() — maps MIME to the canonical
short format name the server's Capabilities reports.
- attachment-chip.ts: swapped to use the shared cache. Behavior
unchanged.
- attachment-image.ts: NodeView now wraps the <img> in a
positioned <span> and lazy-builds a 3-button rotate toolbar
(rotate left 90°, rotate 180°, rotate right 90°). selectNode
shows it; deselectNode hides it. On click → calls
options.transform → setNodeMarkup with the returned UUID at
getPos(); cached metadata for the OLD UUID is invalidated.
Per-button gating via refreshToolbarState: empty
supportedFormats list (degraded build) → all disabled with a
"this build doesn't have image processing" tooltip. MIME
probed and not in supportedFormats → disabled with a format-
specific tooltip ("Image editing for image/webp requires
libvips"). Otherwise → enabled with the action tooltip.
- Editor.svelte: configures AttachmentImage with the workspace
slug, the supportedFormats list (initially empty, populated
asynchronously after capabilities resolve), and the transform
callback wired to api.attachments.transform. Errors surface via
console.error + window.alert — same fallback as the upload
plugin until a centralized toast system lands.
- app.css: wrapper + toolbar styles. Toolbar pinned top-right with
absolute positioning; selected-state ring on the image; disabled
button state at 40% opacity.
Parent: PLAN-866. Unblocks TASK-880 (crop) — the /transform endpoint
already accepts the crop op shape, the editor's toolbar pattern is
the same, and the supportedFormats gating composes cleanly.
* fix(attachments): rotate attribution + toolbar refresh per Codex review (round 1)
Two findings from the round-1 Codex review:
1. The transform handler set UploadedBy = currentUserOrSystem(r),
contradicting the comment that said "inherit attribution from
the parent" and creating an audit-attribution drift whenever a
user rotated/cropped someone else's upload. Inherit
parent.UploadedBy instead — same policy as the thumbnail
pipeline. Added TestTransform_DerivedRowInheritsUploadedByFromParent
to lock in the contract. Removed the now-unused
currentUserOrSystem helper.
2. The rotate toolbar's per-format gating could permanently stick
in "all-disabled" state if the user selected an image before
the async capabilities fetch resolved. supportedFormats started
as [] (matching "no processor"), refreshToolbarState ran once
in that state, and the later mutation of ext.options.
supportedFormats had no observer to push the change down to
already-open toolbar DOM. Fix: module-level toolbarRefreshers
set, populated by each NodeView at ensureToolbar() and torn
down in destroy(); a new notifyAttachmentImageCapabilitiesChanged()
export iterates the set and re-runs each toolbar's refresh
hook. Editor.svelte calls it after the capabilities fetch
updates ext.options.supportedFormats, so any toolbar opened
during the in-flight request snaps to its correct state the
moment caps arrive.
Verification: go test ./internal/server -run TestTransform passes
(11 cases now); npm run check passes with the existing 6 warnings.
|
||
|
|
7e5b15722f |
feat(attachments): editor paste + drag-drop upload plugin (TASK-875) (#294)
Tiptap extension that intercepts paste/drop events with files, uploads
each through the attachment API, and replaces the placeholder with the
right node (attachmentImage for image MIMEs, attachmentChip for
everything else) at the position the user dropped.
The plugin's flow:
1. Detect file payloads in clipboardData.items / dataTransfer.files
(skip the event when there are none, so plain text paste / cursor
drag still go through tiptap-markdown's transformPastedText path).
2. Insert a position-tracked placeholder at the paste/drop position
via a setMeta transaction. Placeholders are widget decorations
(zero document width) so they never enter serialized markdown
even if the user navigates away mid-upload.
3. Race the network. The plugin's apply() handler maps every
placeholder position through every intervening transaction, so
continued editing doesn't strand the spinner.
4. On success: schema-aware replacement — attachmentImage for
category=image, attachmentChip otherwise. The placeholder is
removed in the same transaction.
5. On error: remove the placeholder and surface the failure via the
injected onError callback. The upload bytes that did land become
orphans; orphan-GC reclaims them after the grace period.
6. If the placeholder has been deleted before the upload completes
(user cancelled, navigated away, etc.) the upload is dropped
silently — same orphan-GC outcome.
Multiple files in a single drop fan out as concurrent uploads; each
gets its own placeholder and replaces independently as the network
completes.
Editor.svelte wires:
- upload -> api.attachments.upload(workspaceSlug, file)
(rejects with a clear message when no workspace context)
- onError -> console.error + window.alert as a minimal fallback
until a centralized toast system lands.
Styles (app.css) cover the placeholder bubble (dashed border, faded
colour) and a CSS spinner — kept inline via decoration widget DOM so
ProseMirror's selection ignores it (ignoreSelection: true).
Parent: PLAN-866. Closes the editor-input flow on top of TASK-874
(markdown resolver), TASK-876 (image node), TASK-877 (chip node).
|
||
|
|
934794b606 |
feat(attachments): editor file chip node (TASK-877) (#293)
* feat(attachments): editor file chip node (TASK-877)
Custom Tiptap node `attachmentChip` for non-image `pad-attachment:UUID`
references — Notion-style chip rendering with type icon, filename, and
human-readable size.
Node shape:
- uuid: string — the attachments-row UUID
- filename: string — display name; preserved across save/reload
Markdown round-trip:
- Serialize: `[filename](pad-attachment:UUID)` — same standard link
syntax the markdown resolver in TASK-874 understands. `]` and `\`
in the filename are escaped to keep the link label balanced.
- Parse: markdown-it's link token produces
`<a href="pad-attachment:UUID">filename</a>`. Our parseHTML rule
`a[href^="pad-attachment:"]` runs at priority 1000 to beat
SafeLink's default mark rule (priority 50), so attachment refs
become a chip Node instead of a Link Mark on plain text.
Editor display (NodeView):
- <a class="file-chip"> with icon + name + optional size span
- Icon: filename-extension heuristic on first paint, upgraded to a
MIME-based icon once a single HEAD request resolves the canonical
Content-Type. The HEAD goes against the existing GET handler — no
new API endpoint required, and Go's net/http strips the body
automatically for HEAD.
- Size: rendered from Content-Length once HEAD resolves; hidden
until then (CSS `:empty { display: none }`).
- Module-level Promise cache keyed by `${ws}:${uuid}` deduplicates
repeated chips for the same attachment and survives undo/redo
without re-fetching.
- target=_blank + download attribute so a click opens / saves the
file with its canonical filename.
- atom: true → Backspace/Delete remove the chip as a single unit.
Styles in app.css (not Editor.svelte) so they reach the read-only
markdown render path too — TASK-874's Go and TS resolvers emit the
same `.file-chip` / `.file-chip-icon` / `.file-chip-name` /
`.file-chip-size` markup.
Parent: PLAN-866. Unblocks TASK-875 — the upload plugin needs a chip
node to insert on successful non-image uploads.
* fix(attachments): chip HEAD route + chip click handler per Codex review (round 1)
Two findings from the round-1 Codex review:
1. chi router does not auto-route HEAD to GET handlers, so the chip's
metadata HEAD probe was returning 405 and chip size + MIME-refined
icons never loaded. Fix: register HEAD on the same path/handler;
http.ServeContent already strips the body for HEAD on the seekable
path, and the streaming fallback short-circuits before io.Copy so
future S3-style backends don't burn GetObject bandwidth on HEAD.
Tests added: HEAD returns 200 with Content-Type + Content-Length
and an empty body; HEAD cross-workspace returns 404 (not 403) so
the new endpoint can't become a side-channel for ID enumeration.
2. Editor.svelte installs a global anchor-click suppressor that
preventDefaults every <a> inside the editor, so the chip looked
clickable but did nothing in edit mode. Fix: the chip's NodeView
now attaches an explicit click handler that calls window.open with
the download URL and stops propagation before the global handler
runs. Mirrors the AttachmentImage lightbox click pattern.
|
||
|
|
f1ce9ca24a |
feat(attachments): editor inline image node (TASK-876) (#292)
Custom Tiptap node for inline `pad-attachment:UUID` image references.
Stores the attachment UUID (not a backend URL) so item content survives
a storage-backend migration untouched. See DOC-865.
Node shape:
- uuid: string — the attachments-row UUID (required)
- alt: string — preserved across save/reload for accessibility
Markdown round-trip:
- Serialize:  via tiptap-markdown's
addStorage.markdown.serialize, with [/] in the alt text escaped so
brackets stay balanced.
- Parse: markdown-it's default image token already produces
<img src="pad-attachment:UUID" alt="…">, captured by parseHTML
rule img[src^="pad-attachment:"]. The alternate parseHTML rule
img[data-attachment-id] catches editor-rendered HTML on copy/paste.
Editor display:
- addNodeView renders <img class="attachment-image" loading="lazy">
pointing at /api/v1/workspaces/{ws}/attachments/{id}?variant=thumb-md
via an injected getDownloadUrl callback (Editor.svelte resolves the
workspace slug from page.params at mount time, falling back to the
workspace store).
- Single-click opens a native <dialog> lightbox with the original-
resolution variant; multi-click events fall through so users can
drag-select around the image.
- atom: true means Backspace/Delete remove the image as a single
unit and the cursor never lands inside the node.
Lightbox styles live in app.css because the <dialog> is appended to
document.body, outside Editor.svelte's scoped style block.
The configure() default returns the literal `pad-attachment:UUID`
href — sufficient for markdown round-trip in headless / SSR contexts
and a clearly-broken render in any environment that hasn't wired the
URL builder, which is the right signal to fix.
Parent: PLAN-866. Unblocks TASK-875 (the upload plugin needs a node
to insert on success).
|
||
|
|
cf3ba5510d |
fix(web): drop repeating print-header, clean page-1 layout, skip empty rows (BUG-626) (#157)
* fix(web): drop repeating print-header, clean page-1 layout, skip empty rows (BUG-626)
Real-print testing after BUG-625 showed the repeating fixed-position
`.print-header` approach is fragile -- even with a generous @page top
margin, Chromium's handling of fixed elements during pagination can
overlap content on the first page, and there's no clean way to
coordinate the header with page-break behavior across browsers.
Replace the repeating header with a page-1 document header in normal
flow and simplify.
## Changes
### Template (+page.svelte)
- Remove `.print-header` entirely; drop the `workspaceStore` import
(no longer needed in print).
- Tag non-computed field-rows with `class:print-empty={isFieldEmpty}`
when the raw value is null / empty string / empty array. Flag at
the template level because :empty can't see FieldEditor children.
### Styles (+page.svelte @media print)
- `.title-row` becomes a flex row: title on the left (20pt, wraps),
item ref on the right (10pt, tabular-nums, nowrap), both aligned to
the title's first-line baseline.
- `.meta-info` gets a 1px bottom border to separate the document
header block from the properties card.
- `.field-row.print-empty { display: none !important; }`.
- Drop all `.print-header*` CSS (dead) and the padding/border shared
rule between header+footer. `.print-footer` now stands alone.
### Global (app.css)
- Shrink @page top margin from 1.25in to 0.6in -- no reserved header
strip means no clearance needed. Bottom margin stays 1in for the
fixed footer + `@bottom-right` page number counter.
## Outcome
- No repeating header, no overlap, no workspace/collection context on
subsequent pages (users who want it can leave browser headers
enabled in the print dialog).
- Page 1 shows: title+ref header row, meta subtitle, border, properties
(with empty rows skipped), body, relationships/children if present.
- Footer repeats on every page with Printed date, URL, and Page N.
- 116-line file net -16 lines smaller, app.css -2.
Verified locally via Ctrl+P preview in Chromium before committing.
* fix(web): flip print title-row order so title is left, ref is right (PR #157)
Address Codex P2: DOM order in the template is `[item-ref, title]`,
so `display: flex; justify-content: space-between` kept the ref on
the left and pushed the (flex:1) title to fill the remaining space on
the right — the opposite of the intended BUG-626 header layout.
Use the flex `order` property to reverse only the visual sequence in
print, keeping the template DOM untouched. `.title` gets `order: 1`,
`.item-ref` gets `order: 2` + `margin-left: auto` so the ref sits
baseline-aligned at the right edge and the title claims everything
to its left.
|
||
|
|
7c29413685 |
fix(web): print title overlap, page number, and select chevrons (BUG-625) (#156)
* fix(web): print title overlap, page number, and select chevrons (BUG-625)
Address three issues surfaced by a real Ctrl/Cmd+P test on an Idea
detail page (PLAN-620 follow-up):
1. Title cut off at top of page 1. The `@page { margin: 1.1in ... }`
rule was declared in +page.svelte's scoped style block, but Svelte
scoped-CSS at-rule loading meant the 0.75in default from app.css
(TASK-621) kept winning. The fixed print header was ~0.4in tall and
the content area started at 0.75in, but layout timing left them
overlapping. Consolidate to a single @page rule in app.css with a
widened `margin: 1.25in 0.6in 1in 0.6in` -- guaranteed clearance.
2. Footer showed "Page 0" on every page. `counter(page)` inside the
::after pseudo-element of a fixed-positioned element is captured
once at initial layout (before pagination) and reused, so it never
increments. Move the page number into a `@page { @bottom-right {
content: "Page " counter(page); } }` margin-box where the counter
evaluates correctly per page. Remove the `.print-footer-page` span
and its `.print-page-num::after` rule from the item detail page.
Add `padding-right: 1.2in` to the fixed footer so its content
doesn't overlap the new margin-box page number.
3. FieldEditor selects still showed a `∨` chevron in print output --
the chevron is an inline <svg class="select-chevron">, not the
native UA dropdown arrow, so `appearance: none` on the button had
no effect. Hide `.select-chevron` and `.select-dropdown` explicitly
in the global print block.
Bonus: skip empty `.field-row`s via `.field-row:has(.field-value:empty)`
so rows like an unset "Category" don't print as a label with no value.
* fix(web): drop dead empty-field-row print rules (PR #156)
Address Codex P2 review comment on BUG-625. The `:empty`-based rules
added as a bonus to hide label-only rows (e.g. unset "Category")
never actually match in this codebase:
- Non-computed fields wrap a `<FieldEditor>` child inside `.field-value`,
so `.field-value` always has children and is never `:empty`.
- Computed fields call `formatFieldDisplay(value)`, which returns `"—"`
for null / empty, so `.computed-value` is never `:empty` either.
Remove the rules rather than leaving dead selectors that suggest the
behavior exists. Hiding blank rows in print is worth revisiting with a
real signal (e.g. a `data-empty` attribute or a template `{#if}`
guard), but out of scope for BUG-625 -- the title / page-number /
chevron fixes are what this PR is about.
|
||
|
|
f9a248f4da |
feat(web): print-format the item detail page (TASK-622) (#153)
* feat(web): print-format the item detail page (TASK-622) Layer item-page print formatting on top of the base stylesheet added in TASK-621: - Title row renders as plain text (large, serif-friendly, no button affordance); issue ref prefix stays as a subtle prefix. - Meta info (created/updated + actor) keeps as small-print subtitle. - Properties panel becomes a definition-list block (label / value grid) wrapped in a light card, with form widgets stripped so the selected value reads as plain text. - Content layout stacks the fields panel above the markdown body (no side-by-side columns in print). - Code context section, relationships list, and child items stay. - Comments / activity / version timeline are hidden entirely. - Action buttons, breadcrumb, share/move/delete controls, edit-mode toggle, add-relationship form, save-status chip, link-delete buttons are all stripped. Rendered markdown (.prose) gets a print tune-up in app.css: 11pt body, inline URL suffix on external links (skipped for wiki-links and fragment links), break-inside guards on code / images / tables, light-palette overrides for code blocks and blockquotes. Editor overlays (bubble menu, link popover, slash menu, mobile toolbar, table toolbar, editor toolbar) are hidden in print. Parent: PLAN-620. * fix(web): keep relationship status chips + print title during edit mode (PR #153) Address Codex P2 review comments on TASK-622: - Relationship rows: previously hid the entire `.link-row-actions` wrapper, which silently dropped the `.link-status` chip alongside the destructive delete button. Hide only `.link-delete-btn` so the status stays visible in print. - Title during inline edit: the screen renders either a `.title` button (read) or a `.title-input` textarea (edit); previous rules displayed the button and hid the textarea, so printing while editing produced a title-less page. Apply the same print typography to both, turning the textarea into a non-interactive, borderless plain-text heading. * fix(web): preserve checkbox field state in print output (PR #153) Address Codex P2 review comment on TASK-622. The form-widget strip rule `.field-value button { border: none; background: transparent; }` killed the visual state of `.toggle` (the checkbox field's switch button), since it renders state purely via styling — no text label. The printed page would lose the on/off signal entirely. Exempt `.toggle` from the strip rule via `:not(.toggle)` and add a dedicated print style that renders the toggle as an outlined 11pt box; when the field is on, overlay a check mark via `::after`. The toggle-knob is hidden (it's the sliding switch visual, not useful in print). * fix(web): print URL suffixes for SafeLink + print raw markdown legibly (PR #153) Address Codex P2 review comments on TASK-622: - Rich-editor links (Tiptap SafeLink extension) render with `data-href` instead of `href`, so the print suffix rule `.prose a[href]::after` never fired for the main document body. Add a parallel selector `.prose a[data-href]::after { content: " (" attr(data-href) ")"; }` plus matching skips for internal data-href wiki-links. - The Markdown editor's raw textarea had no print styling. Printing while the Markdown tab was active either clipped the textarea to its screen height or rendered with dark-theme chrome. Add a @media print block to `RawMarkdownEditor.svelte` that flattens the textarea into a plain monospace flow: no border, no background, auto height, visible overflow, page-break-inside: auto. Content prints as markdown source -- not ideal, but readable and content-preserving. * fix(web): hoist FieldEditor print strip rules to global scope (PR #153) Address Codex P2: the `.field-value select / input / button / .toggle` print overrides were defined inside the item detail page's scoped style block. Svelte scoped selectors don't cross component boundaries, so the form widgets rendered inside `FieldEditor` kept their interactive styling in print preview -- selects rendered with their screen chrome, toggles disappeared, etc. Move these rules into app.css's @media print block (which applies globally) and leave a note in +page.svelte explaining why. The `.assignment-select` rule stays in +page.svelte because those selects are inline in this template and correctly scoped. |
||
|
|
e81da7a24f |
feat(web): add base @media print stylesheet for workspace layout (TASK-621) (#152)
Tune Ctrl/Cmd+P output so Pad pages can be saved as clean PDFs. This is the first of four tasks under PLAN-620 (Print-friendly item detail pages) and handles the layout-level chrome: hides the sidebar, top bar, floating expand toggles, toasts, command palette, modals, and any [data-print-hide] opt-in element; unlocks the 100vh / overflow:hidden app shell so content flows across pages; forces a light color palette regardless of theme; strips shadows and background images; sets a default 0.75in @page margin. Item-level formatting (title, properties, markdown body), the rendered print header / footer, and the child-item checklist ship in TASK-622, TASK-623, and TASK-624 respectively. Parent: PLAN-620. |
||
|
|
1d26c2b542 |
feat: add workspace top bar with drag-to-reorder (#80)
* feat: add workspace top bar with drag-to-reorder Replace the sidebar WorkspaceSwitcher dropdown with a dedicated top bar that provides fast workspace switching and a user menu. Desktop: - Horizontal bar above sidebar + content with workspace icons (colored first-letter circles) and names as real <a> links - Drag-and-drop reorder via svelte-dnd-action - User avatar on right with dropdown (settings, theme toggle, sign out) - "+" button to create new workspaces Mobile: - Full-width fixed bar at top when sidebar opens (above sidebar/backdrop) - Tap workspace to navigate and close sidebar - Reorder button opens full-screen vertical list with drag handles - Sidebar starts below the top bar with adjusted positioning Backend: - Migration 028: add sort_order to workspace_members (per-user ordering) - GET /workspaces now returns workspaces in user's sort order - PUT /workspaces/reorder endpoint for persisting order Sidebar simplified: - Removed WorkspaceSwitcher component, user section, theme toggle - Theme initialization moved to root layout - Cleaner footer with search, settings, and notification bell Implements IDEA-129, relates to IDEA-126. * fix: address codex review findings (P1+P2) - Remove unsupported `direction` option from svelte-dnd-action dndzone - Add Postgres migration 008 for workspace_members.sort_order - Handle sql.ErrNoRows gracefully in reorder endpoint for admins who aren't members of all workspaces - Restore mobile sign-out: add user name + logout button to sidebar footer on mobile (was only in desktop TopBar user menu) |
||
|
|
a8059c5a0f |
Implement 8 ideas from the idea board
Quick wins: - IDEA-31: URL autolink + link popover in editor (SafeLink with data-href prevents mobile navigation, popover shows open/edit/remove actions) - IDEA-36: Focus title on new item creation, Enter moves to editor - IDEA-38: Add `pad link` CLI command to link directory to existing workspace - IDEA-34: Show checklist progress bar on item cards (parses markdown checkboxes) - IDEA-28: Workspace rename (already existed in settings) Medium effort: - IDEA-33: Drag-and-drop task reordering in Phase documents via svelte-dnd-action - IDEA-26: Archive collections (frontend wiring — backend already supported soft delete) - IDEA-29: Archive workspaces with danger zone confirmation in settings - IDEA-37: Raw markdown editor toggle + inline Mermaid diagram rendering (NodeView with ignoreMutation to prevent ProseMirror re-parse loops) |
||
|
|
7ba69abb88 |
Misc improvements: CLI field summaries, editor enhancements, CI and UI polish
Show field summary after create/update CLI commands. Make svelte-check blocking in CI. Improve editor block handling, field editor layout, conventions page, and minor UI consistency fixes across pages. |
||
|
|
81579847c6 |
Initial release
Pad — project management for developers and AI agents. Single Go binary with embedded SvelteKit web UI, SQLite storage, CLI, and Claude Code /pad skill integration. https://getpad.dev |