mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-22 02:23:46 +00:00
5f7b7af50f
* feat(web): surface required/default/suffix/relation field controls
Closes TASK-596 in PLAN-593.
Expose field capabilities that already round-tripped through
EditableField but had no UI. Controls live in a collapsible Advanced
section on each field card.
FieldEditor
- New exported CollectionOption type for the relation picker input.
- Advanced section (collapsed by default, auto-expanded when any of
required/default/suffix/collection is already set) containing:
* Required checkbox (all types)
* Default value — type-appropriate input:
text/url -> text input
number -> number input (+ Suffix row below it)
date -> date picker
checkbox -> "Checked by default" toggle
select -> dropdown restricted to the field's options
multi_select / relation -> deliberately skipped
* Relates to dropdown (relation type only), populated from the
workspace collections list passed in via props. Shows a helpful
empty-state when no other collections exist.
- Computed fields render a muted "computed" badge and the advanced
inputs are disabled (changing defaults / suffix / required on a
computed field is nonsensical). Label / type / remove remain
editable to preserve current behavior.
- Typed input handlers coerce field.default into the right shape
(string / number / boolean) so the polymorphic value stays clean.
CreateCollectionModal + EditCollectionModal
- Both fetch api.collections.list(ws) lazily on open and pass the
result down to every FieldEditor as `collections`.
- Both new-field save paths now emit required / computed / suffix /
collection / default onto the serialized FieldDef. Existing-field
save path in EditCollectionModal already handled these; this brings
the new-field path to parity and adds equivalent handling in the
Create modal.
Behavior notes
- Values round-trip: set in Advanced -> save -> reopen -> still there.
- Emit-when-set keeps payloads compact and compatible with existing
schemas that don't carry these fields.
- Known visual quirk: the taller card may exacerbate the type-select
alignment already tracked on TASK-598; deferred to the visual pass.
* fix(web): gate advanced field properties by current field type
Codex P2 (PR #135): the save paths emitted `suffix`, `collection`,
and `default` for every new field regardless of f.type, so a user
could set a number default/suffix, switch the field to `relation` or
`multi_select`, and still persist the hidden value — producing schema
defaults that don't match the final type and are then auto-applied
to new items by ValidateFields.
Fix: gate type-specific advanced-value emission by the current type
at save time. This keeps the user's in-memory state intact (no
surprise clears on type toggle) but prevents stale values from
leaking into the saved schema.
- suffix: only when type === 'number'
- collection (relation target): only when type === 'relation'
- default: only when typeSupportsDefault(type) returns true
Apply the gating in all three save paths:
- CreateCollectionModal.handleCreate (new fields)
- EditCollectionModal.handleSave addedFields (new fields)
- EditCollectionModal.handleSave updatedExisting (existing fields) —
same pre-existing risk if the user changes an existing field's
type and hits save
Extract the default-support check into typeSupportsDefault() in
field-editor-types.ts so FieldEditor (which gates the rendered
default input) and the save paths share one predicate. Adjust
FieldEditor's local `supportsDefault` derived to call through it.
* fix(web): coerce and normalize default values at save time
Two related Codex findings on PR #135:
P1: Coerce default values to active field type before save
Type-switch drift — user sets a boolean default on a checkbox, then
switches the type to `text`, the stale boolean was previously
serialized as the text default. ValidateFields later auto-applies
it to new items without re-validating the value type.
P2: Trim select defaults to match normalized option values
Option text is trimmed on save ("open " -> "open"), but the select
default handler stored raw option text, producing schemas with
`options:["open"]` + `default:"open "` — defaults that aren't in
the allowed set and get auto-injected as invalid values.
Fix: add coerceDefault(raw, type, options?) to field-editor-types.ts.
Returns undefined when the raw value can't be represented in the
target type (caller drops it). Handles:
- text/url -> must be a non-empty string
- number -> number, or parseable non-empty numeric string
- date -> non-empty string (server validates format)
- checkbox -> must be boolean
- select -> trimmed string that exists in normalized options
Wire through all three save paths:
- CreateCollectionModal.handleCreate (new fields)
- EditCollectionModal.handleSave addedFields (new fields)
- EditCollectionModal.handleSave updatedExisting (existing fields,
where the same type-switch risk applies)
The select-options branch passes the already-normalized `def.options`
into coerceDefault so whitespace drift is caught in the same step as
type coercion.
* fix(web): tighten date coercion, preserve opaque defaults, stable keys
Three Codex findings on PR #135:
P1: Validate date defaults before persisting them
coerceDefault was accepting any non-empty string for the date type,
so switching a field from text/select to date could serialize stale
garbage like "soon" as the date default even though the date input
renders blank. Tighten the date branch to require ISO 8601 format
(YYYY-MM-DD, optionally followed by a T-prefixed datetime tail).
Server still performs stricter parsing; this guard blocks obvious
invalid strings from leaking through.
P1: Preserve unsupported field defaults during edit saves
The existing-fields save path dropped `default` whenever
typeSupportsDefault(f.type) returned false. Opening and saving a
collection that contained a multi_select or relation default (e.g.
from an API import) would silently strip those defaults as a side
effect of unrelated edits — schema-mutating regression.
Fix: in the existing-fields branch, if the active type isn't UI-
editable for defaults, pass field.default through verbatim instead
of dropping it. Types that *are* UI-editable still run through
coerceDefault. New-field paths are unchanged because new fields
never carry a pre-existing opaque default.
P2: Use stable unique keys for select default options
The default-value dropdown for select fields keyed its <option>s by
text, but duplicate option labels aren't prevented anywhere in the
editor or save path. A collection with duplicate options would hit
Svelte's keyed-each duplicate-key behavior and break the control.
Switch to keying by index for display stability.
* fix(web): clear stale relation options before async reload
Codex P2 (PR #135): loadCollectionOptions() awaited the fetch before
replacing collectionOptions, so a reopened modal — especially after
a workspace switch — briefly showed the previous workspace's
relation targets. A fast user could pick one and persist a slug that
doesn't exist in the current workspace.
Fix: clear collectionOptions = [] synchronously at the start of
loadCollectionOptions(), before awaiting the request. If the fetch
fails the picker falls back to its empty-state hint. Applied in both
CreateCollectionModal and EditCollectionModal.
* fix(web): token-guard collection fetch + checkbox default clear
Two Codex findings on PR #135:
P2: Ignore stale collection-list responses before setting options
The previous fix cleared collectionOptions at fetch start but still
unconditionally applied whichever response resolved last. Rapid
reopens or slow networks could let an older response land after a
newer one and overwrite it, letting a user persist a relation slug
from the wrong workspace.
Fix: add a monotonic collectionsRequestToken in both modals. Bump it
on each fetch, capture the current value, and drop the response if
the token has moved on when it resolves. Applied in both success
and error paths.
P2: Allow clearing checkbox defaults instead of forcing false
The checkbox default was tri-state at the schema level (no default
/ default false / default true) but the UI only toggled between
true and false. Unchecking stored `false`, and there was no way to
get back to `undefined` — so ValidateFields would auto-inject
`false` into new items even when the user meant "no default".
Fix: add an explicit "Clear" affordance next to the checkbox that
shows only when field.default is set. Clears to undefined, leaving
schema with no default for that field. Preserves the intentional
`false` case (user wants new items to default to unchecked).
* fix(web): calendar-validate date defaults instead of regex shape only
Codex P2 (PR #135): the date branch of coerceDefault accepted any
string matching the YYYY-MM-DD shape, so impossible dates like
"2026-99-99" or "2026-01-32" could be persisted when users switched
a field from text/select to date. ValidateFields later auto-applies
these as defaults on new items without re-checking, propagating
invalid dates silently.
Replace the shape-only regex with real calendar validation:
- Plain date branch (YYYY-MM-DD): parse month/day, then round-trip
through Date.UTC and verify the resulting year/month/day match
the input. Rejects out-of-range components (month > 12) and
overflow cases (day 32 rolling to next month).
- RFC3339 datetime branch: keep the shape check (stricter than a
loose `T.+` suffix — rejects "2026-01-01Tnot-a-time"), then confirm
Date.parse yields a finite timestamp.
Both branches return undefined on rejection so the caller drops the
default rather than persisting garbage.
* fix(web): strict datetime coercion + drop select defaults w/ empty opts
Two follow-up Codex findings on PR #135:
P1: Reject non-RFC3339 datetime defaults in coercion
Previous fix did shape + Date.parse, but `new Date(...)` silently
rolls calendar-invalid dates (e.g. "2026-02-31T10:00:00Z" becomes
March 3) so impossible timestamps still passed. Switch the datetime
branch to the same component-parse + round-trip technique as the
YYYY-MM-DD branch:
- Extract Y/M/D + h/m[/s] from the regex capture groups
- Range-check each component (month 1–12, day 1–31, h ≤ 23, m/s ≤ 59)
- Construct a UTC Date from Y/M/D and verify the resulting
components match the input to catch day overflow
Date.parse is no longer trusted alone. Out-of-range days,
impossible calendar dates, and non-RFC3339 strings are all dropped.
P2: Drop select defaults when normalized options are empty
The save paths passed `def.options` into coerceDefault, but
`def.options` is omitted when the normalized list is empty, so a
select field with no options would skip the membership check and
keep a stale string default. ValidateFields would then auto-apply
a default that doesn't exist in any allowed set.
Fix: in all three save paths, pass the already-normalized opts
array (including []) to coerceDefault when the type is select.
Non-select types continue to pass undefined since they don't
consult the options parameter.
- CreateCollectionModal: use the local `opts` variable
- EditCollectionModal addedFields: use the local `opts` variable
- EditCollectionModal updatedExisting: extract a
`normalizedOpts` local (options were previously inlined) and
reuse it for both def.options and the coerceDefault call
* fix(web): drop stale default on type switch to multi_select/relation
Two Codex findings on PR #135:
P1: Drop stale default when existing field switches to relation/multi_select
The existing-fields save path preserved f.default verbatim for every
UI-unsupported type. That's correct when the field was loaded with a
pre-existing opaque default (API/import). But it misfires when the
user sets a default while the field is text/number/select and then
switches the type to relation or multi_select — the default UI
hides, but the stale value persists and gets saved.
Fix: track the load-time type as `originalType` on EditableField and
only fall through to the verbatim-preserve branch when the active
type still matches the original. In-session type switches to a
UI-unsupported type now drop the default instead. New-field paths
don't need this because new fields never carry pre-existing
opaque defaults.
P2: Enforce strict RFC3339 datetime shape in default coercion
The previous datetime regex accepted optional timezone and
offsets without the colon, so "2026-01-01T10:00" and
"2026-01-01T10:00+0100" round-tripped as defaults even though the
backend's time.RFC3339 parser requires seconds + a colon in the
offset. That lets defaults survive here that the server rejects.
Fix: require seconds, require timezone, require colon in offset.
Matches strict RFC3339 / Go time.RFC3339.
* fix(web): validate RFC3339 timezone offsets in date coercion
Codex P2 (PR #135): the datetime regex enforced the `±hh:mm` shape
but never validated the numeric ranges of the offset, so values like
"2026-01-01T10:00:00+99:99" were treated as valid and serialized.
Go's time.RFC3339 (backend parser) rejects those, and defaults are
auto-applied to new items without re-validation, so an invalid
offset would silently propagate.
Add explicit offset bounds: hours 0–23, minutes 0–59 (matching Go's
time.RFC3339 acceptance of ±23:59). `Z` skips the check. Applied
after the regex match in the datetime branch.
* fix(web): raw string number default + defaults-equal type switch check
Two Codex findings on PR #135:
P2: Preserve raw number input until commit
The number-default input called Number(v) on every oninput and
wrote the coerced value back to field.default. Because the input
was controlled by `value={defaultAsString}`, partial typing states
like "1." collapsed to "1" on each keystroke (Number("1.") === 1),
making it impossible to type decimals. Negative signs had the same
problem.
Fix: keep the raw string in field.default while editing.
coerceDefault already handles string→number conversion at save
time and drops garbage strings, so no save-path change is needed.
P2: Track any type switch before preserving hidden defaults
The existing-fields unsupported-type fallback preserved f.default
whenever the active type matched originalType. That missed the
round-trip case: relation → text → relation with a new default
injected in the middle. Type matches at save but the default is
stale and un-editable through the UI.
Fix: snapshot originalDefault at load alongside originalType, and
only preserve the default when BOTH are unchanged. Otherwise drop.
Add defaultsEqual() helper to field-editor-types.ts for
polymorphic comparison (JSON-stringify-based — fine for schema
defaults, which are always JSON primitives/arrays).
* fix(web): truncate datetime defaults to YYYY-MM-DD for date input binding
Codex P2 (PR #135): <input type="date"> only accepts a YYYY-MM-DD
value. An RFC3339 datetime default like "2026-01-01T10:00:00Z" was
bound directly via defaultAsString and rendered blank, leading users
to believe the field had no default — while field.default remained
populated and was preserved on save through coerceDefault. Result:
hidden datetime defaults that silently survived unrelated edits.
Fix: derive a display-only dateDefaultDisplay string that truncates
anything after the YYYY-MM-DD prefix, and bind the date input to
that. field.default itself stays untouched until the user actually
picks a new date, at which point onDefaultDateInput writes the pure
YYYY-MM-DD value. This keeps API-loaded datetime defaults round-
tripping untouched (when the user doesn't edit them) while making
them visible for manual correction.
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
activity/ ActivityFeed
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