Commit Graph
632 Commits
Author SHA1 Message Date
Sergey KozyrenkoandClaude Opus 4.8 3ea784bc2f fix(markdown-editor): stop a stale table hover-grip from crashing or misediting
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>
2026-07-07 19:19:10 +07:00
Sergey KozyrenkoandClaude Opus 4.8 c87a31f38b fix(markdown-editor): drop the U+001F cell-join byte from saved table markdown
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>
2026-07-07 19:13:11 +07:00
Sergey KozyrenkoandClaude Opus 4.8 2067b50359 fix(markdown-editor): protect tables inside a blockquote from cell loss
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>
2026-07-07 19:04:12 +07:00
Sergey KozyrenkoandClaude Opus 4.8 5e7dbb7441 fix(markdown-editor): escape URL pipes by cell-count, killing a table-row DoS
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>
2026-07-07 19:00:25 +07:00
Sergey KozyrenkoandClaude Opus 4.8 19fc4b3092 fix(markdown-editor): track fence length so a 4-backtick block isn't closed early
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>
2026-07-07 18:56:09 +07:00
Sergey KozyrenkoandClaude Opus 4.8 81e448cf05 fix(markdown-editor): normalize CRLF in escapeTablePipes so tables keep cells
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>
2026-07-07 18:53:11 +07:00
Sergey KozyrenkoandClaude Opus 4.8 b015f3ba98 fix(markdown-editor): bound paste link cue to kill ReDoS on large pastes
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>
2026-07-07 18:46:15 +07:00
Sergey KozyrenkoandClaude Opus 4.8 6326719ae1 fix(file-manager): mark the built-in delete action destructive
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>
2026-07-07 17:15:02 +07:00
Sergey KozyrenkoandClaude Opus 4.8 1ef56d1972 refactor(forms): migrate FlowForm to useAppForm
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>
2026-07-07 15:51:48 +07:00
Sergey KozyrenkoandClaude Opus 4.8 3ac35ee0d2 feat(forms): enforce the validation convention (noValidate + useForm lint guard)
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>
2026-07-07 15:24:31 +07:00
Sergey KozyrenkoandClaude Opus 4.8 dcce62397f test(auth): cover LoginForm silent-until-submit convention
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>
2026-07-07 15:23:54 +07:00
Sergey KozyrenkoandClaude Opus 4.8 0a5a9a6cda refactor(ui): adopt shadcn v4 data-slot + aria-invalid, fix primitive drift
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>
2026-07-07 14:51:05 +07:00
Sergey KozyrenkoandClaude Opus 4.8 73d8c27e46 fix(ui): show the destructive border on invalid Input/Textarea/Select
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>
2026-07-07 13:21:27 +07:00
Sergey KozyrenkoandClaude Opus 4.8 31b91c56be fix(auth): suppress native email validation popup on the email-change form
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>
2026-07-07 13:13:23 +07:00
Sergey KozyrenkoandClaude Opus 4.8 a3f2a73ca0 feat(forms): migrate auth forms to useAppForm
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>
2026-07-07 12:49:32 +07:00
Sergey KozyrenkoandClaude Opus 4.8 d31c73d35a feat(forms): silent-until-submit validation convention via useAppForm
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>
2026-07-07 05:28:43 +07:00
Sergey KozyrenkoandClaude Opus 4.8 7912ebab1e feat(markdown-editor): purpose-built highlight tokens with WCAG-AA contrast
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>
2026-07-07 05:27:49 +07:00
Sergey KozyrenkoandClaude Opus 4.8 36608a9dd0 feat(markdown-editor): shadcn toolbar with link/image popovers and table controls
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>
2026-07-07 05:27:26 +07:00
Sergey KozyrenkoandClaude Opus 4.8 30b22f9c17 refactor(markdown-editor): clearer names for three helpers
- 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>
2026-07-05 19:07:54 +07:00
Sergey KozyrenkoandClaude Opus 4.8 d1ed015778 refactor(markdown-editor): rename cycleToVariable → selectNextUse
`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>
2026-07-05 18:59:16 +07:00
Sergey KozyrenkoandClaude Opus 4.8 831d9b8be2 docs(markdown-editor): trim restatement/redundant comments
A comment audit against the zero-default policy: the module's comments are overwhelmingly
genuine framework gotchas (undo → silent data corruption / crash / perf regression) and stay.
Cut only the handful that fail the wrong-action test:
- the "closures read latest props" reassurance (no wrong action it prevents),
- two JSDoc lines that restated self-documenting function names (cycleTextareaToVariable,
  insertTextareaText — the caret gotcha stays inline),
- two per-member handle JSDocs that restated method names (the interface-level mode contract stays),
- tightened the 6-line undo-clear block to 2.

No behavior change; 220 module tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:20:44 +07:00
Sergey KozyrenkoandClaude Opus 4.8 587d8a0ecf fix(markdown-editor): guard the link toolbar against non-navigable protocols (M3)
handleSetLink passed window.prompt output straight to setLink with no protocol check,
while the image toolbar already guards via isSafeImageSrc — so a user could toolbar-insert
`javascript:alert(1)` as a link href into the persisted document. Not a live XSS today (the
read-only viewer's react-markdown urlTransform and tiptap Link's isAllowedUri both sanitize
on render), but defense-in-depth: keep dangerous protocols out of the document at authoring
time so it never relies on every future render path sanitizing them.

Add isSafeUrl (mirror of isSafeImageSrc): allow http/https/mailto/tel + relative/anchor
(which resolve to the page protocol); reject javascript:/data:/vbscript:/file:/malformed.
Apply it in handleSetLink. Unit-tested alongside isSafeImageSrc; 889 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:04:41 +07:00
Sergey KozyrenkoandClaude Opus 4.8 5eb4ea22ad fix(markdown-editor): close two table-pipe defects missed by prior reviews + hardening
A full-feature review surfaced two real defects both living in the file that fixes
the table-pipe class — verified first-hand (repro + fix) through the real extension
stack and live on docker:

MEDIUM (data loss): escapeTablePipes escaped pipes only inside code spans and Go-template
actions, NOT inside link/image destinations or bare autolink URLs. A cell like
`[x](https://h/?a=1|2)` had its URL truncated at the pipe and the trailing cells silently
dropped on the FIRST load — the exact H1 corruption class, uncovered for URLs. Fix: a
URL_RUN pass escapes pipes inside any `scheme://` run (which never legitimately contains a
space, so a `|` in it is always content, never a real separator). Live: the URL and
trailing row now survive intact.

MEDIUM (ReDoS): TABLE_DELIMITER_LINE `/…\|? *:?-+:? *(?:…)*\|? *$/` had two adjacent ` *`
runs competing for the same characters, so a `|`-line followed by "dashes + a long space
run + a non-matching tail" backtracked O(n²) (2.4s at 64k) on every parse — mount, external
reset, paste. A crafted/AI-emitted doc froze the tab. Fix: a linear rewrite (trailing
`(?: *\|)? *$`, per-cell spacing) — same matches, sub-millisecond on the pathological input.

LOW: the imperative handle's insertAtCursor mutated a disabled editor (dispatch bypasses
the editable gate) and dirtied the form — guard rich on `editor.isEditable`, raw on `disabled`.

