The AppShell navbar breakpoint and both burger toggles' hiddenFrom/visibleFrom
must be equal, or the sidebar becomes unreachable on tablet widths (the round-1
regression). A comment guarded that before; now a shared const does. Add
NAVBAR_COLLAPSE_BREAKPOINT='md' to sidebar-atom.ts and reference it from the navbar
breakpoint (global-app-shell) + both toggles (app-header). aside.breakpoint and the
sm brand/search gates are intentionally separate contracts, left untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the navbar sm->md change on this branch: the two header sidebar
toggles were still gated at sm, so in the 768-991 band the DESKTOP toggle was
shown while the navbar used the MOBILE drawer collapse state — clicking it
flipped the wrong atom and the drawer could not be opened (sidebar unreachable
at 768/820, caught by QA). Gate the mobile toggle hiddenFrom=md and the desktop
toggle visibleFrom=md so the mobile toggle drives the drawer across the whole
tablet band.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
At tablet widths (~768px) the fixed ~300px global sidebar stayed pinned, leaving
too little room for content: the settings tables (Members etc.) overflowed the
offset content area and pushed the Role/actions columns off-screen with no
horizontal scroll (unreachable). Raise the AppShell navbar (and page aside)
breakpoint from `sm` (768px) to `md` (992px) so the whole tablet band uses the
toggle drawer (closed by default) and content gets the full width.
Verified with Playwright screenshots: 768px settings/members now fits all columns
(table right 736<768, no overflow); desktop (>=992px) unchanged (sidebar pinned,
content offset); mobile unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The #285 gate dropped every remapped (wrong-layout) candidate shorter than 3
chars, which broke the legitimate short prefix '/сщ' -> 'co' -> Code while '/co'
still worked. Replace the blanket length filter with a match-TYPE gate: the
original query and remaps >= 3 chars match fully (title/description/searchTerms);
a short (1-2 char) remap is restricted to a TITLE fuzzy-match. So '/сщ' -> 'co'
matches the 'Code' title again, while '/cy' -> 'сн' and '/b' -> 'и' still do not
surface Footnote (they only ever leaked in via the 'сноска'/'примечание'
searchTerm substrings, not the title).
Adds positive tests for /сщ and /co; keeps the /cy and /b negatives.
closes#283
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F4: menu-items.layout.test.ts imports from './menu-items' (relative, no extension),
matching the sibling test files (was still the aliased '@/.../menu-items.ts').
F5: remove the dead 'candidate !== originalCandidate' clause from the remapped-candidate
filter — buildLayoutCandidates dedupes remaps against the original via Set, so the tail
after destructuring can never equal the original; the length gate is the only real
condition. Comment updated to state the dedup invariant instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This actually lands F1+F2 (round 1 pushed only the test rename by mistake).
F1: only the ORIGINAL query matches without length limits; remapped (wrong-layout)
candidates must be >= 3 chars before they can match, via a shared candidateMatchesItem
helper applied to both the item filter and the tie-break sort. Stops a 1-2 char ASCII
query from spuriously substring-matching Cyrillic searchTerms (/cy->сн no longer hits
'сноска', /b->и no longer hits 'примечание'), while keeping real wrong-layout commands
(/сщву->Code, /cyjcrf->Footnote), genuine short queries (/p, /h1) and Cyrillic terms
(/сноска->Footnote) working.
F2: reword the buildLayoutCandidates JSDoc (an ASCII query yields multiple candidates;
dedup only collapses when nothing is remappable).
Adds negative tests for /cy and /b.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: only the ORIGINAL query does full matching; remapped (wrong-layout) candidates
must be >= 3 chars and differ from the original before they can match (via a shared
candidateMatchesItem helper, applied to both the filter and the tie-break sort). This
stops a short remapped candidate from substring-matching the only cyrillic searchTerms
(/cy->сн, /b->и no longer surface Footnote) while keeping real wrong-layout commands
(/сщву->Code, /cyjcrf->Footnote) and genuine cyrillic terms (/сноска->Footnote) working.
F2: fix the buildLayoutCandidates JSDoc (an ascii query yields multiple candidates,
not a single-element set).
F3: rename the test to menu-items.layout.test.ts + relative import, per sibling convention.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Typing a command with the wrong layout (e.g. Russian ЙЦУКЕН -> /сщву for 'code')
matched nothing and collapsed the popup. Add ЙЦУКЕН<->QWERTY layout maps and a
buildLayoutCandidates(query) = [original, RU->EN, EN->RU]; getSuggestionItems now
matches an item if ANY candidate hits (fuzzy title / description / searchTerms),
and the tie-break sort is candidate-aware. Keeping the original among candidates
preserves genuine Cyrillic search terms (сноска -> Footnote). One-function change;
slash-command.ts allow() reuses it, so the popup-collapse is fixed transitively.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a new value "inline" to the image align attribute (alongside
left/center/right/floatLeft/floatRight). Inline images render as
inline-block containers, so consecutive ones form a row that wraps
naturally on narrow viewports; unlike the float modes, text does not
wrap around them.
- applyAlignment: reset-then-apply extended to display/vertical-align;
the reset restores the constructor's inline display:flex so non-inline
modes keep byte-identical styles and editor-ext stays independent of
the client CSS class
- image bubble menu: new "Inline (side by side)" button (IconLayoutColumns)
with active state, mirroring the float buttons
- i18n: key registered in en-US and ru-RU ("В ряд"), like the float labels
- tests: 3 new applyAlignment specs (apply, reset on switch-away, float->inline)
- no schema/MCP/markdown changes needed: align round-trips as data-align
F1: extract the navbar-visibility crux (width/height 0 or right<=0 -> hidden)
from getNavbarRect into a pure isNavbarRectVisible in dock-helpers.ts + 3 tests;
getNavbarRect calls it (identical null cases).
F2: base the dock/undock button's label/icon/title on the effective useDock state
(docked && dockRect present) rather than the raw docked flag, so a docked window
that fell back to floating (collapsed sidebar) doesn't show 'Undock'. Toggle
action unchanged; no remount.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Icons are rendered only as <item.icon style={...} stroke={2} />, so type the
field as ComponentType<{ style?; stroke? }> instead of typeof IconBold. stroke is
string|number to match Tabler's own prop type, so Tabler icons and the local
IconStress both satisfy it without the 'as unknown as' cast.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unit-test the focus-restore guard: an external <input> active -> editor.view.focus
NOT called (deliberate move respected); a non-focusable element active -> focus
called once. Fake editor + fake timers (rAF via setTimeout stub); view.focus is a
spy. Regression lock for the guard that keeps focus out of the page-title input.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: add code-block-view.test.tsx (mirrors the footnote structure harness) asserting
the language selector renders only when editor.isEditable, and the copy button is
present in both modes.
F2: remove the now-dead justify=flex-end on the absolutely-positioned menu Group.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drag the floating AI-chat window onto the sidebar and release over it to DOCK it
— the window pins to the live navbar rect, overlaying the page tree; a drop-zone
highlight shows while dragging over it. Closing the chat re-shows the tree.
Undock via a header button or by dragging the docked window back onto content
(pops out floating at the drop point). The docked/floating mode persists in
localStorage and the docked window follows the navbar width (manual resize,
space<->shared route change) via a ResizeObserver + sidebar-toggle/transitionend
re-sync; when the navbar is collapsed/absent the window falls back to floating
instead of vanishing. Dock/undock only flips a mode atom + geometry — ChatThread
is never remounted, so an in-flight response stream is not interrupted.
Frontend only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Select a vowel and one click places a combining acute accent over it; clicking
again removes it (toggle). Inserts the literal Unicode char U+0301 right after
the letter — plain text, not a custom TipTap mark — so it survives HTML/Markdown
export, full-text search and public share with zero server/converter changes.
Insert/remove is a single transaction (one Ctrl+Z), inherits the letter's marks
(bold/italic/color), and restores the original selection so the active state
toggles correctly. Editable bubble menu only. New pure helper stress-accent.ts
(+ 5 unit tests). i18n: en 'Stress' / ru 'Ударение'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The row/column grip and cell-chevron menus are Mantine <Menu>s with
returnFocus:true whose targets live outside the editor's contenteditable. After
a menu action focus returns to that outside target, so ProseMirror's undo keymap
never sees Ctrl+Z until the user clicks back into a cell. Add
refocusEditorAfterMenuClose(editor): on the next frame (after Mantine's
returnFocus) restore editor focus via view.focus(), unless the user intentionally
moved to another input/editable. Wired into both onClose paths (the shared
row/column lifecycle hook + cell-chevron).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The code-block control panel (language selector + copy) took a full row above
the code. Move both to an absolute overlay in the top-right corner and hide the
language selector until the block is hovered/focused; the copy button stays
always visible. In read-only the language selector isn't rendered at all. The
<pre> (editable contentDOM) stays FIRST in the DOM so click hit-testing (#146)
is not regressed; the panel leaves the flow via position:absolute.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The banner only offered 'Make permanent'. Add a secondary destructive
'Move to trash' button that soft-deletes the note now instead of waiting for
TTL expiry, reusing the tree/header soft-delete path (useTreeMutation.handleDelete):
optimistic tree removal, the undo-toast, the deletedAt cache stamp, and the
redirect to space home. No confirm modal (project convention = undo-toast).
Gated on the existing Edit permission. Client-only, no server/i18n changes
(both labels already exist).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- F1: gate the card on rows-WITH-text (`thread.some(row => row.text.length > 0)`)
instead of thread length. A text-less root whose only reply is also text-less
would otherwise open an empty <Paper> (the render already filters empty rows).
New test locks it (parent + reply both empty → no card).
- F2: ESTIMATED_CARD_HEIGHT 200 -> 300 (= CARD_MAX_HEIGHT) so the flip-above
decision reserves the real worst-case height and a tall thread near the
viewport bottom flips up instead of overflowing off-screen.
vitest 19/19, tsc 0, eslint 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per maintainer feedback: show the comment author and the whole thread (parent
+ replies), but as simple "Author: text" lines — no avatars, timestamps, or
thread chrome ("it's already clear they're comments on one entry, one after
another"). Also lengthen the open delay so the card doesn't pop up on a
passing glance.
- Render each comment in the thread as a plain line: bold "Name:" + text,
parent first then replies (createdAt asc). Empty-text comments are skipped.
- OPEN_DELAY_MS 120 -> 350.
- Drop the avatar/relative-time/divider UI (and the CustomAvatar/timeAgo
imports). buildThread (root + direct replies) is unchanged — the comment
model is flat, so direct children of the root are the full thread.
Tests updated to the "Author: text" shape (textContent-based, incl. ordering).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lock the feature's central invariant — the tooltip must never intercept the
comment-mark's click (which opens the side panel). pointer-events:none is the
single property guaranteeing that, and it was unasserted: a regression dropping
it from the style object would let a lingering card swallow the click with no
test failing. Assert it in the "shows after delay" test.
Adds CommentHoverPreview, mounted in page-editor next to <EditorContent>:
hovering a `.comment-mark[data-comment-id]` span shows a small floating card
(createPortal, position:fixed, pointer-events:none so it never intercepts the
mark's click) with the parent comment's plain text. Uses useCommentsQuery
(shares the ["comments", pageId] cache with the side panel — no extra
request). Skips unknown/not-yet-loaded, resolved (data-resolved attr or
resolvedAt/resolvedById), and empty-text comments. A ~120ms open delay avoids
flicker; hides on mouseout / mousedown / scroll(capture) / resize / page
change. commentContentToText flattens the comment's ProseMirror doc
(stringified or parsed) to plain text, preserving hardBreaks as newlines and
never throwing. Main editor only (read-only / shares / history out of scope).
closes#268
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review round 1 on the scroll-position feature:
- F1: add two tests for the hook's subtlest invariants — (a2) the restore
target is captured synchronously at mount and survives a fresh scroll@0
overwriting storage on load (a regression moving the capture into an effect
would now fail); (a3) restore runs at most once per mount even when called
again (the wiring effect can re-run).
- F2: log instead of silently swallowing sessionStorage errors in
readStorage/writeStorage (AGENTS.md "errors must never be swallowed" rule);
no user notification since a missed scroll restore is not actionable.
- F3: document the hard dependency on PageEditor remounting per page
(key={page.id}) at the refs declaration — the per-mount refs are not reset
on an in-place pageId change, so removing that key would break restore on
the 2nd page.
vitest 9/9, tsc 0, eslint 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds useScrollPosition(pageId): saves window.scrollY to sessionStorage
(key gitmost:scroll-position:<pageId>) on throttled scroll / pagehide /
visibilitychange / cleanup, capturing the previously-saved value
synchronously at mount before any handler can overwrite it with the fresh 0.
restoreScrollPosition() (wired in page-editor.tsx to fire once the live
content is laid out, !showStatic && editor) yields to a #hash anchor, then
polls the document height and scrolls to the saved Y once the content is
tall enough, with a 5s timeout clamped to the max reachable position. All
storage access is try/caught so a disabled/quota'd Storage never breaks the
page. The in-flight restore poll is held in a ref and cancelled on unmount,
so a fast SPA navigation can't scroll the next page. closes#266
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reviewer noted the in-order emitter's else branch (a NOT-next-to-emit
segment failing → buffer an empty placeholder so the drain can skip it,
use-streaming-dictation.ts:215-218) was the one reachable ordering branch
left uncovered. Add a non-vacuous case: with 3 segments, reject seq 1
(out of order) → one notification, nothing emitted; resolve seq 0 → "alpha";
resolve seq 2 → "gamma". The seq-2 flush proves the empty placeholder let the
emitter advance PAST the failed seq 1 — without the else branch the drain
would stall at the missing seq 1 and "gamma" would never emit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After "Reindex now" the "Indexed X of Y" counter froze at 0 until a manual
reload. Root cause is purely client-side: right after the mutation the
client still holds the PRE-reindex settings snapshot, which for an already
fully-indexed workspace reads reindexing=false, indexed>=total. The
deadline-clearing effect evaluated isReindexComplete() against that stale
snapshot, read it as "done", and cleared the poll deadline before the first
post-reindex poll ever landed — so polling never ran and the counter stayed
at 0 (a reload just fetched one fresh snapshot).
Gate completion on having actually observed the active run: a
reindexSeenActiveRef, reset on each new reindex (mutation onSuccess, before
setting the deadline) and latched true once a poll reports reindexing=true.
isReindexComplete(status, seenActive) and nextReindexPollInterval now require
seenActive, so the stale fully-indexed snapshot no longer reads as finished.
The server pre-seeds reindexing=true from enqueue time, so seenActive latches
early and a genuine completion still stops polling promptly; the
REINDEX_POLL_CAP_MS cap is checked first and always wins, so polling can
never run away. closes#262
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backfill the two genuinely-uncovered infra-free units from the #244 Part B
test backlog (the rest was already covered by #248/#257):
- use-streaming-dictation: the in-order transcription emitter. Drives the
real hook via renderHook with mocked VAD + deferred transcribeAudio so the
test controls response order. Asserts out-of-order HTTP responses still
emit text in segment order; whitespace trimmed and empty results dropped
while the sequence advances; a failed segment shows one notification and is
skipped so later segments still flush; a response resolving after cancel()
is dropped (stale-epoch guard).
- internal-link-paste (handleInternalLink / createMentionAction): validateFn
reject → no resolve/dispatch; resolve → mention node with the resolved page
+ anchor dispatched via replaceWith at pos; "Untitled" fallback; reject →
raw url inserted as text under a link mark; createMentionAction wiring to
getPageById on success + failure.
Test-only; no production code changed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The floating bubble menu had no way to clear formatting, so in the
default configuration (fixed toolbar disabled) users could not reset
inline formatting at all. Mirror the fixed-toolbar action into the
bubble menu: a new "Clear formatting" item running unsetAllMarks().
- bubble-menu.tsx: import IconClearFormatting; append a non-toggle
"Clear formatting" item (isActive: () => false) to the items array.
- No i18n changes — the "Clear formatting" key already exists in all
locales.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an inline spoiler (Telegram/Discord-style hidden text): a TipTap mark
`spoiler` rendered as <span data-spoiler="true" class="spoiler">, blurred via
CSS and revealed on click (UI-only is-revealed class, never persisted).
- packages/editor-ext: the Spoiler mark (inclusive:false, set/toggle/unset
commands, ||text|| input rule), exported; a lossless turndown rule emitting
raw inline HTML; round-trip test.
- apps/client: SpoilerView mark-view (ReactMarkViewRenderer, Link pattern),
registration in extensions, bubble-menu toggle button (editable only), CSS
(blur + @media print reveal), en/ru i18n.
- apps/server: register Spoiler in collaboration.util tiptapExtensions so the
mark survives HTML<->JSON export/index/import/Yjs; a test proving the public
share keeps the spoiler (it isn't stripped with comments).
No keyboard shortcut: the proposed Mod-Shift-s collides with Strike (and
Mod-Shift-h with Highlight); the ||text|| input rule + the bubble-menu button
cover ergonomics.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: add a test that empties a non-empty doc via a change-origin transaction
(ySyncPluginKey meta, the shape y-tiptap sets for remote/merge updates) and
asserts the intentional-clear signal is NOT emitted — pinning the
isChangeOrigin early-return that keeps remote emptiness from punching through
the #248 server guard. The 4 existing tests use local transactions and never
exercised that true-path (verified: removing the guard fails only this test).
F2: record the #248 empty-overwrite guard and the #251 intentional-clear in the
CHANGELOG [Unreleased] Fixed section.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F6: extend embeddablePredicate to pages with body content but null text_content,
keyed on the text-node marker "type":"text" (not a bare "text": key, which
also matched math nodes' attrs.text and would leave math-only pages stuck
below 100%). Numerator and denominator share the predicate; tests assert the
compiled WHERE is byte-identical and a math-only doc is excluded.
F7: correct the start() JSDoc (both totals are the real page count).
F8: nextReindexPollInterval reuses isReindexComplete.
F9: getMasked reads progress first and skips the two COUNTs while a reindex is active.
F10: pre-seed the progress entry with a short 45s TTL so a deduped enqueue's
phantom "0 of N" expires quickly instead of sticking for the 1h TTL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
findBreadcrumbPath set node.name='Untitled' in place, mutating the shared
sidebar tree (treeData passed from resolveBreadcrumbNodes). Surface 'Untitled'
via a shallow copy on the returned chain only; input nodes stay untouched.
Add tests for the non-mutation invariant plus applyUpdateOne reducer,
formatRelativeTime buckets, and the pure tree mappers (sortPositionKeys,
pageToTreeNode).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The #248 store-side empty-guard (onStoreDocument) unconditionally refuses to
overwrite non-empty persisted content with an empty document, because a
momentarily-empty live Y.Doc is indistinguishable from a real clear at the
store layer. That correctly blocks glitches/bad-merges, but also blocks a user
who genuinely wants to empty a page. This re-introduces a WORKING, narrow,
non-spoofable exception (the dead context.intentionalClear hatch #248 removed
never had a real channel).
Definition of an intentional clear (client, IntentionalClear editor extension):
a LOCAL user transaction (docChanged, NOT a remote y-sync change — filtered via
isChangeOrigin) that reduces a non-empty doc to the empty single-paragraph
shape. This is exactly the select-all + Delete/Backspace keystroke path.
Transport (option b — hocuspocus stateless message): on that transition the
client sends a `{type:'intentional-clear'}` stateless message. The server
(PersistenceExtension.onStateless) records a short-lived (TTL 60s > 45s
maxDebounce), single-use "pending clear" flag keyed by the connection's
document. The next debounced onStoreDocument consumes it on the empty-guard
branch to let that one empty write through.
Why this is the right channel and non-spoofable:
- Yjs transaction origin/metadata does not survive to the server store; awareness
is per-connection and racy. A stateless message ties the signal to a specific
clear, survives the debounce, and rides the authenticated connection.
- The document is taken from the connection, never the payload, so a client
cannot target another page.
- The flag is read ONLY on the empty-over-non-empty branch, so the worst a forged
signal can do is clear a page the connection may already edit; it can never
force or alter a non-empty write. Read-only connections cannot arm it. Every
non-empty store drops a pending flag, so "cleared then retyped" leaves nothing
usable; the flag is single-use and TTL-bounded.
NOTE: #248 is not yet on develop, so the empty-guard block is included here as
the foundation this exception extends. If #248 lands first this rebases cleanly
(the guard logic is identical; the #251-unique additions are the exception,
onStateless, the pending-flag state, and the client extension).
Tests:
- Server (real transport path, not a hand-poke): onStateless sets the flag with
the exact client payload, then the debounced onStoreDocument persists the empty
doc; plus single-use consumption, read-only rejection, non-empty-store drops
the flag, and the unchanged #248 guard tests (empty-over-non-empty blocked,
empty-over-empty allowed).
- Client: a real Editor + the actual selectAll+deleteSelection command emits the
signal; typing / non-emptying edits / already-empty docs do not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F4: extract the reindex button `loading` predicate into a pure, unit-tested
`isReindexButtonLoading({ mutationPending, deadline, status })` next to the
other reindex helpers, replacing the inline JSX expression. Covers the
load-bearing post-cap case (deadline nulled, reindexing stale-true -> not
loading) plus mutationPending, active-run, and finished cases.
F5: rewrite the `useAiSettingsQuery` poll comment to match the actual
`nextReindexPollInterval` stop condition (continues while reindexing===true OR
within deadline and not fully indexed; stops only when reindexing===false &&
indexed>=total, or the deadline cap) instead of the stale "until indexed===total".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
F1: clear the "Reindex now" spinner once the poll cap fires. Gate the
reindexing part of the button's loading state on the active poll window
(reindexDeadline !== null) so a run that outlives the 120s cap no longer
leaves the button stuck-disabled with a stale `reindexing: true`; the
admin can restart.
F2: rewrite reindexWorkspace JSDoc to describe the EMBEDDABLE page set
(text OR existing embeddings), matching getEmbeddablePageIds /
countEmbeddablePages instead of the old "every non-deleted page".
F3: extract the shared embeddable-content predicate into a private
PageRepo.embeddablePredicate helper, called by both countEmbeddablePages
and getEmbeddablePageIds, removing the verbatim duplication. Behavior is
identical (lockstep int-spec stays green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the ~110 duplicated lines into one parameterized
useImageTextFieldControl and make useAltTextControl/useCaptionControl
thin wrappers. Behavior identical; t("...") literals stay in the
wrappers so i18n extraction keeps working. sanitizeCaption still
exported for its unit test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The comment claimed 250 groups -> 499 chars -> slice past 500; the
input is 120 "a b " groups collapsing to 479 chars, under the cap
with no slice. Correct the comment and assert the 479 length.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>