From 2f7610aa0bb22fc4164089bff8c3c55af734197f Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Tue, 28 Jul 2026 16:32:09 -0600 Subject: [PATCH 01/12] Apply comment rules to src/types Drop @file banners, @param/@returns that restate the signature, and prose references to other code by name. Correct the ASSIGNMENT_STATUSES note, which claimed O(1) membership for an array .includes lookup. --- src/types/empty-factories.ts | 23 ++--- src/types/interlinear-project-summary.ts | 18 ++-- src/types/phrase-mode.ts | 27 ++--- src/types/status-colors.ts | 24 ++--- src/types/token-layout.ts | 26 ++--- src/types/type-guards.ts | 122 ++++------------------- src/types/view-options.ts | 7 +- 7 files changed, 70 insertions(+), 177 deletions(-) diff --git a/src/types/empty-factories.ts b/src/types/empty-factories.ts index 70b1c6b1..818f9a9f 100644 --- a/src/types/empty-factories.ts +++ b/src/types/empty-factories.ts @@ -1,15 +1,9 @@ -/** - * @file Factory functions that return zero-value instances of core types, giving each caller a - * fresh independent object. - */ import type { DraftProject, TextAnalysis } from 'interlinearizer'; import type { FocusContext } from './token-layout'; /** - * Returns a `TextAnalysis` with empty collections for every analysis and link array. Each call - * produces a fresh object so callers can mutate it without affecting other instances. - * - * @returns A new, empty `TextAnalysis` object. + * A {@link TextAnalysis} with every analysis and link collection empty. Each call returns a fresh + * object, so callers may mutate the result without affecting other instances. */ export function emptyAnalysis(): TextAnalysis { return { @@ -23,12 +17,11 @@ export function emptyAnalysis(): TextAnalysis { } /** - * Returns a fresh, empty {@link DraftProject} for a source project: empty analysis, no analysis - * languages yet, and `dirty: false`. Used by the storage layer when no draft has been written and - * as the seed for the "New" (reset) flow. Each call produces a fresh object with its own analysis. + * An empty {@link DraftProject} for a source project: empty analysis, no analysis languages yet, and + * not dirty. Used as the fallback when no draft has been written and as the seed for the "New" + * (reset) flow. Each call returns a fresh object with its own analysis. * * @param sourceProjectId - The Platform.Bible source project ID the draft belongs to. - * @returns A new, empty `DraftProject` for the given source. */ export function emptyDraft(sourceProjectId: string): DraftProject { return { @@ -40,10 +33,8 @@ export function emptyDraft(sourceProjectId: string): DraftProject { } /** - * Returns a `FocusContext` with all fields set to `undefined`, representing the state where nothing - * is focused. Each call produces a fresh object so callers can use it without sharing references. - * - * @returns A new, empty `FocusContext` object. + * A {@link FocusContext} representing "nothing is focused". Each call returns a fresh object, so + * callers never share a reference. */ export function emptyFocusContext(): FocusContext { return { diff --git a/src/types/interlinear-project-summary.ts b/src/types/interlinear-project-summary.ts index e1d1968e..e307f1bb 100644 --- a/src/types/interlinear-project-summary.ts +++ b/src/types/interlinear-project-summary.ts @@ -14,18 +14,14 @@ export type InterlinearProjectSummary = Pick< >; /** - * Rebuilds a new object holding only the {@link InterlinearProjectSummary} fields. The parameter is - * statically typed as `InterlinearProjectSummary`, but structural typing lets a full - * `InterlinearProject` (with its potentially large `analysis`, plus `links` / `segmentation`) - * satisfy that type, so at runtime the argument may still carry those extra properties. Caching - * such a value verbatim would persist the whole envelope in WebView state; copying only the summary - * fields keeps the cached active project lean. + * Copies out just the {@link InterlinearProjectSummary} fields, dropping any extra ones. * - * @param summary - A value typed as `InterlinearProjectSummary` (e.g. narrowed via - * `isInterlinearProjectSummary`) that may carry extra runtime properties from a wider - * `InterlinearProject`. - * @returns A new object containing only the summary fields, with `targetProjectId` / `name` / - * `description` present only when they are set on the input. + * Structural typing lets a full {@link InterlinearProject} — with its potentially large `analysis`, + * `links`, and `segmentation` — satisfy the parameter type, so the argument may carry far more at + * runtime than the type admits. Caching such a value verbatim would persist the whole envelope in + * WebView state; rebuilding keeps the cached project lean. + * + * @returns A new object whose optional fields are present only when set on the input. */ export function toProjectSummary(summary: InterlinearProjectSummary): InterlinearProjectSummary { return { diff --git a/src/types/phrase-mode.ts b/src/types/phrase-mode.ts index eedfe8d7..baa149a8 100644 --- a/src/types/phrase-mode.ts +++ b/src/types/phrase-mode.ts @@ -1,17 +1,20 @@ import type { PhraseAnalysisLink } from 'interlinearizer'; -/** - * Discriminated union describing the current phrase-interaction mode in the Interlinearizer. - * - * - `view` — Normal reading/glossing mode; no phrase operation is in progress. - * - `edit` — User is adding/removing tokens from an existing phrase identified by `phraseId`. - * `originalTokens` is the token list at the moment edit mode was entered, used to restore state - * on cancel. When `revert` is `true` the cancel button has been pressed and any component - * observing this should restore the phrase to `originalTokens` and return to `view`. - * - `confirm-unlink` — Awaiting user confirmation before deleting the phrase identified by - * `phraseId`. - */ +/** The phrase-interaction mode the interlinear view is currently in. */ export type PhraseMode = + /** Normal reading and glossing; no phrase operation is in progress. */ | { kind: 'view' } - | { kind: 'edit'; phraseId: string; originalTokens: PhraseAnalysisLink['tokens']; revert?: true } + /** The user is adding tokens to or removing them from an existing phrase. */ + | { + kind: 'edit'; + phraseId: string; + /** The phrase's token list as it stood when edit mode was entered, so a cancel can restore it. */ + originalTokens: PhraseAnalysisLink['tokens']; + /** + * Set once cancel has been pressed: observers should restore `originalTokens` and return to + * `view`. + */ + revert?: true; + } + /** Awaiting the user's confirmation before deleting the phrase. */ | { kind: 'confirm-unlink'; phraseId: string }; diff --git a/src/types/status-colors.ts b/src/types/status-colors.ts index efc34867..d878fa7a 100644 --- a/src/types/status-colors.ts +++ b/src/types/status-colors.ts @@ -1,26 +1,16 @@ -/** @file Maps an analysis assignment status to the Tailwind text color the renderer shows it in. */ - import type { AssignmentStatus } from 'interlinearizer'; /** - * Tailwind `tw:` text-color classes per assignment status, so approved work always reads as plain - * foreground while machine output reads as a subordinate color. Kept as a complete map (every - * `AssignmentStatus` member) even though v1 only ever renders `approved` / `suggested` / - * `candidate` — the `rejected` / `stale` colors are defined for completeness but no reducer - * produces those statuses in v1. Indexed directly at the call site, so there is no accessor to - * test; the map is data, which is why this lives in `types/`. + * Tailwind text-color class per assignment status, so human-confirmed work reads as plain + * foreground and machine output reads as subordinate to it. * - * The colors track Paratext 9: approved renders in the default foreground and the engine's pick in - * blue; PT9 leaves the unselected alternatives in default, but we set them grey so a homograph - * candidate reads as subordinate to the suggested pick. Approved uses the core `foreground` theme - * token; the colored statuses use the local `gloss-suggested` / `gloss-candidate` / - * `gloss-rejected` / `gloss-stale` utilities defined in `tailwind.css` (each light/dark aware), so - * all the status colors live in one place rather than as raw palette strings scattered here. See - * that file for which reuse a theme token (grey, red) and which use a raw palette shade (blue, - * orange). + * The palette tracks Paratext 9, with one deliberate divergence: PT9 leaves unselected homograph + * alternatives in the default foreground, but `candidate` is grey here so it reads as subordinate + * to the engine's `suggested` pick. The map is complete even though v1 never produces `rejected` or + * `stale`. * * - `approved` — plain foreground (the canonical, human-confirmed render). - * - `suggested` — blue (the engine's single best pick; subordinate to approved). + * - `suggested` — blue (the engine's single best pick). * - `candidate` — grey (an unselected homograph alternative a reviewer can promote). * - `rejected` — orange (a dismissed analysis). * - `stale` — red (drifted text needing re-review). diff --git a/src/types/token-layout.ts b/src/types/token-layout.ts index 3daf40a0..90d367f5 100644 --- a/src/types/token-layout.ts +++ b/src/types/token-layout.ts @@ -1,14 +1,8 @@ -/** - * @file Types describing the intermediate data structures produced when laying out tokens for - * rendering. These are pure structural types with no runtime behavior; the functions that build - * and consume them live in `utils/token-layout.ts`. - */ import type { PhraseAnalysisLink, Token } from 'interlinearizer'; /** - * Resolved focus state shared by SegmentView and ContinuousView so both views derive their - * highlight / link-icon rules from the same source. Built once per render from the parent's - * `focusedTokenRef`. + * Resolved focus state shared by the segment and continuous views so both derive their highlight + * and link-icon rules from one source. Built once per render. */ export type FocusContext = { /** The focused word token itself, or `undefined` when nothing is focused. */ @@ -24,15 +18,14 @@ export type FocusContext = { }; /** - * The focus-derived inputs `TokenLinkIcon` needs for a single between-group slot: the slot-specific - * direction/segment flags plus the two focus-context fields the icon reads directly - * (`focusedPhraseLink` / `focusedFreeToken`), bundled so the icon takes one focus object. + * The focus-derived inputs a single between-group link slot needs: its own direction and segment + * flags plus the two {@link FocusContext} fields the link icon reads, bundled so the icon takes one + * focus object. */ export type SlotFocusInfo = { /** * `true` when the focused group is start-ward of this slot, `false` when end-ward, `undefined` - * when nothing is focused. Caller must compute this from its own ordering (`focusedGroupSeen` - * cursor for SegmentView; flat index comparison for ContinuousView). + * when nothing is focused. Callers compute this from their own ordering of groups. */ focusedSideIsPrev: boolean | undefined; /** @@ -48,14 +41,13 @@ export type SlotFocusInfo = { /** A grouped render unit: one or more adjacent tokens that share the same phrase (or no phrase). */ export type TokenGroup = { - /** The tokens to render together in one `PhraseBox`. */ + /** The tokens to render together as a single phrase box. */ tokens: (Token & { type: 'word' })[]; /** The phrase link shared by all tokens in this group, or `undefined` for ungrouped solo tokens. */ phraseLink: PhraseAnalysisLink | undefined; /** - * The index of the first token in this group within the flat token array it was built from. Used - * to slice the flat token list to the group's range (e.g. for the punctuation that renders - * between groups); not passed to `PhraseBox`. + * The index of the first token in this group within the flat token array it was built from, so + * callers can slice that array back to the group's range. */ firstIndex: number; /** diff --git a/src/types/type-guards.ts b/src/types/type-guards.ts index fc8c9366..b502d33b 100644 --- a/src/types/type-guards.ts +++ b/src/types/type-guards.ts @@ -1,4 +1,3 @@ -/** @file Type guards for narrowing interlinearizer types and validating parsed JSON payloads. */ import type { AssignmentStatus, DraftProject, @@ -8,23 +7,12 @@ import type { } from 'interlinearizer'; import type { InterlinearProjectSummary } from './interlinear-project-summary'; -/** - * Narrows a `Token` to a word token. - * - * @param token - The token to test. - * @returns `true` when `token.type === 'word'`. - */ +/** Narrows a token to a word token. */ export function isWordToken(token: Token): token is Token & { type: 'word' } { return token.type === 'word'; } -/** - * Type guard for {@link InterlinearProjectSummary} parsed from unknown JSON. - * - * @param p - The value to test, typically a parsed JSON object of unknown shape. - * @returns `true` if `p` satisfies the {@link InterlinearProjectSummary} shape, narrowing its type - * accordingly. - */ +/** Validates an {@link InterlinearProjectSummary} parsed from unknown JSON. */ export function isInterlinearProjectSummary(p: unknown): p is InterlinearProjectSummary { return ( !!p && @@ -46,7 +34,7 @@ export function isInterlinearProjectSummary(p: unknown): p is InterlinearProject ); } -/** All valid {@link AssignmentStatus} string literals, used for O(1) membership checks. */ +/** All valid {@link AssignmentStatus} string literals. */ const ASSIGNMENT_STATUSES: readonly string[] = [ 'approved', 'suggested', @@ -55,22 +43,14 @@ const ASSIGNMENT_STATUSES: readonly string[] = [ 'stale', ]; -/** - * Narrows `v` to {@link AssignmentStatus}. - * - * @param v - The value to test. - * @returns `true` if `v` is one of the valid {@link AssignmentStatus} string literals. - */ function isAssignmentStatus(v: unknown): v is AssignmentStatus { return typeof v === 'string' && ASSIGNMENT_STATUSES.includes(v); } -/** - * Checks that `v` has the required fields of a `TokenSnapshot` (`tokenRef` and `surfaceText`). - * - * @param v - The value to test. - * @returns `true` if `v` is an object with string `tokenRef` and `surfaceText` properties. - */ +// The helpers below check shape only and return plain booleans rather than narrowing, because the +// types they validate are structural fragments that callers never hold on their own. + +/** Checks the required fields of a token snapshot. */ function isTokenSnapshot(v: unknown): boolean { return ( !!v && @@ -82,14 +62,7 @@ function isTokenSnapshot(v: unknown): boolean { ); } -/** - * Checks that `v` satisfies the base `Analysis` shape (`id` and `surfaceText`). Used directly as - * the `.every()` predicate for `segmentAnalyses` and `phraseAnalyses`, and as the base of - * {@link isTokenAnalysisRecord} for `tokenAnalyses`. - * - * @param v - The value to test. - * @returns `true` if `v` is an object with string `id` and `surfaceText` properties. - */ +/** Checks the fields common to every analysis record. */ function isAnalysisRecord(v: unknown): boolean { return ( !!v && @@ -101,13 +74,7 @@ function isAnalysisRecord(v: unknown): boolean { ); } -/** - * Checks that `v` has the required fields of a `MorphemeAnalysis` (`id`, `form`, `writingSystem`). - * Used to validate the elements of a `TokenAnalysis.morphemes` array at the persistence boundary. - * - * @param v - The value to test. - * @returns `true` if `v` is an object with string `id`, `form`, and `writingSystem` properties. - */ +/** Checks the required fields of a single morpheme analysis. */ function isMorphemeAnalysis(v: unknown): boolean { return ( !!v && @@ -122,14 +89,9 @@ function isMorphemeAnalysis(v: unknown): boolean { } /** - * Checks that `v` satisfies the `TokenAnalysis` shape: the base `Analysis` fields plus, when - * present, a `morphemes` array whose every element passes {@link isMorphemeAnalysis}. Morphemes are - * first-class data read by the UI (`m.id` keys, `m.form`, `m.writingSystem`), so a malformed array - * must be rejected before persisting rather than surfacing as a render-time fault. - * - * @param v - The value to test. - * @returns `true` if `v` is a valid analysis record with a well-formed (or absent) `morphemes` - * array. + * Checks a token analysis, including its morphemes when present. Morphemes are first-class data the + * UI reads field by field, so a malformed array has to be rejected at the persistence boundary + * rather than surfacing later as a render-time fault. */ function isTokenAnalysisRecord(v: unknown): boolean { return ( @@ -140,14 +102,7 @@ function isTokenAnalysisRecord(v: unknown): boolean { ); } -/** - * Checks that `v` satisfies the base `AnalysisLink` shape (`analysisId` and a valid `status`). Used - * as a building block by the three link-specific guards. - * - * @param v - The value to test. - * @returns `true` if `v` is an object with a string `analysisId` and a valid - * {@link AssignmentStatus} `status`. - */ +/** Checks the fields common to every analysis link. */ function isAnalysisLink(v: unknown): boolean { return ( !!v && @@ -159,13 +114,7 @@ function isAnalysisLink(v: unknown): boolean { ); } -/** - * Checks that `v` satisfies the `SegmentAnalysisLink` shape: valid `AnalysisLink` fields plus a - * string `segmentId`. - * - * @param v - The value to test. - * @returns `true` if `v` passes {@link isAnalysisLink} and has a string `segmentId` property. - */ +/** Checks a link that attaches an analysis to a segment. */ function isSegmentAnalysisLink(v: unknown): boolean { return ( isAnalysisLink(v) && @@ -176,28 +125,14 @@ function isSegmentAnalysisLink(v: unknown): boolean { ); } -/** - * Checks that `v` satisfies the `TokenAnalysisLink` shape: valid `AnalysisLink` fields plus a - * `token` property that passes {@link isTokenSnapshot}. - * - * @param v - The value to test. - * @returns `true` if `v` passes {@link isAnalysisLink} and has a valid `TokenSnapshot` `token` - * property. - */ +/** Checks a link that attaches an analysis to a single token. */ function isTokenAnalysisLink(v: unknown): boolean { return ( isAnalysisLink(v) && !!v && typeof v === 'object' && 'token' in v && isTokenSnapshot(v.token) ); } -/** - * Checks that `v` satisfies the `PhraseAnalysisLink` shape: valid `AnalysisLink` fields plus a - * non-empty `tokens` array whose every element passes {@link isTokenSnapshot}. - * - * @param v - The value to test. - * @returns `true` if `v` passes {@link isAnalysisLink} and has a `tokens` array of valid - * `TokenSnapshot` objects. - */ +/** Checks a link that attaches an analysis to a non-empty run of tokens. */ function isPhraseAnalysisLink(v: unknown): boolean { return ( isAnalysisLink(v) && @@ -211,12 +146,8 @@ function isPhraseAnalysisLink(v: unknown): boolean { } /** - * Type guard for {@link TextAnalysis} parsed from unknown JSON. Validates array presence and minimal - * element shapes for all six arrays so malformed payloads are rejected before persisting. - * - * @param value - The value to test, typically a parsed JSON object of unknown shape. - * @returns `true` if `value` satisfies the {@link TextAnalysis} shape, narrowing its type - * accordingly. + * Validates a {@link TextAnalysis} parsed from unknown JSON, down to the element shapes of every + * collection, so a malformed payload is rejected before it is persisted. */ export function isTextAnalysis(value: unknown): value is TextAnalysis { return ( @@ -244,12 +175,8 @@ export function isTextAnalysis(value: unknown): value is TextAnalysis { } /** - * Type guard for {@link SegmentationDelta} parsed from unknown JSON. Both arrays must be present and - * contain only strings, so a malformed delta is rejected before it can corrupt re-segmentation. - * - * @param value - The value to test, typically a parsed JSON object of unknown shape. - * @returns `true` if `value` satisfies the {@link SegmentationDelta} shape, narrowing its type - * accordingly. + * Validates a {@link SegmentationDelta} parsed from unknown JSON, so a malformed delta cannot + * corrupt re-segmentation. */ export function isSegmentationDelta(value: unknown): value is SegmentationDelta { return ( @@ -265,13 +192,8 @@ export function isSegmentationDelta(value: unknown): value is SegmentationDelta } /** - * Type guard for {@link DraftProject} parsed from unknown JSON. Validates the envelope fields and - * delegates the `analysis` to {@link isTextAnalysis}, so malformed drafts are rejected before - * persisting. - * - * @param value - The value to test, typically a parsed JSON object of unknown shape. - * @returns `true` if `value` satisfies the {@link DraftProject} shape, narrowing its type - * accordingly. + * Validates a {@link DraftProject} parsed from unknown JSON, so a malformed draft is rejected before + * it is persisted. */ export function isDraftProject(value: unknown): value is DraftProject { return ( diff --git a/src/types/view-options.ts b/src/types/view-options.ts index 25f89108..3e262f64 100644 --- a/src/types/view-options.ts +++ b/src/types/view-options.ts @@ -1,8 +1,7 @@ /** - * Bundled display toggles threaded from {@link InterlinearizerLoader} down through the segment and - * continuous views to the phrase strips. Grouping them in one object lets intermediate components - * forward `viewOptions` unchanged, so adding a new toggle only touches the loader that builds it - * and the leaf that reads it — not every component in between. + * Bundled display toggles threaded from the loader down through the views to the phrase strips. + * Grouping them in one object lets intermediate components forward the bundle unchanged, so adding + * a toggle touches only the code that builds it and the leaf that reads it. */ export type ViewOptions = Readonly<{ /** When true, link buttons between phrases are hidden in segments other than the active verse. */ From 9e74ed124228ae8a99d846ed30bbacd99f4df9cb Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Tue, 28 Jul 2026 16:34:25 -0600 Subject: [PATCH 02/12] Apply comment rules to the shared type declarations Normalize @param tags to TSDoc dash form, drop a @returns that only restated Promise, and remove @returns text duplicated verbatim from the summary above it. --- src/types/interlinearizer.d.ts | 60 ++++++++++++++++------------------ 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index ce1ff184..ee1714d3 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -62,16 +62,15 @@ declare module 'papi-shared-types' { * after the user fills in the create-project modal. Returns the persisted `InterlinearProject` * serialized as a JSON string. * - * @param sourceProjectId Platform.Bible project ID of the source text to interlinearize. - * @param analysisLanguages BCP 47 tags for all languages used in glosses and annotations (e.g. - * `['en']`). LCM: one per writing system present on `IWfiGloss.Form`. Paratext: one per + * @param sourceProjectId - Platform.Bible project ID of the source text to interlinearize. + * @param analysisLanguages - BCP 47 tags for all languages used in glosses and annotations + * (e.g. `['en']`). LCM: one per writing system present on `IWfiGloss.Form`. Paratext: one per * merged `GlossLanguage` file. BT Extension: typically one language. - * @param targetProjectId Optional Platform.Bible project ID of the target text. Required for BT + * @param targetProjectId - Platform.Bible project ID of the target text. Required for BT * Extension projects so that `AlignmentLink.targetEndpoints` can be resolved at runtime. * Omitted for analysis-only projects (LCM, PT9 single-sided). - * @param name Optional user-facing name for the project. - * @param description Optional user-facing description for the project. - * @returns The persisted `InterlinearProject` as a JSON string. + * @param name - User-facing name for the project. + * @param description - User-facing description for the project. * @throws If storage fails. The error is logged and an error notification is sent before * rethrowing so callers do not need to send a second notification. */ @@ -84,11 +83,11 @@ declare module 'papi-shared-types' { ) => Promise; /** - * Returns all interlinearizer projects for the given source project, serialized as a JSON - * string. Returns `"[]"` when none exist. The WebView uses this to populate the project picker - * and to decide whether to show "create new" vs "select existing" on first open. + * Returns all interlinearizer projects for the given source project. The WebView uses this to + * populate the project picker and to decide whether to show "create new" vs "select existing" + * on first open. * - * @param sourceProjectId Platform.Bible project ID of the source text to query. + * @param sourceProjectId - Platform.Bible project ID of the source text to query. * @returns A JSON string containing an `InterlinearProject[]`; `"[]"` when none exist. * @throws {SyntaxError} If the project-IDs index or any stored project record contains invalid * JSON. @@ -101,7 +100,7 @@ declare module 'papi-shared-types' { /** * Deletes an interlinearizer project by UUID. No-ops silently if the project does not exist. * - * @param interlinearProjectId UUID of the interlinearizer project to delete. + * @param interlinearProjectId - UUID of the interlinearizer project to delete. * @throws If the underlying storage write fails (the failure is also logged and surfaced as an * error notification before being re-thrown). */ @@ -149,11 +148,10 @@ declare module 'papi-shared-types' { 'interlinearizer.wipe': () => Promise; /** - * Returns the interlinearizer project with the given UUID as a JSON string, including its full - * `TextAnalysis`. The WebView calls this when the active project changes to load the stored - * analysis. + * Returns the interlinearizer project with the given UUID, including its full `TextAnalysis`. + * The WebView calls this when the active project changes to load the stored analysis. * - * @param interlinearProjectId UUID of the interlinearizer project to fetch. + * @param interlinearProjectId - UUID of the interlinearizer project to fetch. * @returns JSON-stringified `InterlinearProject`, or `undefined` if not found. * @throws If storage fails (logged before rethrowing). */ @@ -164,10 +162,10 @@ declare module 'papi-shared-types' { * interlinearizer project. Called from the WebView on Save so analysis and boundary changes * survive tab restores and project switches. * - * @param interlinearProjectId UUID of the interlinearizer project to update. - * @param analysisJson JSON-stringified `TextAnalysis` to persist. - * @param segmentationJson Optional JSON-stringified `SegmentationDelta` to persist, or the - * string `"null"` to clear any stored custom boundaries. Omit entirely to leave the project's + * @param interlinearProjectId - UUID of the interlinearizer project to update. + * @param analysisJson - JSON-stringified `TextAnalysis` to persist. + * @param segmentationJson - JSON-stringified `SegmentationDelta` to persist, or the string + * `"null"` to clear any stored custom boundaries. Omit entirely to leave the project's * existing boundaries unchanged. * @returns The saved `InterlinearProject` (with its refreshed `updatedAt`) as a JSON string, or * `undefined` if no project with the given ID exists. The WebView uses the refreshed @@ -187,7 +185,7 @@ declare module 'papi-shared-types' { * mount to seed the editor. The draft is decoupled from saved projects and never appears in the * project picker. * - * @param sourceProjectId Platform.Bible source project ID whose draft to fetch. + * @param sourceProjectId - Platform.Bible source project ID whose draft to fetch. * @returns JSON-stringified `DraftProject`. * @throws {SyntaxError} If the stored draft contains invalid JSON. * @throws If `papi.storage.readUserData` rejects for a reason other than the draft not @@ -200,9 +198,8 @@ declare module 'papi-shared-types' { * every edit (auto-save) and whenever the draft is reset (New), opened from a project, or * wiped. * - * @param sourceProjectId Platform.Bible source project ID whose draft to write. - * @param draftJson JSON-stringified `DraftProject` to persist. - * @returns Promise that resolves to void once the draft has been written to storage. + * @param sourceProjectId - Platform.Bible source project ID whose draft to write. + * @param draftJson - JSON-stringified `DraftProject` to persist. * @throws If JSON parsing, validation, or storage fails. The error is logged and an error * notification is sent before rethrowing so callers do not need to send a second * notification. @@ -210,16 +207,15 @@ declare module 'papi-shared-types' { 'interlinearizer.saveDraft': (sourceProjectId: string, draftJson: string) => Promise; /** - * Updates the metadata of an existing interlinearizer project. Returns the updated project as a - * JSON string, or `undefined` if no project with the given ID exists. + * Updates the metadata of an existing interlinearizer project. * - * @param interlinearProjectId UUID of the interlinearizer project to update. - * @param name New user-facing name; omit or pass `undefined` to clear. - * @param description New user-facing description; omit or pass `undefined` to clear. - * @param analysisLanguages New BCP 47 analysis language tags. Must be a non-empty array; pass + * @param interlinearProjectId - UUID of the interlinearizer project to update. + * @param name - New user-facing name; omit or pass `undefined` to clear. + * @param description - New user-facing description; omit or pass `undefined` to clear. + * @param analysisLanguages - New BCP 47 analysis language tags. Must be a non-empty array; pass * the current value to leave it unchanged (the field is required and cannot be cleared). - * @param targetProjectId New target-project ID; omit or pass `undefined` to clear (removes the - * target-side text binding). + * @param targetProjectId - New target-project ID; omit or pass `undefined` to clear (removes + * the target-side text binding). * @returns The updated project as a JSON string, or `undefined` if no project with that ID * exists. * @throws If storage fails. The error is logged and an error notification is sent before From 0b4f506dfdf4e327c572adeffcc72b262fe120ae Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Tue, 28 Jul 2026 16:43:17 -0600 Subject: [PATCH 03/12] Apply comment rules to the smaller src/utils helpers Fold @file banner rationale into the symbols it describes, drop @param/@returns that restate the signature, and trim how-narration while keeping the domain rules (punctuation travel, verse-label grammar, chapter qualification). --- src/utils/analysis-book.ts | 34 +++++-------- src/utils/language-tags.ts | 12 ++--- src/utils/localized-strings.ts | 16 ++----- src/utils/multi-string.ts | 12 ++--- src/utils/project-summary-format.ts | 32 ++++--------- src/utils/segment-labels.ts | 35 ++++---------- src/utils/split-anchor.ts | 43 +++++------------ src/utils/verse-ref.ts | 60 +++++++---------------- src/utils/verse-superscripts.ts | 74 +++++++++-------------------- 9 files changed, 91 insertions(+), 227 deletions(-) diff --git a/src/utils/analysis-book.ts b/src/utils/analysis-book.ts index 1fcbc8d1..90c9dd01 100644 --- a/src/utils/analysis-book.ts +++ b/src/utils/analysis-book.ts @@ -1,13 +1,9 @@ -/** @file Pure helpers for filtering a `TextAnalysis` by the book a record belongs to. */ import type { SegmentationDelta, TextAnalysis } from 'interlinearizer'; /** * Returns the 3-letter book code embedded at the start of a segment id or token ref. Both are - * formatted `" :[:]"` (e.g. `"GEN 1:1"`, `"1JN 2:3:5"`), so the - * book code is the substring before the first space. - * - * @param ref - A `Segment.id` or `Token.ref` / `TokenSnapshot.tokenRef` value. - * @returns The leading book code, or the whole string when it contains no space. + * formatted `" :[:]"` (e.g. `"GEN 1:1"`, `"1JN 2:3:5"`). A string + * with no space is returned whole. */ export function bookOfRef(ref: string): string { const spaceIndex = ref.indexOf(' '); @@ -15,15 +11,12 @@ export function bookOfRef(ref: string): string { } /** - * Returns a copy of `analysis` with every record belonging to `bookCode` removed. A token- or - * segment-level record is dropped when its referenced token/segment is in the book; a phrase is - * dropped when **any** of its member tokens is in the book (so a rare cross-book phrase is removed - * when wiping either side). Analysis payloads left unreferenced by a surviving link are also - * dropped, so no orphans remain. + * Returns a copy of the analysis, unmutated, with every record belonging to the book removed. * - * @param analysis - The analysis to filter. Not mutated. - * @param bookCode - The 3-letter book code (e.g. `"GEN"`) whose records to remove. - * @returns A new `TextAnalysis` with the book's records (and any orphaned payloads) removed. + * A token- or segment-level record is dropped when its referenced token or segment is in the book; + * a phrase is dropped when **any** of its member tokens is, so a rare cross-book phrase goes when + * either side is wiped. Analysis payloads left unreferenced by a surviving link are dropped too, so + * no orphans remain. */ export function removeBookFromAnalysis(analysis: TextAnalysis, bookCode: string): TextAnalysis { const tokenAnalysisLinks = analysis.tokenAnalysisLinks.filter( @@ -51,16 +44,11 @@ export function removeBookFromAnalysis(analysis: TextAnalysis, bookCode: string) } /** - * Returns a copy of `delta` with every boundary anchor belonging to `bookCode` removed, so wiping a - * book also drops its custom segment boundaries (the anchors are book-prefixed token refs, keyed - * the same way as analysis records). Returns `undefined` when nothing for any other book remains, - * so a wiped-then-emptied delta collapses to the default segmentation rather than persisting empty - * arrays. + * Returns a copy of the delta with every boundary anchor belonging to the book removed, so wiping a + * book also drops its custom segment boundaries. * - * @param delta - The current boundary delta, or `undefined` for the default segmentation. - * @param bookCode - The 3-letter book code (e.g. `"GEN"`) whose anchors to remove. - * @returns A new `SegmentationDelta` without the book's anchors, or `undefined` when the result is - * the default segmentation (or the input already was). + * Yields `undefined` when no other book's anchors remain, so an emptied delta collapses back to the + * default segmentation rather than persisting empty arrays. */ export function removeBookFromSegmentation( delta: SegmentationDelta | undefined, diff --git a/src/utils/language-tags.ts b/src/utils/language-tags.ts index 20bcb7c0..b5df3be3 100644 --- a/src/utils/language-tags.ts +++ b/src/utils/language-tags.ts @@ -1,13 +1,9 @@ -/** @file Shared parsing for the comma-separated BCP 47 analysis-language inputs in the modals. */ - /** - * Parses a comma-separated analysis-language field into BCP 47 tags: splits on commas, trims each - * entry, and drops empty entries. The single source of this parse, shared by the create and - * metadata modals so they cannot drift. Does not apply any fallback when the result is empty — each - * modal decides how to treat an empty list (e.g. defaulting to `['und']` or disabling Save). + * Parses a comma-separated analysis-language field into BCP 47 tags. The single source of this + * parse, shared by the create and metadata modals so the two cannot drift. * - * @param input - The raw comma-separated language field value. - * @returns The trimmed, non-empty tags in input order; an empty array when the field is blank. + * Applies no fallback when the result is empty — each modal decides how to treat an empty list (for + * instance defaulting to `['und']`, or disabling Save). */ export function parseLanguageTags(input: string): string[] { return input diff --git a/src/utils/localized-strings.ts b/src/utils/localized-strings.ts index b5c770fb..cac42e71 100644 --- a/src/utils/localized-strings.ts +++ b/src/utils/localized-strings.ts @@ -1,17 +1,11 @@ -/** @file Helpers for working with values returned by PAPI's `useLocalizedStrings` hook. */ - /** * Returns a localized value, or an empty string while it is still an unresolved key. * - * `useLocalizedStrings` resolves asynchronously: until the lookup completes it returns the raw - * localize key (e.g. `%interlinearizer_glossInput_placeholder%`) as the value. Rendering that - * directly flashes the bare `%…%` key in user-visible text — most noticeable in input placeholders, - * which paint immediately on mount/toggle. Substituting an empty string for an unresolved key - * replaces that flash with a momentarily-empty field, which then fills in once localization - * resolves. A resolved localized string is returned unchanged. - * - * @param value - A value from a `useLocalizedStrings` result record. - * @returns The value, or `''` when it is still an unresolved `%…%` key. + * PAPI's localization hook resolves asynchronously, yielding the raw localize key (e.g. + * `%interlinearizer_glossInput_placeholder%`) until the lookup completes. Rendering that directly + * flashes the bare `%…%` key in user-visible text — most noticeable in input placeholders, which + * paint immediately on mount. Substituting an empty string turns that flash into a momentarily + * empty field instead. */ export function resolvedOrEmpty(value: string): string { return /^%.*%$/.test(value) ? '' : value; diff --git a/src/utils/multi-string.ts b/src/utils/multi-string.ts index 4adda8e9..383c80f7 100644 --- a/src/utils/multi-string.ts +++ b/src/utils/multi-string.ts @@ -1,15 +1,9 @@ -/** @file Helpers for working with `MultiString` (BCP 47 tag → string) values. */ - import type { MultiString } from 'interlinearizer'; /** - * Reports whether a `MultiString` carries no usable text, so callers deciding whether an analysis - * record is worth keeping can treat "absent", "no entries", and "only whitespace entries" the same. - * A `MultiString` counts as empty when it is `undefined`, has no entries, or every entry is blank - * once trimmed. - * - * @param value - The `MultiString` to inspect, or `undefined`. - * @returns `true` when the value holds no non-whitespace text. + * Reports whether a {@link MultiString} carries no usable text, so callers deciding whether an + * analysis record is worth keeping can treat "absent", "no entries", and "only whitespace entries" + * alike. */ export function isEmptyMultiString(value: MultiString | undefined): boolean { return !value || Object.values(value).every((entry) => entry.trim() === ''); diff --git a/src/utils/project-summary-format.ts b/src/utils/project-summary-format.ts index 4826a1bd..9db76469 100644 --- a/src/utils/project-summary-format.ts +++ b/src/utils/project-summary-format.ts @@ -1,16 +1,9 @@ /** - * @file Shared formatting and sorting helpers for interlinear project summaries, used by the - * project-selection and Save As modals so both lists order and label projects identically. - */ - -/** - * Parses an ISO 8601 timestamp to epoch milliseconds, treating an unparsable string as `0`. The - * summary type guard only checks that `updatedAt` is a string, so a corrupted value could otherwise - * yield `NaN` and make the sort comparator's result undefined; normalizing to `0` keeps ordering - * deterministic (corrupted entries sort last). + * Parses an ISO 8601 timestamp to epoch milliseconds, treating an unparsable string as `0`. * - * @param value - The timestamp string to parse. - * @returns The parsed epoch milliseconds, or `0` when `value` is not a valid date. + * The summary type guard only checks that `updatedAt` is a string, so a corrupted value could + * otherwise yield `NaN` and leave the sort comparator's result undefined. Normalizing to `0` keeps + * ordering deterministic, with corrupted entries sorting last. */ export function parseUpdatedAt(value: string): number { const time = Date.parse(value); @@ -18,27 +11,18 @@ export function parseUpdatedAt(value: string): number { } /** - * Compares two ISO 8601 timestamps for a descending (newest-first) sort by their parsed epoch - * milliseconds, so ordering is locale-independent (unlike `localeCompare`, whose result can vary by - * collator). - * - * @param a - The first ISO 8601 timestamp. - * @param b - The second ISO 8601 timestamp. - * @returns A negative number when `a` is newer than `b` (sorts first), positive when older, `0` - * when the two timestamps are equal. + * Compares two ISO 8601 timestamps newest-first. Ordering is locale-independent, unlike + * `localeCompare`, whose result can vary by collator. */ export function compareUpdatedAtDescending(a: string, b: string): number { return parseUpdatedAt(b) - parseUpdatedAt(a); } /** - * Formats the modified-date subline for a project row, e.g. `"Modified Jan 1, 2026, 12:00 PM"`. The - * prefix is a localized label; the timestamp is rendered in the user's locale via - * `toLocaleString`. + * Formats the modified-date subline for a project row, e.g. `"Modified Jan 1, 2026, 12:00 PM"`. * * @param prefix - Localized `"Modified"` label to precede the date. - * @param updatedAt - ISO 8601 modification timestamp. - * @returns The prefix followed by the locale-formatted timestamp. + * @param updatedAt - ISO 8601 timestamp, rendered in the user's locale. */ export function formatModified(prefix: string, updatedAt: string): string { return `${prefix} ${new Date(updatedAt).toLocaleString()}`; diff --git a/src/utils/segment-labels.ts b/src/utils/segment-labels.ts index 89b62b9d..672c3ec1 100644 --- a/src/utils/segment-labels.ts +++ b/src/utils/segment-labels.ts @@ -1,16 +1,3 @@ -/** - * @file Pure helper deriving each segment's verse-range gutter label in a (re)segmented book, shown - * in the segment-view gutter column (never in the running text). - * - * After boundary edits a segment need not map 1:1 to a verse, so the label names the verse range it - * covers: a single-verse segment (and each portion of a split verse) shows its bare number, a - * multi-verse segment the range between its covered ends (`2–3`, `29–2:1`). Split portions are - * not lettered — two portions of verse 1 both reading `1` reflects the overlap honestly. - * - * The label is derived from the segment's `verseStarts` — the exact set of covered source verses — - * not the `startRef`/`endRef` interval, so a cross-chapter or gapped merge names only the verses - * it actually covers instead of over-claiming every verse between its endpoints. - */ import type { Segment, VerseStart } from 'interlinearizer'; /** @@ -21,25 +8,21 @@ export type SegmentLabel = string; /** * Builds the verse-range gutter label of every segment in book order, keyed by segment id. The - * label is the segment's first covered verse, extended to `start–end` when the segment covers more - * than one verse, with the end qualified by its chapter (`start–chapter:end`) when the segment - * crosses a chapter boundary. + * labels appear in the segment-view gutter column, never in the running text. * - * @param segments - The book's segments in document order. - * @returns Map from segment id to its {@link SegmentLabel}. + * After boundary edits a segment need not map 1:1 to a verse, so a label names the verse range it + * covers rather than a single verse. Split portions are deliberately not lettered — two portions of + * verse 1 both reading `1` reflects the overlap honestly. + * + * Labels derive from each segment's covered verse starts rather than its start/end refs, so a + * cross-chapter or gapped merge names only the verses it actually covers instead of over-claiming + * every verse between its endpoints. */ export function buildSegmentLabels(segments: readonly Segment[]): Map { return new Map(segments.map((seg) => [seg.id, labelForSegment(seg.verseStarts)])); } -/** - * Formats one segment's verse-range label from its covered verse starts: the first verse's bare - * number, extended to `first–last` when the segment covers more than one verse, with the last - * qualified by its chapter (`first–chapter:last`) across a chapter boundary. - * - * @param verseStarts - The segment's covered verse starts, in document order. - * @returns The verse-range label. - */ +/** Formats one segment's label as `first`, `first–last`, or `first–chapter:last`. */ function labelForSegment(verseStarts: readonly VerseStart[]): SegmentLabel { const first = verseStarts[0]; const last = verseStarts[verseStarts.length - 1]; diff --git a/src/utils/split-anchor.ts b/src/utils/split-anchor.ts index f49a496a..04d264fe 100644 --- a/src/utils/split-anchor.ts +++ b/src/utils/split-anchor.ts @@ -1,9 +1,3 @@ -/** - * @file The punctuation-travel rule that decides which token a segment split is anchored before, - * for a gap between two adjacent words. `resegmentBook` cuts immediately before whatever anchor - * it is given, so which side of a split punctuation lands on is entirely a matter of anchor - * choice. - */ import type { Token } from 'interlinearizer'; /** @@ -31,15 +25,8 @@ const OPENING_MARKS: ReadonlySet = new Set([ ]); /** - * Reports whether the two adjacent tokens `before` and `after` touch — i.e. the literal baseline - * substring between them (`baselineText.slice(before.charEnd, after.charStart)`) is empty or - * contains no whitespace. Pure adjacency, script-agnostic: the caller never scans the token text, - * only the gap between tokens. - * - * @param before - The earlier token in document order. - * @param after - The later token in document order. - * @param baselineText - The owning segment's baseline text. - * @returns `true` when there is no whitespace between the two tokens. + * Reports whether two adjacent tokens touch, meaning no whitespace separates them. Pure adjacency + * and script-agnostic: only the gap between the tokens is examined, never the token text itself. */ function touches(before: Token, after: Token, baselineText: string): boolean { const gap = baselineText.slice(before.charEnd, after.charStart); @@ -47,18 +34,15 @@ function touches(before: Token, after: Token, baselineText: string): boolean { } /** - * Reports whether the punctuation at index `i` in `run` is part of an unbroken (whitespace-free) - * chain of tokens reaching the run's word at `boundary`, scanning in `step` direction. Because a - * cluster of adjacent marks with no internal whitespace travels as a unit, a mark reaches the - * following word when every gap from it forward to `wordNext` is whitespace-free, and the preceding - * word symmetrically. The word endpoints of `run` are always `boundary` when the chain arrives. + * Reports whether the punctuation at index `i` is joined to the word at `boundary` by an unbroken, + * whitespace-free chain of tokens. A cluster of adjacent marks with no internal whitespace travels + * as a unit, so a mark counts as reaching a word when every gap along the way is whitespace-free. * * @param run - The full token run for the gap: `[wordPrev, ...punctuation, wordNext]`. * @param i - Index of the punctuation token to test. - * @param step - `+1` to scan toward `wordNext`, `-1` to scan toward `wordPrev`. + * @param step - `+1` to scan toward the following word, `-1` toward the preceding one. * @param boundary - Index of the word the chain must reach (`run.length - 1` forward, `0` back). * @param baselineText - The owning segment's baseline text; inter-token gaps are read from it. - * @returns `true` when an unbroken whitespace-free chain connects the token to that word. */ function reachesWord( run: Token[], @@ -89,7 +73,6 @@ function reachesWord( * @param run - The full token run for the gap: `[wordPrev, ...punctuation, wordNext]`. * @param i - Index of the punctuation token within `run` (strictly between the two words). * @param baselineText - The owning segment's baseline text; inter-token gaps are read from it. - * @returns `true` when the punctuation binds to the following word, `false` when to the preceding. */ function bindsFollowing(run: Token[], i: number, baselineText: string): boolean { if (reachesWord(run, i, 1, run.length - 1, baselineText)) return true; @@ -99,17 +82,15 @@ function bindsFollowing(run: Token[], i: number, baselineText: string): boolean } /** - * Computes the anchor token ref for a segment split at the word-word gap between `prevToken` and - * `nextToken`. Walks the punctuation tokens between them and returns the ref of the first token (in - * document order) that binds to the **following** segment; when no punctuation binds following, the - * anchor is `nextToken` itself. + * Computes the token ref a segment split at a word-word gap should be anchored before, applying the + * punctuation-travel rule. Re-segmentation cuts immediately before whatever anchor it is given, so + * which side of a split the gap's punctuation lands on is entirely a matter of this choice. * * @param prevToken - The word token immediately before the gap. - * @param nextToken - The word token immediately after the gap (the default anchor). + * @param nextToken - The word token immediately after the gap, and the anchor used when no + * punctuation binds following. * @param punctuation - The punctuation tokens sitting in the gap, in document order. - * @param baselineText - The owning segment's baseline text; whitespace between tokens is read from - * it via the literal gap substring between adjacent tokens. - * @returns The ref of the token the boundary should be placed before. + * @param baselineText - The owning segment's baseline text; inter-token gaps are read from it. */ export function resolveSplitAnchor( prevToken: Token, diff --git a/src/utils/verse-ref.ts b/src/utils/verse-ref.ts index e350c2c9..e87a7205 100644 --- a/src/utils/verse-ref.ts +++ b/src/utils/verse-ref.ts @@ -2,12 +2,8 @@ import type { SerializedVerseRef } from '@sillsdev/scripture'; import type { ScriptureRef, Segment } from 'interlinearizer'; /** - * Whether `ref` and `scrRef` name the same verse, bridging `ScriptureRef`'s `chapter`/`verse` field - * names to `SerializedVerseRef`'s `chapterNum`/`verseNum`. - * - * @param ref - Verse coordinate in the internal `ScriptureRef` shape. - * @param scrRef - Verse coordinate in the platform's `SerializedVerseRef` shape. - * @returns `true` when both name the same book, chapter, and verse. + * Whether the two refs name the same verse, bridging {@link ScriptureRef}'s `chapter`/`verse` field + * names to {@link SerializedVerseRef}'s `chapterNum`/`verseNum`. */ export function isSameVerse(ref: ScriptureRef, scrRef: SerializedVerseRef): boolean { return ( @@ -16,14 +12,9 @@ export function isSameVerse(ref: ScriptureRef, scrRef: SerializedVerseRef): bool } /** - * Parses the leading verse range out of a verbatim USJ verse label. A plain number (e.g. `"7"`) - * yields `{ first: 7, last: 7 }`; a hyphenated range (e.g. `"3-4"`) yields `{ first: 3, last: 4 }`. - * A label that begins with no digits (e.g. an empty or note-only marker) yields `undefined`. Shared - * by {@link firstVerseNumber} and {@link verseLabelCovers} so the label grammar lives in one place. - * - * @param verseStartNumber - The verse start's verbatim `number`. - * @returns The label's `first`/`last` endpoints (equal for a plain number), or `undefined` when it - * names no verse. + * Parses the leading verse range out of a verbatim USJ verse label: `"7"` yields `7`–`7` and a + * hyphenated range `"3-4"` yields `3`–`4`. A label beginning with no digits (an empty or note-only + * marker) yields `undefined`. The one place the label grammar is defined. */ function parseVerseLabel(verseStartNumber: string): { first: number; last: number } | undefined { // Accept an ASCII hyphen or any common Unicode dash (U+2010–U+2015: hyphen, non-breaking hyphen, @@ -38,26 +29,16 @@ function parseVerseLabel(verseStartNumber: string): { first: number; last: numbe } /** - * Parses the first verse number out of a verbatim USJ verse label. A plain number returns itself; a - * hyphenated range (e.g. `"3-4"`) returns its first endpoint. A label that begins with no digits - * (e.g. an empty or note-only marker) returns `undefined`. - * - * @param verseStartNumber - The verse start's verbatim `number`. - * @returns The label's leading verse number, or `undefined` when it names none. + * The first verse number named by a verbatim USJ verse label, or `undefined` when the label names + * no verse. */ export function firstVerseNumber(verseStartNumber: string): number | undefined { return parseVerseLabel(verseStartNumber)?.first; } /** - * Reports whether `verseStartNumber` (a verbatim USJ verse label, e.g. `"7"` or a range like - * `"3-4"`) names the verse `verseNum`. A plain number matches on equality; a hyphenated range - * matches any verse from its first to its last endpoint inclusive. A label that parses to no digits - * matches nothing. - * - * @param verseStartNumber - The verse start's verbatim `number`. - * @param verseNum - The verse number to test for membership. - * @returns `true` when the label names `verseNum`. + * Whether a verbatim USJ verse label names the given verse. A range matches any verse between its + * endpoints inclusive; a label that parses to no digits matches nothing. */ function verseLabelCovers(verseStartNumber: string, verseNum: number): boolean { const range = parseVerseLabel(verseStartNumber); @@ -66,17 +47,13 @@ function verseLabelCovers(verseStartNumber: string, verseNum: number): boolean { } /** - * Whether `segment` contains the verse named by `scrRef`. Containment is verse-level and tested - * against the segment's `verseStarts` — the exact set of covered source verses — rather than a - * lexicographic `startRef`..`endRef` interval: for a cross-chapter merge (e.g. `1:2`..`2:1`) the - * interval would over-claim every verse in the start chapter above `2`, whereas the covered-verse - * set claims only `1:2` and `2:1`. Character anchors are ignored, so every portion of a split verse - * "contains" it. Used wherever a verse must resolve to its owning segment (navigation, active - * highlight). + * Whether the segment contains the verse named by the ref. Used wherever a verse must resolve to + * its owning segment, such as navigation and active-verse highlighting. * - * @param segment - The segment whose covered verses to test. - * @param scrRef - Verse coordinate in the platform's `SerializedVerseRef` shape. - * @returns `true` when the segment covers the verse named by `scrRef`. + * Containment is tested against the segment's covered source verses rather than a lexicographic + * start-to-end interval: for a cross-chapter merge (say `1:2`..`2:1`) the interval would over-claim + * every verse in the start chapter above `2`. Character anchors are ignored, so every portion of a + * split verse contains it. */ export function segmentContainsVerse(segment: Segment, scrRef: SerializedVerseRef): boolean { if (segment.startRef.book !== scrRef.book) return false; @@ -86,11 +63,8 @@ export function segmentContainsVerse(segment: Segment, scrRef: SerializedVerseRe } /** - * Converts an internal `ScriptureRef` to the platform's `SerializedVerseRef` shape, dropping any - * character anchor. - * - * @param ref - Verse coordinate in the internal `ScriptureRef` shape. - * @returns The same verse coordinate as a `SerializedVerseRef`. + * Converts an internal {@link ScriptureRef} to the platform's {@link SerializedVerseRef} shape, + * dropping any character anchor. */ export function toSerializedVerseRef(ref: ScriptureRef): SerializedVerseRef { return { book: ref.book, chapterNum: ref.chapter, verseNum: ref.verse }; diff --git a/src/utils/verse-superscripts.ts b/src/utils/verse-superscripts.ts index 9388a166..730c2a5c 100644 --- a/src/utils/verse-superscripts.ts +++ b/src/utils/verse-superscripts.ts @@ -1,29 +1,10 @@ -/** - * @file Pure helper deriving inline verse-superscript labels for a (re)segmented book. - * - * Verse numbers render as inline superscripts sourced verbatim from the USJ verse marker (carried - * on `Segment.verseStarts`). This module decides, over the whole book in document order, which - * verse starts open a new chapter — those get a `chapter:number` qualifier; all others render - * their bare verbatim number. (The pinned "Book N" chapter header is a separate scroll-driven - * overlay in the list, not derived here.) - * - * The chapter of a verse start is read from `VerseStart.chapter`, not from a token, so the - * qualifier triggers off "chapter exceeds every chapter seen so far" regardless of where segment - * boundaries fall — correct even for an empty verse or one whose baseline begins with - * whitespace. - */ import type { Segment, Token, VerseStart } from 'interlinearizer'; import type { LinkSlot } from '../types/token-layout'; /** - * Finds the token that renders a verse start — the first token whose `charStart` is at or after the - * verse start's offset. Not an exact match: a verse whose baseline begins with whitespace has its - * first token a few characters in, which a strict `===` would miss. Returns `undefined` for an - * empty verse, which has no token to carry the superscript. - * - * @param segment - The segment the verse start belongs to. - * @param vs - The verse start whose leading token is wanted. - * @returns The first token at or after the verse start's offset, or `undefined` when none exists. + * Finds the token that renders a verse start. Deliberately not an exact offset match: a verse whose + * baseline begins with whitespace has its first token a few characters in. An empty verse has no + * token to carry the superscript and yields `undefined`. */ export function verseStartToken(segment: Segment, vs: VerseStart): Token | undefined { return segment.tokens.find((t) => t.charStart >= vs.charStart); @@ -31,13 +12,13 @@ export function verseStartToken(segment: Segment, vs: VerseStart): Token | undef /** * Builds each segment's inline verse-superscript labels over the whole book in document order, - * chapter-qualifying the label of every verse start that opens a new chapter. - * - * A verse start opens a new chapter when its `chapter` exceeds the highest chapter seen so far; its - * label becomes `chapter:number`. Every other verse start keeps its bare verbatim number. + * keyed by segment id and parallel by index to that segment's verse starts. A verse start opening a + * new chapter is qualified as `chapter:number`; every other keeps its bare verbatim number. * - * @param segments - The book's segments in document order. - * @returns Map from segment id to the parallel-by-index labels for that segment's `verseStarts`. + * Chapter is read from the verse start itself rather than from a token, so qualification stays + * correct regardless of where segment boundaries fall — including for an empty verse or one whose + * baseline opens with whitespace. The pinned chapter header is a separate scroll-driven overlay, + * not derived here. */ export function buildVerseStartLabels(segments: readonly Segment[]): Map { const labelsBySegmentId = new Map(); @@ -56,22 +37,15 @@ export function buildVerseStartLabels(segments: readonly Segment[]): Map { const labelsBySegmentId = buildVerseStartLabels(segments); From 2f896f9a1e6de4780f7cf767faab8f41e0a6d5b5 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Tue, 28 Jul 2026 16:50:22 -0600 Subject: [PATCH 04/12] Apply comment rules to the mid-size src/utils modules Drop the repeated @param verseBook/@param delta/@returns boilerplate across the segmentation transforms, and condense how-narration in the identity helpers while keeping the normalization and dedupe rationale. --- src/utils/analysis-identity.ts | 102 +++++++++++---------------- src/utils/segmentation.ts | 121 ++++++++------------------------- src/utils/token-layout.ts | 63 ++++++----------- 3 files changed, 88 insertions(+), 198 deletions(-) diff --git a/src/utils/analysis-identity.ts b/src/utils/analysis-identity.ts index 3b241b0b..0375e316 100644 --- a/src/utils/analysis-identity.ts +++ b/src/utils/analysis-identity.ts @@ -1,42 +1,33 @@ -/** @file Content-identity helpers for deduplicating `TokenAnalysis` payloads on write. */ - import type { MorphemeAnalysis, TokenAnalysis } from 'interlinearizer'; /** - * Soft cap on {@link normalizedFormCache}. One project's vocabulary is far smaller than this, so the - * cap is effectively never hit within a single project; it exists only to bound the module-global - * cache across a long-lived WebView session that opens many different projects (each a different - * source vocabulary, plus baseline-drift variants), so the cache cannot grow without limit for the - * process lifetime. Generous enough that the working set of the active project always fits. + * Soft cap on the normalized-form cache. One project's vocabulary is far smaller than this, so the + * cap is effectively never hit within a single project. It exists only to bound the module-global + * cache across a long-lived WebView session that opens many projects, each with its own vocabulary + * plus baseline-drift variants, so memory cannot grow without limit for the process lifetime. */ const NORMALIZED_FORM_CACHE_MAX = 50_000; /** - * Memoizes {@link normalizeSurfaceForm} by raw input string — called once per visible un-approved - * token on every store dispatch, so the same forms are normalized repeatedly. The cache is module- - * global rather than project-scoped, so a bound is needed: when it reaches - * {@link NORMALIZED_FORM_CACHE_MAX} the oldest entry is evicted (insertion-order FIFO, free from - * `Map`), keeping memory flat across a session that opens many projects rather than retaining every - * surface form ever seen. + * Memoizes {@link normalizeSurfaceForm} by raw input string. Normalization runs once per visible + * un-approved token on every store dispatch, so the same handful of forms would otherwise be + * re-derived constantly. Bounded by {@link NORMALIZED_FORM_CACHE_MAX} because the cache is + * module-global rather than project-scoped. */ const normalizedFormCache = new Map(); /** - * Normalizes a token surface form for matching and dedupe so trivial Unicode and case differences - * never split one analysis into two. Applies Unicode NFC (so a composed `é` matches a decomposed - * `e` + combining acute), then a locale-independent lowercase (so a sentence-initial `"The"` - * matches a mid-sentence `"the"`), then NFC a final time so the returned key is guaranteed - * canonical even for the rare code point whose lowercase mapping emits a decomposed sequence — the - * key never depends on which Unicode form an equivalent input arrived in. Locale-independent - * lowercasing — `String.prototype.toLowerCase`, not `toLocaleLowerCase` — is deliberate so the - * result does not depend on the host's locale; the trade-off is that locale-specific folds are not - * applied (e.g. a Turkish dotted `İ` is not folded to an ASCII `i`), an accepted miss that can only - * cost a suggestion, never produce a wrong one. The result is memoized per raw input - * ({@link normalizedFormCache}, bounded by {@link NORMALIZED_FORM_CACHE_MAX}) so the per-token derive - * path does not re-run three Unicode passes for a constant surface form on every store change. + * Normalizes a token surface form for matching and dedupe, so trivial Unicode and case differences + * never split one analysis into two. + * + * Normalization is applied on both sides of the case fold: the leading pass makes a composed `é` + * match a decomposed `e` + combining acute, and the trailing pass re-canonicalizes the rare code + * point whose lowercase mapping emits a decomposed sequence. The result therefore never depends on + * which Unicode form an equivalent input arrived in. * - * @param text - The raw surface text to normalize. - * @returns The case-folded surface form in NFC. + * The fold is deliberately locale-independent so the result does not vary with the host's locale. + * The trade-off is that locale-specific folds are skipped — a Turkish dotted `İ` is not folded to + * an ASCII `i` — an accepted miss, since it can only cost a suggestion, never produce a wrong one. */ export function normalizeSurfaceForm(text: string): string { const cached = normalizedFormCache.get(text); @@ -57,19 +48,13 @@ export function normalizeSurfaceForm(text: string): string { } /** - * Structural deep-equality for the JSON-shaped values an analysis carries (strings, plain objects, - * and arrays). Used to compare glosses, features, morpheme refs, and projected morpheme lists - * without depending on key order. Never receives `null` — the analysis types use absence - * (`undefined`) rather than `null` — so no `null` guard is needed. - * - * Kept as a small, dedicated helper rather than reusing `platform-bible-utils`' `deepEqual`: that - * package is fully mocked in this project's Jest setup and the mock does not surface `deepEqual`, - * so depending on it here would mean the dedupe path silently ran against `undefined` under test. A - * local, directly-tested helper keeps content-identity matching honest in the suite. + * Structural deep-equality for the JSON-shaped values an analysis carries, used to compare glosses, + * features, and morpheme refs without depending on key order. Never receives `null`, since the + * analysis types use absence rather than `null`. * - * @param a - First value. - * @param b - Second value. - * @returns `true` when the two values are structurally equal. + * Kept as a dedicated local helper rather than reusing `platform-bible-utils`' equivalent: that + * package is fully mocked in this project's Jest setup and the mock does not surface it, so + * depending on it here would mean the dedupe path silently ran against `undefined` under test. */ function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; @@ -84,13 +69,9 @@ function deepEqual(a: unknown, b: unknown): boolean { } /** - * Projects a morpheme down to the fields that define analysis identity — form, gloss, and its - * lexicon refs (entry, sense, allomorph, grammar) — dropping `id` (a per-instance UUID) and - * `writingSystem` (a presentation detail that self-corrects on save), so two analyses that differ - * only in those are still considered identical. - * - * @param morpheme - The morpheme to project. - * @returns A plain object holding only the identity-defining fields. + * Projects a morpheme down to the fields that define analysis identity, dropping `id` (a + * per-instance UUID) and `writingSystem` (a presentation detail that self-corrects on save), so two + * analyses differing only in those still count as identical. */ function morphemeIdentity(morpheme: MorphemeAnalysis) { const { form, gloss, entryRef, senseRef, allomorphRef, grammarRef } = morpheme; @@ -98,24 +79,19 @@ function morphemeIdentity(morpheme: MorphemeAnalysis) { } /** - * Reports whether two `TokenAnalysis` payloads carry the same meaning and so should share one - * stored payload instead of being duplicated. Identity is content-based: equal normalized surface - * forms (see {@link normalizeSurfaceForm}) plus deep-equal `gloss` (all language keys), `pos`, - * `features`, `glossSenseRef` (the lexicon sense the gloss resolves through), and `morphemes` - * (compared on form + gloss + refs only — see {@link morphemeIdentity}). Only `morphemes` treats a - * missing list and an empty one as equal (the absent list is normalized to `[]` before comparison); - * every other field is compared by exact structural equality, so a missing `gloss`/`features`/ - * `glossSenseRef` does not compare equal to an empty `{}`. That direction is deliberately - * conservative: the only risk this dedupe carries is merging two analyses that actually differ (see - * `glossSenseRef` below), never failing to merge two that don't. `glossSenseRef` is part of - * identity because `isEmptyTokenAnalysis` counts it as content — were it excluded here, two - * analyses differing only in their lexicon sense would be merged on write and one sense reference - * silently dropped. Provenance fields (`confidence`, `producer`, `sourceUser`) and the record `id` - * are intentionally excluded — they describe who produced an analysis, not what it means. + * Reports whether two token analyses carry the same meaning and so should share one stored payload + * rather than being duplicated. + * + * Identity is content-based: normalized surface form plus the gloss, part of speech, features, + * lexicon sense, and morphemes. Provenance (`confidence`, `producer`, `sourceUser`) and the record + * `id` are excluded — they describe who produced an analysis, not what it means. The lexicon sense + * _is_ part of identity, because excluding it would merge two analyses differing only in their + * sense and silently drop one reference. * - * @param a - First analysis. - * @param b - Second analysis. - * @returns `true` when the two analyses are content-identical. + * Only morphemes treat a missing list and an empty one as equal; every other field is compared by + * exact structural equality, so a missing gloss does not equal an empty one. That asymmetry is + * deliberately conservative: the only risk this dedupe carries is merging two analyses that + * genuinely differ, never failing to merge two that don't. */ export function analysesAreIdentical(a: TokenAnalysis, b: TokenAnalysis): boolean { return ( diff --git a/src/utils/segmentation.ts b/src/utils/segmentation.ts index b1c4339f..7df7ed4c 100644 --- a/src/utils/segmentation.ts +++ b/src/utils/segmentation.ts @@ -1,16 +1,9 @@ /** - * @file Pure transforms over a {@link SegmentationDelta} — the user's custom segment boundaries - * expressed as a delta from the default one-segment-per-verse segmentation. + * Pure transforms over a {@link SegmentationDelta}, the user's custom segment boundaries expressed + * as a delta from the default one-segment-per-verse segmentation. * - * A segment is a maximal contiguous run of the book's document-order token stream between "start" - * tokens. The default start tokens are each verse's first token; the delta records where the - * user's boundaries differ (a removed verse start merges that verse into the previous segment; an - * added start splits a verse). Because a segment can only be a contiguous run between starts, - * discontiguous segments are unrepresentable. - * - * Every function here is pure and store-free (mirrors `phrase-arc.ts`). They take the original - * verse-tokenized {@link Book} (from `tokenizeBook`, before re-segmentation) so they can derive - * the default verse starts; they never need the re-segmented book. + * Every transform takes the _original_ verse-tokenized book — never the re-segmented one — because + * that is what the default verse starts are derived from, and returns a normalized delta. */ import type { Book, SegmentationDelta } from 'interlinearizer'; @@ -19,7 +12,7 @@ const EMPTY_DELTA: SegmentationDelta = { removedVerseStarts: [], addedStarts: [] /** * The whole-book lookups every transform in this module needs, derived in a single pass over the - * token stream so one boundary op walks the book once. + * token stream so one boundary operation walks the book once. */ type BookLookups = Readonly<{ /** @@ -36,18 +29,11 @@ type BookLookups = Readonly<{ }>; /** - * Per-book cache of {@link BookLookups}, keyed by book reference. `verseBook` identity is stable for - * a given tokenization (changing only on re-tokenization), so every op reuses one traversal. + * Per-book cache of {@link BookLookups}. A tokenized book's identity is stable until it is + * re-tokenized, so every operation reuses one traversal. */ const bookLookupsCache = new WeakMap(); -/** - * Returns the {@link BookLookups} for `verseBook`, computing them in one pass on first use and - * caching by book reference thereafter. - * - * @param verseBook - The original verse-tokenized book. - * @returns The cached (or freshly built) lookups. - */ function bookLookups(verseBook: Book): BookLookups { const cached = bookLookupsCache.get(verseBook); if (cached) return cached; @@ -75,25 +61,18 @@ function bookLookups(verseBook: Book): BookLookups { } /** - * The default segment-start refs — each verse segment's first token (of any type, so a verse's - * leading punctuation stays with that verse). - * - * @param verseBook - The original verse-tokenized book. - * @returns The set of first-token refs, one per verse segment that has tokens. + * The default segment-start refs — each verse segment's first token, of any type, so a verse's + * leading punctuation stays with that verse. */ export function defaultVerseStarts(verseBook: Book): ReadonlySet { return bookLookups(verseBook).defaults; } /** - * The effective set of segment-start refs after applying `delta` to the default verse starts: - * `(defaults \ removedVerseStarts) ∪ addedStarts`, with added anchors dropped when their token no - * longer exists and the book's first token always forced to be a start. Shared with `resegmentBook` - * so re-segmentation and the editing operations agree on where boundaries fall. - * - * @param verseBook - The original verse-tokenized book. - * @param delta - The user's boundary delta, or `undefined` for the default segmentation. - * @returns The set of token refs that begin a segment. + * The token refs that begin a segment once the delta is applied to the default verse starts: + * `(defaults \ removedVerseStarts) ∪ addedStarts`. Added anchors whose token no longer exists are + * dropped, and the book's first token is always forced to be a start. Shared with re-segmentation + * so it and the editing operations agree on where boundaries fall. */ export function effectiveStarts( verseBook: Book, @@ -116,13 +95,8 @@ export function effectiveStarts( } /** - * Returns a canonicalized copy of `delta`: each array deduped, stripped of no-op entries (a removed - * ref that is not a default start, or an added ref that is already a default start or whose token - * is gone), and sorted by document order so equal segmentations serialize identically. - * - * @param verseBook - The original verse-tokenized book. - * @param delta - The delta to canonicalize. - * @returns A normalized {@link SegmentationDelta}. + * Canonicalizes a delta so that equal segmentations serialize identically: each array is deduped, + * stripped of no-op entries, and sorted by document order. */ function normalize(verseBook: Book, delta: SegmentationDelta): SegmentationDelta { const { defaults, all, order, first } = bookLookups(verseBook); @@ -141,18 +115,9 @@ function normalize(verseBook: Book, delta: SegmentationDelta): SegmentationDelta } /** - * Makes `ref` begin a segment — i.e. splits before it. - * - * - When `ref` is a default verse start that was merged away, it is un-merged (dropped from - * `removedVerseStarts`). - * - Otherwise `ref` is recorded as an added start. - * - * No-op (returns an equivalent normalized delta) when `ref` already begins a segment. - * - * @param verseBook - The original verse-tokenized book. - * @param delta - The current delta, or `undefined` for the default segmentation. - * @param ref - The token ref that should begin a segment. - * @returns The updated, normalized delta. + * Makes a token begin a segment — that is, splits before it. A default verse start that had been + * merged away is un-merged; any other token is recorded as an added start. Already being a segment + * start is a no-op. */ export function addBoundaryBefore( verseBook: Book, @@ -174,17 +139,9 @@ export function addBoundaryBefore( } /** - * Stops `ref` from beginning a segment — i.e. merges it into the preceding segment. - * - * - When `ref` is a default verse start, it is recorded in `removedVerseStarts`. - * - Otherwise (it was an added split) it is dropped from `addedStarts`. - * - * No-op when `ref` is the book's first token (the first segment cannot be merged leftward). - * - * @param verseBook - The original verse-tokenized book. - * @param delta - The current delta, or `undefined` for the default segmentation. - * @param ref - The segment-start token ref to remove. - * @returns The updated, normalized delta. + * Stops a token from beginning a segment, merging it into the preceding one. A default verse start + * is recorded as removed; a previously added split is dropped. Merging the book's first token is a + * no-op, since the first segment cannot merge leftward. */ export function removeBoundaryAt( verseBook: Book, @@ -207,14 +164,8 @@ export function removeBoundaryAt( } /** - * Moves a boundary from `fromRef` to `toRef` in one step — the primitive behind pulling a single - * edge token across a segment boundary. Removes the start at `fromRef` and adds one at `toRef`. - * - * @param verseBook - The original verse-tokenized book. - * @param delta - The current delta, or `undefined` for the default segmentation. - * @param fromRef - The current segment-start ref to remove. - * @param toRef - The new segment-start ref to add. - * @returns The updated, normalized delta. + * Moves a boundary from one token to another in a single step — the primitive behind pulling one + * edge token across a segment boundary. */ export function moveBoundary( verseBook: Book, @@ -226,14 +177,10 @@ export function moveBoundary( } /** - * Merges the segment that starts at `secondSegmentStartRef` into the segment before it. Thin alias - * for {@link removeBoundaryAt}, named for the explicit merge control. + * Merges a segment into the one before it. A thin alias for {@link removeBoundaryAt}, named for the + * explicit merge control. * - * @param verseBook - The original verse-tokenized book. - * @param delta - The current delta, or `undefined` for the default segmentation. - * @param secondSegmentStartRef - The first-token ref of the segment being merged into its - * predecessor. - * @returns The updated, normalized delta. + * @param secondSegmentStartRef - First-token ref of the segment being merged into its predecessor. */ export function mergeSegments( verseBook: Book, @@ -244,13 +191,8 @@ export function mergeSegments( } /** - * Splits a segment so a new one begins at `ref`. Thin alias for {@link addBoundaryBefore}, named for - * the explicit split control. - * - * @param verseBook - The original verse-tokenized book. - * @param delta - The current delta, or `undefined` for the default segmentation. - * @param ref - The token ref the new segment should begin at. - * @returns The updated, normalized delta. + * Splits a segment so a new one begins at the given token. A thin alias for + * {@link addBoundaryBefore}, named for the explicit split control. */ export function splitSegmentBefore( verseBook: Book, @@ -260,12 +202,7 @@ export function splitSegmentBefore( return addBoundaryBefore(verseBook, delta, ref); } -/** - * Whether `delta` represents the default verse segmentation (absent or both arrays empty). - * - * @param delta - The delta to test. - * @returns `true` when applying `delta` yields the default segmentation. - */ +/** Whether the delta represents the default verse segmentation: absent, or both arrays empty. */ export function isDefaultSegmentation(delta: SegmentationDelta | undefined): boolean { return !delta || (delta.removedVerseStarts.length === 0 && delta.addedStarts.length === 0); } diff --git a/src/utils/token-layout.ts b/src/utils/token-layout.ts index 7da9ab74..34a6e2ab 100644 --- a/src/utils/token-layout.ts +++ b/src/utils/token-layout.ts @@ -11,15 +11,9 @@ import { isWordToken } from '../types/type-guards'; export type { FocusContext, LinkSlot, RenderUnit, SlotFocusInfo, TokenGroup }; /** - * Resolves the focus context from a single `focusedTokenRef`. All views use the same rules; the - * only thing that differs between layouts is how they discover which token is focused, not what - * that focus means. - * - * @param focusedTokenRef - Ref of the focused word token, or `undefined`. - * @param tokensByRef - Lookup from token ref to the full token (word or other). - * @param phraseLinkByRef - Map from token ref to the phrase link containing it. - * @param tokenSegmentMap - Map from token ref to the id of the segment containing it. - * @returns The resolved focus context. All fields are `undefined` when `focusedTokenRef` is unset. + * Resolves the {@link FocusContext} implied by a single focused token ref. All views share these + * rules; layouts differ only in how they discover which token is focused, not in what that focus + * means. */ export function resolveFocusContext( focusedTokenRef: string | undefined, @@ -50,18 +44,13 @@ export function resolveFocusContext( } /** - * Computes the slot's focus-derived inputs to `TokenLinkIcon`. Pure function over the slot's - * segment ids and the supplied focus context; bundles the slot-specific flags together with the - * focused phrase/token so the icon receives a single `slotFocus` object. + * Computes one link slot's {@link SlotFocusInfo}, bundling the slot-specific flags with the focused + * phrase and token so the link icon receives a single focus object. * - * @param prevSegmentId - Segment id of the group before the slot, or `undefined` for the leading - * slot. - * @param nextSegmentId - Segment id of the group after the slot, or `undefined` for the trailing - * slot. + * @param prevSegmentId - Segment id of the group before the slot; `undefined` for the leading slot. + * @param nextSegmentId - Segment id of the group after the slot; `undefined` for the trailing slot. * @param focus - Resolved focus context for the whole strip. - * @param focusedSideIsPrev - The layout-specific bool indicating whether focus is start-ward of - * this slot. - * @returns Slot focus info ready to pass as `slotFocus` to `MemoizedTokenLinkIcon`. + * @param focusedSideIsPrev - Layout-specific flag for whether focus is start-ward of this slot. */ export function resolveSlotFocus( prevSegmentId: string | undefined, @@ -83,8 +72,8 @@ export function resolveSlotFocus( } /** - * The "no focus" slot-focus bundle: nothing focused, so the link button is inert. Used by - * `PhraseBox` for the in-phrase unlink icons, which never participate in focus-driven linking. + * The "no focus" bundle: nothing focused, so the link button is inert. Used for the unlink icons + * inside a phrase, which never participate in focus-driven linking. */ export const NO_SLOT_FOCUS: SlotFocusInfo = { focusedSideIsPrev: undefined, @@ -94,15 +83,10 @@ export const NO_SLOT_FOCUS: SlotFocusInfo = { }; /** - * Groups adjacent word tokens that share the same approved `PhraseAnalysisLink` into single - * `TokenGroup` entries. Non-word tokens are skipped. Discontiguous phrase members produce separate - * groups that share the same `phraseLink`. `punctuationBetween` is initialized to empty arrays - * here; {@link buildRenderUnits} fills it in with any punctuation tokens that appear between the - * word tokens in document order. - * - * @param tokens - The flat token list to group. - * @param phraseLinkByRef - Map from `tokenRef` to the `PhraseAnalysisLink` containing it. - * @returns An ordered array of `TokenGroup`s ready for rendering. + * Groups adjacent word tokens sharing the same approved phrase into single {@link TokenGroup} + * entries, skipping non-word tokens. Discontiguous phrase members produce separate groups that + * share one phrase link. Each group's punctuation is left empty here and filled in when the render + * units are built. */ export function groupTokens( tokens: Token[], @@ -123,20 +107,13 @@ export function groupTokens( } /** - * Walks `tokens` in document order and emits an alternating sequence of phrase groups and link - * slots. A leading slot is always emitted before the first group and a trailing slot after the - * last, so punctuation at segment boundaries still renders. Slots between groups always carry both - * `prevGroup` and `nextGroup`. Unlink icons between tokens within a multi-token phrase are rendered - * inside `PhraseBox`, not as separate slots here. - * - * Punctuation that appears between two word tokens of the same group is stored in the group's - * `punctuationBetween` array (at the index corresponding to the gap between those tokens) so - * `PhraseBox` can render it inline between the token chips, rather than pushing it into the - * following inter-group slot. + * Emits an alternating sequence of phrase groups and link slots in document order. A leading slot + * always precedes the first group and a trailing slot follows the last, so punctuation at segment + * boundaries still renders. Unlink icons between tokens of a multi-token phrase are rendered by the + * phrase box itself rather than emitted as slots here. * - * @param tokens - Flat token list from the segment or strip. - * @param tokenGroups - Pre-built phrase groups produced by {@link groupTokens}. - * @returns An ordered list of render units interleaving groups and slots. + * Punctuation falling between two word tokens of the same group is stored on that group instead of + * being pushed into the following inter-group slot, so it renders inline between the token chips. */ export function buildRenderUnits(tokens: Token[], tokenGroups: TokenGroup[]): RenderUnit[] { const units: RenderUnit[] = []; From 3b4c3722cbe8d9d2192eb8b1064dfb6b5de4f335 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Tue, 28 Jul 2026 16:51:33 -0600 Subject: [PATCH 05/12] Apply comment rules to the suggestion engine --- src/utils/suggestion-engine.ts | 164 +++++++++++++-------------------- 1 file changed, 64 insertions(+), 100 deletions(-) diff --git a/src/utils/suggestion-engine.ts b/src/utils/suggestion-engine.ts index 2a33df94..c30f8113 100644 --- a/src/utils/suggestion-engine.ts +++ b/src/utils/suggestion-engine.ts @@ -1,26 +1,24 @@ /** - * @file Pure suggestion-engine core: builds the analysis pool, ranks competing payloads, and - * derives per-token suggestions. Everything here is a pure function over plain data so the engine - * is trivially testable; the memoized Redux selectors that feed it live in - * `store/analysisSlice`. + * Pure suggestion-engine core: builds the analysis pool, ranks competing payloads, and derives + * per-token suggestions. Everything here is a pure function over plain data, so the engine is + * trivially testable; the memoized selectors that feed it live in the store. * - * Suggestions and candidates are never persisted — they are derived on read; only the approved - * human decisions are stored. The pool is the set of approved analyses in the current draft. + * Suggestions and candidates are never persisted — they are derived on read, and only approved + * human decisions are stored. The pool is the set of approved analyses in the current draft. */ import type { AssignmentStatus, TokenAnalysis } from 'interlinearizer'; import { normalizeSurfaceForm } from './analysis-identity'; /** - * Shared empty candidate list returned for every non-homograph match (the common case), so - * {@link deriveTokenSuggestion} never allocates a throwaway `[]` per call. Read-only and never - * mutated by any consumer. + * Shared empty candidate list returned for every non-homograph match, so the common case never + * allocates a throwaway array. Read-only and never mutated by any consumer. */ const NO_CANDIDATES: readonly TokenAnalysis[] = []; /** One distinct approved payload in the pool together with how many tokens currently approve it. */ export interface PoolEntry { - /** The shared approved `TokenAnalysis` payload. */ + /** The shared approved payload. */ analysis: TokenAnalysis; /** Number of tokens whose approved link points at this payload — its approval frequency. */ frequency: number; @@ -28,40 +26,39 @@ export interface PoolEntry { /** * The analysis pool indexed for matching: normalized surface form → the distinct approved payloads - * sharing that form, each bucket pre-ranked best-first ({@link comparePoolEntries}) so its head is - * the suggested pick. A single-element list is the common case; multiple entries mean a homograph - * (competing analyses of the same surface form). Buckets are read-only — they are ranked once at - * build time and never re-sorted per token. + * sharing that form. Each bucket is pre-ranked best-first at build time and never re-sorted per + * token, so its head is the suggested pick. A single-element bucket is the common case; multiple + * entries mean a homograph. */ export type PoolIndex = ReadonlyMap; -/** The engine's derived proposal for one un-approved token (never persisted — derived on read). */ +/** The engine's derived proposal for one un-approved token. Never persisted. */ export interface TokenSuggestion { - /** The top-ranked matching payload — the engine's single best pick (the `suggested` analysis). */ + /** The top-ranked matching payload — the engine's single best pick. */ suggested: TokenAnalysis; /** - * The remaining matching payloads, in rank order — the `candidate` alternatives a reviewer can - * promote instead of the suggestion. Empty unless the surface form is a homograph. Read-only so - * the single shared empty array returned for the common non-homograph case is never mutated. + * The remaining matching payloads, in rank order — the alternatives a reviewer can promote + * instead. Empty unless the surface form is a homograph. Read-only, because the non-homograph + * case returns one shared empty array. */ candidates: readonly TokenAnalysis[]; } /** * The merged per-token read the renderer consumes: the token's approved decision when one exists, - * otherwise the engine's derived suggestion. The selector that produces this returns `undefined` - * (not modeled here) when the token has neither — an unanalyzed token with no pool match. + * otherwise the engine's derived suggestion. The selector producing this yields `undefined`, not + * modeled here, when the token has neither — an unanalyzed token with no pool match. */ export type ResolvedTokenAnalysis = | { - /** The token has a human-confirmed analysis; `analysis` is canonical for rendering. */ + /** The token has a human-confirmed analysis, canonical for rendering. */ status: 'approved'; /** The approved payload. */ analysis: TokenAnalysis; /** - * Pool alternatives for this surface form, when any exist, so the suggestion dropdown can - * offer re-promotion even after the token is approved. `undefined` when the pool has no match - * for this surface form (the token was manually glossed with no pool peers). + * Pool alternatives for this surface form, so the suggestion dropdown can offer re-promotion + * even after the token is approved. `undefined` when the pool has no match — the token was + * manually glossed with no pool peers. */ poolSuggestion?: TokenSuggestion; } @@ -71,15 +68,9 @@ export type ResolvedTokenAnalysis = } & TokenSuggestion); /** - * Orders two competing pool entries best-first: the more-approved entry sorts before the less, and - * a frequency tie is broken by the lower `analysis.id`. The id tiebreak is deterministic and - * content-independent, so the elected suggestion never flickers between equally-frequent homographs - * as unrelated edits reorder the pool. Used by {@link buildPoolIndex} to pre-rank each bucket once - * at build time so per-token derives never re-sort. - * - * @param a - First pool entry. - * @param b - Second pool entry. - * @returns A negative number when `a` ranks first, positive when `b` ranks first. + * Orders two competing pool entries best-first, breaking a frequency tie by the lower analysis id. + * That tiebreak is deterministic and content-independent, so the elected suggestion never flickers + * between equally-frequent homographs as unrelated edits reorder the pool. */ function comparePoolEntries(a: PoolEntry, b: PoolEntry): number { if (a.frequency !== b.frequency) return b.frequency - a.frequency; @@ -87,23 +78,17 @@ function comparePoolEntries(a: PoolEntry, b: PoolEntry): number { } /** - * Groups the approved analyses into the {@link PoolIndex} used for matching: each distinct payload - * is filed under the normalized form of its `surfaceText` ({@link normalizeSurfaceForm}), carrying - * its approval frequency. Because the write path dedupes identical analyses and - * `appendApprovedAnalysis` only shares a payload across tokens with the same normalized surface - * form, every token under one key truly competes for the same word — so two entries under one key - * are genuine homographs, never accidental near-duplicates. + * Groups the approved analyses into the {@link PoolIndex} used for matching, filing each distinct + * payload under the normalized form of its surface text along with its approval frequency. * - * Keying on the normalized surface form alone (not also the writing system) is correct for v1: the - * pool is a single source project whose word tokens share one writing system, and NFC keeps - * different scripts on distinct code points — so equal normalized forms already imply the same - * writing system. + * Because the write path dedupes identical analyses and only shares a payload across tokens with + * the same normalized surface form, every token under one key truly competes for the same word — so + * two entries under one key are genuine homographs, never accidental near-duplicates. * - * @param analysisById - Map from `TokenAnalysis.id` to its payload (every approved id resolves - * here). - * @param approvedCountByAnalysisId - Map from each approved `TokenAnalysis.id` to its approval - * frequency; its keys are exactly the distinct approved payloads. - * @returns The pool indexed by normalized surface form. + * Keying on the normalized surface form alone, rather than also the writing system, is correct for + * v1: the pool is a single source project whose word tokens share one writing system, and NFC keeps + * different scripts on distinct code points, so equal normalized forms already imply the same + * writing system. */ export function buildPoolIndex( analysisById: ReadonlyMap, @@ -126,25 +111,15 @@ export function buildPoolIndex( } /** - * Derives the suggestion for one token from the pool by matching on its normalized surface form - * ({@link normalizeSurfaceForm}). When the form matches, the matched bucket is already ranked - * best-first ({@link buildPoolIndex}), so its head is the `suggested` analysis and the rest are the - * ranked `candidate`s; when it does not match, there is no suggestion. The caller is responsible - * for only asking about tokens that have no approved analysis (an approved token reads its - * decision, not a suggestion). - * - * Pass `excludeAnalysisId` to preview the suggestion a token would resolve to if its own approval - * were removed (used the instant an approved gloss is cleared, before the empty value commits): - * that payload's frequency is decremented — dropping it from the bucket entirely when this was its - * last approval — and the remainder re-ranked, so the preview matches the pool the committed - * deletion will produce rather than the approved payload's mere alternatives. + * Derives one token's suggestion from the pool by matching on its normalized surface form, or + * `undefined` when nothing matches. Callers are responsible for only asking about tokens that have + * no approved analysis, since an approved token reads its decision rather than a suggestion. * - * @param poolIndex - The pool indexed by normalized surface form. - * @param surfaceText - The token's raw surface text. - * @param excludeAnalysisId - When given, the id of an approved payload to discount by one approval - * before ranking, previewing the pool as if this token were unapproved. - * @returns The token's suggestion, or `undefined` when no pooled analysis matches (or none remains - * once the excluded payload is discounted). + * @param excludeAnalysisId - An approved payload to discount by one approval before ranking, + * previewing the suggestion this token would fall back to if its own approval were removed. Used + * the instant an approved gloss is cleared, before the empty value commits, so the preview + * matches the pool the committed deletion will produce rather than the approved payload's mere + * alternatives. */ export function deriveTokenSuggestion( poolIndex: PoolIndex, @@ -184,37 +159,30 @@ export function deriveTokenSuggestion( export interface GlossedSuggestionEntry { /** The matching payload's id — the approve/promote target and the React key. */ id: string; - /** The payload's gloss in the active analysis language; never blank (blank entries are dropped). */ + /** The payload's gloss in the active analysis language; never blank. */ gloss: string; /** - * How the row is offered: `'suggested'` for the engine's pick on an un-approved token (blue, - * "accept"), or `'candidate'` for every promotable alternative (grey, "promote"). Carried on the - * entry — rather than re-derived from row position by the renderer — so dropping a blank-in- - * language pick can never leave a candidate masquerading as the accept row. + * How the row is offered: `'suggested'` for the engine's pick on an un-approved token (the + * "accept" row), or `'candidate'` for a promotable alternative. Carried on the entry rather than + * re-derived from row position, so dropping a blank-in-language pick can never leave a candidate + * masquerading as the accept row. */ status: Extract; } /** * Flattens the merged per-token read into the entries the gloss UI renders, in rank order, keeping - * only those that carry a non-blank gloss in `analysisLanguage`. Centralizes the - * suggestion-presentation policy in one place: which matches are renderable, how a blank-in-active- - * language pick falls through to the next-ranked one rather than showing an empty button, the - * already-approved payload's exclusion from its own promote list, and — critically — each row's - * assignment status, so every surface ranks, colors, and labels suggestions identically instead of - * re-deriving any of it from the raw {@link TokenSuggestion} or from row position. + * only those with a non-blank gloss in the active language. * - * For an un-approved (`'suggested'`) token the highest-ranked renderable match is offered as - * `'suggested'` (the blue "accept" row) and the rest as `'candidate'` (grey "promote"). The status - * is assigned _after_ dropping blank-in-language picks, so when the engine's top pick has no gloss - * in the active language the next-ranked glossed match correctly becomes the accept row rather than - * the whole suggestion vanishing. For an already-approved token every pool peer is a `'candidate'` - * (promote) — the approved payload itself is excluded, and there is no accept row, so even the top - * row reads as a promotion rather than masquerading as an "accept the suggestion" row. + * This is the single home of suggestion-presentation policy — which matches are renderable, how a + * blank-in-active-language pick falls through, the approved payload's exclusion from its own + * promote list, and each row's assignment status — so every surface ranks, colors, and labels + * suggestions identically instead of re-deriving any of it from row position. * - * @param resolved - The token's merged approved/suggested read, or `undefined` when it has neither. - * @param analysisLanguage - BCP 47 tag whose gloss to read from each matching payload. - * @returns The glossed entries in rank order; empty when there is nothing renderable. + * Status is assigned _after_ blank picks are dropped. So when the engine's top pick has no gloss in + * the active language, the next-ranked glossed match becomes the accept row rather than the whole + * suggestion vanishing. An already-approved token has no accept row at all: every pool peer is a + * promotion, so even the top row reads as one. */ export function glossedSuggestionEntries( resolved: ResolvedTokenAnalysis | undefined, @@ -246,20 +214,16 @@ export function glossedSuggestionEntries( } /** - * Equality predicate for two {@link ResolvedTokenAnalysis} results, for use as a `useSelector` + * Equality predicate for two {@link ResolvedTokenAnalysis} results, for use as a selector's * `equalityFn` so a per-token subscription stays referentially stable across unrelated store - * changes. {@link selectResolvedTokenAnalysis} (in `store/analysisSlice`) freshly allocates its - * wrapper object — and the suggested branch a fresh `candidates` array — on every call, so the - * default `Object.is` comparison would re-render every visible suggested token on any store change. - * This compares by the meaningful identity instead: the `analysis` / `suggested` payloads and each - * `candidate` are reference-stable store objects (the pool only re-files the same payloads), so - * comparing them by reference is both correct and cheap — equal whenever the rendered suggestion is - * unchanged, even when an incidental edit elsewhere rebuilt the pool index around the same - * payloads. + * changes. * - * @param a - The previous resolved analysis (or `undefined` when the token had neither). - * @param b - The next resolved analysis (or `undefined`). - * @returns `true` when the two describe the same approved decision or suggestion. + * The selector freshly allocates its wrapper object — and, on the suggested branch, a fresh + * candidates array — on every call, so a default `Object.is` comparison would re-render every + * visible suggested token on any store change. Comparing the payloads by reference instead is both + * correct and cheap, because the pool only ever re-files the same reference-stable store objects: + * the result is equal whenever the rendered suggestion is unchanged, even when an incidental edit + * elsewhere rebuilt the pool index around those same payloads. */ export function resolvedTokenAnalysisEqual( a: ResolvedTokenAnalysis | undefined, From d007d8733e124ee10fdbedffaf89a6eeeec1a3b7 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Tue, 28 Jul 2026 16:54:37 -0600 Subject: [PATCH 06/12] Apply comment rules to phrase-arc geometry Drop @param/@returns that restate the signature, keeping only the tags that carry real constraints (pre-sort order, axis-aligned waypoints, radius clamping), and trim how-narration from the arc-routing docs. --- src/utils/phrase-arc.ts | 288 ++++++++++++---------------------------- 1 file changed, 84 insertions(+), 204 deletions(-) diff --git a/src/utils/phrase-arc.ts b/src/utils/phrase-arc.ts index 1f259fc9..5b2b5c4a 100644 --- a/src/utils/phrase-arc.ts +++ b/src/utils/phrase-arc.ts @@ -56,13 +56,10 @@ type SplitPhraseDispatch = { }; /** - * Sorts token snapshots by flat document index so a stored phrase token list reflects visual - * left-to-right order. Shared by every path that slices a phrase so document order is computed - * identically everywhere. Tokens missing from `tokenDocOrder` sort to the front (index 0). - * - * @param tokens - Token snapshots to sort. Not mutated; a new array is returned. - * @param tokenDocOrder - Map from token ref to flat document index. - * @returns A new array sorted by ascending document index. + * Sorts token snapshots by flat document index, without mutating the input, so a stored phrase + * token list reflects visual left-to-right order. Shared by every path that slices a phrase, so + * document order is computed identically everywhere. Tokens missing from the order map sort to the + * front. */ export function sortByDocOrder( tokens: readonly T[], @@ -75,17 +72,11 @@ export function sortByDocOrder( } /** - * Sorts a phrase's tokens into document order and slices them at the boundary just after - * `splitAfterTokenRef`. The single source of this slice, read by both {@link computeSplitFreeRefs} - * and {@link splitPhraseAtBoundary}, so the destructive-border preview can't drift from the split it - * previews. + * Slices a phrase's tokens, in document order, into the half up to and including the boundary token + * and the remainder after it; `undefined` when the boundary token is not among them. * - * @param tokens - The phrase's token snapshots. Not mutated. - * @param splitAfterTokenRef - Token ref marking the end of the earlier fragment (`before`). - * @param tokenDocOrder - Map from token ref to flat document index; tokens are ordered by this - * before slicing. - * @returns The `before` half (up to and including the boundary token) and the `after` remainder, or - * `undefined` when the boundary token is not found. + * The single source of this slice, so the destructive-border preview cannot drift from the split it + * previews. */ function sliceAtBoundary( tokens: readonly TokenSnapshot[], @@ -100,17 +91,12 @@ function sliceAtBoundary( } /** - * Enumerates the tokens of `phraseLink` that would become solo (free) after splitting just after - * `splitAfterTokenRef` — a half with exactly one token leaves it unattached. Shares - * {@link sliceAtBoundary} with {@link splitPhraseAtBoundary} so the destructive-border preview - * matches the resulting split. + * Enumerates the tokens that a split just after the given boundary would leave solo (free), since a + * half with exactly one token leaves it unattached. Yields `undefined` when both halves would keep + * at least two tokens, the phrase is absent, or the boundary token is not found. * - * @param phraseLink - The phrase to split, or `undefined` when it cannot be resolved. - * @param splitAfterTokenRef - Token ref marking the end of the earlier fragment. - * @param tokenDocOrder - Map from token ref to flat document index; tokens are ordered by this - * before slicing. - * @returns The refs of tokens that would become free, or `undefined` when no token would be left - * solo (both halves ≥ 2 tokens), the phrase is absent, or the boundary token is not found. + * Shares its slice with the split itself, so the destructive-border preview matches what the split + * will do. */ export function computeSplitFreeRefs( phraseLink: PhraseAnalysisLink | undefined, @@ -129,25 +115,19 @@ export function computeSplitFreeRefs( } /** - * Splits `phraseLink` just after `splitAfterTokenRef` and dispatches the resulting - * create/update/delete calls. Shared by both views' arc-split buttons and TokenLinkIcon's unlink - * button so the three paths can't drift apart. + * Splits a phrase just after the given token and dispatches the resulting create, update, and + * delete calls. Shared by both views' arc-split buttons and by the unlink button, so the three + * paths cannot drift apart. * - * Outcomes (`before` is the half up to and including `splitAfterTokenRef`, `after` the remainder): + * Outcomes, where `before` is the half up to and including the boundary token: * - * - Both halves ≤ 1 token → delete the phrase (only 2 tokens to begin with). - * - Both halves ≥ 2 tokens → shrink the phrase to `before`, create a new phrase from `after`. - * - Exactly one half has 1 token → shrink the phrase to the larger half; the solo token becomes free. + * - Both halves ≤ 1 token → delete the phrase; it only had 2 tokens to begin with. + * - Both halves ≥ 2 tokens → shrink the phrase to `before` and create a new phrase from `after`. + * - Exactly one half has 1 token → shrink to the larger half; the solo token becomes free. * - * The boundary is in document order (how the buttons present it), so tokens are sorted by - * `tokenDocOrder` before slicing — correct even if the stored list is out of order. No-op when - * `splitAfterTokenRef` is absent or is the last token (which would leave the phrase unchanged). - * - * @param phraseLink - The phrase link to split. - * @param splitAfterTokenRef - Token ref of the last token to keep in the earlier fragment. - * @param dispatch - Phrase create/update/delete callbacks. - * @param tokenDocOrder - Map from token ref to flat document index. Defaults to empty (stored - * order). + * The boundary is in document order, matching how the buttons present it, so this holds even when + * the stored token list is out of order. Omitting the document-order map falls back to stored + * order. A boundary that is absent or is the phrase's last token is a no-op. */ export function splitPhraseAtBoundary( phraseLink: PhraseAnalysisLink, @@ -188,18 +168,14 @@ export type StraddledPhrase = { }; /** - * Finds every phrase that a segment boundary placed before `boundaryRef` would cut — phrases with - * tokens on both sides of the boundary in document order. A discontiguous phrase counts even when - * `boundaryRef` itself is not one of its tokens (the boundary can fall in the gap between two - * fragments). Used by the segmentation dispatch to force-break straddling phrases, and by the split - * control to suppress itself at boundaries that would force-break — one predicate, so the two can't - * drift apart. + * Finds every phrase that a segment boundary would cut — those with tokens on both sides of it in + * document order — together with where each would have to be severed. A discontiguous phrase counts + * even when the boundary token is not one of its own, since the boundary can fall in the gap + * between two fragments. * - * @param boundaryRef - Token ref the new segment would begin at. - * @param phraseLinks - The phrase links to test (one entry per phrase). - * @param tokenDocOrder - Map from token ref to flat document index. Must contain `boundaryRef` and - * the phrase tokens; a boundary ref absent from the map yields no matches. - * @returns The straddled phrases with their split points; empty when the boundary cuts nothing. + * One predicate serves both the segmentation dispatch, which force-breaks straddling phrases, and + * the split control, which suppresses itself at boundaries that would force-break, so the two + * cannot drift apart. A boundary ref absent from the document-order map yields no matches. */ export function phrasesStraddlingBoundary( boundaryRef: string, @@ -238,25 +214,18 @@ export function phrasesStraddlingBoundary( // #region Arc geometry and strip sizing /** - * Stem height (px) an arc run rises above its box top at a given nesting level: the base stem plus - * one {@link ARC_LEVEL_STEP} per level. The single source of this formula, so same-row and cross-row - * runs at the same level share a channel (see {@link buildSameRowArcPath} / - * {@link buildCrossRowArcPath}). - * - * @param level - The run's nesting level (0 = outermost). - * @returns The stem height in pixels. + * Stem height (px) an arc run rises above its box top at a given nesting level, where level 0 is + * outermost. The single source of this formula, so same-row and cross-row runs at the same level + * share a channel. */ function stemForLevel(level: number): number { return ARC_BASE_STEM + level * ARC_LEVEL_STEP; } /** - * Vertical room (px) the topmost arc run at `maxArcLevel` needs above the line it rises from: its - * stem ({@link stemForLevel}), the corner, and the clearance margin. Shared by - * {@link computeStripTopPadding} and {@link computeStripRowGap} so both grow with arc depth alike. - * - * @param maxArcLevel - Maximum arc nesting level among the visible arcs. - * @returns The clearance height in pixels. + * Vertical room (px) the topmost arc run needs above the line it rises from: its stem, the corner, + * and the clearance margin. Shared by the strip's top padding and row gap so both grow with arc + * depth alike. */ function arcClearancePx(maxArcLevel: number): number { return stemForLevel(maxArcLevel) + ARC_CORNER_RADIUS + ARC_CLEARANCE_MARGIN_PX; @@ -266,15 +235,10 @@ function arcClearancePx(maxArcLevel: number): number { * Top padding (px) a token strip needs so arcs and the floating controls pill both fit above the * boxes: {@link arcClearancePx} when any arc is drawn, plus controls headroom. * - * The pill rides the arc top (or box top for contiguous phrases) with its upper half extending - * above; on box-top phrases it sits at `top: -CONTROLS_HALF_HEIGHT_PX`, so the strip needs `2 * - * CONTROLS_HALF_HEIGHT_PX` to keep the whole pill visible. + * The pill rides the arc top, or the box top for contiguous phrases, with its upper half extending + * above — so a box-top phrase needs the pill's full height reserved to stay visible. * - * @param hasArcs - Whether at least one arc is currently drawn. - * @param maxArcLevel - Maximum arc nesting level among the visible arcs. - * @param hasRealPhrase - Whether any committed phrase is rendered in the current window. - * @returns The required top padding in pixels, with a floor of {@link VERSE_SUPERSCRIPT_HEADROOM_PX} - * so a peeking verse number is never clipped. + * Floored at {@link VERSE_SUPERSCRIPT_HEADROOM_PX} so a peeking verse number is never clipped. */ export function computeStripTopPadding( hasArcs: boolean, @@ -297,16 +261,10 @@ export const BASE_ROW_GAP_PX = 24; /** * Vertical gap (px) between wrapped token rows so arcs above a lower row clear the boxes of the row - * above. Where {@link computeStripTopPadding} only protects the topmost row, this protects every - * inter-row gap: a run in a lower row rises {@link arcClearancePx} above its box top into the shared - * gap, and the controls pill's upper half rides on top of that. Floored at {@link BASE_ROW_GAP_PX} - * so shallow/absent arcs never pack rows tighter than the static layout. - * - * @param hasArcs - Whether at least one arc is currently drawn. - * @param maxArcLevel - Maximum arc nesting level among the visible arcs. - * @param hasRealPhrase - Whether any committed phrase is rendered (so a controls pill may ride the - * arc top of a lower row). - * @returns The required inter-row vertical gap in pixels, never below {@link BASE_ROW_GAP_PX}. + * above. Where {@link computeStripTopPadding} protects only the topmost row, this protects every + * inter-row gap: a run in a lower row rises into the shared gap, with the controls pill's upper + * half riding on top of that. Floored at {@link BASE_ROW_GAP_PX} so shallow or absent arcs never + * pack rows tighter than the static layout. */ export function computeStripRowGap( hasArcs: boolean, @@ -332,11 +290,8 @@ type ArcStrokeProps = { strokeWidth: number; }; -/** - * Arc stroke constants mirror the `phrase-*` Tailwind utilities in `tailwind.css`. If you change - * the opacity values here, update the matching `--phrase-stroke-opacity` in the CSS too, and - * vice-versa. - */ +// Arc stroke constants mirror the `phrase-*` Tailwind utilities. If you change the opacity values +// here, update the matching `--phrase-stroke-opacity` in the CSS too, and vice-versa. /** Matches `phrase-dimmed`: border-color at full opacity. */ const DIMMED_ARC_STROKE: ArcStrokeProps = { @@ -372,12 +327,6 @@ const HIGHLIGHTED_ARC_STROKE: ArcStrokeProps = { * - `confirm-unlink`: target arc destructive, others dimmed. * - `edit`: edited arc foreground (matches its box ring), others dimmed, hover suppressed. * - `view`: focused arc full-foreground, hovered arc mid-foreground, others border-color. - * - * @param phraseMode - Current phrase-interaction mode. - * @param phraseId - The phraseId of the arc being styled. - * @param hoveredPhraseId - The phraseId currently hovered, if any. - * @param focusedPhraseId - The phraseId of the focused token's phrase, if any. - * @returns Stroke styling props for the arc. */ export function getArcStrokeProps( phraseMode: PhraseMode, @@ -424,15 +373,13 @@ type ArcSegment = { }; /** - * Greedy interval-graph coloring shared by {@link assignSegmentLevels} and {@link assignGutterLanes}: - * walks `items` in order, giving each the lowest level not already taken by an earlier item it - * `conflicts` with. The two wrappers differ only in their pre-sort and conflict predicate (which - * axis they overlap on). + * Greedy interval-graph coloring shared by both level assignments, which differ only in their + * pre-sort and in the axis their conflict predicate overlaps on. * * @param items - The items to color, pre-sorted into the order they should be assigned. - * @param conflicts - Returns whether two items overlap and so must take different levels. - * @returns Map from each item to its assigned level. 0 is the level nearest the boxes — outermost - * nesting for segments, the lane nearest the content edge for descents. + * @param conflicts - Whether two items overlap and so must take different levels. + * @returns Each item's level, where 0 is nearest the boxes — outermost nesting for arc segments, + * the lane nearest the content edge for gutter descents. */ function assignLevels(items: readonly T[], conflicts: (a: T, b: T) => boolean): Map { const levels = new Map(); @@ -460,9 +407,6 @@ function assignLevels(items: readonly T[], conflicts: (a: T, b: T) => boolean * row's overlaps demand. Cross-row runs share the same top channel as same-row runs (not an * inter-row gap), so the two kinds do conflict when they share a row, keeping rerouted arcs aware * of the same-row brackets they cross. - * - * @param segments - All arc segments across every phrase. - * @returns Map from each segment to its assigned nesting level (0 = outermost). */ function assignSegmentLevels(segments: ArcSegment[]): Map { const ordered = [...segments].sort((a, b) => a.left - b.left || a.right - b.right); @@ -492,9 +436,6 @@ type GutterDescent = { * {@link assignSegmentLevels}: two descents conflict when they route down the same side and their * `[top, bottom]` spans overlap. Catches the vertically-nested case (C..D inside A..F) that per-row * segment levels miss, since a descent's two run lines never share a top channel. - * - * @param descents - All cross-row gutter descents across every phrase. - * @returns Map from each descent to its assigned lane (0 = nearest the content edge). */ function assignGutterLanes(descents: GutterDescent[]): Map { const ordered = [...descents].sort((a, b) => a.top - b.top || a.bottom - b.bottom); @@ -556,13 +497,7 @@ type ContainerRect = { bottom: number; }; -/** - * Converts a viewport-relative `DOMRect` to container-relative coordinates. - * - * @param rect - The viewport-relative bounding rect of a phrase-box element. - * @param containerRect - The viewport-relative bounding rect of the arc container. - * @returns The same rect with every edge expressed relative to the container's top-left corner. - */ +/** Re-expresses a viewport-relative rect against the arc container's top-left corner. */ function toContainerSpace(rect: DOMRect, containerRect: DOMRect): ContainerRect { const left = rect.left - containerRect.left; const right = rect.right - containerRect.left; @@ -572,14 +507,8 @@ function toContainerSpace(rect: DOMRect, containerRect: DOMRect): ContainerRect } /** - * Constructs an {@link ArcSegment} whose `left`/`right` span is `[min(x1, x2), max(x1, x2)]`, so - * callers pass the two x values in any order without worrying which is smaller. - * - * @param phraseId - The phrase this segment belongs to. - * @param row - Rounded scroll-space top of the row whose top channel the horizontal run occupies. - * @param x1 - One endpoint x of the run (scroll-space). - * @param x2 - Other endpoint x of the run (scroll-space). - * @returns An {@link ArcSegment} with `left`/`right` normalized so `left ≤ right`. + * Constructs an {@link ArcSegment} with its span normalized, so callers may pass the run's two + * endpoint x values in either order. */ function makeArcSegment(phraseId: string, row: number, x1: number, x2: number): ArcSegment { return { phraseId, row, left: Math.min(x1, x2), right: Math.max(x1, x2) }; @@ -621,26 +550,18 @@ type PhraseBoxMeasurements = { /** Container-space x of the strip's right content edge (right gutter anchor); 0 when no boxes. */ contentRight: number; /** - * Maps a box's top edge to its row's top line — the highest (minimum) top among boxes sharing the - * box's row band. Cross-row arcs anchor each endpoint here rather than at its own box top, so a - * gloss-less box of differing height still meets the channel shared by its row-mates. - * - * @param boxTop - The top edge of the box whose row top line is wanted. - * @param boxBottom - The bottom edge of the box; half the box height is the row-matching - * tolerance. - * @returns The minimum top across boxes on the same row, never greater than `boxTop`. + * Maps a box's top edge to its row's top line — the highest top among boxes sharing that row + * band, and so never below the box's own top. Cross-row arcs anchor each endpoint here rather + * than at their own box top, so a gloss-less box of differing height still meets the channel + * shared by its row-mates. Half the box height serves as the row-matching tolerance. */ rowTopFor: (boxTop: number, boxBottom: number) => number; }; /** - * Reads every `[data-phrase-box]` element inside `container` once and projects it into the - * container-relative measurements both arc passes need: per-phrase box lists, the strip's content - * extent, and the row-top lookup. Splitting this off keeps {@link computeAllArcPaths} a pipeline of - * named phases (measure → describe → level → build) rather than one long function. - * - * @param container - The arc container element to search. - * @returns The measured phrase boxes, content edges, and row-top lookup. + * Reads every phrase-box element in the container once and projects it into the container-relative + * measurements both arc passes need. Split out so arc computation reads as a pipeline of named + * phases — measure, describe, level, build — rather than one long function. */ function measurePhraseBoxes(container: Element): PhraseBoxMeasurements { const containerRect = container.getBoundingClientRect(); @@ -686,30 +607,20 @@ function measurePhraseBoxes(container: Element): PhraseBoxMeasurements { } /** - * Measures all `[data-phrase-box]` elements inside `container` and computes SVG arc paths (in - * container-relative coordinates) connecting each phrase's discontiguous boxes — same-row upward - * brackets and cross-row brackets. Same-row arcs are leveled so they don't overlap; cross-row arcs - * rise into the upper row's top channel then drop down a side gutter (the side nearer the arc's - * average x, ties left), one lane further out per descent overlap so legs never cross a box. + * Computes the container-relative SVG arc paths connecting each phrase's discontiguous boxes, plus + * the nesting depth and gutter padding the strip must reserve. * - * @param container - The arc container element to search. - * @returns The arc paths, max nesting level, and the left/right padding to reserve for gutter - * lanes. + * Same-row arcs are leveled so they never overlap. Cross-row arcs rise into the upper row's top + * channel and then drop down a side gutter — whichever side is nearer the arc's average x, ties + * going left — one lane further out per overlapping descent, so no leg ever crosses a box. */ export function computeAllArcPaths(container: Element): ArcState { const { boxesByPhrase, contentLeft, contentRight, rowTopFor } = measurePhraseBoxes(container); /** * Builds a {@link SameRowPair} for two boxes that share a row. The single arc run spans between - * their centers, anchored to the row's normalized top channel (minimum box top on the row) so it - * conflicts correctly with cross-row runs on the same channel. - * - * @param phraseId - The phrase the pair belongs to. - * @param a - Container-space rect of the earlier (document-order) box. - * @param b - Container-space rect of the later (document-order) box. - * @param splitAfterTokenRef - Token ref of the last token in box `a`; stored for the split - * button. - * @returns A {@link SameRowPair} with its level-assignment segment embedded. + * their centers, anchored to the row's normalized top channel so it conflicts correctly with + * cross-row runs sharing that channel. */ const describeSameRowPair = ( phraseId: string, @@ -726,17 +637,8 @@ export function computeAllArcPaths(container: Element): ArcState { /** * Builds a {@link CrossRowPair} for two boxes on different rows. Emits two independently-leveled * segments — one per row's top channel, each spanning from its box center to the chosen side - * gutter — so a nested arc routed out the opposite side doesn't conflict. The side is fixed here - * (average x vs content edges, ties left) before levels exist. - * - * @param phraseId - The phrase the pair belongs to. - * @param a - Container-space rect of the earlier (document-order) box; assumed upper (`a.top ≤ - * b.top`). - * @param b - Container-space rect of the later (document-order) box. - * @param splitAfterTokenRef - Token ref of the last token in box `a`; stored for the split - * button. - * @returns A {@link CrossRowPair} with its two level-assignment segments and routing side - * embedded. + * gutter — so a nested arc routed out the opposite side doesn't conflict. The earlier box is + * assumed to be the upper one. The routing side is fixed here, before levels exist. */ const describeCrossRowPair = ( phraseId: string, @@ -787,12 +689,6 @@ export function computeAllArcPaths(container: Element): ArcState { // Deepest nesting level across every run; sizes the strip's top padding. const maxLevel = segmentLevels.size > 0 ? Math.max(...segmentLevels.values()) : 0; - /** - * Reads a segment's assigned nesting level. - * - * @param seg - The segment whose level is wanted; always present, having been leveled above. - * @returns The segment's nesting level (0 = outermost). - */ const levelOf = (seg: ArcSegment): number => /* v8 ignore next -- every descriptor stores segments that were passed to assignSegmentLevels */ segmentLevels.get(seg) ?? 0; @@ -874,8 +770,6 @@ const SPLIT_BUTTON_HEIGHT_PX = 14; * * The scan repeats to a fixed point — nudging one button can collide it with another — capped at * one pass per button so an unresolvable residual terminates rather than loops. - * - * @param paths - All computed arc paths for the current layout; mutated in place. */ export function deconflictSplitButtons(paths: ArcPath[]): void { for (let pass = 0; pass < paths.length; pass += 1) { @@ -913,15 +807,12 @@ export function deconflictSplitButtons(paths: ArcPath[]): void { /** * Builds the SVG path and midpoint for a same-row upward-bracket arc between two boxes. The run - * sits `stem` px above the box top with corners rounded _into_ the stem (not added on top), so it - * shares the same channel as a cross-row run at the same stem — keeping intra- and inter-row arcs - * aligned at a given level. Coordinates are scroll-space. + * sits the full stem above the box top with corners rounded _into_ the stem rather than added on + * top, so it shares a channel with a cross-row run at the same stem, keeping intra- and inter-row + * arcs aligned at a given level. * - * @param a - Scroll-space rect of the left/earlier box. - * @param b - Scroll-space rect of the right/later box. - * @param stem - Total stem height in pixels (base + level offset). - * @returns The SVG path `d`, the arc's visual midpoint, and the x-extent of its run (the channel - * the split button slides along). + * All coordinates are scroll-space. The returned run extent is the channel the split button slides + * along. */ export function buildSameRowArcPath( a: { left: number; right: number; top: number }, @@ -951,15 +842,12 @@ export function buildSameRowArcPath( } /** - * Builds an SVG path for an axis-aligned polyline through `points`, rounding each interior corner - * with a quarter-circle of radius `r` (a line stopping `r` short of the corner, an arc onto the - * next leg, continuing `r` past it). The radius is clamped to half the shorter adjacent leg so a - * short leg never self-overlaps. Used by {@link buildCrossRowArcPath} so its multi-bend route reads - * as a single rounded bracket. + * Builds an SVG path for an axis-aligned polyline, rounding each interior corner with a + * quarter-circle so a multi-bend route reads as a single rounded bracket. The radius is clamped to + * half the shorter adjacent leg, so a short leg never self-overlaps. * - * @param points - Ordered waypoints; consecutive points must share an x or a y (axis-aligned legs). - * @param r - Desired corner radius in pixels. - * @returns The SVG path `d` attribute string starting with `M`. + * @param points - Ordered waypoints; consecutive points must share an x or a y. + * @param r - Desired corner radius in pixels, before clamping. */ export function roundedPolyline(points: { x: number; y: number }[], r: number): string { const [first] = points; @@ -993,20 +881,12 @@ export function roundedPolyline(points: { x: number; y: number }[], r: number): } /** - * Builds the SVG path for a cross-row arc between two boxes on different rows, routed so it never - * passes behind a box: up from the upper box into its row's top channel (`aStem` above the box), - * across to the gutter at `gutterX`, down the gutter, across into the lower row's channel (`bStem` - * above that box), then down into its top. Each run sits its own (independently-leveled) stem above - * its box; keeping the whole descent in the gutter is what avoids the boxes between the rows. - * Coordinates are scroll-space. + * Builds the SVG path for an arc between two boxes on different rows, routed so it never passes + * behind a box. Each run sits its own independently-leveled stem above its box, and keeping the + * whole descent out in the side gutter is what clears the boxes lying between the two rows. * - * @param a - Scroll-space rect (top edge) of the earlier (upper) box. - * @param b - Scroll-space rect (top edge) of the later (lower) box. - * @param aStem - Stem height (px) of the upper run above the upper box top. - * @param bStem - Stem height (px) of the lower run above the lower box top. - * @param gutterX - Scroll-space x of the box-free side gutter the descent travels down. - * @returns The SVG path `d`, the midpoint on the upper run line (for the split button), and the - * x-extent of the upper run (box center → gutter) the button slides along. + * All coordinates are scroll-space. The returned midpoint sits on the upper run line, whose extent + * is the channel the split button slides along. */ export function buildCrossRowArcPath( a: { left: number; right: number; top: number }, From bf3a82447f67eefa2fda0f51ef68daf792c4ff56 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Tue, 28 Jul 2026 17:01:18 -0600 Subject: [PATCH 07/12] Apply comment rules to src/hooks Drop @param/@returns that restate the signature, including the args-destructuring blocks that duplicated the args interfaces' own field docs. Keep the tags whose values carry semantics the types don't (force/isVerify, deps, edge). --- src/hooks/useAltHeld.ts | 14 -------- src/hooks/useArcPaths.ts | 38 ++++++--------------- src/hooks/useBookIndexes.ts | 5 +-- src/hooks/useDraftProject.ts | 43 ++++++------------------ src/hooks/useInterlinearizerBookData.ts | 12 +------ src/hooks/useLatestRef.ts | 3 -- src/hooks/useOptimisticBooleanSetting.ts | 9 ++--- src/hooks/usePhraseHoverState.ts | 30 ++++++----------- src/hooks/usePhraseStripSetup.ts | 42 +++++++---------------- src/hooks/useRecenterSnap.ts | 21 ++++-------- src/hooks/useSegmentWindow.ts | 25 +------------- src/hooks/useSubmitGuard.ts | 15 +++------ 12 files changed, 61 insertions(+), 196 deletions(-) diff --git a/src/hooks/useAltHeld.ts b/src/hooks/useAltHeld.ts index 92366bcf..733818a2 100644 --- a/src/hooks/useAltHeld.ts +++ b/src/hooks/useAltHeld.ts @@ -9,8 +9,6 @@ import { useEffect, useRef, useState } from 'react'; * `window` `blur` and `document` `visibilitychange` to hidden — which is the common "stuck Alt" * failure mode after Alt+Tab out of the iframe. A `keyup` reading `altKey === false` likewise * clears it. - * - * @returns `true` while Alt is held, `false` otherwise. */ export function useAltHeld(): boolean { const [altHeld, setAltHeld] = useState(false); @@ -21,26 +19,14 @@ export function useAltHeld(): boolean { const altHeldRef = useRef(false); useEffect(() => { - /** - * Sets the held state to `next` only when it differs from the current value, so the repeated - * `keydown` events fired while Alt is held do not trigger a re-render each frame. - * - * @param next - The desired held state. - */ const set = (next: boolean) => { if (altHeldRef.current === next) return; altHeldRef.current = next; setAltHeld(next); }; - /** - * Updates the held state from a keyboard event's `altKey` flag. - * - * @param event - The keyboard event whose `altKey` is read. - */ const onKey = (event: KeyboardEvent) => set(event.altKey); - /** Clears the held state when focus leaves or the document is hidden. */ const clear = () => set(false); window.addEventListener('keydown', onKey); diff --git a/src/hooks/useArcPaths.ts b/src/hooks/useArcPaths.ts index e5a2af62..ff667de5 100644 --- a/src/hooks/useArcPaths.ts +++ b/src/hooks/useArcPaths.ts @@ -59,12 +59,6 @@ const SETTLE_VERIFY_DELAYS_MS = [200, 400, 800]; * — leaving the button in its pre-shift position. Two measurements with the same signature * therefore produce both the same applied padding and the same rendered arcs, so they cannot * represent genuine progress. - * - * @param paths - The measured arc paths. - * @param maxLevel - The measured max nesting level. - * @param leftPadding - The measured left gutter padding. - * @param rightPadding - The measured right gutter padding. - * @returns A signature string that is equal iff the layout- and render-affecting outputs match. */ function signatureOf( paths: ArcPath[], @@ -81,11 +75,10 @@ function signatureOf( } /** - * Measures the rendered phrase boxes inside `containerRef` after each layout commit and computes - * the arcs connecting every discontiguous phrase's runs. Shared by SegmentView and ContinuousView - * so the two strip layouts can't drift apart. State is only replaced when the serialized arc shape - * changes, so the layout effect settles after one extra pass; when `enabled` is `false` the result - * is reset to empty instead of measured. + * Measures the rendered phrase boxes after each layout commit and computes the arcs connecting + * every discontiguous phrase's runs. Shared by both strips so the two layouts can't drift apart. + * State is only replaced when the serialized arc shape changes, so the layout effect settles after + * one extra pass. A disabled container resets to empty instead of being measured. * * Measurement is settle-aware: because box geometry can drift without any resize event on the * observed container (see {@link SETTLE_VERIFY_DELAYS_MS}), each pass arms a delayed verification @@ -96,14 +89,11 @@ function signatureOf( * padding shifts the layout the arcs are measured against — otherwise a 0→1 arc transition would * leave paths positioned for the old padding until an unrelated render. * - * @param containerRef - Ref to the element wrapping the `[data-phrase-box]` elements to measure. - * @param enabled - Whether the container is mounted and should be measured; `false` resets to - * empty. + * @param containerRef - Wraps the phrase-box elements to measure. + * @param enabled - Whether the container is mounted and should be measured. * @param hasRealPhrase - Whether any committed phrase is rendered; feeds the controls headroom. - * @param deps - Extra dependencies that should trigger a re-measure (token data, phrase mode, - * etc.). - * @returns The current arc paths, max nesting level, and the strip's top/row-gap/left/right - * padding. + * @param deps - Extra dependencies that should trigger a re-measure, such as token data or phrase + * mode. */ export function useArcPaths( containerRef: RefObject, @@ -226,15 +216,9 @@ export function useArcPaths( // other applies prepend onto a 2-deep window so entry 0 stays the applied signature. recentArcSignaturesRef.current = force ? [signature] : [signature, ...history].slice(0, 2); setArcPaths((prev) => { - /** - * Serializes one arc path into an identity key for the referential-equality guard below. - * Includes the split-button geometry (midX/midY and run bounds) so a deconfliction-only - * shift — which mutates midX without touching `d` — still replaces the paths rather than - * leaving the button in its pre-shift position. - * - * @param p - The arc path to serialize. - * @returns A string equal iff the rendered arc and its split-button geometry match. - */ + // Identity key for the referential-equality guard below. Includes the split-button geometry + // so a deconfliction-only shift — which moves the button without touching the arc path — + // still replaces the paths rather than leaving the button in its pre-shift position. const key = (p: ArcPath) => `${p.phraseId}:${p.splitAfterTokenRef}:${p.d}:${p.midX}:${p.midY}:${p.runLeft}:${p.runRight}`; const prevKey = prev.map(key).join('|'); diff --git a/src/hooks/useBookIndexes.ts b/src/hooks/useBookIndexes.ts index 94011d03..b339da42 100644 --- a/src/hooks/useBookIndexes.ts +++ b/src/hooks/useBookIndexes.ts @@ -36,11 +36,8 @@ export interface BookIndexes { /** * Builds the book-wide lookup indexes the interlinear views share, in a single pass over * `book.segments`. The indexes always travel together through the view prop plumbing, so deriving - * them in one memo keeps them in lockstep (one traversal, one identity change per book change) + * them in one memo keeps them in lockstep — one traversal, one identity change per book change — * instead of separate memos each walking the segment list. - * - * @param book - The tokenized book to index. - * @returns The lookup indexes; stable identities until `book.segments` changes. */ export default function useBookIndexes(book: Book): BookIndexes { return useMemo(() => { diff --git a/src/hooks/useDraftProject.ts b/src/hooks/useDraftProject.ts index be059217..a9c3e9a7 100644 --- a/src/hooks/useDraftProject.ts +++ b/src/hooks/useDraftProject.ts @@ -1,4 +1,3 @@ -/** @file Hook owning the always-present, auto-saved draft buffer for one source project. */ import papi, { logger } from '@papi/frontend'; import type { DraftProject, @@ -62,30 +61,22 @@ export type UseDraftProjectResult = { /** * Returns the latest draft envelope synchronously by reading the live ref rather than a render * snapshot. Save / Save As must use this so they persist edits that auto-saved without a - * re-render. - * - * @returns The current draft, or `undefined` before the initial load completes. + * re-render. Yields `undefined` before the initial load completes. */ getDraftSnapshot: () => DraftProject | undefined; /** * Persists an edited analysis into the draft and marks it dirty. Wire as the editor's * `onSaveAnalysis`. - * - * @param analysis - The updated analysis from the store. */ autosaveAnalysis: (analysis: TextAnalysis) => void; /** * Persists an edited segment-boundary delta into the draft and marks it dirty. Pass `undefined` * (or a default/empty delta) to clear custom boundaries back to the default verse segmentation. - * - * @param segmentation - The updated boundary delta, or `undefined` for the default segmentation. */ autosaveSegmentation: (segmentation: SegmentationDelta | undefined) => void; /** * Replaces the draft with a working copy of an existing project's analysis and config — the * "Open" flow. - * - * @param project - The project whose analysis / languages / target to copy into the draft. */ loadFromProject: (project: OpenableProject) => void; /** @@ -93,15 +84,9 @@ export type UseDraftProjectResult = { * languages and retains the typed name/description as `suggestedName`/`suggestedDescription`. The * new draft is clean (`dirty: false`), so the unsaved-changes indicator stays clear until the * first edit. The caller is responsible for immediately persisting the project to the backend. - * - * @param config - The languages and optional suggested name/description from the New dialog. */ newDraft: (config: NewDraftConfig) => void; - /** - * Removes one book's analysis from the draft and marks it dirty. - * - * @param bookCode - The 3-letter book code (e.g. `"GEN"`) to wipe. - */ + /** Removes one book's analysis from the draft, by 3-letter book code, and marks it dirty. */ wipeBook: (bookCode: string) => void; /** * Clears the draft's analysis entirely and marks it **not** dirty — a wiped draft is treated as a @@ -115,9 +100,8 @@ export type UseDraftProjectResult = { * save round-trip), the draft is left dirty so the unsaved-changes indicator and the next Save * reflect that un-persisted edit rather than being cleared against a now-stale snapshot. * - * @param savedAnalysis - The `TextAnalysis` reference that was actually persisted to the project. - * @param savedSegmentation - The `SegmentationDelta` reference that was persisted alongside it - * (`undefined` when the draft had the default segmentation). + * Pass the exact references that were written; a `undefined` boundary delta means the draft had + * the default segmentation. */ markSynced: ( savedAnalysis: TextAnalysis, @@ -135,8 +119,7 @@ export type UseDraftProjectResult = { * per-edit auto-saves never re-render the loader unless the dirty flag actually flips. * * @param sourceProjectId - The Platform.Bible source project whose draft to manage. - * @param platformLanguage - BCP 47 tag used to seed `analysisLanguages` for a brand-new source. - * @returns Draft state and the callbacks described on {@link UseDraftProjectResult}. + * @param platformLanguage - BCP 47 tag used to seed the analysis languages of a brand-new source. */ export default function useDraftProject( sourceProjectId: string, @@ -166,8 +149,6 @@ export default function useDraftProject( * unavailable during editing the backend sends one error notification per failed write; should * that notification itself fail, edits in that window are silently lost on the next refresh. The * `dirty` flag is set optimistically before the write, not in response to its outcome. - * - * @param draft - The draft envelope to write. */ const persist = useCallback( (draft: DraftProject) => { @@ -185,8 +166,6 @@ export default function useDraftProject( /** * Loads the stored draft for the source (falling back to an empty draft on failure), seeds a * gloss language when none is present, and publishes it to the ref and state. - * - * @returns A promise that resolves once the draft has been published or the load was canceled. */ const load = async () => { let draft: DraftProject; @@ -225,8 +204,6 @@ export default function useDraftProject( /** * Applies a wholesale draft replacement: update the ref, persist, refresh `dirty`, and bump the * remount counter so the editor reseeds. - * - * @param next - The replacement draft. */ const applyReplacement = useCallback( (next: DraftProject) => { @@ -245,13 +222,13 @@ export default function useDraftProject( ); /** - * Shared per-edit auto-save pipeline: derives the next draft from the current one via `mutate`, - * swaps it into the ref, debounces the persistence write, and marks the draft dirty. No version - * bump (no remount) and `setDirty(true)` is a no-op when already dirty, so editing does not - * re-render. + * Shared per-edit auto-save pipeline: swaps the mutated draft into the ref, debounces the + * persistence write, and marks the draft dirty. There is no version bump and so no remount, and + * re-marking an already-dirty draft is a no-op, so editing does not re-render. + * + * Returns `false` without applying anything when no draft has loaded yet. * * @param mutate - Produces the next draft from the current one; must set `dirty: true`. - * @returns `true` when the edit was applied; `false` when no draft has loaded yet. */ const autosaveDraft = useCallback( (mutate: (current: DraftProject) => DraftProject): boolean => { diff --git a/src/hooks/useInterlinearizerBookData.ts b/src/hooks/useInterlinearizerBookData.ts index d7cefc3b..75ef0b1a 100644 --- a/src/hooks/useInterlinearizerBookData.ts +++ b/src/hooks/useInterlinearizerBookData.ts @@ -36,13 +36,6 @@ export interface UseInterlinearizerBookDataResult { * scripture picker firing two signals in quick succession): when the serialized content is * identical to the previous result, the prior reference is preserved so consumers avoid redundant * tokenization and recenter churn. - * - * @param args - Hook arguments. - * @param args.projectId - PAPI project ID whose USJ book data should be loaded. - * @param args.scrRef - Current scripture reference; only `book` is used (chapter/verse are fixed to - * 1). - * @returns The tokenized book (reference-stable across duplicate USJ results), loading state, and - * any errors encountered. */ export default function useInterlinearizerBookData({ projectId, @@ -76,10 +69,7 @@ export default function useInterlinearizerBookData({ return rawBookResult; }, [rawBookResult]); - /** - * Writing-system tag used for tokenization and error logging; falls back to `'und'` when the - * project setting is unavailable or empty. - */ + // Falls back to `'und'` when the project setting is unavailable or empty. const writingSystemTag = isPlatformError(writingSystem) ? 'und' : writingSystem || 'und'; const [book, tokenizeError] = useMemo((): [ diff --git a/src/hooks/useLatestRef.ts b/src/hooks/useLatestRef.ts index d06c78b3..6c46c737 100644 --- a/src/hooks/useLatestRef.ts +++ b/src/hooks/useLatestRef.ts @@ -8,9 +8,6 @@ import { useRef } from 'react'; * value changes identity each render — the PAPI host, for instance, hands `scrRef` back as a fresh * object on many renders, and closing over it directly would churn the identity of any callback * that reads it. Reading through the ref decouples that churn. - * - * @param value - The value to mirror; the returned ref's `.current` is set to it on every render. - * @returns A stable ref object whose `.current` tracks the latest `value`. */ export default function useLatestRef(value: T): RefObject { const ref = useRef(value); diff --git a/src/hooks/useOptimisticBooleanSetting.ts b/src/hooks/useOptimisticBooleanSetting.ts index 8350f419..e89f7176 100644 --- a/src/hooks/useOptimisticBooleanSetting.ts +++ b/src/hooks/useOptimisticBooleanSetting.ts @@ -11,19 +11,14 @@ type BooleanProjectSettingKey = { const TIMEOUT_MS = 15_000; /** - * Manages a boolean project setting with optimistic UI updates. + * Manages a boolean project setting with optimistic UI updates, falling back to the given default + * until the setting has been persisted for the first time. * * The local value is updated immediately on change and stays locked for {@link TIMEOUT_MS} to allow * the stored setting to finish updating without causing a visible bounce. If the platform confirms * the new value before the timeout, the lock is released; if the platform never responds (or * responds after the timeout), the lock clears and subsequent platform updates flow through * normally. - * - * @param projectId - PAPI project ID - * @param settingKey - A valid key for a boolean setting - * @param defaultValue - Default value used when the setting has not been persisted yet - * @returns `isLoading` — whether the setting value is still loading from the platform; `onChange` — - * stable change handler; `value` — the current display value */ export default function useOptimisticBooleanSetting( projectId: string, diff --git a/src/hooks/usePhraseHoverState.ts b/src/hooks/usePhraseHoverState.ts index 3f8216d8..7129fd88 100644 --- a/src/hooks/usePhraseHoverState.ts +++ b/src/hooks/usePhraseHoverState.ts @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; -/** Hover-driven preview state shared by SegmentView and ContinuousView. */ +/** Hover-driven preview state shared by both phrase strips. */ export type PhraseHoverState = { /** * Group key (first token ref) of the phrase box currently hovered; drives controls-pill placement @@ -22,18 +22,13 @@ export type PhraseHoverState = { */ splitFreeTokenRefs: ReadonlySet; /** - * Mirrors the free token refs emitted by `ArcOverlay`'s internal split-hover state so the phrase - * boxes can show a destructive border preview. - * - * @param freeTokenRefs - Token refs that would become solo after the split, or an empty set on - * leave. + * Mirrors the free token refs emitted by the arc overlay's own split-hover state so the phrase + * boxes can show a destructive border preview. Pass an empty set on leave. */ handleSplitHoverChange: (freeTokenRefs: ReadonlySet) => void; /** - * Sets (or clears) the would-become-free token refs previewed with a destructive border when a - * link/unlink icon is hovered. - * - * @param refs - The would-be-free token refs, or `undefined`/empty on leave. + * Sets the would-become-free token refs previewed with a destructive border when a link or unlink + * icon is hovered. Pass `undefined` or an empty array on leave. */ handleHoverSplitFreeTokens: (refs: readonly string[] | undefined) => void; /** Clears every hover preview at once; wired to the token row's `onMouseLeave`. */ @@ -41,16 +36,13 @@ export type PhraseHoverState = { }; /** - * Owns the hover-preview state that both phrase strips ({@link SegmentView} and - * {@link ContinuousView}) need: the hovered group key, link-candidate token refs, and - * would-become-free token refs, plus the stable callbacks that feed them from `ArcOverlay` and the - * link/unlink icons. Extracted so the two views can never drift apart in how these previews are - * wired, and so each view's body is freed of five near-identical state declarations. + * Owns the hover-preview state both phrase strips need, along with the stable callbacks that feed + * it from the arc overlay and the link icons. Extracted so the two views can never drift apart in + * how these previews are wired, and so neither view's body carries five near-identical state + * declarations. * - * `hoveredPhraseId` is intentionally **not** owned here: ContinuousView keeps it locally while - * SegmentView receives it as a prop from its parent, so the two views manage it differently. - * - * @returns The shared hover-preview state and its setters/handlers. + * The hovered phrase id is deliberately **not** owned here: one view keeps it locally while the + * other receives it as a prop, so the two manage it differently. */ export function usePhraseHoverState(): PhraseHoverState { const [hoveredGroupKey, setHoveredGroupKey] = useState(); diff --git a/src/hooks/usePhraseStripSetup.ts b/src/hooks/usePhraseStripSetup.ts index 11977e9e..710e1bd3 100644 --- a/src/hooks/usePhraseStripSetup.ts +++ b/src/hooks/usePhraseStripSetup.ts @@ -1,10 +1,7 @@ /** - * @file Shared phrase-strip setup hooks used by both `SegmentView` and `ContinuousView`. - * - * Consolidates the setup logic the two views share — the arc-split handler, the candidate-phrase-id - * derivation, and the strip-wide context value — so they can't drift apart in how a split is - * dispatched, how hovered candidates resolve to phrase ids, or which fields the leaf components - * receive. + * Setup hooks shared by both phrase strips — the arc-split handler, the candidate-phrase-id + * derivation, and the strip-wide context value — so the two views cannot drift apart in how a split + * is dispatched, how hovered candidates resolve to phrase ids, or which fields the leaves receive. */ import { useCallback, useMemo } from 'react'; import type { Dispatch, SetStateAction } from 'react'; @@ -17,9 +14,6 @@ import { splitPhraseAtBoundary } from '../utils/phrase-arc'; /** * Returns the token list of the phrase currently being edited, or `undefined` outside edit mode. * Reads the by-id phrase-link map internally so both views share one derivation. - * - * @param phraseMode - Current phrase-interaction mode; only `edit` mode resolves a token list. - * @returns The edit-mode phrase's token snapshots, or `undefined` when not editing. */ export function useEditPhraseTokens(phraseMode: PhraseMode): TokenSnapshot[] | undefined { const phraseLinkById = usePhraseLinkByIdMap(); @@ -35,14 +29,10 @@ export function useEditPhraseTokens(phraseMode: PhraseMode): TokenSnapshot[] | u } /** - * Returns a memoized handler that splits a phrase arc at a token boundary and dispatches the - * resulting create/update/delete operations via {@link splitPhraseAtBoundary}. The handler no-ops - * when its `phraseId` is not in the phrase-link-by-id map. Reads the by-id map internally so - * callers only supply `tokenDocOrder`. - * - * @param tokenDocOrder - Word token ref → flat document index, used to keep the split fragments in - * document order. - * @returns A stable `(phraseId, splitAfterTokenRef) => void` callback. + * Returns a stable handler that splits a phrase arc at a token boundary and dispatches the + * resulting create, update, and delete operations. Reads the by-id phrase map internally, so + * callers supply only the document order used to keep the split fragments ordered. Splitting a + * phrase that is not in the map is a no-op. */ export function useArcSplitHandler( tokenDocOrder: ReadonlyMap, @@ -67,12 +57,7 @@ export function useArcSplitHandler( /** * Derives the set of phrase ids that contain at least one of the hovered link-candidate tokens, so - * the arcs of those phrases can be highlighted as link targets. Returns an empty set when nothing - * is hovered. - * - * @param candidateTokenRefs - Token refs a hovered link icon would join into a new phrase. - * @param phraseLinkByRef - Token ref → phrase link map for the whole strip. - * @returns The set of `analysisId`s whose phrase contains a candidate token. + * the arcs of those phrases can be highlighted as link targets. Empty when nothing is hovered. */ export function useCandidatePhraseIds( candidateTokenRefs: ReadonlySet, @@ -122,7 +107,7 @@ export type PhraseStripContextParams = Readonly<{ boundaryMergeAltHint: string; /** Label and tooltip for the Alt-gated split marker. */ boundarySplitLabel: string; - /** Placeholder for all gloss inputs, fetched once per strip (see the context field's doc). */ + /** Placeholder for all gloss inputs, fetched once per strip rather than per token. */ glossPlaceholder: string; /** When true, the link-slot sliding-door transition is suppressed (duration 0ms). */ skipLinkTransition: boolean; @@ -132,12 +117,9 @@ export type PhraseStripContextParams = Readonly<{ /** * Builds the memoized strip-wide {@link PhraseStripContextValue} shared by every phrase group and - * link slot in one render, so the leaf `MemoizedPhraseBox` / `MemoizedTokenLinkIcon` consumers - * don't re-render on unrelated changes. Centralizing the build keeps the (long) dependency list in - * one place so the two views can't drift apart when a field is added. - * - * @param params - The fields each view resolves and passes in. - * @returns The memoized strip-wide context value. + * link slot in one render, so the memoized leaves don't re-render on unrelated changes. + * Centralizing the build keeps the long dependency list in one place, so the two views cannot drift + * apart when a field is added. */ export function usePhraseStripContextValue( params: PhraseStripContextParams, diff --git a/src/hooks/useRecenterSnap.ts b/src/hooks/useRecenterSnap.ts index 33b00f51..4815aa4a 100644 --- a/src/hooks/useRecenterSnap.ts +++ b/src/hooks/useRecenterSnap.ts @@ -89,21 +89,14 @@ export interface UseRecenterSnapResult { * Owns the post-recenter re-snap and settle lifecycle for the segment window. * * After a recenter rebuilds the window the freshly-mounted segments do not reach their final - * heights synchronously — arc padding is measured and applied by `useArcPaths` across several later - * frames (a ResizeObserver → rAF → setState chain) — so a one-shot snap can compute its target - * against stale heights and leave the verse off screen, pinned to an edge. This hook re-snaps the - * verse against each settling wave (event-driven, via {@link UseRecenterSnapResult.relayResize}, not - * a per-frame loop) and reports settled once the layout goes quiet. All of it runs behind the - * recenter fade, so none of the intermediate corrections are seen. + * heights synchronously, because arc padding is measured and applied across several later frames. A + * one-shot snap would therefore compute its target against stale heights and leave the verse off + * screen, pinned to an edge. This hook instead re-snaps the verse against each settling wave — + * event-driven rather than a per-frame loop — and reports settled once the layout goes quiet. All + * of it runs behind the recenter fade, so none of the intermediate corrections are seen. * - * Extracted from {@link useSegmentWindow} so its intricate timing (epoch bump, quiet-debounce, and - * deadline backstop) lives behind one boundary rather than tangled into the main hook body. - * - * @param args - Hook arguments. - * @param args.snapActiveToTop - Snaps the recenter target to the top of the container. - * @param args.needsInitialSnap - Whether the initial mount needs a snap (cross-book remount). - * @param args.onSettled - Reported once per settle; lifts the cross-book loader curtain. - * @returns The recenter epoch, in-flight ref, and the start/begin/relay handlers. + * Extracted from the segment-window hook so its intricate timing (epoch bump, quiet-debounce, and + * deadline backstop) lives behind one boundary rather than tangled into that hook's body. */ export default function useRecenterSnap({ snapActiveToTop, diff --git a/src/hooks/useSegmentWindow.ts b/src/hooks/useSegmentWindow.ts index e35bca89..3f1147bd 100644 --- a/src/hooks/useSegmentWindow.ts +++ b/src/hooks/useSegmentWindow.ts @@ -98,8 +98,6 @@ export interface UseSegmentWindowArgs { * Routing this through a callback in the timeout — rather than the parent reacting to a * hook-returned value via an effect, which would land a commit later — keeps the two in one * commit, so the strip is present when the snap settles. - * - * @param displayContinuousScroll - The continuous-scroll mode to render from now on. */ onDisplayContinuousScrollChange: (displayContinuousScroll: boolean) => void; /** @@ -159,10 +157,6 @@ export interface UseSegmentWindowResult { * verse absorbed into a multi-verse segment — or the later portions of a split verse — resolves to * the segment that actually contains it rather than only to exact segment starts. Falls back to the * first segment of the same book+chapter, then to `0`, so there is always a valid anchor. - * - * @param segments - The book's flat segment list. - * @param scrRef - The scripture reference whose owning segment to locate. - * @returns The index of the anchor segment, clamped to a valid position (or `0` when empty). */ function findAnchorIndex(segments: readonly Segment[], scrRef: SerializedVerseRef): number { const containing = segments.findIndex((seg) => segmentContainsVerse(seg, scrRef)); @@ -173,13 +167,7 @@ function findAnchorIndex(segments: readonly Segment[], scrRef: SerializedVerseRe return chapter === -1 ? 0 : chapter; } -/** - * Builds the initial/recentered window range surrounding `anchorIndex`, clamped to `[0, total)`. - * - * @param anchorIndex - Index of the segment to center on. - * @param total - Total number of segments in the book. - * @returns The half-open window range to mount. - */ +/** Builds the half-open window range centered on an anchor segment, clamped to the book. */ function buildCenteredRange(anchorIndex: number, total: number): WindowRange { const start = Math.max(0, anchorIndex - INITIAL_HALF_WINDOW); const end = Math.min(total, anchorIndex + INITIAL_HALF_WINDOW + 1); @@ -201,17 +189,6 @@ function buildCenteredRange(anchorIndex: number, total: number): WindowRange { * the mounted window, so the fade and the strip's fade can never disagree. Internal navigation (a * segment/token click here, or strip arrow nav echoed back) skips the fade entirely: the target is * already on screen. - * - * @param args - Hook arguments. - * @param args.book - The tokenized book whose `segments` are windowed. - * @param args.scrRef - Current scripture reference; its verse is the recenter anchor. - * @param args.segmentationVersion - Monotonic boundary-edit counter; classifies segments-identity - * changes as boundary edits (no fade) vs. re-tokenizations (recenter with fade). - * @param args.scrollContainerRef - Ref to the scrollable list container. - * @param args.consumeInternalNav - Returns whether the navigation to a given verse was internal - * (and clears the marker); used to suppress the fade for navigation that came from within the - * views. - * @returns The mounted segment slice, fade state, and the sentinel/content ref callbacks. */ export default function useSegmentWindow({ book, diff --git a/src/hooks/useSubmitGuard.ts b/src/hooks/useSubmitGuard.ts index a3f94318..4c38a41d 100644 --- a/src/hooks/useSubmitGuard.ts +++ b/src/hooks/useSubmitGuard.ts @@ -1,4 +1,3 @@ -/** @file Hook that guards an async submit handler against re-entrant (double-click) invocation. */ import { useCallback, useRef, useState } from 'react'; /** Return value of {@link useSubmitGuard}. */ @@ -6,11 +5,8 @@ export type UseSubmitGuardResult = { /** True while a guarded call is in flight; wire to the submit controls' `disabled`. */ isSubmitting: boolean; /** - * Runs `fn` unless a prior guarded call is still in flight, in which case the call is ignored. - * Flips {@link isSubmitting} for the duration of `fn` and always clears it afterward. - * - * @param fn - The async submit work to run. - * @returns A promise that resolves once `fn` settles (or immediately when the call is ignored). + * Runs the given submit work unless a prior guarded call is still in flight, in which case the + * call is ignored. Flips {@link isSubmitting} for the duration and always clears it afterward. */ runGuarded: (fn: () => Promise) => Promise; }; @@ -18,11 +14,10 @@ export type UseSubmitGuardResult = { /** * Guards an async submit handler against re-entrant invocation (a double-click, or a programmatic * second call before the re-render that disables the control lands). A ref mirror short-circuits - * the second call synchronously; the state drives the disabled UI. Factors out the `isSubmitting` + - * `isSubmittingRef` + try/finally pattern the Save As and metadata modals each repeated for every - * submit handler. + * the second call synchronously, while the state drives the disabled UI. * - * @returns The in-flight flag and a `runGuarded` wrapper for submit handlers. + * Factors out the flag-plus-mirror-plus-try/finally pattern the Save As and metadata modals each + * repeated for every submit handler. */ export default function useSubmitGuard(): UseSubmitGuardResult { const [isSubmitting, setIsSubmitting] = useState(false); From fabfc3ff56107b095a0356b0d2b221639de8c822 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Tue, 28 Jul 2026 17:06:09 -0600 Subject: [PATCH 08/12] Apply comment rules to parsers, store wiring, and the web-view root Fold @file banner rationale into the exported symbols, drop the repeated '@param state - Shared traversal state updated in place' boilerplate across the USJ walker, and trim @returns that restated each parser's field mapping. Keep every @throws. --- src/components/recenter-fade.ts | 13 +++------ src/interlinearizer.web-view.tsx | 17 +++++------- src/parsers/papi/bookTokenizer.ts | 16 +++-------- src/parsers/papi/resegmentBook.ts | 33 ++++++++--------------- src/parsers/papi/usjBookExtractor.ts | 35 ++----------------------- src/parsers/pt9/interlinearXmlParser.ts | 30 +++++++-------------- src/store/index.ts | 9 +++---- 7 files changed, 40 insertions(+), 113 deletions(-) diff --git a/src/components/recenter-fade.ts b/src/components/recenter-fade.ts index faebbb7f..73cc593a 100644 --- a/src/components/recenter-fade.ts +++ b/src/components/recenter-fade.ts @@ -1,10 +1,3 @@ -/** - * @file Shared fade timing for the recenter animation used by both the continuous strip and the - * segment list. Both views fade out, refocus on the externally-navigated verse, and fade back in; - * importing the duration and easing from a single source keeps the two animations in lockstep so - * an external navigation never shows one view fading on a different clock than the other. - */ - /** * CSS easing for the recenter opacity fade-in/out. A sine-like curve gives a natural feel at both * ends of the transition. @@ -12,8 +5,10 @@ const RECENTER_FADE_EASING = 'cubic-bezier(0.65, 0, 0.35, 1)'; /** - * Duration of the recenter fade, in milliseconds. Both views must use this value for their CSS - * transition and for the `setTimeout` that swaps content at the midpoint, so they fade as one. + * Duration of the recenter fade, in milliseconds. Both views fade out, refocus on the + * externally-navigated verse, and fade back in; both must use this value for their CSS transition + * and for the timeout that swaps content at the midpoint, so an external navigation never shows one + * view fading on a different clock than the other. */ export const RECENTER_FADE_MS = 500; diff --git a/src/interlinearizer.web-view.tsx b/src/interlinearizer.web-view.tsx index 8a89fa45..d6a673da 100644 --- a/src/interlinearizer.web-view.tsx +++ b/src/interlinearizer.web-view.tsx @@ -4,16 +4,13 @@ import InterlinearizerLoader from './components/InterlinearizerLoader'; /** * Root WebView component for the Interlinearizer. * - * @param props - WebView props injected by the PAPI host - * @param props.projectId - PAPI project ID passed from the host; undefined when the WebView is - * opened outside a project context - * @param props.useWebViewScrollGroupScrRef - Hook that exposes the shared scroll-group scripture - * reference and its setter - * @param props.useWebViewState - Hook for reading and writing values persisted in the WebView's - * saved state (survives tab restores) - * @param props.updateWebViewDefinition - Host-injected callback to update this WebView's - * definition; forwarded so the loader can toggle the tab's unsaved-changes title marker - * @returns The full interlinearizer WebView layout + * @param props.projectId - `undefined` when the WebView is opened outside a project context. + * @param props.useWebViewScrollGroupScrRef - Exposes the shared scroll-group scripture reference + * and its setter. + * @param props.useWebViewState - Reads and writes values persisted in the WebView's saved state, + * which survives tab restores. + * @param props.updateWebViewDefinition - Forwarded so the loader can toggle the tab's + * unsaved-changes title marker. */ globalThis.webViewComponent = function InterlinearizerWebView({ projectId, diff --git a/src/parsers/papi/bookTokenizer.ts b/src/parsers/papi/bookTokenizer.ts index d0d59260..cb16a3f7 100644 --- a/src/parsers/papi/bookTokenizer.ts +++ b/src/parsers/papi/bookTokenizer.ts @@ -1,5 +1,3 @@ -/** @file Tokenizes a {@link RawBook} into the interlinear model's `Book → Segment → Token` chain. */ - import { VerseRef } from '@sillsdev/scripture'; import type { Book, ScriptureRef, Segment, Token, TokenType } from 'interlinearizer'; @@ -66,8 +64,6 @@ const WORD_CONTAIN_RE = new RegExp(`[${CHAR_SET}]`, 'v'); /** * Parses a USJ verse SID (e.g. `"GEN 1:1"`) into a {@link ScriptureRef}. * - * @param sid - Verse SID string from the USJ `verse` marker (e.g. `"GEN 1:1"`). - * @returns A `ScriptureRef` with `book`, `chapter`, and `verse` populated. * @throws {SyntaxError} If `sid` is not a valid scripture reference string. */ function parseSid(sid: string): ScriptureRef { @@ -84,13 +80,11 @@ function parseSid(sid: string): ScriptureRef { * offsets are zero-based relative to `text`; `charEnd` is exclusive. * * Each token inherits `writingSystem` from the book so that downstream consumers (renderers, - * alignment tools) can identify the script without access to the parent `RawBook`. + * alignment tools) can identify the script without access to the parent book. * - * @param text - The verse's `baselineText` string. - * @param sid - The verse SID used as the token `ref` prefix (e.g. `"GEN 1:1"`). - * @param writingSystem - BCP 47 tag assigned to every token's `writingSystem` field. - * @returns Ordered array of {@link Token}s; empty when `text` contains no word or punctuation - * characters. + * @param text - The verse's baseline text. + * @param sid - Verse SID used as each token ref's prefix, e.g. `"GEN 1:1"`. + * @param writingSystem - BCP 47 tag assigned to every emitted token. */ function tokenizeVerse(text: string, sid: string, writingSystem: string): Token[] { return Array.from(text.matchAll(TOKEN_RE), (match) => { @@ -114,8 +108,6 @@ function tokenizeVerse(text: string, sid: string, writingSystem: string): Token[ * Invariant upheld for every token: `segment.baselineText.slice(token.charStart, token.charEnd) === * token.surfaceText`. * - * @param rawBook - Extracted book data from {@link extractBookFromUsj}. - * @returns A `Book` with one `Segment` per verse, each containing its ordered `Token`s. * @throws {SyntaxError} If any `RawVerse.sid` cannot be parsed as a valid scripture reference * (propagated from {@link parseSid}). * @throws {SyntaxError} If any `RawVerse.sid`'s book code does not match `rawBook.bookCode`. diff --git a/src/parsers/papi/resegmentBook.ts b/src/parsers/papi/resegmentBook.ts index 51e8a687..5ae20fad 100644 --- a/src/parsers/papi/resegmentBook.ts +++ b/src/parsers/papi/resegmentBook.ts @@ -1,13 +1,3 @@ -/** - * @file Re-groups a verse-tokenized {@link Book} into the user's custom segments per a - * {@link SegmentationDelta}, without touching the text-layer tokenizer. - * - * {@link tokenizeBook} always produces one `Segment` per verse; this pass runs on its output and - * cuts the flat document-order token stream at the delta's effective boundaries. Untouched verses - * are reused by reference so analyses keep resolving and React memoization is undisturbed; only - * merged or split segments are rebuilt, with `baselineText` and per-token char offsets recomputed - * so the `baselineText.slice(charStart, charEnd) === surfaceText` invariant still holds. - */ import type { Book, ScriptureRef, @@ -34,7 +24,6 @@ type SourcedToken = { token: Token; verse: Segment }; * number (a split's later piece keeps its source verse's number). * * @param run - The run's tokens in document order, each tagged with its source verse. Non-empty. - * @returns The rebuilt segment. */ function buildSegment(run: SourcedToken[]): Segment { const firstSourced = run[0]; @@ -105,19 +94,19 @@ function buildSegment(run: SourcedToken[]): Segment { } /** - * Re-groups `book`'s verse segments into the user's custom segments per `delta`. + * Re-groups a verse-tokenized {@link Book} into the user's custom segments, without touching the + * text-layer tokenizer. * - * Returns `book` unchanged (by reference) for the default segmentation, so the common no-custom- - * boundaries case incurs no work and no identity churn. Otherwise the flat token stream is cut at - * the effective boundaries; a run that is exactly one original verse reuses that verse's `Segment` - * object verbatim, while merged or split runs are rebuilt via {@link buildSegment}. Token-less - * verses (empty verse markers) pass through as their own segments in document order, so they - * survive a custom segmentation exactly as they do the default one. + * The book is returned unchanged, by reference, for the default segmentation, so the common + * no-custom-boundaries case incurs no work and no identity churn. Otherwise the flat document-order + * token stream is cut at the delta's effective boundaries. A run that is exactly one original verse + * reuses that verse's segment verbatim, so analyses keep resolving and React memoization is + * undisturbed; only merged or split runs are rebuilt, with baseline text and char offsets + * recomputed so the `baselineText.slice(charStart, charEnd) === surfaceText` invariant still + * holds. * - * @param book - The verse-tokenized book from {@link tokenizeBook}. - * @param delta - The user's boundary delta, or `undefined` for the default verse segmentation. - * @returns A book with the custom segmentation applied, or `book` itself when `delta` is the - * default. + * Token-less verses (empty verse markers) pass through as their own segments in document order, so + * they survive a custom segmentation exactly as they do the default one. */ export function resegmentBook(book: Book, delta: SegmentationDelta | undefined): Book { if (isDefaultSegmentation(delta)) return book; diff --git a/src/parsers/papi/usjBookExtractor.ts b/src/parsers/papi/usjBookExtractor.ts index ca00431e..937e7cac 100644 --- a/src/parsers/papi/usjBookExtractor.ts +++ b/src/parsers/papi/usjBookExtractor.ts @@ -139,8 +139,6 @@ interface TraversalState { * Every emitted verse's SID is recorded in `seenVerseIds`. Real markers are already recorded when * opened (see {@link handleVerseNode}); recording synthetic verse-0 scopes here lets a later * explicit marker with the same SID be rejected as a duplicate. - * - * @param state - Shared traversal state updated in place. */ function closeCurrentVerse(state: TraversalState): void { if (state.currentVerse === undefined) return; @@ -153,12 +151,7 @@ function closeCurrentVerse(state: TraversalState): void { state.currentVerseIsSynthetic = false; } -/** - * Captures the book code from a `book` node, then recurses into its content. - * - * @param node - The `book` USJ node; `node.code` is the 3-letter book code. - * @param state - Shared traversal state updated in place. - */ +/** Captures the book code from a `book` node, then recurses into its content. */ function handleBookNode(node: UsjNode, state: TraversalState): void { if (node.code) state.bookCode = node.code; if (node.content) traverse(node.content, state); @@ -174,9 +167,6 @@ function handleBookNode(node: UsjNode, state: TraversalState): void { * heading before verse 1 accumulates nothing and the empty scope is dropped on close. The scope's * SID is `" :0"`, parsed downstream into a verse-0 `Segment`. When the chapter node * carries no `number` the scope cannot be named, so it is not opened. - * - * @param node - The `chapter` USJ node; `node.number` is the chapter number (e.g. `"3"`). - * @param state - Shared traversal state updated in place. */ function handleChapterNode(node: UsjNode, state: TraversalState): void { closeCurrentVerse(state); @@ -191,9 +181,6 @@ function handleChapterNode(node: UsjNode, state: TraversalState): void { * Derives the verse-label fallback from a verse SID: the text after the last colon (e.g. `"7"` from * `"GEN 1:7"`, `"1a"` from `"GEN 1:1a"`). Used as the rendered verse number when a `verse` marker * omits its `number` attribute. - * - * @param sid - Verse SID string (e.g. `"GEN 1:7"`). - * @returns The verse portion after the final colon; the whole `sid` when it contains no colon. */ function verseNumberFromSid(sid: string): string { return sid.slice(sid.lastIndexOf(':') + 1); @@ -204,8 +191,6 @@ function verseNumberFromSid(sid: string): string { * verse number is the marker's verbatim `number` attribute, or the sid-derived verse portion when * the marker omits it. * - * @param node - The `verse` USJ node; must carry a `sid` attribute (e.g. `"GEN 1:1"`). - * @param state - Shared traversal state updated in place. * @throws {SyntaxError} If the `verse` node is missing its required `sid` attribute. * @throws {SyntaxError} If the `verse` SID has already been seen (duplicate verse SID). */ @@ -227,9 +212,6 @@ function handleVerseNode(node: UsjNode, state: TraversalState): void { * Recurses into a `para` node's content, appending a space between adjacent para nodes when needed. * Heading-class paragraphs (see {@link HEADING_PARA_MARKERS}) are skipped entirely so their text is * not included in the verse baseline. - * - * @param node - The `para` USJ node; `node.marker` determines whether to skip or recurse. - * @param state - Shared traversal state updated in place. */ function handleParaNode(node: UsjNode, state: TraversalState): void { if (node.marker && HEADING_PARA_MARKERS.has(node.marker)) return; @@ -257,8 +239,6 @@ const NODE_HANDLERS: Partial :0"`, * but only when it has text; see {@link handleChapterNode}. * - * @param usj - USJ document returned by `useProjectData('platformScripture.USJ_Book', ...)`. - * @param writingSystem - BCP 47 tag for the baseline, from `platform.languageTag`. - * @returns A `RawBook` with `bookCode`, `writingSystem`, `contentHash`, and `verses` populated. * @throws {SyntaxError} If no `book` marker with a `code` attribute is found in the document. * @throws {SyntaxError} If a `verse` marker is missing its required `sid` attribute (propagated * from {@link handleVerseNode} via {@link traverse}). diff --git a/src/parsers/pt9/interlinearXmlParser.ts b/src/parsers/pt9/interlinearXmlParser.ts index 04d37574..5e8acab0 100644 --- a/src/parsers/pt9/interlinearXmlParser.ts +++ b/src/parsers/pt9/interlinearXmlParser.ts @@ -138,10 +138,9 @@ interface ParsedInterlinearXml { } /** - * Maps a parsed Cluster's Lexeme array to {@link LexemeData} array. + * Maps a parsed Cluster's Lexeme children to {@link LexemeData}. A lexeme with no gloss id yields an + * empty sense id rather than being dropped. * - * @param clusterElement - Parsed Cluster from fast-xml-parser (may have Lexeme array or none). - * @returns Array of LexemeData with LexemeId (from Id) and SenseId (from GlossId, or ''). * @throws {SyntaxError} If any Lexeme element is missing the required Id attribute. */ function extractLexemesFromCluster(clusterElement: ParsedCluster): LexemeData[] { @@ -160,10 +159,8 @@ function extractLexemesFromCluster(clusterElement: ParsedCluster): LexemeData[] * Parses a string to a non-negative integer, returning `undefined` for empty or non-integer values. * Used to validate XML attribute strings before converting them to numeric ranges. * - * @param raw - The string to parse (e.g. an XML attribute value). `undefined` is accepted because - * fast-xml-parser may omit attributes at runtime despite the declared type. - * @returns The parsed non-negative integer, or `undefined` if the input is absent, empty, or - * non-integer. + * @param raw - `undefined` is accepted because fast-xml-parser may omit attributes at runtime + * despite the declared type. */ const parseStrictNumber = (raw: string | undefined): number | undefined => { if (raw === undefined || raw.trim() === '') return undefined; @@ -178,11 +175,6 @@ const parseStrictNumber = (raw: string | undefined): number | undefined => { * non-integer `Index`/`Length`) are silently filtered out rather than throwing. Punctuation data is * non-critical to the interlinear display; clusters are validated strictly in * {@link extractClustersFromVerse} because they are required for alignment rendering. - * - * @param verseDataElement - Parsed VerseData from fast-xml-parser (may have Punctuation array or - * none). - * @returns Array of PunctuationData (TextRange from Range Index/Length, BeforeText, AfterText). - * Entries without a valid Range element are skipped. */ function extractPunctuationsFromVerse(verseDataElement: ParsedVerseData): PunctuationData[] { const elements = verseDataElement.Punctuation ?? []; @@ -204,11 +196,10 @@ function extractPunctuationsFromVerse(verseDataElement: ParsedVerseData): Punctu } /** - * Maps a parsed VerseData's Cluster array to {@link ClusterData} array. + * Maps a parsed VerseData's Cluster children to {@link ClusterData}. Each cluster's composite `Id` + * is its slash-joined lexeme ids followed by its text range, or the range alone when it has no + * lexemes. * - * @param verseDataElement - Parsed VerseData from fast-xml-parser (may have Cluster array or none). - * @returns Array of ClusterData: TextRange from Cluster's Range, Lexemes from Lexeme children, - * LexemesId (slash-joined), Id (LexemesId/Index-Length or Index-Length when no lexemes). * @throws {SyntaxError} If a Cluster is missing its Range element, Range is missing Index or * Length, or Range Index/Length are not non-negative integers. * @throws {SyntaxError} If any Lexeme element in a Cluster is missing the required Id attribute @@ -296,11 +287,8 @@ export class InterlinearXmlParser { /** * Parses an interlinear XML string into {@link InterlinearData}. * - * @param xml - Raw XML string (e.g. file contents). Must be valid interlinear XML with - * InterlinearData root, GlossLanguage and BookId attributes, and Verses containing item - * entries. - * @returns Parsed interlinear data: ScrTextName, GlossLanguage, BookId, and Verses (record of - * verse key to {@link VerseData} with Hash, Clusters, Punctuations). + * @param xml - Must have an InterlinearData root with GlossLanguage and BookId attributes, and a + * Verses element containing item entries. * @throws {SyntaxError} If the `InterlinearData` root element is absent. * @throws {SyntaxError} If `GlossLanguage` or `BookId` attributes are missing or empty. * @throws {SyntaxError} If the `Verses` element is absent. diff --git a/src/store/index.ts b/src/store/index.ts index a12b3bb2..cb3b7627 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -2,12 +2,9 @@ import { configureStore } from '@reduxjs/toolkit'; import analysisReducer, { type AnalysisState } from './analysisSlice'; /** - * Creates a Redux store scoped to one `AnalysisStoreProvider` instance. Keeping the store local - * rather than global means each WebView (or nested provider) has fully isolated state. - * - * @param preloadedState - Optional initial state, typically used to seed `initialAnalysis` and - * `analysisLanguage` from the provider props. - * @returns A configured Redux store with the analysis reducer mounted at `state.analysis`. + * Creates a Redux store scoped to a single analysis provider instance, optionally seeded from that + * provider's props. Keeping the store local rather than global means each WebView, or nested + * provider, has fully isolated state. */ export function createAnalysisStore(preloadedState?: { analysis: AnalysisState }) { return configureStore({ From 43f9db399fa651da50b001687c8a493471f27b7f Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Tue, 28 Jul 2026 17:09:52 -0600 Subject: [PATCH 09/12] Apply comment rules to the analysis slice and project storage Remove the @param state/@param action boilerplate repeated across every reducer and selector, and the @returns that restated the summary directly above them. Fold the few real details (stable empty array, blank-clears-gloss, per-morpheme writing system) into the prose. Keep every @throws and the storage tags carrying ENOENT, ordering, and null-to-clear semantics. --- src/services/projectStorage.ts | 51 +-------- src/store/analysisSlice.ts | 200 +++------------------------------ 2 files changed, 15 insertions(+), 236 deletions(-) diff --git a/src/services/projectStorage.ts b/src/services/projectStorage.ts index 3300134a..73d61ee1 100644 --- a/src/services/projectStorage.ts +++ b/src/services/projectStorage.ts @@ -56,7 +56,6 @@ const draftQueues = new Map>(); * @param get - Returns the current tail of the queue to chain `fn` after. * @param set - Stores the new tail of the queue (a promise that settles once `fn` settles). * @param fn - The async function to serialize. - * @returns A promise that resolves or rejects with the return value of `fn`. * @throws Whatever `fn` throws; the queue advances past the error so later operations are not * blocked. */ @@ -74,8 +73,6 @@ function enqueueOnQueue( * Enqueues `fn` on the index serialization queue. Thin wrapper over {@link enqueueOnQueue} bound to * {@link indexQueue}. * - * @param fn - The async function to serialize. - * @returns A promise that resolves or rejects with the return value of `fn`. * @throws Whatever `fn` throws; the queue advances past the error so later operations are not * blocked. */ @@ -93,8 +90,6 @@ function enqueueIndexOp(fn: () => Promise): Promise { * Enqueues `fn` on the pending-cleanup serialization queue. Thin wrapper over {@link enqueueOnQueue} * bound to {@link pendingCleanupQueue}. * - * @param fn - The async function to serialize. - * @returns A promise that resolves or rejects with the return value of `fn`. * @throws Whatever `fn` throws; the queue advances past the error so later operations are not * blocked. */ @@ -116,7 +111,6 @@ function enqueuePendingCleanupOp(fn: () => Promise): Promise { * @param queues - The queue map to serialize on; one chain per `key`. * @param key - The key whose queue `fn` should join. * @param fn - The async function to serialize. - * @returns A promise that resolves or rejects with the return value of `fn`. * @throws Whatever `fn` throws; the queue entry is removed and the rejection propagates to the * caller. */ @@ -140,9 +134,6 @@ function enqueueSerialized( * Enqueues `fn` on the per-project serialization queue for `id`. Thin wrapper over * {@link enqueueSerialized} bound to {@link projectQueues}. * - * @param id - The project UUID whose queue `fn` should join. - * @param fn - The async function to serialize. - * @returns A promise that resolves or rejects with the return value of `fn`. * @throws Whatever `fn` throws; the queue entry is removed and the rejection propagates to the * caller. */ @@ -150,22 +141,12 @@ function enqueueProjectOp(id: string, fn: () => Promise): Promise { return enqueueSerialized(projectQueues, id, fn); } -/** - * Returns the storage key for a project by ID. - * - * @param id - The project UUID. - * @returns The storage key string used to read and write the project record. - */ +/** Returns the storage key for a project by ID. */ function projectKey(id: string): string { return `project:${id}`; } -/** - * Returns the storage key for a source project's draft. - * - * @param sourceProjectId - The Platform.Bible source project ID the draft belongs to. - * @returns The storage key string used to read and write the draft record. - */ +/** Returns the storage key for a source project's draft. */ function draftKey(sourceProjectId: string): string { return `draft:${sourceProjectId}`; } @@ -173,9 +154,6 @@ function draftKey(sourceProjectId: string): string { /** * Returns true when `e` is a file-not-found error (ENOENT) from the Node.js file system, which is * what `papi.storage.readUserData` throws when the requested key has never been written. - * - * @param e - The caught value. - * @returns Whether the error represents a missing storage key. */ function isNotFound(e: unknown): boolean { return !!e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT'; @@ -184,9 +162,6 @@ function isNotFound(e: unknown): boolean { /** * Type guard for a JSON-parsed value that must be an array of strings — the shape of both the * `projectIds` index and the `pendingCleanup` set. - * - * @param value - The parsed value to test. - * @returns Whether `value` is an array whose every element is a string. */ function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((id) => typeof id === 'string'); @@ -197,8 +172,6 @@ function isStringArray(value: unknown): value is string[] { * Shared by {@link readIds} and {@link readPendingCleanup}. Returns the raw parsed value as `unknown` * and does not validate that it is an array of strings; each caller validates the shape itself. * - * @param token - The execution token for storage access. - * @param key - The storage key to read. * @returns The parsed value, or an empty array if `key` has never been written (ENOENT). * @throws {SyntaxError} If the stored value contains invalid JSON. * @throws If `papi.storage.readUserData` rejects for any non-ENOENT reason (e.g. permission denied, @@ -221,7 +194,6 @@ async function readJsonArray(token: ExecutionToken, key: string): Promise * per-record deletions run concurrently within the cycle; the set is small and each targets a * distinct key. * - * @param token - The execution token for storage access. * @returns A promise resolving to the number of orphaned records successfully deleted this pass * (live ids dropped from the set without deleting their record are not counted). * @throws If reading the set or rewriting it (`papi.storage.writeUserData`) rejects for a @@ -387,7 +354,6 @@ export function sweepPendingCleanup(token: ExecutionToken): Promise { * when omitted the project is analysis-only and `links` is left undefined. * @param name - Optional user-facing name for the project. * @param description - Optional user-facing description for the project. - * @returns The newly created project record. * @throws {SyntaxError} If the `projectIds` storage value contains invalid JSON. * @throws If the project or index `papi.storage.writeUserData` rejects. On an index-write failure * the original error is rethrown after best-effort rollback; a failed rollback does not throw — @@ -444,8 +410,6 @@ export async function createProject( /** * Returns the project with the given ID. * - * @param token - The execution token for storage access. - * @param id - The project UUID. * @returns The project record, or `undefined` if it does not exist in storage (ENOENT). * @throws {SyntaxError} If the project's storage value contains invalid JSON. * @throws If `papi.storage.readUserData` rejects for any non-ENOENT reason. @@ -467,7 +431,6 @@ export async function getProject( * after a failed delete) are silently omitted. Projects that fail to read or parse are logged and * skipped so a single corrupted record does not prevent access to the rest. * - * @param token - The execution token for storage access. * @returns All stored projects, ordered by creation time. * @throws {SyntaxError} If `projectIds` contains invalid JSON. * @throws If `papi.storage.readUserData` rejects for any non-ENOENT reason when reading the index. @@ -491,8 +454,6 @@ export async function listProjects(token: ExecutionToken): Promise t.surfaceText).join(' '); } @@ -124,9 +119,6 @@ function phraseSurfaceText(tokens: TokenSnapshot[]): string { /** * Removes the `PhraseAnalysis` record and its `PhraseAnalysisLink` matching `phraseId` from the * Immer draft state in a single step, ensuring both collections stay in sync. - * - * @param state - Current slice state (Immer draft). - * @param phraseId - ID of the phrase to remove. */ function removePhraseById(state: AnalysisState, phraseId: string): void { state.analysis.phraseAnalyses = state.analysis.phraseAnalyses.filter((pa) => pa.id !== phraseId); @@ -141,10 +133,6 @@ function removePhraseById(state: AnalysisState, phraseId: string): void { * corruption or a migration), the link is removed from the draft — so the corruption never persists * or accumulates duplicate approved links — and `undefined` is returned as if no approved link * existed. Mirrors {@link resolveApprovedAnalysis} for the segment layer. - * - * @param state - Current slice state (Immer draft). - * @param segmentId - `Segment.id` of the segment to look up. - * @returns The approved link and its analysis, or `undefined` when the segment has none. */ function resolveApprovedSegmentAnalysis( state: AnalysisState, @@ -172,9 +160,6 @@ function resolveApprovedSegmentAnalysis( * imported word-for-word translation) still survives a free-translation clear while a record left * holding only whitespace is dropped — mirroring how {@link isEmptyTokenAnalysis} preserves * morphemes/pos. - * - * @param analysis - The `SegmentAnalysis` to inspect. - * @returns `true` when the record holds no content worth keeping. */ function isEmptySegmentAnalysis(analysis: SegmentAnalysis): boolean { return ( @@ -185,10 +170,6 @@ function isEmptySegmentAnalysis(analysis: SegmentAnalysis): boolean { /** * Removes a `SegmentAnalysis` record and its `SegmentAnalysisLink` from the draft in a single step, * keeping the two collections in sync. Called when an edit empties an analysis of all content. - * - * @param state - Current slice state (Immer draft). - * @param analysis - The `SegmentAnalysis` record to remove. - * @param link - The `SegmentAnalysisLink` referencing it. */ function removeSegmentAnalysis( state: AnalysisState, @@ -211,10 +192,6 @@ function removeSegmentAnalysis( * the link is removed from the draft — so the corruption never persists or accumulates duplicate * approved links — and `undefined` is returned as if no approved link existed. Every token-analysis * reducer resolves through this helper so they all repair orphaned links the same way. - * - * @param state - Current slice state (Immer draft). - * @param tokenRef - `Token.ref` of the token to look up. - * @returns The approved link and its analysis, or `undefined` when the token has none. */ function resolveApprovedAnalysis( state: AnalysisState, @@ -241,10 +218,6 @@ function resolveApprovedAnalysis( * records _this_ token's surface text (from `analysis.surfaceText`), not the shared payload's, so * per-token drift detection stays accurate even when a sentence-initial form links to a payload * first created from a mid-sentence form. - * - * @param state - Current slice state (Immer draft). - * @param analysis - The candidate `TokenAnalysis` record to link or, if novel, append. - * @param tokenRef - `Token.ref` of the token the link points at. */ function appendApprovedAnalysis( state: AnalysisState, @@ -268,10 +241,6 @@ function appendApprovedAnalysis( * remaining references is what stops an edit on one token from orphaning a payload that another * token still links to. A payload kept alive by a surviving link may be momentarily empty; it is * reclaimed when that last link is cleared. - * - * @param state - Current slice state (Immer draft). - * @param analysis - The emptied `TokenAnalysis` payload. - * @param link - The `TokenAnalysisLink` from the editing token to remove. */ function detachTokenAnalysisLink( state: AnalysisState, @@ -292,11 +261,6 @@ function detachTokenAnalysisLink( * i.e. whether an edit or clear reaching it through `link`'s token would also affect a different * token. The shared/sole distinction is what tells a clear, delete, or fork whether it must work on * a private clone (to spare the co-linked tokens) or may mutate the payload in place. - * - * @param state - Current slice state (Immer draft). - * @param link - The editing token's approved `TokenAnalysisLink` (excluded from the check). - * @param analysisId - `TokenAnalysis.id` of the payload to test. - * @returns `true` when at least one other link points at the same payload. */ function isPayloadSharedByOtherLinks( state: AnalysisState, @@ -316,12 +280,6 @@ function isPayloadSharedByOtherLinks( * and `morphemes` containers so the returned draft can be edited or cleared in the same reducer * without writing through to the frozen shared payload. Used by the per-token edit, clear, and * delete paths so a token can edit or detach from a shared analysis without affecting the others. - * - * @param state - Current slice state (Immer draft). - * @param link - The editing token's approved `TokenAnalysisLink`, repointed to the clone. - * @param analysis - The shared payload to clone. - * @param cloneId - Pre-generated UUID for the clone (from the action's `prepare`). - * @returns The clone's draft, for the caller to edit or clear in place. */ function forkSharedAnalysis( state: AnalysisState, @@ -348,9 +306,6 @@ function forkSharedAnalysis( * payload and `analysis` is dropped — collapsing a homograph instance that was edited to match a * sibling back onto one shared payload (frequency re-merged, no duplicate suggestion). A no-op when * the edit left the payload unique. - * - * @param state - Current slice state (Immer draft). - * @param analysis - The payload just edited in place. */ function mergeIntoIdenticalPayload(state: AnalysisState, analysis: TokenAnalysis): void { const other = state.analysis.tokenAnalyses.find( @@ -378,9 +333,6 @@ function mergeIntoIdenticalPayload(state: AnalysisState, analysis: TokenAnalysis * empty and may be dropped when its last content field is cleared. This is a deliberate choice — if * a future workflow needs provenance-only records (e.g. imported parser metadata) to survive a * gloss clear, add the relevant fields to the check below. - * - * @param analysis - The `TokenAnalysis` to inspect. - * @returns `true` when the record holds no analysis content worth keeping. */ function isEmptyTokenAnalysis(analysis: TokenAnalysis): boolean { return ( @@ -401,11 +353,6 @@ const analysisSlice = createSlice({ /** * Generates a UUID for a potential new `TokenAnalysis` record before the action reaches the * reducer, keeping the reducer pure. - * - * @param tokenRef - `Token.ref` of the token being glossed. - * @param surfaceText - Surface text of the token. - * @param value - New gloss string. - * @returns The prepared action payload including a pre-generated `id`. */ prepare(tokenRef: string, surfaceText: string, value: string) { return { payload: { tokenRef, surfaceText, value, id: crypto.randomUUID() } }; @@ -432,9 +379,6 @@ const analysisSlice = createSlice({ * gloss rather than being stranded on an emptied payload. A blank write to a token with no * approved analysis is a no-op, so a focus/blur cycle on an empty gloss never creates a * record. - * - * @param state - Current slice state (Immer draft). - * @param action - Action carrying the `WriteGlossPayload`. */ reducer(state, action: PayloadAction) { const { tokenRef, surfaceText, value, id } = action.payload; @@ -484,14 +428,8 @@ const analysisSlice = createSlice({ writeMorphemes: { /** * Generates UUIDs for new morpheme records and a potential new `TokenAnalysis` before the - * action reaches the reducer. - * - * @param tokenRef - `Token.ref` of the token whose morphemes are being set. - * @param surfaceText - Surface text of the token. - * @param forms - Ordered morpheme form strings as entered by the user. - * @param writingSystem - BCP 47 tag of the token's surface text (`Token.writingSystem`), - * stored on each morpheme as the writing system of its form. - * @returns The prepared action payload. + * action reaches the reducer. The token's own writing system is stored on each morpheme as + * the writing system of its form. */ prepare(tokenRef: string, surfaceText: string, forms: string[], writingSystem: string) { return { @@ -520,9 +458,6 @@ const analysisSlice = createSlice({ * stamped with the supplied writing system, so records written before the writing system was * threaded through (which wrongly stored the analysis language) self-correct on the next * save. - * - * @param state - Current slice state (Immer draft). - * @param action - Action carrying the morpheme payload. */ reducer( state, @@ -588,10 +523,6 @@ const analysisSlice = createSlice({ * only when the breakdown is removed from a shared payload — keeping the reducer pure. * Accepts the same `{ tokenRef }` argument the action took before, so call sites are * unchanged. - * - * @param arg - Object carrying the `tokenRef` whose breakdown is removed. - * @param arg.tokenRef - `Token.ref` of the token whose morphemes are removed. - * @returns The prepared action payload including a pre-generated clone `id`. */ prepare(arg: { tokenRef: string }) { return { payload: { tokenRef: arg.tokenRef, id: crypto.randomUUID() } }; @@ -605,10 +536,6 @@ const analysisSlice = createSlice({ * {@link forkSharedAnalysis}) so the co-linked tokens keep their morphemes. No-ops when the * token has no approved analysis or the analysis has no morphemes (an orphaned approved link * is still repaired; see {@link resolveApprovedAnalysis}). - * - * @param state - Current slice state (Immer draft). - * @param action - Action carrying the `tokenRef` whose breakdown is removed and the clone - * `id`. */ reducer(state, action: PayloadAction<{ tokenRef: string; id: string }>) { const { tokenRef, id } = action.payload; @@ -655,21 +582,11 @@ const analysisSlice = createSlice({ /** * Generates a UUID for the clone a per-token edit forks from a shared payload, before the * action reaches the reducer, keeping the reducer pure. Unused when the payload is not - * shared. - * - * @param arg - Object carrying the token, morpheme, and new gloss value. - * @param arg.tokenRef - `Token.ref` of the token whose morpheme gloss is written. - * @param arg.morphemeId - Id of the morpheme within the token's approved analysis. - * @param arg.value - New gloss string (blank clears the morpheme's active-language gloss). - * @returns The prepared action payload including a pre-generated clone `id`. + * shared. A blank gloss value clears the morpheme's active-language gloss. */ prepare(arg: { tokenRef: string; morphemeId: string; value: string }) { return { payload: { ...arg, id: crypto.randomUUID() } }; }, - /** - * @param state - Current slice state (Immer draft). - * @param action - Action carrying the morpheme gloss payload and a pre-generated clone `id`. - */ reducer( state, action: PayloadAction<{ @@ -733,11 +650,6 @@ const analysisSlice = createSlice({ * as a fresh orphan. The link's snapshot records _this_ token's `surfaceText` (not the shared * payload's), matching {@link appendApprovedAnalysis} so per-token drift detection stays * accurate. - * - * @param state - Current slice state (Immer draft). - * @param action - Action carrying the accepting `tokenRef`, its `surfaceText`, and the - * `analysisId` of the payload to approve (the suggested payload, or a candidate when - * promoting). */ approveAnalysisForToken( state, @@ -774,19 +686,11 @@ const analysisSlice = createSlice({ /** * Generates a UUID for the new `PhraseAnalysis` before the action reaches the reducer, * keeping the reducer pure. - * - * @param tokens - Ordered `TokenSnapshot`s forming the phrase, in document order. - * @returns The prepared action payload including a pre-generated `id`. */ prepare(tokens: TokenSnapshot[]) { return { payload: { id: crypto.randomUUID(), tokens } }; }, - /** - * Appends a new approved `PhraseAnalysis` and its `PhraseAnalysisLink` to the analysis. - * - * @param state - Current slice state (Immer draft). - * @param action - Action carrying the `CreatePhrasePayload`. - */ + /** Appends a new approved `PhraseAnalysis` and its `PhraseAnalysisLink` to the analysis. */ reducer(state, action: PayloadAction) { const { id, tokens } = action.payload; const newAnalysis: PhraseAnalysis = { id, surfaceText: phraseSurfaceText(tokens) }; @@ -802,9 +706,6 @@ const analysisSlice = createSlice({ * phrase id and any gloss already written on it. When `tokens` is empty the phrase is removed * entirely (both the analysis record and its link) so a zero-token phrase can never persist in * the store. - * - * @param state - Current slice state (Immer draft). - * @param action - Action carrying the `UpdatePhrasePayload`. */ updatePhrase(state, action: PayloadAction) { const { phraseId, tokens } = action.payload; @@ -817,12 +718,7 @@ const analysisSlice = createSlice({ const analysis = state.analysis.phraseAnalyses.find((pa) => pa.id === phraseId); if (analysis) analysis.surfaceText = phraseSurfaceText(tokens); }, - /** - * Removes the `PhraseAnalysis` record and its `PhraseAnalysisLink` for the given phrase id. - * - * @param state - Current slice state (Immer draft). - * @param action - Action carrying the `DeletePhrasePayload`. - */ + /** Removes the `PhraseAnalysis` record and its `PhraseAnalysisLink` for the given phrase id. */ deletePhrase(state, action: PayloadAction) { const { phraseId } = action.payload; removePhraseById(state, phraseId); @@ -837,9 +733,6 @@ const analysisSlice = createSlice({ * * No-ops when `absorbedPhraseId === targetPhraseId` to prevent the update from being * immediately undone by the delete. - * - * @param state - Current slice state (Immer draft). - * @param action - Action carrying the `MergePhrasesPayload`. */ mergePhrases(state, action: PayloadAction) { const { targetPhraseId, tokens, absorbedPhraseId } = action.payload; @@ -854,9 +747,6 @@ const analysisSlice = createSlice({ /** * Writes a gloss value into the `PhraseAnalysis` record for the given phrase id. No-ops when no * matching `PhraseAnalysis` is found. - * - * @param state - Current slice state (Immer draft). - * @param action - Action carrying the `WritePhraseGlossPayload`. */ writePhraseGloss(state, action: PayloadAction) { const { phraseId, value } = action.payload; @@ -870,11 +760,6 @@ const analysisSlice = createSlice({ /** * Generates a UUID for a potential new `SegmentAnalysis` record before the action reaches the * reducer, keeping the reducer pure. - * - * @param segmentId - `Segment.id` of the segment being translated. - * @param surfaceText - Baseline text of the segment. - * @param value - New free-translation string. - * @returns The prepared action payload including a pre-generated `id`. */ prepare(segmentId: string, surfaceText: string, value: string) { return { payload: { segmentId, surfaceText, value, id: crypto.randomUUID() } }; @@ -892,9 +777,6 @@ const analysisSlice = createSlice({ * ({@link isEmptySegmentAnalysis}) the record and its link are removed entirely. A blank write * to a segment with no approved analysis is a no-op, so a focus/blur cycle on an empty input * never creates a record. - * - * @param state - Current slice state (Immer draft). - * @param action - Action carrying the `WriteSegmentFreeTranslationPayload`. */ reducer(state, action: PayloadAction) { const { segmentId, surfaceText, value, id } = action.payload; @@ -952,28 +834,13 @@ export default analysisSlice.reducer; // #region Selectors -/** - * Projects `tokenAnalyses` out of `AnalysisState` for use as a `createSelector` input. - * - * @param state - The analysis slice state. - * @returns The `tokenAnalyses` array. - */ +/** Projects `tokenAnalyses` out of `AnalysisState` for use as a `createSelector` input. */ const selectTokenAnalyses = (state: AnalysisState) => state.analysis.tokenAnalyses; -/** - * Projects `tokenAnalysisLinks` out of `AnalysisState` for use as a `createSelector` input. - * - * @param state - The analysis slice state. - * @returns The `tokenAnalysisLinks` array. - */ +/** Projects `tokenAnalysisLinks` out of `AnalysisState` for use as a `createSelector` input. */ const selectTokenAnalysisLinks = (state: AnalysisState) => state.analysis.tokenAnalysisLinks; -/** - * Projects `analysisLanguage` out of `AnalysisState` for use as a `createSelector` input. - * - * @param state - The analysis slice state. - * @returns The active BCP 47 analysis language tag. - */ +/** Projects `analysisLanguage` out of `AnalysisState` for use as a `createSelector` input. */ export const selectAnalysisLanguage = (state: AnalysisState) => state.analysisLanguage; /** @@ -1002,21 +869,12 @@ const selectApprovedIdByTokenRef = createSelector( }, new Map()), ); -/** - * Returns the `TextAnalysis` from the analysis slice state. - * - * @param state - The analysis slice state. - * @returns The current `TextAnalysis`. - */ +/** Returns the `TextAnalysis` from the analysis slice state. */ export const selectAnalysis = (state: AnalysisState) => state.analysis; /** * Returns the approved gloss string for `tokenRef` in the active analysis language, or `''` when no * approved analysis exists or the analysis has no gloss for the active language. - * - * @param state - The analysis slice state. - * @param tokenRef - The `Token.ref` to look up. - * @returns The approved gloss string, or `''` when absent. */ export function selectApprovedGloss(state: AnalysisState, tokenRef: string): string { const approvedId = selectApprovedIdByTokenRef(state).get(tokenRef); @@ -1070,12 +928,6 @@ export const selectPoolIndex = createSelector( * therefore NOT rely on the default `Object.is` equality: subscribe through a per-token memoized * selector or pass a shallow/custom `equalityFn` (or `useMemo` the result), or every store change * will re-render the token and trip react-redux's "selector returned a different result" warning. - * - * @param state - The analysis slice state. - * @param tokenRef - The `Token.ref` to resolve. - * @param surfaceText - The token's current surface text, matched against the pool when the token is - * unapproved. - * @returns The approved or suggested analysis for the token, or `undefined` when it has neither. */ export function selectResolvedTokenAnalysis( state: AnalysisState, @@ -1102,11 +954,6 @@ export function selectResolvedTokenAnalysis( * deletion will surface rather than the approved payload's mere alternatives. Returns `undefined` * when the token has no approval (callers only consult this for an approved token) or when nothing * in the pool still matches once that approval is discounted. - * - * @param state - The analysis slice state. - * @param tokenRef - The `Token.ref` whose approval to discount. - * @param surfaceText - The token's current surface text, matched against the pool. - * @returns The suggested-status resolved analysis as if the token were unapproved, or `undefined`. */ export function selectSuggestionAfterClearing( state: AnalysisState, @@ -1128,10 +975,6 @@ export function selectSuggestionAfterClearing( * no glosses is bare segmentation that is cheap to retype. Sharing is read through * {@link selectApprovedTokenCountByAnalysisId} — the same approved-link count the reducer's * `isPayloadSharedByOtherLinks` tests — so the two can never disagree about what "shared" means. - * - * @param state - The analysis slice state. - * @param tokenRef - The `Token.ref` whose breakdown would be removed. - * @returns `true` when the reset would irrecoverably discard glosses belonging to this token alone. */ export function selectMorphemeResetLosesGlosses(state: AnalysisState, tokenRef: string): boolean { const approvedId = selectApprovedIdByTokenRef(state).get(tokenRef); @@ -1149,12 +992,8 @@ export function selectMorphemeResetLosesGlosses(state: AnalysisState, tokenRef: const EMPTY_MORPHEMES: readonly MorphemeAnalysis[] = []; /** - * Returns the morpheme array from the approved `TokenAnalysis` for `tokenRef`, or an empty array - * when no approved analysis exists or it has no morphemes. - * - * @param state - The analysis slice state. - * @param tokenRef - The `Token.ref` to look up. - * @returns The morpheme array, or a stable empty array when absent. + * Returns the morpheme array from the approved `TokenAnalysis` for `tokenRef`, or a shared + * reference-stable empty array when no approved analysis exists or it has no morphemes. */ export function selectApprovedMorphemes( state: AnalysisState, @@ -1166,12 +1005,7 @@ export function selectApprovedMorphemes( return ta?.morphemes ?? EMPTY_MORPHEMES; } -/** - * Projects `phraseAnalysisLinks` out of `AnalysisState` for use as a `createSelector` input. - * - * @param state - The analysis slice state. - * @returns The `phraseAnalysisLinks` array. - */ +/** Projects `phraseAnalysisLinks` out of `AnalysisState` for use as a `createSelector` input. */ const selectPhraseAnalysisLinksRaw = (state: AnalysisState) => state.analysis.phraseAnalysisLinks; /** @@ -1205,10 +1039,6 @@ export const selectPhraseLinkByAnalysisId = createSelector( /** * Returns the approved gloss string for the given phrase in the active analysis language, or `''` * when no phrase with that id exists or it has no gloss for the active language. - * - * @param state - The analysis slice state. - * @param phraseId - The `PhraseAnalysis.id` to look up. - * @returns The gloss string, or `''` when absent. */ export function selectPhraseGloss(state: AnalysisState, phraseId: string): string { const pa = state.analysis.phraseAnalyses.find((p) => p.id === phraseId); @@ -1220,10 +1050,6 @@ export function selectPhraseGloss(state: AnalysisState, phraseId: string): strin * language, or `''` when no approved analysis exists or it has no free translation for the active * language. An approved link referencing a missing analysis is treated as absent (read-only here; * the orphan is repaired on the next write through {@link resolveApprovedSegmentAnalysis}). - * - * @param state - The analysis slice state. - * @param segmentId - The `Segment.id` to look up. - * @returns The free-translation string, or `''` when absent. */ export function selectSegmentFreeTranslation(state: AnalysisState, segmentId: string): string { const link = state.analysis.segmentAnalysisLinks.find( From 2469e0eaa180ca533f26fa804780014d712d469f Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Tue, 28 Jul 2026 17:17:25 -0600 Subject: [PATCH 10/12] Apply comment rules to src/components and main.ts Drop the '@param props - Component props' umbrella repeated across 47 component docs and the @returns that only restated the rendered markup, keeping the ones that express conditional absence or real semantics. Delete the single-line @file banners and fold the substantive multi-line ones (Alt-context isolation, the strip context's deliberate exclusions, the dropdown's portaling, editor-vs-display for morphemes) into the symbols they describe. --- src/components/AltHeldContext.tsx | 19 +++++++-------- src/components/AnalysisStore.tsx | 23 ------------------- src/components/ArcOverlay.tsx | 3 --- src/components/ContinuousView.tsx | 1 - src/components/InterlinearNavContext.tsx | 4 ---- src/components/Interlinearizer.tsx | 2 -- src/components/InterlinearizerLoader.tsx | 5 ---- src/components/MorphemeBox.tsx | 23 +++++++------------ src/components/MorphemeEditor.tsx | 18 ++++++--------- src/components/PhraseBox.tsx | 4 ---- src/components/PhraseStripContext.tsx | 20 ++++++---------- src/components/PhraseStripParts.tsx | 6 ----- .../SegmentFreeTranslationInput.tsx | 2 -- src/components/SegmentListView.tsx | 1 - src/components/SegmentView.tsx | 5 ---- src/components/SegmentationStore.tsx | 19 +++++++-------- src/components/SuggestionDropdown.tsx | 22 ++++++++---------- src/components/TokenChip.tsx | 3 --- src/components/TokenLinkIcon.tsx | 1 - .../controls/EditPhraseControls.tsx | 3 --- .../controls/ScriptureNavControls.tsx | 1 - .../controls/ViewOptionsDropdown.tsx | 3 --- src/components/modals/ModalShell.tsx | 2 -- .../modals/ProjectMetadataModal.tsx | 2 -- src/components/modals/ProjectModals.tsx | 7 ------ .../modals/ProjectSummaryDetails.tsx | 1 - src/components/modals/UnlinkPhraseConfirm.tsx | 3 --- src/main.ts | 6 ----- src/parsers/papi/usjBookExtractor.ts | 2 -- 29 files changed, 47 insertions(+), 164 deletions(-) diff --git a/src/components/AltHeldContext.tsx b/src/components/AltHeldContext.tsx index 78382b68..c9fe6742 100644 --- a/src/components/AltHeldContext.tsx +++ b/src/components/AltHeldContext.tsx @@ -1,14 +1,14 @@ -/** - * @file Dedicated context carrying only whether the Alt key is currently held, consumed by the - * split-gap markers so an Alt press re-renders just those markers. - * - * Kept separate from the memoized `SegmentationContext`: a frequently-flipping boolean folded into - * that context would defeat its memoization for every consumer, so the churn is isolated here. - */ import { createContext, useContext } from 'react'; import type { ReactNode } from 'react'; -/** The Alt-held context. Defaults to `false` so consumers outside a provider read "not held". */ +/** + * Carries only whether the Alt key is currently held, so an Alt press re-renders just the split-gap + * markers that consume it. Deliberately separate from the memoized segmentation context: a + * frequently-flipping boolean folded in there would defeat that memoization for every consumer, so + * the churn is isolated here. + * + * Defaults to `false`, so consumers outside a provider read "not held". + */ const AltHeldContext = createContext(false); /** Props for {@link AltHeldProvider}. */ @@ -25,7 +25,6 @@ type AltHeldProviderProps = Readonly<{ * @param props - Component props. * @param props.value - Whether the Alt key is currently held. * @param props.children - The subtree. - * @returns The children wrapped in the context provider. */ export function AltHeldProvider({ value, children }: AltHeldProviderProps) { return {children}; @@ -34,8 +33,6 @@ export function AltHeldProvider({ value, children }: AltHeldProviderProps) { /** * Reads whether the Alt key is currently held, falling back to `false` outside a provider so leaf * components can be unit-tested without wiring one. - * - * @returns `true` while Alt is held, `false` otherwise. */ export function useAltHeldValue(): boolean { return useContext(AltHeldContext); diff --git a/src/components/AnalysisStore.tsx b/src/components/AnalysisStore.tsx index b4eb01de..6b82be19 100644 --- a/src/components/AnalysisStore.tsx +++ b/src/components/AnalysisStore.tsx @@ -1,4 +1,3 @@ -/** @file Analysis store backed by Redux Toolkit with per-token subscriptions via `useSelector`. */ import type { MorphemeAnalysis, PhraseAnalysisLink, @@ -128,7 +127,6 @@ type AnalysisStoreProviderProps = Readonly<{ * uncommitted text, so the caller can show the unsaved indicator while the user types * @param props.showSuggestions - When true, un-approved tokens render the engine's derived * suggestion with accept / promote affordances - * @returns A context provider wrapping the subtree */ export function AnalysisStoreProvider({ children, @@ -212,7 +210,6 @@ export function AnalysisStoreProvider({ * called outside a provider. Centralizes the guard every analysis hook shares. * * @param hookName - Name of the calling hook, used in the thrown error message. - * @returns The provider's {@link CallbackRefs}. * @throws When called outside an {@link AnalysisStoreProvider}. */ function useRequiredCallbacks(hookName: string): CallbackRefs { @@ -227,7 +224,6 @@ function useRequiredCallbacks(hookName: string): CallbackRefs { * `onSave`. Factors out the dispatch-then-save pattern every write hook repeats. * * @param hookName - Name of the calling hook, used in the guard's error message. - * @returns The provider callbacks, the typed `dispatch`, and a stable `save` callback. * @throws When called outside an {@link AnalysisStoreProvider}. */ function useAnalysisSave(hookName: string): { @@ -392,7 +388,6 @@ export function useMorphemeResetLosesGlosses(tokenRef: string): boolean { /** * Returns the active BCP 47 analysis-language tag from the nearest {@link AnalysisStoreProvider}. * - * @returns The analysis language string. * @throws When called outside an {@link AnalysisStoreProvider}. */ export function useAnalysisLanguage(): string { @@ -405,7 +400,6 @@ export function useAnalysisLanguage(): string { * Returns the current `TextAnalysis` snapshot, re-rendering on every analysis change. Intended for * components that need the full analysis (e.g. an analysis-selection popup). * - * @returns The current `TextAnalysis` from the nearest {@link AnalysisStoreProvider}. * @throws When called outside an {@link AnalysisStoreProvider}. */ export function useAnalysis(): TextAnalysis { @@ -420,7 +414,6 @@ export function useAnalysis(): TextAnalysis { * rewrites the others. Commits immediately, invoking `onSave` and the optional (test-only) * `onGlossChange` spy. * - * @returns A function `(tokenRef, surfaceText, value) => void`. * @throws When called outside an {@link AnalysisStoreProvider}. */ export function useGlossDispatch(): (tokenRef: string, surfaceText: string, value: string) => void { @@ -434,9 +427,6 @@ export function useGlossDispatch(): (tokenRef: string, surfaceText: string, valu * adds an approved link to the existing payload (raising its frequency), it does not rewrite the * shared content, so no other token's gloss changes. * - * @returns A function `(tokenRef, surfaceText, analysisId) => void`, where `analysisId` is the - * payload to approve and `surfaceText` is this token's surface text, snapshotted on the new - * link. * @throws When called outside an {@link AnalysisStoreProvider}. */ export function useApproveAnalysisDispatch(): ( @@ -461,9 +451,6 @@ export function useApproveAnalysisDispatch(): ( * re-segmenting, so editing one token's breakdown never rewrites the others. Commits immediately * and triggers `onSave`. * - * @returns A function `(tokenRef, surfaceText, forms, writingSystem) => void`, where - * `writingSystem` is the BCP 47 tag of the token's surface text (`Token.writingSystem`), stored - * on each morpheme as the writing system of its form. * @throws When called outside an {@link AnalysisStoreProvider}. */ export function useMorphemeBreakdownDispatch(): ( @@ -489,7 +476,6 @@ export function useMorphemeBreakdownDispatch(): ( * with no other content — no gloss, POS, features, or lexicon sense reference). Dispatches the * `deleteMorphemes` action and triggers `onSave`. * - * @returns A function `(tokenRef) => void`. * @throws When called outside an {@link AnalysisStoreProvider}. */ export function useMorphemeDeleteDispatch(): (tokenRef: string) => void { @@ -510,7 +496,6 @@ export function useMorphemeDeleteDispatch(): (tokenRef: string) => void { * before writing, so editing one token's morpheme gloss never rewrites the others. Commits * immediately and triggers `onSave`. * - * @returns A function `(tokenRef, morphemeId, value) => void`. * @throws When called outside an {@link AnalysisStoreProvider}. */ export function useMorphemeGlossDispatch(): ( @@ -537,7 +522,6 @@ export function useMorphemeGlossDispatch(): ( * Returns a `Map` from every token ref that belongs to an approved phrase to its * `PhraseAnalysisLink`. Re-renders only when the phrase link map reference changes. * - * @returns The current phrase link map. * @throws When called outside an {@link AnalysisStoreProvider}. */ export function usePhraseLinkMap(): Map { @@ -550,7 +534,6 @@ export function usePhraseLinkMap(): Map { * Returns a `Map` from `analysisId` to the approved `PhraseAnalysisLink` for O(1) phrase lookup by * id. Re-renders only when the phrase link map reference changes. * - * @returns The current phrase-link-by-id map. * @throws When called outside an {@link AnalysisStoreProvider}. */ export function usePhraseLinkByIdMap(): Map { @@ -609,7 +592,6 @@ export function usePhraseGloss(phraseId: string): string { /** * Returns a stable callback that writes a gloss value for the given phrase, then calls `onSave`. * - * @returns A function `(phraseId, value) => void`. * @throws When called outside an {@link AnalysisStoreProvider}. */ export function usePhraseGlossDispatch(): (phraseId: string, value: string) => void { @@ -630,7 +612,6 @@ export type PhraseDispatch = { * Creates a new approved phrase from an ordered list of token snapshots. * * @param tokens - Ordered `TokenSnapshot`s in document order. - * @returns The UUID assigned to the new phrase. */ createPhrase: (tokens: TokenSnapshot[]) => string; /** @@ -668,8 +649,6 @@ export type PhraseDispatch = { * the corresponding Redux action then calls `onSave` with the updated `TextAnalysis`, matching the * pattern of {@link useGlossDispatch}. * - * @returns An object with `createPhrase`, `updatePhrase`, `deletePhrase`, and `mergePhrases` - * functions. * @throws When called outside an {@link AnalysisStoreProvider}. */ export function usePhraseDispatch(): PhraseDispatch { @@ -744,8 +723,6 @@ export function useSegmentFreeTranslation(segmentId: string): string { * `onSave`. Mirrors {@link useGlossDispatch}: a blank value clears the translation and may drop the * now-empty `SegmentAnalysis` record. * - * @returns A function `(segmentId, surfaceText, value) => void`, where `surfaceText` is the - * segment's current baseline text, stored on the `SegmentAnalysis` record. * @throws When called outside an {@link AnalysisStoreProvider}. */ export function useSegmentFreeTranslationDispatch(): ( diff --git a/src/components/ArcOverlay.tsx b/src/components/ArcOverlay.tsx index 1308d6d6..504aee7f 100644 --- a/src/components/ArcOverlay.tsx +++ b/src/components/ArcOverlay.tsx @@ -212,7 +212,6 @@ export function ArcOverlay({ * hover/focus stroke. * * @param arc - The arc path to render. - * @returns The SVG path element. */ const renderArcPath = ({ phraseId, d, splitAfterTokenRef }: ArcPath) => { const effectiveHoveredPhraseId = candidatePhraseIds.has(phraseId) ? phraseId : hoveredPhraseId; @@ -241,7 +240,6 @@ export function ArcOverlay({ * Classifies a phrase into one of three emphasis tiers (see {@link EmphasisTier}). * * @param phraseId - The phrase id to classify. - * @returns The emphasis tier. */ const tierOf = (phraseId: string): EmphasisTier => { if (phraseId === focusedPhraseId) return 'focused'; @@ -266,7 +264,6 @@ export function ArcOverlay({ * tier so its red stroke renders above other arcs. * * @param arc - The arc path to classify. - * @returns The emphasis tier the arc should be painted in. */ const effectiveTierOf = (arc: ArcPath): EmphasisTier => { if (isSplitHovered(arc.phraseId, arc.splitAfterTokenRef) && splitHoveredArc?.kind === 'free') diff --git a/src/components/ContinuousView.tsx b/src/components/ContinuousView.tsx index b27d35b0..f9268516 100644 --- a/src/components/ContinuousView.tsx +++ b/src/components/ContinuousView.tsx @@ -152,7 +152,6 @@ type ContinuousViewProps = Readonly<{ * merges * @param props.wordTokenByRef - Word token ref → token lookup for focus resolution * @param props.viewOptions - Bundled display toggles forwarded to the strip. - * @returns A horizontal phrase strip with previous/next navigation arrows and edge-fade overlays */ export default function ContinuousView({ book, diff --git a/src/components/InterlinearNavContext.tsx b/src/components/InterlinearNavContext.tsx index 890e1dcd..b5134785 100644 --- a/src/components/InterlinearNavContext.tsx +++ b/src/components/InterlinearNavContext.tsx @@ -68,7 +68,6 @@ function areScrRefsEqual(a: SerializedVerseRef, b: SerializedVerseRef): boolean * 1. * * @param ref - The scripture reference to key. - * @returns A `book:chapter:verse` string uniquely identifying the verse. */ export function verseKey(ref: SerializedVerseRef): string { return `${ref.book}:${ref.chapterNum}:${ref.verseNum}`; @@ -139,7 +138,6 @@ export interface InterlinearNav { * misclassify a later external navigation to the un-echoed verse. * * @param ref - The reference whose pending classification to consume. - * @returns `true` if the navigation to `ref` was internal (skip the fade), else `false`. */ consumeInternalNav: (ref: SerializedVerseRef) => boolean; /** The currently active scroll-group ID (`undefined` = unlinked). */ @@ -185,7 +183,6 @@ const InterlinearNavContext = createContext(undefine * @param props.useWebViewScrollGroupScrRef - The PAPI hook exposing the shared scroll-group * reference and its setter; injected by the host (not imported) so it can be stubbed in tests. * @param props.children - The subtree that consumes navigation through {@link useInterlinearNav}. - * @returns The provider wrapping `children`. */ export function InterlinearNavProvider({ useWebViewScrollGroupScrRef, @@ -380,7 +377,6 @@ export function InterlinearNavProvider({ /** * Reads the {@link InterlinearNav} surface from the nearest {@link InterlinearNavProvider}. * - * @returns The navigation surface. * @throws {Error} When called outside an {@link InterlinearNavProvider}. */ export function useInterlinearNav(): InterlinearNav { diff --git a/src/components/Interlinearizer.tsx b/src/components/Interlinearizer.tsx index 3b13f869..623213b1 100644 --- a/src/components/Interlinearizer.tsx +++ b/src/components/Interlinearizer.tsx @@ -117,7 +117,6 @@ type InterlinearizerProps = Readonly<{ * transition modes. * @param props.viewOptions - Bundled display toggles forwarded to the segment list and continuous * views. - * @returns The interlinearizer layout without the provider wrapper. */ function InterlinearizerInner({ book, @@ -473,7 +472,6 @@ function InterlinearizerInner({ * views. * @param props.showSuggestions - When true, un-approved tokens render the engine's derived * suggestion with accept / promote affordances; forwarded to {@link AnalysisStoreProvider}. - * @returns The full interlinearizer layout with optional continuous strip and segment list */ export default function Interlinearizer({ initialAnalysis, diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index 4612ee56..97e2d1f5 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -68,7 +68,6 @@ const UNSAVED_TAB_MARKER = ' ●'; * by the PAPI host * @param props.updateWebViewDefinition - Host-injected callback to update this WebView's * definition; used to toggle the tab's unsaved-changes title marker - * @returns The nav provider wrapping {@link InterlinearizerLoaderInner} */ export default function InterlinearizerLoader({ projectId, @@ -104,8 +103,6 @@ export default function InterlinearizerLoader({ * by the PAPI host * @param props.updateWebViewDefinition - Host-injected callback used to toggle the tab's * unsaved-changes title marker - * @returns The toolbar and either an error/loading state or the fully rendered - * {@link Interlinearizer} */ function InterlinearizerLoaderInner({ projectId, @@ -397,8 +394,6 @@ function InterlinearizerLoaderInner({ * Saves the current draft to the active project. When no project is active there is nothing to * save to yet, so it opens Save As instead. Errors are logged; the backend surfaces the * notification. - * - * @returns A promise that resolves once the save completes or Save As is opened. */ const handleSave = useCallback(async () => { if (!activeProject) { diff --git a/src/components/MorphemeBox.tsx b/src/components/MorphemeBox.tsx index 21191a83..a3da86e8 100644 --- a/src/components/MorphemeBox.tsx +++ b/src/components/MorphemeBox.tsx @@ -1,11 +1,3 @@ -/** - * @file Inline display of an analyzed token's morpheme breakdown, rendered inside {@link TokenChip} - * when the morphology toggle is active and the token has a breakdown. {@link MorphemeBox} boxes - * the breakdown and lays it out as a grid so each morpheme form aligns vertically with its gloss - * field (and, in future, its lexicon link); {@link MorphemeGlossInput} provides a per-morpheme - * gloss field that fills its grid column. The breakdown _editor_ (the popover where forms are - * entered) lives separately in {@link ./MorphemeEditor}. - */ import type { MorphemeAnalysis, Token } from 'interlinearizer'; import { useLocalizedStrings } from '@papi/frontend/react'; import { PopoverAnchor } from 'platform-bible-react'; @@ -21,11 +13,14 @@ const MORPHEME_BOX_STRING_KEYS = [ ] as const satisfies `%${string}%`[]; /** - * Renders an analyzed token's morpheme breakdown as a boxed grid: each grid column is one morpheme, - * with its form on the top row directly above its gloss field on the bottom row, so a morpheme and - * its gloss always share a column (a future lexicon link slots into a third row with the same - * column alignment). The box appears only for tokens that have a breakdown; an unanalyzed token's - * "define breakdown" affordance lives in {@link TokenChip} instead. + * Inline _display_ of an analyzed token's morpheme breakdown, shown inside the token chip when the + * morphology toggle is active. The popover where forms are actually entered lives separately. + * + * The breakdown renders as a boxed grid: each grid column is one morpheme, with its form on the top + * row directly above its gloss field on the bottom row, so a morpheme and its gloss always share a + * column (a future lexicon link slots into a third row with the same column alignment). The box + * appears only for tokens that have a breakdown; an unanalyzed token's "define breakdown" + * affordance lives in {@link TokenChip} instead. * * The whole forms row is a single accessible "edit breakdown" control rather than one labeled * button per morpheme: every form cell opens the same whole-breakdown editor, so per-cell labels @@ -50,7 +45,6 @@ const MORPHEME_BOX_STRING_KEYS = [ * @param props.onGlossFocus - Called when any morpheme gloss input receives focus, so the chip can * report the token as focused; these fields are gloss fields of the same token as the chip's own * gloss input, so focusing one must move the view's focus just as focusing that input does. - * @returns A boxed grid of morpheme forms and their gloss fields, wrapped in a popover anchor. */ export function MorphemeBox({ token, @@ -173,7 +167,6 @@ export function MorphemeBox({ * @param props.column - 1-based grid column the input occupies (shared with the morpheme's form). * @param props.onFocus - Called when the input receives focus, so the containing chip can report * its token as focused. - * @returns A cell-filling text input for the morpheme gloss, placed in the gloss row. */ export function MorphemeGlossInput({ morpheme, diff --git a/src/components/MorphemeEditor.tsx b/src/components/MorphemeEditor.tsx index 7e3bc995..dbb24b82 100644 --- a/src/components/MorphemeEditor.tsx +++ b/src/components/MorphemeEditor.tsx @@ -1,9 +1,3 @@ -/** - * @file The morpheme breakdown editor rendered inside {@link TokenChip} when the morphology toggle - * is active. {@link MorphemeBreakdownPopover} lets the user define or re-split a token's morpheme - * forms. The inline _display_ of the breakdown (the boxed grid of forms and their gloss fields) - * lives separately in {@link ./MorphemeBox}. - */ import { useLocalizedStrings } from '@papi/frontend/react'; import { Button, Input, Label, PopoverContent } from 'platform-bible-react'; import { useId, useRef, useState } from 'react'; @@ -20,10 +14,13 @@ const POPOVER_STRING_KEYS = [ ] as const satisfies `%${string}%`[]; /** - * Inline popover for defining or editing a token's morpheme breakdown. The user types - * space-separated morpheme forms (e.g. "un- believe -able") and commits with Enter, Done, or by - * clicking outside the popover (matching the commit-on-blur behavior of gloss inputs). Cancel and - * Escape dismiss without saving. + * Inline popover for defining or re-splitting a token's morpheme breakdown, shown inside the token + * chip when the morphology toggle is active. This is the _editor_ only; the inline display of a + * breakdown — the boxed grid of forms and their gloss fields — lives separately. + * + * The user types space-separated morpheme forms (e.g. "un- believe -able") and commits with Enter, + * Done, or by clicking outside the popover (matching the commit-on-blur behavior of gloss inputs). + * Cancel and Escape dismiss without saving. * * Committing resolves to one of three outcomes: * @@ -110,7 +107,6 @@ export function MorphemeBreakdownPopover({ * Collapses leading/trailing and repeated internal whitespace to a single space. * * @param s - The string to normalize. - * @returns The string with surrounding whitespace trimmed and internal runs collapsed. */ const normalize = (s: string) => s.trim().replace(/\s+/g, ' '); diff --git a/src/components/PhraseBox.tsx b/src/components/PhraseBox.tsx index fa3cdf71..9c48cb37 100644 --- a/src/components/PhraseBox.tsx +++ b/src/components/PhraseBox.tsx @@ -1,4 +1,3 @@ -/** @file Shared phrase-box wrapper used around word tokens. */ import type { PhraseAnalysisLink, Token } from 'interlinearizer'; import { Trash2 } from 'lucide-react'; import { Button } from 'platform-bible-react'; @@ -29,7 +28,6 @@ import MemoizedTokenLinkIcon from './TokenLinkIcon'; * @param props.disabled - When true, the input is read-only. * @param props.onFocus - Called when the input receives focus; used to center this phrase in the * strip. - * @returns An input element sized to its content. */ function PhraseGlossInput({ phraseId, @@ -160,7 +158,6 @@ type PhraseBoxProps = Readonly<{ * matching chip renders with a destructive border as a preview * @param props.punctuationBetween - Punctuation tokens between adjacent word tokens, in document * order; `punctuationBetween[i]` sits between `tokens[i]` and `tokens[i+1]` - * @returns A bordered inline container */ export function PhraseBox({ isFocused = false, @@ -531,7 +528,6 @@ export function PhraseBox({ * so each token chip is removable via the keyboard as well as by click. * * @param tokenRef - Ref of the token the returned handler removes. - * @returns A keydown event handler for that token's chip. */ const handlePerTokenKeyDown = (tokenRef: string) => (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { diff --git a/src/components/PhraseStripContext.tsx b/src/components/PhraseStripContext.tsx index f8b2fa8c..040b451b 100644 --- a/src/components/PhraseStripContext.tsx +++ b/src/components/PhraseStripContext.tsx @@ -1,14 +1,3 @@ -/** - * @file Render-scoped context shared by the two phrase strips (`SegmentView` and `ContinuousView`). - * - * Holds the values that are identical for every phrase group and link slot within a single strip - * render: the edit-mode context and the hover-preview callbacks. Delivering them via context - * keeps the structural intermediaries (`PhraseGroup`/`PhraseSlot`) from forwarding props they - * never touch, so each remaining prop on them describes something genuinely per-group/per-slot. - * - * Per-instance values (focus, highlight, arc offset, slot geometry) are intentionally **not** here - * — they vary per item and belong at the call site as props. - */ import { createContext, useContext } from 'react'; import type { Dispatch, ReactNode, SetStateAction } from 'react'; import type { PhraseAnalysisLink } from 'interlinearizer'; @@ -17,6 +6,13 @@ import type { PhraseMode } from '../types/phrase-mode'; /** * The stable, strip-wide context shared by every phrase group and link slot in one render. Both * strips build one value per render and wrap their token row in a {@link PhraseStripProvider}. + * + * It holds the values identical across a whole strip render — the edit-mode context and the + * hover-preview callbacks — so the structural intermediaries never forward props they don't touch, + * and each prop that remains on them describes something genuinely per-group or per-slot. + * + * Per-instance values such as focus, highlight, arc offset, and slot geometry are deliberately + * **not** here: they vary per item and belong at the call site as props. */ export type PhraseStripContextValue = Readonly<{ /** Current phrase-interaction mode; controls rendering and click behavior in all leaves. */ @@ -116,7 +112,6 @@ type PhraseStripProviderProps = Readonly<{ * @param props - Component props. * @param props.value - The strip-wide context value. * @param props.children - The strip's token row. - * @returns The children wrapped in the context provider. */ export function PhraseStripProvider({ value, children }: PhraseStripProviderProps) { return {children}; @@ -125,7 +120,6 @@ export function PhraseStripProvider({ value, children }: PhraseStripProviderProp /** * Reads the strip-wide phrase context. Must be called from inside a {@link PhraseStripProvider}. * - * @returns The current {@link PhraseStripContextValue}. * @throws If called outside a {@link PhraseStripProvider}. */ export function usePhraseStripContext(): PhraseStripContextValue { diff --git a/src/components/PhraseStripParts.tsx b/src/components/PhraseStripParts.tsx index 202d2075..7586dfc0 100644 --- a/src/components/PhraseStripParts.tsx +++ b/src/components/PhraseStripParts.tsx @@ -1,4 +1,3 @@ -/** @file Shared render parts for the two phrase strips (SegmentView and ContinuousView). */ import type { Token } from 'interlinearizer'; import { Merge, Split } from 'lucide-react'; import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'platform-bible-react'; @@ -41,7 +40,6 @@ type BoundaryButtonProps = Readonly<{ * @param props.testId - `data-testid` for the button element. * @param props.icon - The icon rendered inside the button. * @param props.action - The boundary edit to run on click. - * @returns The styled boundary button. */ function BoundaryButton({ label, title, testId, icon, action }: BoundaryButtonProps) { return ( @@ -220,7 +218,6 @@ type SplitMarkerProps = Readonly<{ * @param props - Component props. * @param props.label - Accessible label and tooltip. * @param props.onSplit - Runs the split; called only for a genuine Alt+click. - * @returns The split-marker span. */ function SplitMarker({ label, onSplit }: SplitMarkerProps) { return ( @@ -479,7 +476,6 @@ type PhraseGroupProps = Readonly<{ * @param props.setHoveredGroupKey - Called with groupKey on pointer enter/leave * @param props.onFocusPhrase - Called with groupKey when this group's gloss input gains focus * @param props.groupRef - Optional DOM-ref callback for the wrapper span - * @returns A wrapper span containing the phrase box. */ export const MemoizedPhraseGroup = memo(function PhraseGroup({ group, @@ -548,7 +544,6 @@ export const MemoizedPhraseGroup = memo(function PhraseGroup({ * @param props - Component props. * @param props.label - The verse label to display (verbatim number, or `chapter:number` at a * chapter transition). - * @returns A superscript element carrying the verse label. */ export function VerseSuperscript({ label }: Readonly<{ label: string }>) { return ( @@ -646,7 +641,6 @@ type PhraseStripProps = Readonly<{ * @param props.onHoverPhrase - Phrase-box enter/leave callback * @param props.setHoveredGroupKey - Hovered-group-key setter * @param props.onFocusPhrase - Gloss-input focus callback, by group key - * @returns The strip's ordered slot and group elements. */ export function PhraseStrip({ items, diff --git a/src/components/SegmentFreeTranslationInput.tsx b/src/components/SegmentFreeTranslationInput.tsx index fe35358c..e83711da 100644 --- a/src/components/SegmentFreeTranslationInput.tsx +++ b/src/components/SegmentFreeTranslationInput.tsx @@ -1,4 +1,3 @@ -/** @file Segment-level free-translation input rendered by the segment view. */ import { useLocalizedStrings } from '@papi/frontend/react'; import { useEffect, useState } from 'react'; import { @@ -28,7 +27,6 @@ const STRING_KEYS = [ * record so it can detect drift if the baseline changes later. * @param props.onFocus - Called when the input receives focus; used by `SegmentView` to make the * segment active. - * @returns A full-width text input. */ export default function SegmentFreeTranslationInput({ segmentId, diff --git a/src/components/SegmentListView.tsx b/src/components/SegmentListView.tsx index 0aaba628..d7a9a3c4 100644 --- a/src/components/SegmentListView.tsx +++ b/src/components/SegmentListView.tsx @@ -184,7 +184,6 @@ type SegmentListViewProps = Readonly<{ * @param props.tokenSegmentMap - Token ref → segment id lookup. * @param props.tokenDocOrder - Word token ref → flat book-level index. * @param props.wordTokenByRef - Word token ref → token lookup for the whole book. - * @returns The scrollable segment list with its fade wrapper, sentinels, and locate button. */ export default function SegmentListView({ book, diff --git a/src/components/SegmentView.tsx b/src/components/SegmentView.tsx index 09ef5b64..99c0ac70 100644 --- a/src/components/SegmentView.tsx +++ b/src/components/SegmentView.tsx @@ -91,7 +91,6 @@ type BaselinePiece = * @param verseStartLabelByOffset - Char offset → verse superscript label. * @param splitGapByOffset - Anchor char offset → split anchor ref; the gap slice ending at that * offset becomes the splittable gap. - * @returns The ordered baseline pieces. */ function buildBaselinePieces( segment: Segment, @@ -137,7 +136,6 @@ function buildBaselinePieces( * @param props - Component props. * @param props.label - The verse-range label to render, or `undefined` to render an empty gutter * (reserving the column width so the content stays aligned across cards). - * @returns The fixed-width gutter cell. */ function SegmentGutter({ label }: { label: string | undefined }) { return ( @@ -175,7 +173,6 @@ type BaselineSplitGapProps = Readonly<{ * @param props.splitRef - The split anchor ref an Alt+click dispatches. * @param props.splitLabel - Localized tooltip for the split affordance. * @param props.onSplit - Dispatches the split, gated on the Alt key inside the handler. - * @returns The gap span — plain text at rest, an Alt-clickable marker while Alt is held. */ function BaselineSplitGap({ text, splitRef, splitLabel, onSplit }: BaselineSplitGapProps) { const altHeld = useAltHeldValue(); @@ -297,8 +294,6 @@ type SegmentViewProps = Readonly<{ * @param props.wordTokenByRef - Word token ref → token lookup; used to resolve focus context. * @param props.viewOptions - Bundled display toggles; `showFreeTranslation` gates the * free-translation input, while the rest pass through to the phrase strip context. - * @returns A div containing the segment content (baseline text or token chips) with either inline - * verse superscripts or a left verse-range gutter, depending on `viewOptions.showVerseGutter`. */ export function SegmentView({ displayMode, diff --git a/src/components/SegmentationStore.tsx b/src/components/SegmentationStore.tsx index bdc7cfe6..0c056eee 100644 --- a/src/components/SegmentationStore.tsx +++ b/src/components/SegmentationStore.tsx @@ -1,17 +1,15 @@ -/** - * @file Render-scoped context exposing segment-boundary editing to the deep leaves that trigger it - * (the cross-segment link icon and the merge/split boundary controls). - * - * The {@link SegmentationDispatch} closes over the draft's current boundary delta and the original - * verse-tokenized book, applying the pure transforms in `utils/segmentation.ts` and auto-saving - * the result. Boundary edits flow draft → re-segmentation → new `book.segments`, so consumers - * only need to call a dispatch method; they never see the delta itself. - */ import type { Segment } from 'interlinearizer'; import { createContext, useContext } from 'react'; import type { ReactNode } from 'react'; -/** The boundary-editing operations available to leaf controls. Each one auto-saves the result. */ +/** + * The boundary-editing operations available to the deep leaves that trigger them — the + * cross-segment link icon and the merge/split boundary controls. Each one auto-saves its result. + * + * Each operation closes over the draft's current boundary delta and the original verse-tokenized + * book. Edits flow from draft through re-segmentation to a new segment list, so consumers only ever + * call a method here and never see the delta itself. + */ export type SegmentationDispatch = Readonly<{ /** * Merges the segment that begins at `secondSegmentStartRef` into the segment before it. @@ -99,7 +97,6 @@ type SegmentationProviderProps = Readonly<{ * @param props - Component props. * @param props.value - The segmentation context value. * @param props.children - The subtree. - * @returns The children wrapped in the context provider. */ export function SegmentationProvider({ value, children }: SegmentationProviderProps) { return {children}; diff --git a/src/components/SuggestionDropdown.tsx b/src/components/SuggestionDropdown.tsx index fe8ef052..ae362701 100644 --- a/src/components/SuggestionDropdown.tsx +++ b/src/components/SuggestionDropdown.tsx @@ -1,10 +1,3 @@ -/** - * @file The pop-down listbox a {@link TokenChip} shows while its gloss input is the active combobox. - * Rendering and positioning live here; the combobox state (open, active row, keyboard) is owned - * by the chip, which drives this purely through props. Portaled to `document.body` so it escapes - * the clipping and stacking of the interlinear view's scroll viewports and token-row stacking - * contexts. - */ import { useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { STATUS_TEXT_COLOR_CLASS } from '../types/status-colors'; @@ -39,14 +32,17 @@ type SuggestionDropdownProps = Readonly<{ }>; /** - * Renders the portaled suggestion listbox for a token's gloss combobox. Each row is colored and - * labeled by its own `status` — `'suggested'` (blue, "accept") or `'candidate'` (grey, "promote") — - * carried on the entry rather than inferred from position, so a dropped blank-in-language pick can - * never leave a candidate masquerading as the accept row. Each row suppresses its mouse-down - * default so clicking it never blurs the input. + * The pop-down listbox a token chip shows while its gloss input is the active combobox. Rendering + * and positioning live here; the combobox state — open, active row, keyboard — is owned by the + * chip, which drives this purely through props. Portaled to the document body so it escapes the + * clipping and stacking of the interlinear view's scroll viewports and token rows. + * + * Each row is colored and labeled by its own `status` — `'suggested'` (blue, "accept") or + * `'candidate'` (grey, "promote") — carried on the entry rather than inferred from position, so a + * dropped blank-in-language pick can never leave a candidate masquerading as the accept row. Each + * row suppresses its mouse-down default so clicking it never blurs the input. * * @param props - Component props (see {@link SuggestionDropdownProps}). - * @returns A `document.body` portal containing the listbox, positioned under the anchor input. */ export default function SuggestionDropdown({ anchorRef, diff --git a/src/components/TokenChip.tsx b/src/components/TokenChip.tsx index 210782e5..9a9b194a 100644 --- a/src/components/TokenChip.tsx +++ b/src/components/TokenChip.tsx @@ -63,7 +63,6 @@ const STRING_KEYS = [ * @param props.glossPlaceholder - Placeholder for the gloss input, resolved once per strip and * passed down (see `PhraseStripContextValue.glossPlaceholder`). Passed as a prop rather than read * from context so the chip's memoization keeps shielding it from unrelated context churn. - * @returns A styled label containing the surface text, optionally morpheme rows, and a gloss input. */ export function TokenChip({ token, @@ -234,7 +233,6 @@ export function TokenChip({ * assistive tech follows the keyboard-highlighted row. * * @param index - The row's index in {@link glossedRanked}. - * @returns The option element id. */ const optionId = useCallback((index: number) => `${listboxId}-opt-${index}`, [listboxId]); @@ -552,7 +550,6 @@ export function TokenChip({ * * @param props - Component props * @param props.token - The non-word token to render. - * @returns A muted inline span. */ export function InertTokenChip({ token }: Readonly<{ token: Token }>) { return ( diff --git a/src/components/TokenLinkIcon.tsx b/src/components/TokenLinkIcon.tsx index ed3a9bce..ca08c213 100644 --- a/src/components/TokenLinkIcon.tsx +++ b/src/components/TokenLinkIcon.tsx @@ -1,4 +1,3 @@ -/** @file Inline link / unlink icon rendered between adjacent word token groups. */ import type { PhraseAnalysisLink, Token } from 'interlinearizer'; import { Link2, Unlink2 } from 'lucide-react'; import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'platform-bible-react'; diff --git a/src/components/controls/EditPhraseControls.tsx b/src/components/controls/EditPhraseControls.tsx index b432e00c..90cbb6dc 100644 --- a/src/components/controls/EditPhraseControls.tsx +++ b/src/components/controls/EditPhraseControls.tsx @@ -1,4 +1,3 @@ -/** @file Done/Cancel controls shown in the confirm bar while editing a phrase. */ import { Button } from 'platform-bible-react'; import type { Dispatch, SetStateAction } from 'react'; import type { PhraseMode } from '../../types/phrase-mode'; @@ -25,8 +24,6 @@ type EditPhraseControlsProps = Readonly<{ * @param props - Component props * @param props.phraseMode - The current edit-mode phrase mode value * @param props.setPhraseMode - Setter used to commit or revert the edit - * @returns Done / Cancel buttons laid out inline; the host bar supplies the surrounding container - * chrome. */ export default function EditPhraseControls({ phraseMode, setPhraseMode }: EditPhraseControlsProps) { const phraseLinkById = usePhraseLinkByIdMap(); diff --git a/src/components/controls/ScriptureNavControls.tsx b/src/components/controls/ScriptureNavControls.tsx index ddd63a1d..0f24c8e6 100644 --- a/src/components/controls/ScriptureNavControls.tsx +++ b/src/components/controls/ScriptureNavControls.tsx @@ -33,7 +33,6 @@ type ScriptureNavControlsProps = Pick`. * @param props.value - Value string shown as `
`. * @param props.mono - When true, renders the value in a monospace font. - * @returns A `
`/`
` pair. */ function MetadataRow({ label, diff --git a/src/components/modals/ProjectModals.tsx b/src/components/modals/ProjectModals.tsx index ecb2069d..fa51f5bf 100644 --- a/src/components/modals/ProjectModals.tsx +++ b/src/components/modals/ProjectModals.tsx @@ -192,7 +192,6 @@ export default function ProjectModals({ * and notifies on failure, leaving the draft untouched. * * @param project - The project summary the user chose to open. - * @returns A promise that resolves once the draft is loaded or the failure has been handled. */ const openProject = useCallback( async (project: InterlinearProjectSummary) => { @@ -251,9 +250,6 @@ export default function ProjectModals({ * so the catch block only needs to log, matching {@link handleSaveAsNew}. * * @param config - The configuration collected by the New dialog. - * @returns `true` if the project was created and persisted successfully; `false` otherwise. - * Callers are responsible for closing the modal on success and keeping it open on failure so - * the user can retry without re-entering their inputs. */ const createAndPersistProject = useCallback( async (config: CreateDraftConfig): Promise => { @@ -314,7 +310,6 @@ export default function ProjectModals({ * open so the user can retry without re-entering their inputs. * * @param config - The configuration collected by the New dialog. - * @returns A promise that resolves once the creation settles or was ignored as re-entrant. */ const createDraftAndClose = useCallback( async (config: CreateDraftConfig) => { @@ -381,7 +376,6 @@ export default function ProjectModals({ * * @param name - Trimmed project name, or `undefined`. * @param description - Trimmed project description, or `undefined`. - * @returns A promise that resolves once the save completes or the failure has been handled. */ const handleSaveAsNew = useCallback( async (name?: string, description?: string) => { @@ -438,7 +432,6 @@ export default function ProjectModals({ * error notification; here we only log. * * @param project - The existing project to overwrite. - * @returns A promise that resolves once the overwrite completes or the failure has been handled. */ const handleOverwrite = useCallback( async (project: InterlinearProjectSummary) => { diff --git a/src/components/modals/ProjectSummaryDetails.tsx b/src/components/modals/ProjectSummaryDetails.tsx index 5f52d8d6..ca521cd5 100644 --- a/src/components/modals/ProjectSummaryDetails.tsx +++ b/src/components/modals/ProjectSummaryDetails.tsx @@ -18,7 +18,6 @@ import { formatModified } from '../../utils/project-summary-format'; * @param props.modifiedPrefix - Localized `"Modified"` label preceding the formatted date. * @param props.project - The project summary to describe. * @param props.unnamedLabel - Localized fallback label rendered when the project has no name. - * @returns The detail block. */ export function ProjectSummaryDetails({ activeBadgeLabel, diff --git a/src/components/modals/UnlinkPhraseConfirm.tsx b/src/components/modals/UnlinkPhraseConfirm.tsx index 0bae3ff9..bd10de7a 100644 --- a/src/components/modals/UnlinkPhraseConfirm.tsx +++ b/src/components/modals/UnlinkPhraseConfirm.tsx @@ -1,4 +1,3 @@ -/** @file Confirmation controls shown in the confirm bar for unlinking (deleting) a phrase. */ import { Button } from 'platform-bible-react'; import type { Dispatch, SetStateAction } from 'react'; import type { PhraseMode } from '../../types/phrase-mode'; @@ -20,8 +19,6 @@ type UnlinkPhraseConfirmProps = Readonly<{ * @param props - Component props * @param props.phraseId - ID of the phrase to delete on confirmation * @param props.setPhraseMode - Setter used to exit confirm-unlink mode - * @returns A label and Confirm / Cancel buttons laid out inline; the host bar supplies the - * surrounding container chrome. */ export default function UnlinkPhraseConfirm({ phraseId, setPhraseMode }: UnlinkPhraseConfirmProps) { const { deletePhrase } = usePhraseDispatch(); diff --git a/src/main.ts b/src/main.ts index 8d058f90..4a806c49 100644 --- a/src/main.ts +++ b/src/main.ts @@ -35,7 +35,6 @@ const mainWebViewProvider: IWebViewProvider = { * * @param savedWebView - Platform-provided definition (webViewType, etc.). * @param openWebViewOptions - Options passed by the caller; may include a projectId to link. - * @returns WebView definition with title, content, and styles. * @throws {TypeError} When savedWebView.webViewType is not the Interlinearizer type. */ async getWebView( @@ -132,7 +131,6 @@ async function openInterlinearizerForWebView(webViewId?: string): Promise { openWebViewsByProject.clear(); diff --git a/src/parsers/papi/usjBookExtractor.ts b/src/parsers/papi/usjBookExtractor.ts index 937e7cac..c01ca3aa 100644 --- a/src/parsers/papi/usjBookExtractor.ts +++ b/src/parsers/papi/usjBookExtractor.ts @@ -1,5 +1,3 @@ -/** @file Extracts {@link RawBook} from a papi USJ book response. */ - /** Plain text of a single verse extracted from a USJ document, ready to be tokenized. */ export interface RawVerse { /** SID from the USJ verse marker, e.g. `"GEN 1:1"`. Parsed into `Segment.startRef` / `endRef`. */ From 0bff695f5e5d264ec9e47e16c7f2975018459526 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Tue, 28 Jul 2026 17:19:06 -0600 Subject: [PATCH 11/12] Apply comment rules to tests and mocks Drop @param/@returns that restate stub signatures and the 'Unit tests for X.ts' @file banners that only name the file under test. Keep the mock @file blocks that explain why each mock exists (ESM-in-Jest, types-only modules), since those files have no single symbol to carry the rationale. --- __mocks__/lucide-react.tsx | 33 ----- __mocks__/papi-frontend-react.ts | 8 - __mocks__/platform-bible-react.tsx | 138 ------------------ __mocks__/platform-bible-utils.ts | 7 - .../components/AltHeldContext.test.tsx | 7 +- .../components/AnalysisStore.test.tsx | 102 ++----------- src/__tests__/components/ArcOverlay.test.tsx | 9 +- .../components/ContinuousView.test.tsx | 32 +--- .../components/InterlinearNavContext.test.tsx | 12 +- .../components/Interlinearizer.test.tsx | 114 ++------------- .../components/InterlinearizerLoader.test.tsx | 43 +----- src/__tests__/components/MorphemeBox.test.tsx | 4 - .../components/MorphemeEditor.test.tsx | 8 - src/__tests__/components/PhraseBox.test.tsx | 42 +----- .../components/PhraseStripContext.test.tsx | 3 - .../components/PhraseStripParts.test.tsx | 30 +--- src/__tests__/components/SegmentView.test.tsx | 7 - .../components/SegmentationStore.test.tsx | 1 - .../components/TokenChip.suggestions.test.tsx | 35 +---- src/__tests__/components/TokenChip.test.tsx | 13 +- .../components/TokenLinkIcon.test.tsx | 11 -- .../controls/EditPhraseControls.test.tsx | 12 +- .../controls/ScriptureNavControls.test.tsx | 1 - .../controls/ViewOptionsDropdown.test.tsx | 1 - .../modals/CreateProjectModal.test.tsx | 1 - .../modals/DiscardDraftConfirm.test.tsx | 1 - .../modals/ProjectMetadataModal.test.tsx | 1 - .../components/modals/ProjectModals.test.tsx | 9 -- .../modals/ProjectSummaryDetails.test.tsx | 1 - .../modals/SaveAsProjectModal.test.tsx | 1 - .../SelectInterlinearProjectModal.test.tsx | 1 - .../modals/UnlinkPhraseConfirm.test.tsx | 1 - .../components/modals/WipeModal.test.tsx | 1 - src/__tests__/components/test-helpers.tsx | 2 - src/__tests__/hooks/useAltHeld.test.ts | 1 - src/__tests__/hooks/useArcPaths.test.ts | 13 +- src/__tests__/hooks/useDraftProject.test.ts | 16 +- .../hooks/useInterlinearizerBookData.test.ts | 7 +- .../hooks/useOptimisticBooleanSetting.test.ts | 7 +- .../hooks/usePhraseHoverState.test.ts | 1 - src/__tests__/hooks/useSegmentWindow.test.ts | 35 ----- .../interlinearizer.web-view.test.tsx | 1 - src/__tests__/main.test.ts | 29 +--- .../parsers/papi/bookTokenizer.test.ts | 4 - .../parsers/papi/resegmentBook.test.ts | 4 - .../parsers/papi/usjBookExtractor.test.ts | 1 - .../parsers/pt9/interlinearXmlParser.test.ts | 1 - src/__tests__/services/projectStorage.test.ts | 13 +- src/__tests__/store/analysisSlice.test.ts | 32 +--- src/__tests__/test-helpers.ts | 23 --- src/__tests__/utils/analysis-book.test.ts | 23 +-- src/__tests__/utils/analysis-identity.test.ts | 4 - src/__tests__/utils/localized-strings.test.ts | 1 - src/__tests__/utils/multi-string.test.ts | 1 - src/__tests__/utils/phrase-arc.test.ts | 22 --- .../utils/project-summary-format.test.ts | 1 - src/__tests__/utils/segment-labels.test.ts | 5 - src/__tests__/utils/segmentation.test.ts | 8 +- src/__tests__/utils/split-anchor.test.ts | 9 -- src/__tests__/utils/suggestion-engine.test.ts | 10 +- src/__tests__/utils/token-layout.test.ts | 21 +-- src/__tests__/utils/verse-ref.test.ts | 7 - .../utils/verse-superscripts.test.ts | 34 +---- src/components/__mocks__/AnalysisStore.tsx | 36 ----- src/components/__mocks__/TokenChip.tsx | 5 - 65 files changed, 57 insertions(+), 1010 deletions(-) diff --git a/__mocks__/lucide-react.tsx b/__mocks__/lucide-react.tsx index 5cf52279..284c86e7 100644 --- a/__mocks__/lucide-react.tsx +++ b/__mocks__/lucide-react.tsx @@ -7,9 +7,6 @@ import type { ReactElement } from 'react'; /** * Stub for the LocateFixed icon; renders a bare SVG element so tests can locate the icon by test * ID. - * - * @param props - SVG props forwarded from the component, including optional className. - * @returns A ReactElement SVG element used as a locate-fixed icon stub in tests. */ export function LocateFixed(props: Readonly<{ className?: string }>): ReactElement { return ; @@ -17,9 +14,6 @@ export function LocateFixed(props: Readonly<{ className?: string }>): ReactEleme /** * Stub for the Info icon. - * - * @param props - SVG props forwarded from the component. - * @returns A ReactElement SVG element used as an info icon stub in tests. */ export function Info(props: Readonly<{ size?: number; className?: string }>): ReactElement { return ; @@ -27,9 +21,6 @@ export function Info(props: Readonly<{ size?: number; className?: string }>): Re /** * Stub for the Trash2 icon. - * - * @param props - SVG props forwarded from the component. - * @returns A ReactElement SVG element used as a trash icon stub in tests. */ export function Trash2(props: Readonly<{ size?: number; className?: string }>): ReactElement { return ; @@ -37,9 +28,6 @@ export function Trash2(props: Readonly<{ size?: number; className?: string }>): /** * Stub for the X icon. - * - * @param props - SVG props forwarded from the component. - * @returns A ReactElement SVG element used as an X icon stub in tests. */ export function X(props: Readonly<{ size?: number; className?: string }>): ReactElement { return ; @@ -47,9 +35,6 @@ export function X(props: Readonly<{ size?: number; className?: string }>): React /** * Stub for the Link2 (link) icon used by the between-token link button. - * - * @param props - SVG props forwarded from the component. - * @returns A ReactElement SVG element used as a link icon stub in tests. */ export function Link2(props: Readonly<{ size?: number; className?: string }>): ReactElement { return ; @@ -57,9 +42,6 @@ export function Link2(props: Readonly<{ size?: number; className?: string }>): R /** * Stub for the Link2Off (unlink) icon used by the arc-split button. - * - * @param props - SVG props forwarded from the component. - * @returns A ReactElement SVG element used as an unlink icon stub in tests. */ export function Link2Off(props: Readonly<{ size?: number; className?: string }>): ReactElement { return ; @@ -68,9 +50,6 @@ export function Link2Off(props: Readonly<{ size?: number; className?: string }>) /** * Stub for the Unlink2 icon used by the between-token unlink button; a broken-chain glyph whose * chain sits at the same vertical position as Link2 so their button centers line up. - * - * @param props - SVG props forwarded from the component. - * @returns A ReactElement SVG element used as an unlink icon stub in tests. */ export function Unlink2(props: Readonly<{ size?: number; className?: string }>): ReactElement { return ; @@ -78,9 +57,6 @@ export function Unlink2(props: Readonly<{ size?: number; className?: string }>): /** * Stub for the Settings gear icon used by the view-options dropdown button. - * - * @param props - SVG props forwarded from the component. - * @returns A ReactElement SVG element used as a settings icon stub in tests. */ export function Settings(props: Readonly<{ size?: number; className?: string }>): ReactElement { return ; @@ -88,9 +64,6 @@ export function Settings(props: Readonly<{ size?: number; className?: string }>) /** * Stub for the Plus icon used by the token chip's suggestion-dropdown toggle. - * - * @param props - SVG props forwarded from the component. - * @returns A ReactElement SVG element used as a plus icon stub in tests. */ export function Plus(props: Readonly<{ size?: number; className?: string }>): ReactElement { return ; @@ -99,9 +72,6 @@ export function Plus(props: Readonly<{ size?: number; className?: string }>): Re * Stub for the Merge icon used by both merge controls (the row-gap merge in the segment list and the * cross-segment merge in the continuous strip). A single Y-join glyph, deliberately a different * shape from the split marker's arrows-apart glyph. - * - * @param props - SVG props forwarded from the component. - * @returns A ReactElement SVG element used as a merge icon stub in tests. */ export function Merge(props: Readonly<{ className?: string }>): ReactElement { return ; @@ -110,9 +80,6 @@ export function Merge(props: Readonly<{ className?: string }>): ReactElement { /** * Stub for the Split icon used by the Alt-gated split markers (token-chip and baseline-text). One * stroke diverging into two — the mirror of the `Merge` glyph's join. - * - * @param props - SVG props forwarded from the component. - * @returns A ReactElement SVG element used as a split-marker icon stub in tests. */ export function Split(props: Readonly<{ size?: number; className?: string }>): ReactElement { return ; diff --git a/__mocks__/papi-frontend-react.ts b/__mocks__/papi-frontend-react.ts index 042c4e09..181102cc 100644 --- a/__mocks__/papi-frontend-react.ts +++ b/__mocks__/papi-frontend-react.ts @@ -35,9 +35,6 @@ const useProjectData = jest.fn(() => * Mock for `useProjectSetting`. Returns `[defaultState, setSetting, resetSetting, isLoading]`, * passing `defaultState` through unchanged so callers receive a predictable initial value. * - * @param _projectDataProviderSource - Ignored project data provider source. - * @param _key - Ignored setting key. - * @param defaultState - Value surfaced as the current setting state. * @returns Tuple of `[defaultState, jest.fn(), jest.fn(), false]`. */ const useProjectSetting = jest @@ -53,7 +50,6 @@ const useProjectSetting = jest * Mock for `useLocalizedStrings`. Maps each requested key to itself so tests receive a * predictable `Record` without a real localization service. * - * @param keys - BCP47-style string keys to resolve. * @returns Tuple of `[record, isLoading]` where every key maps to itself and `isLoading` is * `false`. */ @@ -66,8 +62,6 @@ const useLocalizedStrings = jest.fn().mockImplementation((keys: string[]) => [ * Mock for `useSetting`. Returns `[defaultState, setSetting, resetSetting, false]`, passing * `defaultState` through unchanged so callers receive a predictable initial value. * - * @param _key - Ignored setting key. - * @param defaultState - Value surfaced as the current setting state. * @returns Tuple of `[defaultState, jest.fn(), jest.fn(), false]`. */ const useSetting = jest @@ -82,8 +76,6 @@ const useSetting = jest /** * Mock for `useRecentScriptureRefs`. Returns an empty history and a no-op `addRecentScriptureRef` * so components that display recent references render without errors. - * - * @returns Object with `recentScriptureRefs` (empty array) and `addRecentScriptureRef` (jest spy). */ const useRecentScriptureRefs = jest .fn() diff --git a/__mocks__/platform-bible-react.tsx b/__mocks__/platform-bible-react.tsx index 612fd4d1..4dc1e730 100644 --- a/__mocks__/platform-bible-react.tsx +++ b/__mocks__/platform-bible-react.tsx @@ -115,15 +115,6 @@ export const MOCK_WIPE_MENU_ITEM: MenuItemContainingCommand = { /** * Stub toolbar that renders project-menu and view-info buttons using sentinel menu items so tests * can trigger menu commands without a real toolbar implementation. - * - * @param props - Component props. - * @param props.startAreaChildren - Content rendered in the start slot. - * @param props.endAreaChildren - Content rendered in the end slot. - * @param props.onSelectProjectMenuItem - Called with a sentinel item when a project-menu button is - * clicked (select-project, new-project, or view-project-info buttons). - * @param props.onSelectViewInfoMenuItem - Called with a generic sentinel item when the view-info - * button is clicked. - * @returns A div with `data-testid="tab-toolbar"` containing the rendered buttons. */ export function TabToolbar({ startAreaChildren, @@ -224,12 +215,6 @@ export function TabToolbar({ /** * Stub scroll-group selector rendered as a native `` element. */ export function ScrollGroupSelector({ availableScrollGroupIds, @@ -265,8 +250,6 @@ export function ScrollGroupSelector({ * className without `size-`, or a numeric `size` prop without a `size-` className. Production * silently overrides all of these (AGENTS.md § Components); jsdom can't reproduce the visual * regression, so this fails loudly instead of letting it pass as a green test. - * - * @param node - A `Button` child (or subtree) to check. */ function assertNoOversizedIconClassName(node: ReactNode): void { if (Array.isArray(node)) { @@ -306,30 +289,6 @@ function assertNoOversizedIconClassName(node: ReactNode): void { * through {@link assertNoOversizedIconClassName}, which throws on the one sizing regression this * stub can catch regardless of variant/size. The mouse/keyboard handlers and `tabIndex`/`style` are * forwarded so migrated icon buttons keep their hover-preview and focus-skipping behavior under test. - * - * @param props - Component props. - * @param props.children - Button content. - * @param props.onClick - Click handler. - * @param props.onMouseEnter - Pointer-enter handler (icon buttons use it to preview hover state). - * @param props.onMouseLeave - Pointer-leave handler (clears the previewed hover state). - * @param props.onMouseDown - Pointer-down handler (e.g. to preventDefault and keep input focus). - * @param props.type - HTML button type attribute. - * @param props.className - CSS class names. - * @param props.style - Inline styles (icon buttons use it for absolute positioning). - * @param props.title - Native tooltip text; the {@link Tooltip} stub clones its trigger child with - * this prop, so a `Button` used as a `TooltipTrigger asChild` child must forward it. - * @param props.disabled - Whether the button is disabled. - * @param props.tabIndex - Tab order; icon buttons set `-1` to skip them in keyboard navigation. - * @param props.variant - Ignored styling variant. - * @param props.size - Ignored size variant. - * @param props['aria-label'] - Accessible label. - * @param props['aria-expanded'] - Expanded state for popup triggers. - * @param props['aria-haspopup'] - Haspopup attribute. - * @param props['aria-controls'] - ID of the element the button controls (e.g. a listbox popup). - * @param props['aria-hidden'] - Hides the button from the accessibility tree when it is inert. - * @param props['data-testid'] - Test identifier. - * @param ref - Forwarded ref to the underlying button element. - * @returns A native `