LOW (styleguide): `cs`→`computedStyle`, `looksLikeMarkdown`→`isMarkdownLike`, `endsTableBody`→`isTableBodyEnd`.

Tests: URL-pipe round-trip (link/image/bare, converges) + a real-separator-safety case +
a linear-regex ReDoS budget guard. 877 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 17:51:17 +07:00
Sergey KozyrenkoandClaude Opus 4.8 2e9eb96f75 refactor(markdown-editor): complete the field handle in raw mode, delete the settings-prompt bypass
Third-review MEDIUM-1: the field's imperative handle implemented cycleToVariable/
insertAtCursor only in rich mode, so settings-prompt reached around the component
with document.querySelector('#{formId} textarea') and hand-rolled a cycle algorithm
that was a structural duplicate of the rich one (drift hazard) plus formId plumbing,
a synthetic field object, a setTimeout caret restore, and a 35-line caretOffsetTop
mirror — ~80 lines of consumer bypass. This completes the already-shipped handle
contract (the same principle as making focus() work in both modes): the raw branch
now cycles/inserts over its textarea, both methods are required (drop the optional
`?`), and the cyclic "next occurrence" pick is one shared pure helper
(nextVariableRange) used by both surfaces. settings-prompt's handleVariableClick
collapses to mode-agnostic editorRef.current.cycleToVariable/insertAtCursor.

