The inline "Create Token" row is a data row prepended at index 0, so an active
table filter (globalFilterFn) or a non-first page hid it — clicking Create Token
appeared to do nothing. handleCreateNew now calls setFilter(''), which clears the
filter and resets pageIndex to 0 (clearPageOnFilterChange default), so the create
row is always visible.
Live-verified (docker/HEAD backend): with a non-matching filter, Create Token now
shows the create row and clears the filter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to a55cac3 / 2a3a553. A percentage minSize scales with the screen:
30% is ~700px on a 2560px display and ~1000px on an ultra-wide — far too large
a floor. Give both resizable splits (DetailSplitLayout + flow.tsx) a fixed 390px
per-panel minimum (numeric = px in react-resizable-panels v4). defaultSize stays
a percentage: flow 50%, DetailSplitLayout 45%/55%.
Verified live (chrome-devtools, dev server, real flow + settings-prompt):
- 1280px (narrowest desktop): group 1024px, both mins 390px — no collision
(2x390 < 1024), drag range 390..633px.
- 2560px: min stays 390px (16.9%), not 691px (30%).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same react-resizable-panels v4 pixels-not-percent pitfall as detail-split-layout
(a55cac3): flow.tsx's desktop two-panel split passed minSize={30}/defaultSize={50}
as numbers, so the 30 was a 30px floor — a user could drag either the central-tabs
panel or the detail panel down to a ~30px sliver instead of the intended 30%.
Use string percentages: minSize "30%", defaultSize "50%".
Verified live (chrome-devtools @1440px, dev server on a real flow): separator
aria-valuemin=30, dragging a panel to its minimum floors at 29.97% (355px).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
react-resizable-panels v4 reads numeric size props as PIXELS, not percent
("Numeric values are assumed to be pixels"). So `minSize={30}` was a 30px
floor — a user could drag either panel down to a ~30px sliver (~2.5% on a
1184px group) instead of the intended 30%. `defaultSize={45/55}` were px too
(they only rendered ~45/55% because default sizes normalize to a ratio;
minSize is an absolute per-panel constraint and is not normalized).
Use string percentages so the constraints mean what they read: minSize "30%",
defaultSize "45%"/"55%".
Verified live (chrome-devtools @1440px): separator aria-valuemin 2.536 -> 30;
dragging a panel to its minimum now floors at 29.97% (355px) instead of 30px.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Re-reviewed every frontend comment against the "names a concrete wrong action"
rubric. Cut pure restatements, change-narration, self-defense and bug-history;
trimmed mixed comments down to their load-bearing gotcha/contract; kept genuine
framework/API/security notes. Relocated a misplaced JSDoc in resources-provider
that sat on `error` but described `resources` (fields are alphabetically sorted).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The JS "desktop" threshold was 1200px, which is no Tailwind breakpoint, so anything
mixing useBreakpoint with a CSS `md:`/`xl:` variant for the same decision could drift
in the 1024–1279 zone. Move the threshold to Tailwind `xl` (1280) — mobile already
sat on `md` (768) — so JS layout switches and CSS variants now share the same values.
With the breakpoint a real token, MarkdownEditorField drops useBreakpoint and expresses
its height in plain CSS: fixed `h-[calc(100dvh-5rem)]` below xl (stacked forms, internal
scroll), `xl:min-h-0 xl:flex-1` to fill its pane on the desktop split. No JS hook, no
comment. Note: the split view now appears at ≥1280 instead of ≥1200.
Verified <xl live (fixed 1194px, internal scroll); the ≥1280 branch resolves to the same
`min-h-0 flex-1` fill already measured at 1440px — this browser window caps at ~1141px so
the wide layout could not be re-observed here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mobile/tablet branch used `min-h-[calc(100dvh-5rem)]`, only a floor — the
stacked forms have no flex parent to cap it, so a long document (a big agent
prompt is ~10k px of text) stretched the editor to its full content height and
the whole page grew with it. Make it a FIXED `h-[calc(100dvh-5rem)]` so the box
stays one viewport tall and the content scrolls inside it. Desktop (fills its
pane) is unchanged. Live-verified: the Adviser prompt editor is now 1194px with
an internal scrollbar instead of 10.6k px.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mobile fixed height was knowledge-only; make it the field's job for all. The
field now derives its own height from useBreakpoint — the SAME hook the layouts
switch on — so it fills its flex parent on desktop and takes a near-viewport fixed
height on mobile/tablet, everywhere. Knowledge drops its last height className.
This also fixes the settings-prompt and template editors, which had no mobile
height and were cramped in their stacked mobile layouts. Live-verified all three
on desktop (fills) and mobile (min-h calc(100dvh-5rem)).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every consumer embedded MarkdownEditorField in a `flex min-h-0 flex-col` column and
repeated `min-h-0 flex-1` to make it fill. Bake that default into the field (applied
first so a consumer can still override the height), and drop the boilerplate:
settings-prompt and template pass no className now, and the knowledge field only
keeps its one deviation — a fixed `min-h-[calc(100dvh-5rem)]` for the mobile stack
where it isn't inside a flex box. Live-verified all four surfaces: rich editor fills
(flex-1, min-h 0) on prompt/template/knowledge-desktop, and knowledge-mobile keeps
the fixed 100dvh-5rem height.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prompt-validation dialog (max-w-2xl) and the provider-test-results dialog
(max-w-3xl) had the same latent bug as the Diff dialog: a bare max-w-* cannot
override DialogContent's default sm:max-w-lg at ≥sm, so both rendered at 512px
instead of 672/768px. Prefix with sm: so they reach their intended width.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Diff dialog passed a bare `max-w-7xl`, but DialogContent's default already
sets `sm:max-w-lg`. tailwind-merge keeps both (different variants), and at ≥sm the
`sm:` rule wins the cascade — so the dialog was pinned at 512px and the split diff
overflowed with a horizontal scrollbar. Use `sm:max-w-7xl` so it overrides the
default at the same breakpoint, matching the app's other wide dialogs. Verified
live: the real Diff dialog now renders at 1280px with no horizontal scroll.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Renames the shared two-pane detail layout to DetailSplitLayout (file
detail-split-layout.tsx) and its slots left/right/rightClassName to
panel/content/contentClassName, which name the actual roles — a form/meta panel
beside the main editor content — instead of bare position. Updates all four
consumers (knowledge form, template, settings prompt, settings provider).
Also drops a dead GripVertical child passed to <ResizableHandle withHandle /> (the
handle renders its own grip and ignores children) and gives the left panel a
bg-card fill so the gutters around the centered card match the card colour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds markdown-editor-heading-autoformat.test.ts (57 cases): levels, marker stripping,
negatives, hardBreak protection, every trigger path (delete/split/merge/insertText/
setContent/insertContent), the canReplaceWith container gate, multi-target ordering,
mark/variable/tag preservation, byte-fidelity interaction, and re-entrancy. The 6
ad-hoc Case B tests move here from the extensions suite.
Writing the coverage surfaced a soundness bug: promoting a MULTI-LINE paragraph whose
first line starts with `# ` (e.g. `# a`+Shift+Enter+`# b`, then deleting the lead-in)
emitted a heading containing a hardBreak, which re-parsed as TWO headings on the next
load. HeadingAutoformat now skips any paragraph that contains a hardBreak — a heading
is single-line — so such a block stays body text and escapeLineLeadingBlockMarkers
round-trips it. Single-line promotion is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The heading input-rule fires only on the keystroke that types `# ` at a block
start. A block that comes to start with `# ` any other way — deleting the text
before an existing `#`, pressing Enter in front of it, pasting — stayed a
paragraph, diverging from CommonMark (a leading ATX marker IS a heading) and from
what the same text becomes on reload.
HeadingAutoformat, a ProseMirror appendTransaction plugin, promotes such a
paragraph to the matching heading (stripping the marker) on every doc-changing
transaction. It keys off the block's first child, not textContent, so a `# ` after
a hardBreak (Shift+Enter) correctly stays body text — that is a soft break inside
the paragraph, so the block does not start with `#`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A paragraph line starting with "# " or "> " (reachable by Shift+Enter then such a
line — the heading input-rule only fires at a block start, not after a soft break)
serialized verbatim through the identity encoder and re-parsed as a heading /
blockquote on the next load, silently changing the block TYPE of body text.
Fix it as a symmetric pair: the paragraph serializer escapes a line-leading
`# `/`> ` to `\# `/`\> `, and the tuned marked Lexer's escape tokenizer — otherwise
off to keep `\d`/`\|`/`\\` literal — unescapes exactly `\#`/`\>` back on load. Only
these two markers are handled; `-`/`*`/`+`/`1.`/fences overlap with literal
regex/glob/backref escapes (`\*`, `\1`, `\|`) the editor must preserve, so they are
left alone. Real headings/blockquotes, regex/glob literals, and mid-line `#` are
unchanged (full byte-fidelity corpus stays green); adds pin tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The roving-tabindex MutationObserver watched childList:true/subtree:true, justified
by a comment claiming "the table control swaps button↔menu". Verified against the
source: the toolbar's item set is static (every control renders unconditionally;
state only drives pressed/disabled props), and each menu keeps its `data-toolbar-item`
trigger in the bar while swapping its content in a Radix body portal — outside the
observed subtree. So childList observed nothing. Keep only the load-bearing
attributeFilter:['disabled'] + subtree (a disabled toggle changes the roving set),
and correct the comment.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- history: assert resetUndoHistory actually empties the undo stack
(editor.can().undo() === false), not just that it doesn't throw — a silent
no-op regression would otherwise pass.
- field: assert insertAtCursor is a no-op (no onChange) when the field is
disabled — the documented "a mid-save variable click can't dirty the form"
guard was untested.
- content-integrity: the pipe-table generative test now also asserts each
pipe-bearing atom survives as an escaped code span, not just the row sentinels.
- code-fence: fix the pinned-limitation comment — an upstream fix turns this
test RED (the canary), it does not "flip green".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The horizontal wheel-scroll handler treated the strip as "at end" only when
scrollLeft + clientWidth exactly reached scrollWidth. At fractional browser zoom
(125%/150%) those metrics never line up to the pixel, so the end was never
detected and the handler kept preventDefault-ing the wheel — locking page
scroll while the pointer was over the toolbar. Allow a 1px tolerance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- index.ts: stop re-exporting findVariableUseRanges — every consumer imports it
from the module directly, so the barrel entry was dead.
- textarea.ts: drop Math.max(0, …) around textarea.selectionStart/End; both are
spec non-negative and non-nullable for a real HTMLTextAreaElement, so the guard
protected nothing. (The Math.max in the scrollTop math stays — it guards a real
negative subtraction.)
- link/image handles: rename the Popover onOpenChange param `next` → `isOpen`,
matching the boolean-naming convention and TableHandles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop comments that restate adjacent code or defend a design choice, keeping only
the clauses that carry a non-derivable contract:
- useMarkdownEditor / useTableHandles: delete the responsibility-enumeration and
"rewriting only this hook" banners.
- clearLineContents: drop the "(Notion's Clear contents)" trivia, keep the
"structure intact, not a delete" distinction.
- normalizeImageSrc: keep the SVG-can-carry-script rationale, drop the restatement.
- nextVariableRange: keep the shared-by-both-panels invariant, drop the algorithm
walkthrough.
- image edit-form isEditing prop, two highlight-test negatives, and a table-pipes
test perf war-story: drop the restatements.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The link edit-form relied on a placeholder + aria-label with no visible <Label>,
and its invalid-URL alert had no aria-describedby back to the input — both
present on the sibling image edit-form. A screen-reader user who tabbed onto the
field after the error fired never heard why it was invalid. Add a visible Label
(via useId) and wire aria-describedby to the error id, matching ImageEditForm.
Also trim two comments per the zero-comment rule: the normalizeLinkUrl-return
restatement (documented at the callee) and the "(matches Docs/Notion)"
competitor-justification.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The three property-based tests each run 120–300 full editor round-trips and sit
right at vitest's default 5s limit, so they flaked to a timeout under a loaded
or serialized full-suite run (never in isolation, and never on an assertion).
Give them an explicit 30s budget so the full suite is deterministic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
openRef was mirrored from the `open` state through a passive effect, but the
document-level mousemove handler and the stale-target droppers read
openRef.current on continuous events. Between a grip click (setOpen) and the
effect flush, a mousemove saw the stale null and retargeted the grip — and the
about-to-open menu — onto a neighbouring cell. Set the ref synchronously inside
onMenuChange before setOpen and drop the mirroring effect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unlike the link and image handles, TableHandles registered only scroll (and,
now, editor update) invalidation — never resize. Resizing the window reflowed
the table but left the fixed-position grips glued to their old coordinates,
detached from the table, until the next hover. Mirror the sibling handles and
drop the target on resize too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The three overlay handles (link/image/table) each found their dismiss-on-scroll
parent with a hardcoded closest('.tiptap-content') ?? window, while the class
itself lived only inside markdown-editor.tsx's className string. A styling
refactor renaming that token would have silently sent all three to `window`
(whose scroll never fires for the inner overflow-auto), freezing popovers/grips
at stale coordinates — with no type error or failing test.
Export EDITOR_CONTENT_CLASS + getEditorScrollParent from the leaf styles module
and consume both from the className and the three handles. Behavior-neutral.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The link edit popover was keyed on `${range.from}-${range.to}`. Typing inside a
link grows range.to on every keystroke (the character inherits the link mark), so
<LinkEditForm key={key}> unmounted and remounted each keystroke and re-seeded its
URL field from initialUrl — silently discarding an in-progress URL edit and
churning the popover DOM.
Key on range.from alone: it is stable while the caret stays in the same link, and
still changes when the selection moves onto a different link.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
normalizeLinkUrl/normalizeImageSrc prepended https:// after stripping ALL
leading slashes (url.replace(/^\/+/, '')), so a root-relative path was
re-hosted onto its first segment: '/settings/profile' → 'https://settings/profile',
'/uploads/logo.png' → 'https://uploads/logo.png'. The result was non-null, so the
Apply button stayed enabled and the bogus href/src was persisted.
Extract toAbsoluteCandidate: only //host (protocol-relative) is promoted (prepend
the scheme); a single leading slash returns null (this field only accepts absolute
external URLs). Scheme-less hosts and existing protocol-relative cases are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A hover grip captures its cell position once on mousemove and was never
invalidated when the document changed. Editing elsewhere (without moving the
mouse) then left cellPos pointing past the shifted doc, so two things could
happen: RowHeaderToggleItem's useEditorState selector called
hasHeaderRow(editor, cellPos) → doc.resolve(pos) → a synchronous RangeError
that takes down the whole editor render tree; and a "Delete row/column" ran
setTextSelection against the stale offset, editing the wrong line.
Guard doc.resolve in hasHeaderRow (an out-of-bounds position has no header
row), and extend the grip's existing scroll-invalidation to fire on editor
'update' too, so the target drops on any doc change and reappears on the next
hover. Adds the first unit tests for table-commands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pressing Enter in a table cell makes a second paragraph, and
renderTableToMarkdown joins a cell's block children with a raw U+001F
separator (outside renderChildren). That control byte was written verbatim
into the saved .tmpl/knowledge markdown and round-tripped back unchanged —
an invisible, persisted corruption of the file.
A GFM cell is single-line, so collapse the separator to a space on the
table's rendered output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The line scanner only recognized top-level tables (^ {0,3}), so a `> `-prefixed
delimiter row failed TABLE_DELIMITER_LINE and the whole blockquote table went
unprotected — marked then stripped the prefix, parsed the table, and dropped
cells whose code span / template / URL held a pipe.
Detect a blockquote run, strip each line's prefix, recurse on the inner content
(reusing every rule here, so nested `> >` and fenced blocks inside the quote are
handled too), then re-apply the original prefix. Escaping never touches the
prefix, so untouched rows stay byte-exact.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
escapeRowPipes escaped pipes inside any `scheme://` run via a greedy
/[^\s]*:\/\/[^\s]*/g. Two bugs: (1) a long non-URL token (base64, a hash)
made that regex backtrack quadratically — 120k chars froze for ~11s on every
rich load, a stored, target-influenceable client-side DoS; (2) in a compact,
spaceless row like `|http://a.com|b|` the run swallowed the structural pipes
and escaped them, collapsing the row into one cell.
A URL pipe and a spaceless cell separator are indistinguishable in isolation,
so disambiguate by cell count: only escape scheme-run pipes when the row splits
into MORE cells than the header expects (a genuine phantom-cell URL pipe). A row
already at the right count keeps its structural pipes. The guard also skips the
scan on ordinary rows, and the replacement is a linear /\S+/g pass, so a long
token can no longer drive it superlinearly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The escapeTablePipes fence tracker matched a fixed 3-char run (```/~~~) and
compared truncated tokens, so a 4-backtick block — which renderTunedCodeBlock
emits whenever the block's content holds a ``` line — was "closed" by that inner
3-backtick line. Two failures followed: pipes inside the real code block got
`\|` injected into the saved bytes, and a genuine table AFTER the block lost its
protection (the tracker thought it was still inside a fence), dropping cells.
Capture the full run and its trailing text; close only on a same-char run of
length >= the opener with no info string, mirroring CommonMark.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The pre-lex pipe protection split on '\n', leaving a trailing '\r' on every
line. The '$'-anchored TABLE_DELIMITER_LINE then failed to match '| --- |\r',
so the whole module no-op'd on CRLF input and marked dropped cells whose code
spans / templates / URLs held a pipe — silently losing data on the first load
of Windows- or API-authored content.
Normalize '\r\n' and lone '\r' to '\n' up front (marked re-normalizes the
returned string anyway). Original bytes are preserved when nothing is escaped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The link cue in MARKDOWN_CUES was /\[.+\]\(.+\)/ — a double-`.+` that
catastrophically backtracks. isMarkdownLike runs synchronously in
handlePaste on every paste carrying an html flavor, so a large
bracket-heavy non-link paste froze the tab: 40k `[x]` reps → 3.7s,
120k → 33s (measured against the exact regex).
Bound both spans with negated classes (`[^\]]+`/`[^)]+`): linear-time,
matches real links identically (120k adversarial input drops to <1ms),
and now also catches multi-line link text. Add a ReDoS-budget regression
test and an invariant comment for future cue additions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The built-in single deleteAction's JSDoc promised a "destructive variant"
but the object never set variant:'destructive', so the row-menu Delete
rendered as a default (non-red) item — unlike the bulk delete. Now that
the DropdownMenu/ContextMenu items support variant="destructive" and
file-manager-row forwards action.variant, set the flag so single deletes
read as destructive, matching the bulk action and the documented intent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
FlowForm was the last form on raw useForm with mode:'onChange' (behind the
lint guard's inline disable). Migrating is behavior-neutral: the file has no
error UI, and RHF recomputes isValid eagerly on every change when it's
subscribed — so the send button keeps toggling live under useAppForm's
mode:'onSubmit' exactly as before, verified live (empty→disabled, typed→
enabled, cleared→disabled) across the composer.
Swaps useForm({mode,resolver}) for useAppForm({schema}) and drops the
eslint-disable. The !isValid submit gate and the three
setValue(...,{shouldValidate:true}) calls are load-bearing for the send
button (imperative setValue doesn't refresh isValid without the flag) and
are kept intentionally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two convention hardenings on top of useAppForm:
- noValidate on every react-hook-form <form>: validation runs through zod,
so native HTML5 constraint validation (type="email"/required) firing
browser-locale popups on submit is unwanted everywhere — not just the
email-change form that first hit it.
- no-restricted-syntax lint rule flagging mode/reValidateMode on useForm,
steering new forms to useAppForm (the single owner of the timing).
use-app-form.ts is exempted (it's the definition); flow-form keeps its
live-validation mode behind an inline disable pending its migration.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LoginForm had no unit tests. Adds the convention-critical cases: fields
stay silent until the first submit (even after type+blur), submit surfaces
errors without calling login, fixing a field clears its error live once
submitted, and a valid submit calls login and navigates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Our ui/* primitives were an older shadcn v4 generation. This ports the
low-risk, invisible/bugfix tier (Tier A of the drift audit), preserving all
local customizations (badge color variants, input spinner suppression,
textarea autoSize API, skeleton bg-primary/10, sidebar Cmd+B guard, etc.).
- data-slot="..." on ~33 primitives (was only 6); bare re-exports wrapped in
functions to host it. className strings verified identical to before.
- aria-invalid destructive styling on checkbox/button/toggle (was already on
input/textarea/select).
- resizable: replace dead data-[panel-group-direction=vertical] selectors with
the aria-[orientation=*] ones react-resizable-panels@4.11.2 actually emits
(latent — no vertical groups today; proven live via DOM experiment).
- dropdown-menu: viewport-aware max-h + overflow-y-auto on Content; add
variant="destructive" (mirrored on context-menu) and rewire file-manager-row
to use it instead of a hand-rolled className.
- checkbox: neutral border-input when unchecked, border-primary only when
checked (was always primary).
- input/textarea: min-w-0, disabled:pointer-events-none, dark:bg-input/30,
text-base md:text-sm (prevents iOS focus-zoom on mobile).
- sidebar: give the mobile Sheet real sr-only title/description.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
These primitives set aria-invalid (via FormControl) but never styled it, so an
invalid plain field showed a red label and message yet kept a neutral border —
while InputGroup/InputPassword and textarea-autosize already went red. Add the
same aria-invalid:border-destructive + ring the group primitives use, so every
field type reads the same when invalid (Input also fixes Autocomplete, which
wraps it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The New Email field is type="email", so on submit the browser fired its own
locale-styled constraint-validation popup, pre-empting our zod error and looking
out of place. Add noValidate to the form so validation runs through zod/RHF and
surfaces the consistent in-app FormMessage ("Invalid email address") instead —
and it no longer masks the current-password error the native popup shadowed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move login, password-change, email-change, and name-change onto the shared
useAppForm wrapper — the last data-entry forms still on raw useForm. These were
already on react-hook-form's default timing (onSubmit + onChange revalidate), so
this is behavior-neutral: it only routes them through the single timing source
so none can later drift to eager validation, and keeps every form consistent.
Schemas and submit buttons (FormSubmitButton) are untouched, so the password
policy and login flow are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce a useAppForm wrapper that owns the validation timing (mode 'onSubmit'
+ reValidateMode 'onChange') and the zod resolver, so no form can drift back to
eager 'onTouched'/'onChange' that paints fields red before the user tries to
save. Three generics mirror useForm<Input, ctx, Output> for transform schemas.
Migrate every data-entry form to it (knowledge, templates, prompts, providers,
api-tokens, resources copy/move/mkdir, flow-files promote); submit buttons no
longer gate on isValid before the first submit (which would dead-lock a fresh
invalid form), except api-tokens which keeps its documented "disabled until a
date is set" gate. Full-height editor fields show the invalid state as a red
border (aria-invalid) with an sr-only message instead of layout-breaking text.
Also fills the knowledge editor to 100dvh-5rem on mobile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add feature-scoped --editor-* tokens (link/variable/tag/inline-code) instead of
reusing --primary, which is a fill color that fails WCAG AA as text on the dark
surface. Four distinct hues so adjacent tokens don't blur (blue link, violet
variable, teal tag, red inline code on a visible chip); every token clears
4.5:1 in both themes. Drop the Tailwind Typography literal `backticks` around
inline code, and mirror the editor's inline-code styling in the read-only .prose
viewer so a document reads the same whether it is edited or viewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework the editor toolbar into a shadcn-styled family of small modules:
heading and list dropdowns, an adaptive table menu (GFM-safe: no merge/split),
inline popovers for links and images with inline validation + URL normalization
(replacing window.prompt), click-to-edit link/image handles anchored to the
node, a reset-formatting action, roving-tabindex a11y, and tooltips. URL/image
sources are normalized to absolute https and gated by a protocol allowlist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- insertTextareaText → insertAtTextareaCaret: mirrors the handle method it backs
(insertAtCursor) and its sibling selectNextTextareaUse, and says WHERE it inserts
("textarea text" read as a noun phrase, not "text into a textarea").
- variableProbe → variableUseRegex: it returns a RegExp; the old name didn't signal
the return type (read as if it returned a boolean).
- dropUnderscore → dropUnderscoreRules: it filters input/paste RULES, not a character.
Behavior unchanged; 889 vitest green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`cycleToVariable` read as "move to a variable" (a single target), but the method
advances the selection to the variable's NEXT use, wrapping — it cycles through the
variable's occurrences. `selectNextUse` says what it targets and matches the panel's
own "used ×N" terminology + findVariableUseRanges; it's the same family as code
editors' next-occurrence selection (CodeMirror's selectNextOccurrence, VS Code's
add-next-occurrence). The raw helper becomes selectNextTextareaUse. Behavior unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>