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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
A multi-encoded entity in prose (`&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>
- 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>
- 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>
"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>
The `literalAmpersand` extension pre-encoded every `&` so @tiptap/markdown's
decodeHtmlEntities netted to a no-op, keeping `<`/`>`/`&`/`"`
byte-verbatim. But bare-prose entities in the knowledge corpus are HTML-encoding
artifacts from ingestion (e.g. `Time difference > 5 min` meaning `>`), and
freezing them as `>` is not what the author wrote. Drop the extension so
marked decodes the 4 named entities in prose (`<`->`<`, etc.).
Verified scoped and safe: numeric refs (`{`, `(`) 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-&->& regressions. Live on :8000: `> 5 min` -> `> 5 min` on load+save
while numeric/code entities stay put.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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+`, `<` 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>
- 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>
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>
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>
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>
- 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>
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>
@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>
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>
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>
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>
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>
- 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>