Also from the third review: LOW-1 the raw cn() argument order now puts the byte-exact
font-mono/no-resize classes last so a consumer className genuinely can't override them
(the comment claimed a contract the code didn't enforce); LOW-3 the handle contract is
JSDoc; LOW-5 the Suspense fallback forwards id/aria/disabled; LOW-6 the loading skeleton
and the editor's own placeholder share one wrapper class (extracted to a chunk-light
module) so first rich mount no longer flashes a different box; INFO-1 drop the unused
MarkdownEditorHandle barrel export; and a field-level unit test pins the mode-aware handle.

Live-verified on docker: raw + rich variable cycle work through the handle (querySelector
gone), console clean; 872 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:21:35 +07:00
Sergey KozyrenkoandClaude Opus 4.8 943b2aab01 fix(markdown-editor): honor field ref in both modes; unify to one handle
Post-review fixes (CODE-REVIEW 2026-07-05_1048):

LOW-1 + LOW-2 (ref contract): the field's `ref` was typed `Ref<MarkdownEditorHandle>`
but only wired in rich mode, so RHF's focus-on-validation-error silently no-oped in
raw mode (a regression: the pre-extraction `<Textarea {...field}>` wired field.ref).
MarkdownEditorField now exposes one honest `MarkdownEditorFieldHandle` — `focus()` in
BOTH modes (raw textarea / rich editor), variable-panel methods optional (rich-only).
Consumers forward `field.ref`; settings-prompt composes it with its variable-panel
editorRef via a new `composeRefs` util. Added `focus()` to the rich editor handle.
Fixed Textarea's ref typing (was an accidental Ref<HTMLTextAreaElement> ∩ Ref<TextareaRef>
intersection; TextareaRef also now declares the focus() it exposes at runtime).

LOW-3 (fallback duplication): dropped the `fallback` prop — both consumers used it only
to swap the spinner glyph while hardcoding layout classes that had to track `className`
by hand. All three now use the default spinner, which threads `className` and carries
aria-busy for the lazy-load window.

Live-verified on docker: invalid submit focuses the field in raw AND rich; the prompt
variable panel still cycles/scrolls through the composed ref; console clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:27:04 +07:00
Sergey KozyrenkoandClaude Opus 4.8 aba7589cb4 refactor(markdown-editor): collapse MarkdownEditorField class props to one className
MarkdownEditorField exposed three class props — className (rich wrapper),
contentClassName (rich content area), rawClassName (raw textarea). They are the
same concept: "size the field's outer box." The rich wrapper and the raw textarea
take identical flex/min-height layout, and contentClassName was used by exactly
one consumer for a mobile content floor that a wrapper min-height expresses just
as well.

