Screening keyword highlighting¶
Project administrators configure two keyword lists, inclusion keywords and exclusion keywords. When a reviewer screens a study on the stage review page, matches for those terms are highlighted inside the PDF shown by the Study Source panel's integrated viewer. The feature is visual assistance only: it never records, suggests or changes a screening decision, and it performs no ranking or classification.
Related issues: #671 (epic), #1366 (highlight keywords from inclusion/exclusion criteria).
Scope and slices¶
| Slice | Content | Status |
|---|---|---|
| A | Domain model + pmProject persistence, project-admin endpoint, feature flag, Screening Settings UI, docs |
Implemented in #3491 |
| B | Text layer + highlight engine in StudyPdfViewerComponent, panel/stage-review wiring, ADR-014 addendum |
Implemented in #3492 |
| C | Title/abstract highlighting in the stage review Study Source card and legacy study card (KeywordHighlightedTextComponent) |
Implemented in #3522 |
| D | Popup viewer parity: optional keywords field on the popup state message (#3504) |
Implemented in #3668 |
| E | Title/abstract highlighting in the detached study source window: optional keywords field on the source-window state message (#3504) |
Implemented in #3673 |
| F | Whole-document match counts and previous/next page-with-matches controls in StudyPdfViewerComponent (inline and popup) (#3504) |
Implemented in #4003 |
| H | Per-reviewer "Show keyword highlights" switch: ReviewPreferences.HideScreeningKeywordHighlights, applied by nulling the one keyword config (#3504) |
Implemented in #4005 |
| Panel | Study Source redesign keyword panel and toggle with per-term hit counts | Built in #4022; wired in #4031 |
| Follow-ups (#3504) | Not started unless noted |
Out of scope by design: OCR for image-only PDFs, automatic include/exclude, AI classification, ranking, any change to screening answers or the decision card.
Flag decision¶
New flag screeningKeywordHighlighting (default off everywhere; featureFlags.services: [api],
web half tsName: screeningKeywordHighlighting). It gates:
- the Screening Settings "Keyword highlighting" section (web),
- the
PUT api/projects/{projectId}/screening-keywordsendpoint ([FeatureGated], 404 while off), - the highlight layer in the viewer (web; the viewer itself is additionally behind
integratedPdfViewer), - title/abstract highlighting on the stage review page (web; slice C, not behind
integratedPdfViewer), - title/abstract highlighting in the detached study source window (web; slice E): the stage review page sends no keywords to the window while the flag is off.
Why flagged: the settings surface would otherwise appear for every project administrator in
production while the viewer that consumes it stays behind integratedPdfViewer; the flag keeps the
two surfaces coherent and provides a kill switch. Reading the lists is not gated (they ride on the
project DTO; absent fields deserialise as empty). No environment enablement is part of this work.
Domain model (slice A)¶
Project.ScreeningInclusionKeywordsandProject.ScreeningExclusionKeywords:IReadOnlyList<string>backed by privateList<string>?fields, mapped withMapField(...).SetElementName(...).SetIgnoreIfNull(true)inProjectRepository.RegisterProjectAndJobMappings, so existing documents need no migration and store no element until first set. Included in the summary projection so every project read carries them.- Single mutator
Project.SetScreeningKeywords(IEnumerable<string> inclusion, IEnumerable<string> exclusion)applyingScreeningKeywordRules: - trim, collapse internal whitespace to one space, drop empties;
- de-duplicate within a list on the folded key (case, diacritics, compatibility forms; same fold as the
viewer's
foldKeywordText; first occurrence wins, original spelling kept); - limits: 100 characters per term, 200 terms per list (
ArgumentException→ 400); - a term that appears in both lists (compared on the folded key) is rejected (400); the reviewer-facing semantics of such a term are undefined, and overlapping phrases are handled at render time instead.
- Existing
Project.Keywords(project metadata tags, JSON-Patch path/keywords, anonymousGET api/projects/keywords) is unrelated and untouched; the new names are deliberately explicit.
API (slice A)¶
PUT api/projects/{projectId:guid}/screening-keywords, bodyScreeningKeywordsUpdateDto { inclusionKeywords: string[], exclusionKeywords: string[] },[Authorize(ProjectAuthorization.ProjectDesignPolicy)](the existing project-administrator gate, Administrator group by default),[FeatureGated(Flag.ScreeningKeywordHighlighting)]. ReturnsScreeningKeywordsDto { projectId, inclusionKeywords, exclusionKeywords }with the normalised lists.- Read: both lists added to
ProjectDbBaseDto, so the generated webIProjectgainsscreeningInclusionKeywords/screeningExclusionKeywordsand the stage review page's existingselectCurrentProjectsignal carries them with no extra request. - Modelled on the category-guidance endpoint (commit
ffadc82ec).
Settings UI (slice A)¶
Screening Settings page (project/:projectId/admin/screening-settings, already guarded by
projectDesignGuardFn): a new "Keyword highlighting" section under the criteria block, flag-gated,
with two app-chip-input lists (inclusion, exclusion), save/discard, a dirty guard integrated into the
page's existing canDeactivate, and explanatory copy: highlights are visual only; whole-word matching;
trailing * for prefix matching; highlights need a PDF with extractable text. Save goes through a new
projectDetailActions.updateScreeningKeywords effect → generated client → reducer merges the returned lists
into the project entity.
Matching model (slice B, pure TypeScript, unit-tested)¶
- Input: the page's text-content items (
TextLayer.textContentItemsStr), joined in order; an item withhasEOLcontributes a newline. Positions map back to the item index and offset. - Folded matching: text and terms are both folded one code point at a time: NFKD (compatibility forms such
as
fiand full-width letters expand; accents split off), combining marks (\p{M}) removed, lower-cased with full case folding forß→ss(andẞ) and finalς→σ, then NFC. Socafematchescafé(precomposed or decomposed),fishmatchesfish,RATmatchesratandstrassematchesStraße, in both directions. The fold is linear, with a per-code-point cache. - Index map: every folded code unit records the
[start, end)range of the original code point it came from. An expansion (fi→fi,ß→ss) gives each produced unit the whole source range. A code point that folds to nothing (a combining mark) extends the end of the preceding character's range. Match ranges are therefore always whole original code points: a surrogate pair is never split, and a match ending on a base character includes its trailing combining marks. The mask and segments below use original offsets. - Whitespace inside a phrase matches any run of whitespace, including line breaks.
- Whole-word by default: a match must not be preceded or followed by a Unicode letter or digit
(evaluated on the folded text).
A trailing
*on a term removes the trailing boundary (prefix match). No other operator syntax; all other characters are literal. - Overlaps and duplicates: matches from both lists are painted into a per-character mask; the mask is
then cut into segments classified
inclusion,exclusionorboth. Nested, overlapping and repeated terms therefore merge without double-wrapping. - Cap: if a page yields more than 5,000 matches the highlighter stops and reports the cap (defensive).
Rendering (slice B)¶
- After each successful page render in
StudyPdfViewerComponent._renderPage, when a keyword config is present:page.getTextContent()→new TextLayer({ textContentSource, container, viewport }).render()into a<div class="textLayer">that is a sibling of the canvas inside aposition: relativewrapper sized to the canvas CSS width, with--scale-factorset to the render scale (the page is fitted to the viewer's width, capped at 1400px; see ADR-014's fit-to-width addendum). The text layer is cancelled/cleared on the same paths that cancel the canvas render (request token,_settleActiveRender,_destroyDocument), and a cancelled or superseded render never paints. - Highlights wrap matched ranges in
<mark class="syrf-keyword syrf-keyword--inclusion|--exclusion|--both">inside the text-layer spans. Text-layer text is transparent; marks usemix-blend-mode: multiplyso the PDF glyphs underneath stay readable. Elements are created through the container'sownerDocumentso Dockview panel windows (live DOM relocation) work. - The
--scale-factorResizeObserveris built from the canvas's owner window and re-bound (after every render) when Dockview moves the canvas into another window, so resizing a popout keeps the text layer aligned. It also watches the page frame: a debounced fit-to-width re-render rebuilds the text layer at the new scale. - The device-pixel-ratio watcher follows the same owner window (#4020):
devicePixelRatioand the(resolution: Ndppx)query are read from the canvas's window, re-armed there on relocation, and the page is redrawn if that window's ratio differs. - Accessibility: colour is never the only cue. Inclusion marks carry a solid 2px bottom border, exclusion
marks a double bottom border,
bothshows both; each mark hasaria-label="Inclusion keyword"/"Exclusion keyword". Colours are theme tokens (--syrf-keyword-inclusion-*,--syrf-keyword-exclusion-*) defined in both the light and.global-dark-themeblocks ofsyrf-theme.scss; the PDF page stays white in both themes, so the tokens are tuned for a white page. - Counts and notices: since #4031 these live in the keyword panel (see Keyword panel), which replaced the original per-page status line ("Keywords: N inclusion, M exclusion on this page") and its legend swatches. When the page has no text items the panel says "This page has no extractable text, so keyword highlights cannot be shown." SyRF does not perform OCR and the copy does not promise it.
- Live configuration: the viewer takes
keywordConfig = input<ScreeningKeywordConfig | null>(null); a change re-runs the highlighter on the existing text layer without reloading the document or altering_applyState, revision, retry or heartbeat semantics. The panel's_pdfIdentitykey is unchanged, so a keyword change never remounts the viewer. - Study switches: the viewer is recreated per study by the host; all highlight state is input-derived.
- Manual search and ordinary interaction: no key handling is added; the text layer makes browser find-in-page and text selection work on the rendered page, which they did not before.
- Popup viewer (
/pdf-viewer): shows no highlights in slice B; slice D below adds them.
Popup viewer parity (slice D)¶
- Protocol: the
syrf-study-pdf-viewer-statemessage gains an optionalkeywords?: { inclusionKeywords: string[]; exclusionKeywords: string[] } | null. It sits on the message, not onStudyPdfViewerStudy, so it plays no part in the popup's study comparison and a keyword change can never look like a study change. - Rolling-deploy compatibility: an absent field is accepted and means "no highlights", as
directUrldid before it. A stage-review tab loaded before the deploy keeps working with a new popup (no highlights), and a new stage-review tab keeps working with an old popup: the old guard ignores unknown properties, so the popup shows no highlights. isStudyPdfViewerMessagestays strict: when present and non-null,keywordsmust be an object with two string arrays; anything else rejects the whole message.- Coordinator:
StudyPdfViewerCoordinatorService.setKeywords(config)normalises the lists and posts the change at the current revision. A revision bump would make the popup treat the state as new and reload the document. Study changes still bump the revision and carry the current keywords.null(flag off) and a config whose lists are both empty send no field at all, so the message is the same as before this slice. Unchanged lists post nothing. - Stage review: an
effectforwards the samepdfKeywordConfigsignal the inline panel uses (so it ridesscreeningKeywordHighlighting) tosetKeywords;ngOnDestroyresets it tonullbeforeclear(). - Popup: the routed
/pdf-viewerpage isStudyPdfViewerComponentitself, so no host binds itskeywordConfiginput there. The component keeps the keywords from the latest authenticated state in a private signal. Its normalisedkeywordscomputed readskeywordConfig() ?? receivedand drives the sameuntrackedre-highlight effect as the inline viewer. The popup adopts keywords before the revision checks, because they are not revisioned and messages from the one opener arrive in order._applyState, revision adoption, retry backoff and heartbeat handling are unchanged.
Whole-document counts and jump-to-match (slice F)¶
- Scan: once
_loadDocumentadopts a document, and while keywords are set, the viewer walks every page (getPage→getTextContent) and runs the samematchKeywordsas the text layer, so folding and the 5,000-matches-per-page cap are identical. Page counts are summed; the result also records whether any page hit the cap, whether any page had text, and the pages with at least one match. - Ownership and cancellation: the extracted text belongs to the load that adopted the document (its
_loadRequesttoken and proxy). Each scan takes its own_scanRequesttoken and re-checks the token, the load token and the document after everyawait, so a superseded load, a document change andngOnDestroy(which bumps_loadRequest) all stop it and it never reports._destroyDocumentdrops the text and resets the result. The scan yields to the browser (setTimeout(0)) before the first page, so "counting…" paints, and then whenever about 16 ms of work has run since the last yield, so a long document does not block input. - Keyword changes: page text is extracted once per document and cached, so a keyword change re-runs
the matcher over the cached text. It never calls
getDocument, never re-extracts a page and never touches_applyState, revision, retry or heartbeat state. Switching highlighting off cancels the scan. A page whose extraction fails is not counted, is reported in the result as unreadable and is retried by the next scan. - Per-term counts: the scan also sums each page's
termCountswithsumKeywordTermCounts, so the result carries whole-document hits per configured term (zero-hit terms included, even when no page could be read) for the keyword panel. - Reporting (since #4031, in the keyword panel): the headline reads "Counting keyword hits…" while the scan runs and "N keyword hits in this PDF" once it completes; notes under the chips say "Some pages stopped counting at 5,000 matches." when a page hit the cap and "Some pages could not be read." when any extraction failed. When every page was read and none has text, the note is "This PDF has no extractable text, so keyword highlights cannot be shown." and the per-page no-text notice is not repeated; a document whose pages failed extraction is never reported as having no text. The headline is a polite live region. (Slice F first shipped these as two status lines above the page; #4031 removed them.)
- Controls: two Material icon buttons (
keyboard_arrow_up/keyboard_arrow_down, theme colours only) labelled "Previous page with keyword matches" and "Next page with keyword matches", with matching tooltips, inside the keyword panel since #4031. They go to the nearest earlier or later page with at least one match (either list) through the same page navigation as the toolbar's previous/next page buttons. They stop at the ends rather than wrap, like the page buttons: a button is disabled when there is no matching page in its direction, and both are disabled while the scan is incomplete or when the document has no matches. - Popup: the routed
/pdf-vieweris the same component, so it gets the scan, the toolbar, the keyword toggle and panel (with no "Edit keywords" link: the popup has no project context). The keyword toggle and panel and the text layer'shiddenbinding read the merged, normalisedkeywordssignal (keywordConfig() ?? received) rather than thekeywordConfiginput alone. Before this slice the popup, which never binds that input, built highlights from received keywords but kept the text layer hidden and showed no status. - Cost: the cached text is the document's extracted strings, held only while the document is on screen. The text layer still extracts the page on screen separately.
Title and abstract (slice C)¶
syrf-keyword-highlighted-text(src/app/shared/keyword-highlight/) runs the slice BmatchKeywordson one string and renders the result as text plus<mark class="syrf-keyword syrf-keyword--…">chunks with the samearia-labels as the viewer ("Inclusion and exclusion keyword"forboth). With anullconfig it renders the bare text, so flag-off output is unchanged.- Stage review renders the title and abstract through it with the same
pdfKeywordConfigsignal the PDF panel uses, in both the redesigned Study Source card and the legacy study card. The abstract is matched on thenewline: 2pipe output, so wrapping is unchanged. The header band title is not highlighted. - Quote fidelity: the component sits inside the elements carrying
data-quotable/data-quote-source, and marks add no characters, so the AF2 quote toolbar resolves the same element and quotes the same text. The component adds no host styles;white-spaceis inherited from the host element (.prelineon the abstract). - Mark styles for ordinary text live in
global-styles/_keyword-highlight.scss(no transparency or blending); the viewer's text-layer rules stay component-scoped and more specific.
Detached study source window (slice E)¶
The redesigned stage review can move the Study Source card into its own window
(/study-source-window, src/app/study-source-window/). The window receives its content over
postMessage from a component-scoped StudySourceWindowCoordinator; before this slice it showed
the title and abstract without highlights.
- Protocol: the
source-statemessage gains an optionalkeywords?: { inclusionKeywords: string[]; exclusionKeywords: string[] } | null. It sits on the message, not onStudySourceSnapshot, so it plays no part in the coordinator's source comparison and never bumps the revision. - Rolling-deploy compatibility: an absent field is accepted and means "no highlights". A review tab loaded before the deploy keeps working with a new window (no highlights), and a new review tab keeps working with an old window: the old guard ignores unknown properties.
isSourceWindowMessagestays strict: when present and non-null,keywordsmust be an object with two string arrays; anything else rejects the whole message. Like the rest of that guard it also bounds sizes (at most 1,000 terms per list, 1,000 characters per term; generous against the server's 200 terms / 100 characters, so a rules change there does not break the window).- Coordinator:
setKeywords(config)normalises the lists and posts the change at the current revision. The window clears the reviewer's captured selection and any pending quote when the revision changes, so a revision bump would lose the reviewer's work.null(flag off) and a config whose lists are both empty send no field at all, so the message is the same as before this slice. Unchanged lists post nothing. Every later state (study change, target change, handshake) carries the current keywords. - Stage review: an
effectforwards the samepdfKeywordConfigsignal the inline card uses (so it ridesscreeningKeywordHighlighting) tosetKeywords;ngOnDestroyresets it tonull, and the coordinator's ownngOnDestroydrops it too. - Window: the title and abstract render through
syrf-keyword-highlighted-textwith the keywords from the latest accepted state. The keyword signal compares structurally, so a state that resends the same lists (for example a comment-target change) does not re-render the text under the reviewer's selection. The "Abstract not available" placeholder is not highlighted. - Quote fidelity: the window captures quotes from
[data-source-kind]regions (its own equivalent of the inlinedata-quotable). The component sits inside those elements and marks add no characters, so a selection that starts or ends inside a mark resolves the same region and quotes the same text; the coordinator'snormalizedSourceTextcheck is unchanged. - Styles: the window is a route of the same Angular app, loaded from the same
index.html, so the globalstyles.scss(which includes_keyword-highlight.scss) and the--syrf-keyword-*tokens onhtml/.global-dark-themeinsyrf-theme.scssapply there without changes.
Per-reviewer hide toggle (slice H)¶
A reviewer can hide the project's keyword highlights for themselves. The keyword lists stay on the project and are unchanged; only the reviewer's view changes.
- Persistence:
ReviewPreferences.HideScreeningKeywordHighlights(bool, defaultfalse) in the existing per-reviewerReviewerWorkspaceSettingsdocument, read and written throughGET/PUT api/account/review-preferenceslike the other review preferences. The repository writes it as its own$setfield, so a concurrent layout or preference save never erases it. It is never stored on the project or onInvestigator. - No migration: the class map ignores absence, so a document written before this field reads as
false(shown). The web normaliser also treats an absent field in a response as shown. - Web:
ReviewPreferencesStore.hideKeywordHighlights/setHideKeywordHighlights, with the store's existing optimistic write, serialised queue and rollback to the last confirmed value. A newsaveFailed$stream names the refused preference, so the switch reports only its own failure. - Applying it: stage review passes the preference as the third argument of
screeningKeywordConfigSignal, which returnsnullwhile it is true, exactly as when the flag is off. Every surface reads onlypdfKeywordConfig: the inline viewer (keywordConfiginput;nullremoves the marks, the counts and the text layer; the host'skeywordsHiddenkeeps the toggle and switch, see Keyword panel), the title/abstract (KeywordHighlightedTextComponentrenders bare text), and the two coordinators. TheirsetKeywords(null)posts a state with nokeywordsfield at the current revision, which the popup and the detached source window both treat as "no highlights", without a document reload or a lost selection. Showing again sends the lists back the same way. No viewer or protocol change was needed. - Control:
syrf-keyword-highlights-toggle, a Materialmat-slide-togglelabelled Show keyword highlights, as the first row of the keyword panel on both Study Source surfaces (redesigned layout, since #4031; the ✎ toggle stays while highlights are hidden so it is reachable) and above the title in the legacy study card. Not in the card header, because Dockview relocates its tabs into that header. It renders only whilescreeningKeywordHighlightingis on and the project has at least one non-blank term. On a refused save the switch moves back and a snackbar says the preference could not be saved. - Flag decision: rides
screeningKeywordHighlighting(default off); the control is hidden while the flag is off.
Keyword panel (redesign)¶
Built in #4022 and wired in #4031 (Study Source card header and PDF toolbar).
- Where it shows: the ✎ N toggle sits in the PDF toolbar (
syrf-study-pdf-toolbar) and opens the panel directly below it with whole-PDF counts (surface="pdf"). The Details view of the Study Source card has its own toggle and panel counting the title and abstract (surface="abstract",countKeywordTermsInText([title, abstract])). Both read the onepdfKeywordConfig; one open/closed state is shared by the card, so switching Full text | Details keeps the panel open or closed. - When it shows: while
screeningKeywordHighlightingis on and at least one list has a non-blank term. The Show keyword highlights switch (slice H) is the panel's first row on both surfaces (the panel's[keywordPanelSwitch]slot; the PDF viewer receives it as aTemplateRefinput so it keeps the stage-review injector). When the reviewer has hidden highlights,pdfKeywordConfigis stillnull, so no marks render on any surface (viewer, popup, title/abstract, detached window); the toggle stays, outlined with anedit_officon, no count and the name "Keyword highlights hidden" (keywordsHidden/hiddeninputs), and the panel shows only the switch and "Keyword highlights are hidden for you", with no chips, counts, notes or link. This keeps the switch reachable on Full text as well as Details. The popup viewer is unchanged: it gets no keywords while hidden and shows no toggle. - PDF notes: the viewer projects its counting notes (cap, unreadable pages, no extractable text on the document or the page, per-page highlight cap) and the jump-to-match buttons into the panel, between the chips and the footnote. The toggle shows the total once the scan completes and just the pen icon while counting.
-
Edit link: the host passes
canEditKeywordsfromselectCurrentProjectPermissionReport→project.designand the current project id; the popup viewer passes neither. -
Matcher:
matchKeywordsalso returnstermCounts({ term, kind, count }[], config order, inclusion list first). Each term's own regex matches count before the highlight mask merges them, so overlapping terms each count;termis the configured spelling after normalisation, fold-equal duplicates share one entry, and terms that can never match (blank, a bare*) are omitted. The existingcountsand the 5,000-per-page cap are unchanged; per-term counting stops at the same cap.sumKeywordTermCountssums per-page lists (whole-document scan),countKeywordTermsInTextcounts plain texts such as the title and abstract (each text matched separately, so a phrase never spans them) andtotalKeywordHitstotals a list. syrf-keyword-panel(shared/keyword-highlight/keyword-panel/): a tonal (primary-container) panel with a headline, one chip per configured term with its hit count (zero-hit terms shown) and a footnote. Counts are per surface: on the Full text tab the host passes whole-PDF counts and the headline reads "13 keyword hits in this PDF"; on the Details tab it passes title/abstract counts ("... in the title and abstract"). The headline is a polite live region and reads "Counting keyword hits…" whilescanning. Chips use the highlight tokens; colour is never the only cue (solid vs double underline on the term, and an accessible name such as "cattle, inclusion keyword, 1 hit"). Users with the project-design permission (the checkprojectDesignGuardFnuses) see an "Edit keywords in Screening settings" link to/projects/:projectId/admin/screening-settings; the host passescanEditKeywords. Renders nothing for anullconfig or empty lists.syrf-keyword-panel-toggle(shared/keyword-highlight/keyword-panel-toggle/): the toolbar button (pen icon plus total count, tonal while open, tooltip "Keyword hits") witharia-pressed,aria-expandedandaria-controlsset to the panel'spanelId; it emitstoggledwith the requested state and owns none.- Flag decision: rides
screeningKeywordHighlighting(default off) for the keyword parts andintegratedPdfViewer/stageReviewRedesignfor the toolbar and card header; no new flag. A per-project "screening profile" for keywords is future work.
Decisions made here that Chris may want to revisit¶
- Whole-word matching with trailing
*as the only wildcard (alternative: substring matching). - Rejecting a term present in both lists at save time; overlapping phrases render as
both. - Keyword lists live on the Screening Settings page under the project-design permission, not on General Settings and not per stage.
- Highlight colours and the non-colour cues (solid vs double underline).
- Popup-viewer parity shipped as slice D; empty lists send nothing to the popup (no text layer), while the inline viewer still builds a text layer for an enabled config with empty lists.
- Jump-to-match stops at the first and last matching pages instead of wrapping (slice F).
- The hide toggle (slice H) is one cross-project preference, not per project or per stage, like the other review preferences. Hiding also removes the inline viewer's text layer, so browser find-in-page on the PDF page stops working while highlights are hidden.
Testing¶
- .NET:
ScreeningKeywordRulesunit tests;BsonClassMapTestsround-trip and absent-element tests; controller authorization/route attribute test;RuntimeFeatureFlagCatalogTestsandProjectAuthorizationActivityParityTestsremain green. - Web: matcher unit tests (case, phrase whitespace, word boundaries, prefix wildcard, overlaps, duplicates,
cap); highlighter DOM tests against a fake text layer; viewer spec additions (render → text layer →
marks; cancelled render paints nothing; config change re-highlights without reload; no-text page notice);
panel and stage-review wiring specs; popup protocol guard (absent/valid/malformed
keywords), coordinator (keyword change keeps the revision, study change bumps it,clear()drops keywords) and popup forwarding specs; source-window protocol guard (absent/valid/malformedkeywords), coordinator (keyword change keeps the revision, omitted when off or empty) and window rendering specs (marks from received keywords, none when absent, selection kept on a keyword change, quote text identical across marks); whole-document scan specs (counts aggregate across pages, per-page cap, no-text document, unreadable pages (some and all), time-sliced yielding, superseded load and destroy never report, keyword change re-scans withoutgetDocument, previous/next skip pages without matches, buttons disabled with no matches or mid-scan, popup shows the toggle and panel from received keywords); keyword panel wiring specs (toggle while the project has keywords, without a count while hidden, the switch in the panel, counts and marks back once shown, per-term whole-PDF counts and notes in the panel, title/abstract counts on Details, the designer-only edit link); settings component spec; effects/reducer specs; guard specs (syrf-theme.spec.ts,no-hardcoded-help-urls.spec.ts). - Browser check on a preview environment with the seeded "Ready for Annotation" project
(fixtures under
assets/seed-pdf-fixtures/seed/contain selectable text).