Collapse to a single `className` applied to whichever element renders. Drop the
now-dead contentClassName prop from the underlying MarkdownEditor too. Consumers:
knowledge non-fillParent bumps min-h-[280px]→min-h-[320px] to keep the mobile
content area at ~240px once the toolbar wraps (previously floored via
contentClassName); template/prompt raw drop the min-h-[640px] floor and become
min-h-0 flex-1 like rich — flex-1 already fills at normal viewports (820px
measured), and raw now shrinks-and-scrolls consistently with rich on short ones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 10:29:15 +07:00
Sergey KozyrenkoandClaude Opus 4.8 e9551f767f refactor(markdown-editor): extract MarkdownEditorField, own lazy() + raw/rich switch
The raw-textarea ⇄ rich-editor switch was hand-wired in all three consumers
(knowledge form, template page, settings-prompt), each repeating the byte-exact
raw config (autoSize={false} + resize-none font-mono) and its own lazy()/Suspense
boundary — and only template/prompt were lazy, knowledge pulled tiptap eagerly.

MarkdownEditorField centralises it: one owner of the mode switch, one place for
the raw="byte-exact source" contract, and one lazy() boundary so raw-only routes
never load the tiptap chunk. Because the field owns the boundary, the barrel can
re-export it eagerly (chunk-free until rich renders) and the a11y passthrough
now reaches the editor in template/prompt too.

Net -112 lines across consumers; settings-prompt's FormCodeItem + FormTextareaItem
collapse into one FormMarkdownItem (5 prop interfaces → 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 02:18:59 +07:00
Sergey KozyrenkoandClaude Opus 4.8 37d1739024 docs(markdown-editor): drop resetUndoHistory restatement comment
The lead comment restated what the function name/body already say and
asserted a caller contract the single-arg function cannot enforce — that
"must not run on user edits" invariant is enforced and documented at the sole
call site (the value-sync effect's guard). Comment audit: everything else in
the module is current + earns its place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 01:45:43 +07:00
Sergey KozyrenkoandClaude Opus 4.8 5de52c125c refactor(markdown-editor): scope the table-pipe escape to the tuned marked Lexer
The load-side escapeTablePipes was installed by patching MarkdownManager.parse
on the shared library CLASS PROTOTYPE — a process-global mutation that every
@tiptap/markdown consumer would inherit. Move it onto the module's own private
marked instance instead: subclass `tunedMarked.Lexer` so `lex()` runs
escapeTablePipes first. The manager builds its block lexer via
`new markedInstance.Lexer(...)` (including the construction-time initial parse),
so this is instance-scoped, catches every load, and touches no shared class.
`inlineTokens` is inherited unchanged, so inline fragments are unaffected.

Behaviour-identical (866/866 green, incl. the pipe-less table regressions);
drops patchManagerParse + the isManagerParsePatched guard + the MarkdownManager
import. Independent-review follow-up (CODE-REVIEW-markdown-editor-2026-07-04_1437).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 01:16:54 +07:00
Sergey KozyrenkoandClaude Opus 4.8 4d96cb3567 fix(markdown-editor): protect pipe-less GFM table rows too (H1 gap)
The H1 table-pipe load fix (escapeTablePipes) only escaped body rows with a
leading `|` and stopped at the first row without one, and never escaped the
header. But GFM makes the outer pipe optional and marked treats a pipe-less
row as a table row, so a `|` inside a code span / Go action in a no-outer-pipe
table (`` A | B\n--- | ---\n`git log | head` | notes | done ``) was left
unescaped and marked dropped the trailing cell on first load — the exact H1
data loss, for the most common table style.

Widen the escaper to every row marked itself treats as the table: the header
plus all body rows up to a block boundary (blank line / heading / blockquote /
fence / list / hr / indented code — mirroring marked's gfmTable body-row
negative lookahead), never touching non-table lines (the escape tokenizer is
neutralised, so a stray `\|` outside a real table row would surface literally).

Regression tests for the pipe-less style + the block-boundary stop; the M7
generative oracle now emits both outer-pipe and pipe-less tables.

Found by an independent adversarial review (CODE-REVIEW-markdown-editor-2026-07-04_1437),
hand-reproduced via the round-trip harness before fixing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 01:09:24 +07:00
Sergey KozyrenkoandClaude Opus 4.8 1b0c67da4e fix(markdown-editor): stop dropping table cells with piped code spans (H1)
marked's GFM table tokenizer splits a row on raw `|` before inline
tokenization, so a pipe inside a code span (`` `x | y` ``) or a Go action
(`{{.X | upper}}`) in a body cell spawned a phantom column and silently
dropped the trailing cells on load. Pre-escape those pipes as `\|` before
the manager lexes (escapeTablePipes, scoped to real table body rows, fences
skipped); the splitter honors `\|` and restores the literal `|` in the cell.
Symmetric with TunedTable's existing save-side pipe escape, so it converges.

Patched on MarkdownManager.prototype because the Markdown extension parses
the initial content inside its own onBeforeCreate, before any lower-priority
extension can wrap the manager instance.

M7: extend the generative content-integrity oracle with a table-cell context
+ pipe-bearing atoms — the one class the single-context oracle can't produce.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 12:23:20 +07:00
Sergey KozyrenkoandClaude Opus 4.8 773e9de5d8 fix(markdown-editor): keep inline formatting from rich-HTML pastes
An inline-only rich paste (Google Docs/Word/mail single paragraph - <b>,
<a href>, styled spans) carries no block tags, so the paste plugin hijacked
it into a plain-text markdown parse and silently dropped bold/italic/links.
Defer to ProseMirror's native HTML parse when the clipboard has HTML and the
text/plain shows no markdown cues - the schema DOMParser keeps those marks.
Markdown-looking text (a VS Code copy of markdown source wrapped in
syntax-color spans) still parses as markdown, matching load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 12:11:13 +07:00
Sergey KozyrenkoandClaude Opus 4.8 c2d04a1d94 test(markdown-editor): pin multi-cycle entity-decode semantics
A multi-encoded entity in prose (`&amp;lt;`) intentionally loses one
encoding level per open+save cycle until fully decoded; inside code it is
byte-stable. Pinned so the cross-session behavior stays an explicit product
decision. Also correct the decode attribution in the tokenizer comment
(@tiptap/markdown's decodeHtmlEntities, not marked).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 12:08:53 +07:00
Sergey KozyrenkoandClaude Opus 4.8 430306193e refactor(markdown-editor): apply mechanical review batch (11 findings)
- M5: replace hand-rolled unknown-typed markdown types with the official
  @tiptap/core exports (MarkdownToken/ParseHelpers/RendererHelpers) and
  drop both casts they forced on the fidelity-critical paths
- M3: forward FormControl-injected id/aria-describedby/aria-invalid to the
  ProseMirror contenteditable (field<->label association + error announce)
- M2: skip the redundant whole-document re-parse on mount (compare against
  the captured initialContent instead of the normalized getMarkdown())
- L1+L10: extract the toolbar + isSafeImageSrc to markdown-editor-toolbar.tsx
  and memo() it (parent re-renders per keystroke under RHF)
- L2: delete dead autoFocus/hasToolbar props (zero consumers)
- L3: drop dead match.index narrowing (required number under es2023 lib)
- L4: own vite chunk for marked; drop stale tiptap-markdown chunk pattern
- L8: TunedStarterKit / TunedTable (consistent Tuned* prefix)
- L9: shouldExternalSync -> isExternalChange
- L14: isSafeImageSrc data: allowlist narrowed to base64 raster formats
  (data:image/svg+xml can carry script)
- L7: trim narration/restatement comments across module + tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 12:07:30 +07:00
Sergey KozyrenkoandClaude Opus 4.8 2b54a0a6b4 refactor(markdown-editor): review-driven quality pass (5 findings)
- content: capture the initial value via useState so useEditor's
  compareOptions no longer fires a redundant setOptions -> view.updateState
  on every keystroke; the value-sync effect stays the single owner of
  later content updates
- setEditable: guard on editor.isEditable to skip the redundant update +
  getMarkdown() serialization emitted on mount
- drop the unreachable `if (!manager) return` in TunedMarkdownText -- the
  Markdown extension always assigns editor.markdown; a non-optional cast now
  fails loud on regression instead of silently reverting to the lossy encoder
- rename param h -> helpers in renderTunedCodeBlock (matches sibling helpers)
- trim the variable-highlight banner to the load-bearing node/mark invariant

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 04:15:28 +07:00
Sergey KozyrenkoandClaude Opus 4.8 a858d81cea refactor(markdown-editor): rename Faithful* -> Tuned* (createTunedMarked etc.)
"faithful" connoted a byte-faithful round-trip, but the editor now deliberately
normalizes some cases toward GFM (bare URLs autolink, named entities decode), so
the output is no longer byte-exact. "tuned" names what the code actually does —
tune marked's tokenizers + the code-block/table serializers for our content —
without claiming a fidelity property the output no longer guarantees. Pure
rename (createFaithfulMarked/FaithfulMarkdownText/parse+renderFaithfulCodeBlock/
StarterKitFaithful -> Tuned), no behavior change; 834 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 03:07:59 +07:00
Sergey KozyrenkoandClaude Opus 4.8 e4f80badd4 feat(markdown-editor): decode named HTML entities outside code instead of freezing them
The `literalAmpersand` extension pre-encoded every `&` so @tiptap/markdown's
decodeHtmlEntities netted to a no-op, keeping `&lt;`/`&gt;`/`&amp;`/`&quot;`
byte-verbatim. But bare-prose entities in the knowledge corpus are HTML-encoding
artifacts from ingestion (e.g. `Time difference &gt; 5 min` meaning `>`), and
freezing them as `&gt;` is not what the author wrote. Drop the extension so
marked decodes the 4 named entities in prose (`&lt;`->`<`, etc.).

Verified scoped and safe: numeric refs (`&#123;`, `&#40;`) and any entity inside
code / inline-code are NOT decoded (code content bypasses inline tokenizers), a
bare `&` survives as `&` (only valid entities decode), raw `<tags>` unaffected,
and the identity serializer keeps the decoded `<` from being re-encoded on save.
Full corpus (378 real knowledge docs): zero new non-convergence, zero
bare-&->&amp; regressions. Live on :8000: `&gt; 5 min` -> `> 5 min` on load+save
while numeric/code entities stay put.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 00:34:47 +07:00
Sergey KozyrenkoandClaude Opus 4.8 b73350ae2f feat(markdown-editor): autolink bare URLs/emails instead of freezing them as text
The faithful marked layer neutralised marked's `autolink`/`url` tokenizers so a
bare `https://…`, `<url>` or email round-tripped as literal text. That was a
deliberate byte-fidelity choice, but the product decision is now to let URLs
become links. Drop the two tokenizer overrides and flip the tiptap Link config
to `autolink/linkOnPaste: true` so load, paste and typing all linkify alike
(the typing==load invariant is preserved).

Verified: removing the two lines has zero collateral — `<input>`, `__init__`,
`\d+`, `&lt;` still round-trip byte-identical (handled by the other tokenizers),
and full-corpus convergence is unchanged (same 7/378 pre-existing non-convergent
docs with and without the change). URLs with `{PLACEHOLDER}` don't autolink;
URLs with balanced `$(…)` linkify and converge. Live on :8000: prose URLs render
clickable, code-block URLs stay literal, raw source stays byte-exact, load does
not dirty the form.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:32:30 +07:00
Sergey KozyrenkoandClaude Opus 4.8 db9b832f48 docs(markdown-editor): trim a narration comment + pin the shared-editorRef assumption
- Drop the insertAtCursor 'focus before scroll (same as cycleToVariable)' comment — the
  gotcha is already documented at cycleToVariable's focus() 15 lines up; the parenthetical
  was pure back-reference narration (report comment-audit).
- settings-prompt: document why one editorRef is shared by both prompt tabs (Radix unmounts
  the inactive TabsContent, so only one editor mounts) and what would break it (F12).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:51:27 +07:00
Sergey KozyrenkoandClaude Opus 4.8 968d5f85d2 refactor(markdown-editor): make the barrel tiptap-free so lazy(MarkdownEditor) is real (L1)
index.ts re-exported the heavy MarkdownEditor value, so any route statically importing
a light util from the barrel (settings-prompt / template import EditorViewModeToggle +
the variable probes) pulled the 527KB tiptap chunk into its eager graph — defeating
their lazy(() => import(MarkdownEditor)). Extract the pure {{ }} helpers (VARIABLE_RE,
variableProbe, findVariableUseRanges) into markdown-editor-variable-syntax.ts (no
tiptap), export only light utils + the erased Handle type from the barrel, and import
MarkdownEditor directly from its module (static in knowledge-form-controls, dynamic in
the two lazy routes). Verified at build-artifact level: settings-prompt/template chunks
now have ZERO static import of the tiptap/markdown-editor chunks — they reference the
editor only via import("./markdown-editor-…"). tsc + lint + build + 831 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:49:38 +07:00
Sergey KozyrenkoandClaude Opus 4.8 996fcf49fa fix(markdown-editor): drive toolbar state via useEditorState (M4)
tiptap v3's useEditor does not re-render the component on every transaction, so the
toolbar's 16 editor.isActive()/can() reads went stale on selection-only cursor moves
— click into a bold word and the Bold button stayed unlit until the next doc edit.
Read all button states through a single useEditorState selector, which re-runs per
transaction and re-renders only when a state flips. Fixes the staleness and drops the
incidental full re-render. Idiomatic tiptap v3 pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:42:34 +07:00
Sergey KozyrenkoandClaude Opus 4.8 e324db3488 test(markdown-editor): assert cycleToVariable actually advances the selection (F7)
The handle test asserted only cycleToVariable's boolean return, so a regression to
'always select hits[0]' would stay green. jsdom has no layout, so the selection can't
be read directly — but insertAtCursor replaces the SELECTED range, making the target
observable: cycle twice, insert, and assert the SECOND occurrence (not the first) was
replaced. Distinguishes real advancement from a stuck-on-first regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:39:13 +07:00
Sergey KozyrenkoandClaude Opus 4.8 0d862d1e18 refactor(markdown-editor): fix false comments, simplify setContent, dedup wrapper (L2/L4/L5/L6/F9)
- L2: the value-sync comment claimed setContent doesn't honor contentType — false for
  @tiptap/markdown 3.27.1 (dist setContent parses via editor.markdown.parse). Collapse
  the manual parse+setContent to setContent(value,{contentType:'markdown',emitUpdate:false});
  verified byte-identical across 45 inputs (39 .tmpl + edge cases).
- L4: isSafeImageSrc comment overstated 'for saved content' — it guards only the toolbar
  button; load/paste bypass it (inert; viewer sanitizes on render). Comment corrected.
- F9: the table pipe-escape comment implied the load path was handled — clarified it is
  save-side only; a raw pipe in inline code on LOAD is a marked-tokenizer limitation.
- L5: hoisted the duplicated ~200-char wrapper className to WRAPPER_CLASS.
- L6: dropped the dead MarkdownEditorProps barrel export (+ its now-dead export keyword).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:35:45 +07:00
Sergey KozyrenkoandClaude Opus 4.8 b7012ee2af test(markdown-editor): add structure-count invariant + nested-composition coverage (M2, M3)
The round-trip tests asserted idempotence + word-multiset but not STRUCTURE, so a
word-preserving structural downgrade (a code block splitting, a dropped list item)
converged and passed green — the exact reason M1 shipped invisible. Add
structuralCounts() (structural node types, paragraph/text excluded as benign reflow)
and assert it in the corpus test (all 39 .tmpl) and a new generative nesting test that
composes primitives to depth ≥2 (ordered>bullet>code, blockquote>list, fence-in-fence)
— the historical drop class, now structure-preserving after the indented-fence + M1
fixes. Updates the stale 'ordered>bullet>code NOT generated' comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:31:22 +07:00
Sergey KozyrenkoandClaude Opus 4.8 15fab1b405 fix(markdown-editor): widen code-block fence past inner backtick runs (M1)
@tiptap/extension-code-block's renderMarkdown always emitted a 3-backtick fence, so
a code block whose content contains a ``` line (a doc demonstrating fenced markdown)
re-parsed as TWO blocks on the next load — the inner fence closed the outer. Override
renderMarkdown to widen the fence to longer than any backtick run inside (CommonMark).
Regression test: the nested-fence doc now stays one codeBlock and converges.

Also documents (with a pinned test) the sibling F2 case — a backtick INSIDE inline
code — as a genuine upstream limitation: @tiptap/markdown derives a mark's delimiter
from a fixed placeholder (getMarkOpening/getMarkClosing), so unlike a node it can't be
content-aware; the reviewer's suggested mark renderMarkdown override is a no-op
(verified against dist). Fixes the stale editor-markdown.ts pointer (L3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:27:01 +07:00
Sergey KozyrenkoandClaude Opus 4.8 ed6b779395 style(frontend): fix prettier drift blocking CI
CI runs prettier --check over src/**; 10 files failed. The markdown-editor module
(7) + settings-prompt (1) drifted this session (eslint --fix was run without a
prettier pass after the rename/barrel + dep edits); knowledge-form-blocker.test /
knowledge-form.test (2) were pre-existing drift from an earlier commit. prettier
--write all; no logic change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:20:17 +07:00
Sergey KozyrenkoandClaude Opus 4.8 8c67ed29fe test(markdown-editor): extract shared jsdom setup + roundTrip helper
The 7-line jsdom polyfill (elementFromPoint + Range rects, needed for any editor
mount) was byte-identical across 7 test files, and the parse↔serialize roundTrip
harness re-declared in 3. Extract both into markdown-editor-test-setup.ts
(setupEditorJsdom + roundTrip); each test now imports them, killing the copy-paste
and the silent-drift risk if the required stubs change.

tsc + lint + full vitest (826) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:00:37 +07:00
Sergey KozyrenkoandClaude Opus 4.8 b9130d2a6c test(markdown-editor): cover the imperative handle + value sync
Component-level test (renders MarkdownEditor with a ref, reusing the jsdom
elementFromPoint/Range polyfills): cycleToVariable rejects a missing variable and
cycles/wraps an existing one without throwing; insertAtCursor emits the inserted
text through onChange; and the value-sync path suppresses onChange for an echoed
value while applying a real external change to the editor content. Exercises the
previously-buggy imperative-handle orchestration that composes the (individually
covered) findVariableOccurrences / resetUndoHistory pieces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:56:06 +07:00
Sergey KozyrenkoandClaude Opus 4.8 3004d94226 test(markdown-editor): cover isSafeImageSrc image-src allowlist
Export isSafeImageSrc (was module-private, so no test could reach the folder's only
security guard) and add an it.each unit test: allows http(s):// and data:image/*,
rejects javascript:, data:text/html, data:application/*, vbscript:, file:, and a
malformed URL (catch branch). Note: a bare relative string resolves to the page
origin and is intentionally allowed — the guard blocks dangerous protocols, not
non-absolute URLs (verified against the actual implementation, not assumed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:53:07 +07:00
Sergey KozyrenkoandClaude Opus 4.8 b59de20556 refactor(markdown-editor): drop dead export + trim duplicated comment
- FaithfulMarkdownText: drop the `export` — it has zero importers, used only
  inline by createMarkdownLayer in the same file (its sibling createFaithfulMarked
  is already a private const).
- markdown-editor.tsx: remove the reset-history tail comment that restated verbatim
  the invariant already in resetUndoHistory's header (lines 52-53); the 'when it
  fires' explanation above it stays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:50:50 +07:00