Skip to content

OTTER-737: count characters instead of words in all capped input fields - #991

Merged
therealmarv merged 12 commits into
OTTER-691-proposal-page-refactorfrom
OTTER-737-character-limits
Aug 26, 2026
Merged

OTTER-737: count characters instead of words in all capped input fields#991
therealmarv merged 12 commits into
OTTER-691-proposal-page-refactorfrom
OTTER-737-character-limits

Conversation

@therealmarv

@therealmarv therealmarv commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

see OTTER-737

Summary

All input fields with a limit now count characters. Before this change, they counted words. The counter below each field, the rule that stops the submission, and the error message all changed together.

Base branch

This branch starts at OTTER-691-proposal-page-refactor (#988). That branch starts at OTTER-690-setup-page-refactor (#980). Merge #980 first. Then merge #988. Then merge this PR.

OTTER-691 made the parts that this work uses: the CharacterCounter component, the countCharactersFromLexical function, and the errorLive option on FormField. OTTER-691 also changed the four Step 2 proposal fields and the Step 1 study title. This PR changes the fields that OTTER-691 did not change.

New limits

Page Field Limit before Limit now
Edit Proposal Study title 20 words 60 characters
Edit Proposal Research question(s) 500 words 3000 characters
Edit Proposal Project summary 1000 words 6000 characters
Edit Proposal Impact 500 words 3000 characters
Edit Proposal Additional notes or requests 300 words 1800 characters
Edit Proposal Resubmission note 300 words 1800 characters
Edit Code Resubmission note 300 words 1800 characters
Proposal Review Decision 500 words 1800 characters
Code Review Decision 300 words 1800 characters
Outputs Review Decision 300 or 1500 words 1800 characters

The error message

Each field shows this message when the text is too long: {field title} exceeds the {limit} limit. Shorten it to continue.

This is the text from the card, character for character. An earlier commit put the word "character" before "limit". The card does not have that word. The card gives the example "Research question(s) exceeds the 3000 limit...". The card also says that the message must be exact. Thus the app now omits the word.

The result reads strange in English. "Study title exceeds the 60 limit" is not a good sentence. Tell me if you want the word "character" back, and I will put it back in one line of code.

The name of the Data Partner field

The card calls this field "Decision" on all three Data Partner pages. The app called it "Feedback". No page shows a field with the name "Feedback": the proposal review page shows the review round, the code review page shows "Code review", and the outputs review page shows "Decision". A reviewer thus read a message about a field that is not on the screen.

The two constants now hold the name "Decision". The message for an empty field still says "Feedback is required." That message is not part of this card.

How the rules measure the text

The card excludes whitespace at the start of the content and at the end of it. The card includes the whitespace between words. Thus "a b" is 3 characters.

One function does this for the whole app. countCharacters removes the whitespace at the two ends, then counts what is left. The counter below each field calls it. The client rule calls it. The server rule calls it. Therefore the counter and the rule that stops the submission always agree, and a field cannot show 1800/1800 and fail with 1801.

The unit is the grapheme cluster, not the UTF-16 code unit. .length measures storage, not what the user typed. A decomposed "é" costs 2 there, and a family emoji costs 11. On a 6000-character field this makes no difference. On the 60-character title it does: a researcher can paste a title of 40 visible letters out of Word and watch the counter pass 60, with nothing on the screen to explain it. countCharacters segments the text instead, so each of those counts 1. Segmenting also settles the NFC/NFD question on its own, because either encoding of "é" is one cluster.

Pure ASCII text skips the segmenter, because .length is already right for it. A 6000-character summary measures 0.27ms segmented against 0.002ms through that check, and both the counter and the rule run on every keystroke.

An earlier commit counted the raw text for this same reason. That was not necessary. The counter and the rule agree if both of them remove the whitespace, and only then does the app also follow the card.

The fields do not truncate the text. The user can type more text than the limit permits. The counter becomes red, the error message appears, and the button does not submit.

The two review pages now show the error at once

The Proposal Review decision and the Code Review decision showed the error only after the user moved away from the field. Mantine useField does not validate on a change, and it removes the error on a change. Thus two things were wrong. The error did not appear on the character that passed the limit. The error also disappeared again on the next character, while the text was still too long.

The hook now calculates the over-limit error on each render. The error appears on the character that passes the limit. The error goes away when the user deletes the extra characters. Outputs Review already worked this way, and the two review pages now match it.

The maxCharacters option on useReviewFeedback has no caller in the app. It is deleted.

Accessibility

CharacterCounter now takes an id. Each of the ten fields names its counter in the aria-describedby of its input. Before this change, only Outputs Review did this, and it borrowed the description id for the counter. It now uses the counter id.

The over-limit message is in a polite live region on the Edit Proposal title, on both resubmission notes, and on both review decisions. The card asks for this on every field in the list. The Step 1 title, the four proposal fields, and Outputs Review already had it.

Effect on existing data

A study with the status CHANGE-REQUESTED can hold a project summary of 1000 words. This can be more than 6000 characters. The researcher must make such a field shorter before resubmission. The error message tells the researcher what to do.

A study from before OTTER-690 can hold a title of more than 60 characters. An earlier commit put the 60-character limit in draftStudyApiSchema. That schema is the parameter schema for the autosave on the Edit Proposal page. Thus every autosave failed for such a study, and the researcher saw only "Failed to save draft". The message did not name the title. The Back button and the "View as reviewer" button also did nothing, because both of them wait for a successful save.

The limit now sits in the actions that submit. draftStudyApiSchema is permissive again, and it also trims the title before the app writes it to the database. These actions apply the limit and report it on the field:

  • onSaveDraftStudyAction, because a new study has no old title.
  • onUpdateDraftStudyAction, for a DRAFT row only. The Edit Proposal autosave is the other caller, and that row can hold an old title.
  • resubmitProposalAction. The Edit Proposal page shows the title field, so the researcher can make the title shorter here.
  • finalizeStudySubmissionAction. This action had no title limit before this change.

Those two actions are the backstop, not the message the researcher is meant to read. Step 2 renders no title field, so an error keyed to title has nothing to attach to on the page the submit came from. /proposal now sends a draft whose stored title is blank or over the cap to Step 1, which owns the field. Before this, only a blank title was redirected. A draft from before OTTER-690 with a 70-character title reached Step 2, and the submit then failed with the generic "Proposal could not be submitted" toast and no way to shorten the title.

Other changes

  • The study title rule is now one function. Step 1 and the Edit Proposal page both use it. Only the "this field is empty" message is different between the two pages.
  • WordCounter, countWords, countWordsFromLexical, and the WordCountPlugin in EditableText have no callers. They are deleted.
  • normalizeFeedbackToLexical no longer reports a word count. It normalizes only. Each caller measures the result in the unit of its own field.
  • overCharacterLimitError moves to src/lib/field-limits.ts, together with countCharacters. The reviewer code and the code resubmission note can then use both without an import from a proposal page module.
  • The counter for the Outputs Review decision showed its unit ("120/1500 words"). It was the only counter in the app that showed a unit. It now shows "120/1800", the same as all the other counters.

Shared code for the three Decision fields

A second commit removes duplication that this change made easy to see. The line count does not decrease. It increases by approximately 47 lines, because each shared part needs a props type and a comment that tells why it is there. The gain is one definition in the two places that already showed a difference between their copies.

There are three changes.

The test for an empty Lexical value was written by hand in six places, as extractTextFromLexical(x).trim().length === 0. The function hasLexicalContent in src/lib/lexical.ts does this test already, and it has unit tests. Those six places now call it. Two other places keep their own test: the resubmission note also accepts plain text, and hasLexicalContent reports a plain-text note as empty; reviewer-preview.tsx is not part of this branch.

The three review actions each had the same block of code: normalize the text, then reject an empty value, then reject a value above the cap. Two of the three blocks were the same, character for character. The third block had different constant names only. All three now call assertDecisionFeedback. The field title and the cap are parameters of that function, so the two review modules keep their own constants.

The proposal review and the code review each had a copy of the Decision editor. The two copies were different in the document name, the ids and the text. The two copies were the same in the error area, the counter, and the aria-describedby that connects those two to the editor. That last part had already become different in the two copies, because each change to it was necessary two times. DecisionFeedbackEditor now holds it one time. Each page keeps its own contentStyle, because the two pages use a different height and a different font size.

Caution: two Data Partner fields get less space

Most of the new limits agree with the old limits, because a word is approximately 6 characters. Two fields do not agree:

  • The proposal review decision had 500 words. This is approximately 3000 characters. The new limit is 1800 characters.
  • The outputs review decision on a completed run had 1500 words. This is approximately 9000 characters. The new limit is 1800 characters.

The reviewer thus gets less space than before. The unit is not the cause. The new number is the cause. The card gives one number for each of these fields, and this PR uses that number. Tell me if this is not correct, and I will change it.

The outputs review limit is now one number

Before this change, the limit for the outputs review decision changed with the result of the run. An errored run had 300 words. A completed run had 1500 words. The card gives one number for the field. Thus the function outputsFeedbackMaxWords is not necessary. The maxWords property is also not necessary in the two reviewer screens, the review panel, the decision section, the hook, and the server action.

Not in this PR

The four rich-text proposal fields have no limit on the server. step2ProposalApiSchema and finalizeStudySubmissionInfoSchema accept text of any length, and the client rules are the only gate. This is also true before this PR, so it is not a new hole. A limit there can stop the resubmission of a study that already holds a long field, in the same way the title limit stopped the autosave. That needs its own card and its own decision about the old rows.

Changes from code review

Four changes came out of the review of the first round.

The counter counts grapheme clusters. See "How the rules measure the text" above. The review asked whether Intl.Segmenter was worth using. It is, on the 60-character title.

/proposal redirects an over-limit stored title to Step 1. See "Effect on existing data" above. The review found that the title cap on finalizeStudySubmissionAction recreated the dead end this PR set out to remove, moved from the autosave to the submit.

The Lexical shape is decided in one place. A note or feedback value arrives either as Lexical JSON or as plain text, and the app decided which one it held in six separate places. Two of those decisions had already drifted: isValidLexicalState and the check inside normalizeFeedbackToLexical disagreed about {"root":{"children":[]}}, so an empty-root document was wrapped on one path and passed through on the other. asLexicalEnvelope now answers that question once, and lexicalToText returns the text whichever shape arrived. The three helpers in edit-and-resubmit/schema.ts lost their ternaries. One of them also stops wrapping an empty Lexical document and showing the user its own JSON.

hasLexicalContent and countCharactersFromLexical stay Lexical-only. The review suggested that the first of them read plain text as well. That breaks the four proposal rich-text fields. They have no plain-text path, so a value like "not valid json" has to read as empty and fail their required rule rather than pass as prose, and proposal/schema.test.ts catches it. The resubmission note is the one field pair where both shapes really arrive, and it now asks for both explicitly.

Tests

pnpm run checks passes. All 3070 unit tests pass, across 297 files. The new tests examine each field at its limit, at one character more than its limit, and with whitespace at the two ends. Each test file also has one test that shows that the field counts characters and not words.

These tests are new in the last commit:

  • The limit and one character more, on the server, for the two resubmission notes, the proposal review decision, and the code review decision. The proposal review test was a word-count test with a 501-word fixture. That fixture is approximately 4000 characters, so the test passed for the wrong reason.
  • The autosave of a row whose stored title is longer than 60 characters.
  • The counter id in the aria-describedby of each field.
  • The polite live region for the over-limit message.
  • The shared assertDecisionFeedback function, in src/server/actions/decision-feedback.test.ts.

These tests are new in the review round:

  • src/lib/field-limits.test.ts, which is new. It covers the whitespace rule, the two encodings of "é", the family emoji, and the CRLF case where the ASCII shortcut and the segmenter would otherwise disagree.
  • The /proposal redirect for a stored title over the cap, and no redirect for one exactly at it.
  • finalizeStudySubmissionAction on the omit path, where the title is not submitted and the stored one is over the cap. The existing test passed the title explicitly, so it exercised the path that has a visible field rather than the one that does not.
  • lexicalToText, and the empty-root document that the two old predicates disagreed about.

The end-to-end tests did not run for this PR.

@therealmarv
therealmarv marked this pull request as ready for review August 24, 2026 23:03
@therealmarv
therealmarv force-pushed the OTTER-737-character-limits branch from 5262079 to 5b5b07b Compare August 24, 2026 23:22
@therealmarv
therealmarv requested a review from a team August 24, 2026 23:25

@nathanstitt nathanstitt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks great, all seem very minor but I do wonder if we should use the suggested Intl.Segmenter

// Submitting is the gate, so the cap is checked here rather than in the params schema: this
// action also carries drafts whose stored title predates the cap, and a schema rejection
// would surface as a generic failure instead of a message on the field (OTTER-737).
if (countCharacters(submittedTitle) > STUDY_TITLE_MAX_CHARACTERS) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this one recreates the exact dead-end the PR set out to remove, just moved from autosave to submit.

On the Step 2 proposal submit path, use-submit-proposal.ts calls buildStudyInfo(values, 'omit'), so title is never sent. submittedTitle above then falls back to the stored column, and this check rejects it. But Step 2 renders no title field (proposal/form.tsx has the explicit {/* No Study title field: it moved to Step 1 ... */} comment), and proposal/page.tsx only redirects to Step 1 when the title is blank (if (!result.title?.trim())) — never when it is merely too long.

So for a DRAFT created before OTTER-690 with a 70-character title, the researcher gets ActionFailure({ title: ... }) keyed to a field that isn't on the page. use-submit-proposal's onError just shows the generic "Proposal could not be submitted" toast. They can't shorten it, and can't submit — a hard stop with no stated cause, which is exactly what the draftStudyApiSchema note describes as the thing to avoid.

A couple of ways out, whichever fits the product better:

  • widen the page.tsx guard to !result.title?.trim() || countCharacters(result.title) > STUDY_TITLE_MAX_CHARACTERS so the researcher lands on Step 1, which owns and renders the field; or
  • only apply the cap here when the title actually came from the request ('title' in snapshotFields), leaving a stored pre-cap title to be fixed on Step 1.

Worth a test for it too — the existing finalizeStudySubmissionAction rejects an over-limit title test passes title explicitly, so it exercises the path that already has a visible field rather than the omit path that doesn't.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and thanks for tracing it all the way through. I confirmed every step: buildStudyInfo(values, 'omit') means title never reaches the action, submittedTitle falls back to the stored column, and page.tsx only redirected on a blank title. A pre-OTTER-690 draft with a 70-character title lands on Step 2, has no field to fix it on, and gets the generic "Proposal could not be submitted" toast. Exactly the dead end the draftStudyApiSchema note was written about, moved from the autosave to the submit.

Took your first option, widening the page.tsx guard, rather than the second. Only applying the cap when the title came from the request would let an over-limit title through to PENDING-REVIEW, which defeats the cap. Sending the researcher to Step 1 instead puts them on the page that owns and renders the field, where the counter reads 70/60 and the error names the problem:

}
// A DRAFT predating OTTER-690 can carry a title this page cannot fix: it may have none (the
// migration that made the column nullable cleared every 'Untitled Draft' placeholder) or one
// longer than the OTTER-737 cap, and Step 2 has no title field to put either right. Submitting
// would then fail on the far side - the check constraint study_title_required_when_not_draft for
// a blank title, finalizeStudySubmissionAction's cap for a long one - and report it against a
// field that is not on the screen, which is a dead end rather than a message. The dashboard
// routes any draft with Step 2 progress straight here, so Step 1, which owns the title and is
// revisitable, is the only way out. Its counter and its error then show the researcher the
// problem on the field itself.
if (!result.title?.trim() || countCharacters(result.title) > STUDY_TITLE_MAX_CHARACTERS) {
redirect(Routes.studyEdit({ orgSlug, studyId }))
}

The throws in the action stay as a backstop, and the comment above them no longer claims the redirect covers a case it didn't. It now says which case each one is actually for:

// Submitting is the gate, so the cap is checked here rather than in the params schema: this
// action also carries drafts whose stored title predates the cap, and a schema rejection
// would surface as a generic failure instead of a message on the field (OTTER-737).
//
// Both checks above are a backstop, not the message the researcher is meant to read. Step 2
// renders no title field, so a failure keyed to `title` has nothing to attach to on the page
// the submit came from. /proposal is what keeps that from happening: it sends a draft whose
// stored title is blank or over the cap to Step 1, which owns the field, before Step 2 can
// be reached. These throws cover the paths that do send a title of their own.
if (countCharacters(submittedTitle) > STUDY_TITLE_MAX_CHARACTERS) {

Tests, including the omit path you pointed out was the one not covered:

Comment thread src/lib/field-limits.ts Outdated
* definition for the counter beside the field, the client rule and the server rule: measuring the
* same value two ways is what lets a field read 1800/1800 while its validator sees 1801.
*/
export const countCharacters = (value: string) => value.trim().length

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is now the single definition of "how long is this field" for the whole app, it's worth being deliberate about what a .length unit actually counts.

.length is UTF-16 code units, so a few things count higher than what the user perceives:

countCharacters('é')          // 2 when NFD-composed (e + combining accent)
countCharacters('👨‍👩‍👧‍👦')  // 11

For a 1800-char body field nobody will notice. For the 60-character study title it's much easier to hit — a researcher pasting an accented title from Word (which often emits NFD) can watch the counter jump past 60 on a title that visibly has ~40 letters, with no way to see why.

The counter and the validator do agree here, so it isn't a correctness split — it's a UX sharp edge. [...value.trim()].length gets code points cheaply, or Intl.Segmenter with granularity: 'grapheme' gets true user-perceived characters and is available in every runtime this app targets. Might be worth at least a normalize (value.normalize('NFC')) before counting, which fixes the accent case for near-free.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, and you called the right field: the 60-character title is where this bites. Went with Intl.Segmenter at grapheme granularity rather than code points, since [...v].length still charges 7 for the family emoji.

// Grapheme clusters, not `.length`. `.length` is UTF-16 code units, which charges the user for
// storage rather than for what they typed: an NFD-composed "é" (the form Word emits) costs 2 and a
// family emoji costs 11. Nobody would notice on an 1800-character body, but the study title has 60,
// where a researcher could watch the counter pass the cap on a title of 40 visible letters with
// nothing on screen to explain it. Segmenting also settles the NFC/NFD question on its own, since
// either encoding of "é" is one cluster, so no normalize pass is needed ahead of it.
//
// Built once at module scope: constructing a Segmenter costs more than the segmenting does, and
// both the counter and the rule that gates the field run on every keystroke.
const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
// Plain ASCII has one grapheme per code unit, so `.length` is already the right answer for it and
// the segmenter can be skipped. CR is excluded because "\r\n" is the one ASCII pair that UAX #29
// joins into a single cluster, which is where the shortcut would otherwise disagree with the
// segmenter. Worth the test: a full 6000-character project summary measures ~0.27ms segmented
// against ~0.002ms here, on a path that runs per keystroke.
const SINGLE_UNIT_ASCII = /^[\n\t\x20-\x7E]*$/
/**
* How every capped field measures its length (OTTER-737).
*
* Surrounding whitespace is excluded and interior whitespace is not, so " a b " counts 3. One
* definition for the counter beside the field, the client rule and the server rule: measuring the
* same value two ways is what lets a field read 1800/1800 while its validator sees 1801.
*/
export const countCharacters = (value: string) => {
const trimmed = value.trim()
if (SINGLE_UNIT_ASCII.test(trimmed)) return trimmed.length
return Array.from(graphemes.segment(trimmed)).length
}

Two notes on the shape of it:

No normalize('NFC') pass. Grapheme clustering subsumes it. Either encoding of "é" is one cluster, so NFC would be a second O(n) pass buying nothing. Verified both directions in the tests, written as and é escapes so the assertion doesn't depend on how the file gets normalized on disk.

ASCII fast path. .length is already correct for plain ASCII, and this runs per keystroke on fields up to 6000 characters. Measured on Node 24: a full 6000-character summary is ~0.27ms segmented against ~0.002ms for the regex test. CR is excluded from the class because \r\n is the one ASCII pair UAX #29 joins into a single cluster, which is the only place the shortcut and the segmenter would disagree. There's a test pinning that.

Intl.Segmenter is safe here: engines.node is >=22, and the browser floor is Firefox 125 (Apr 2024), which is well behind what this app targets.

New file, src/lib/field-limits.test.ts, covers the whitespace rule, both encodings, the emoji, and the CRLF case.

Comment thread src/lib/field-limits.ts
* The wording is the card's, verbatim, down to the missing "character" before "limit".
*/
export const overCharacterLimitError = (fieldTitle: string, maxCharacters: number) =>
`${fieldTitle} exceeds the ${maxCharacters} limit. Shorten it to continue.`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small copy flag, mostly so it's a conscious decision rather than an inherited one.

The PR description already calls this out, and I agree the resulting sentence reads oddly — "Study title exceeds the 60 limit" is missing its unit noun. Reading the card as "the message must be exact" is defensible, but design cards usually write the example with the field's own number filled in rather than specifying the literal token stream, and "60 limit" is the kind of thing that reads as a template bug to a user who has never seen the card.

No change needed from me — but this is worth an explicit yes/no from design before merge, since it's ten user-visible strings and cheap to flip either way while it's still one function.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving it as the card has it for now, so this stays a design call rather than one I make quietly in passing. Agreed on the substance though: "Study title exceeds the 60 limit" reads like a template that lost its noun, and your point that cards usually fill in the field's own number rather than pin the literal token stream is the more likely reading of it.

It's still one function (overCharacterLimitError), so all ten strings flip by adding one word there. I'll get a yes/no from design before this merges and change it in that one line if they want the word back.

@@ -0,0 +1,81 @@
'use client'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file and decision-feedback.ts are good work, but I think they're the reason this PR is hard to review, and they'd be better off on their own.

Breaking down the churn, the PR is doing three things:

  1. convert 10 capped fields from words to characters — the card
  2. delete WordCounter / countWords / countWordsFromLexical — unavoidable, nothing can reference them once (1) lands
  3. dedup three copies of the Decision editor and its server validation — not required by the card

(1) and (2) are genuinely inseparable: you can't leave a word counter behind when no field counts words. (3) is separable — this file (+81), decision-feedback.ts (+32) and decision-feedback.test.ts (+53) are self-contained, touch a different surface, and would stand alone as a small, obviously-correct PR. That's ~165 lines that don't need to ride along with a 55-file conversion.

Splitting it would also help the stack: this PR is already third in a chain behind #980 and #988, so anything that isn't the character conversion widens the window where all three have to stay rebased together.

Not asking for a re-roll if you'd rather not — the code itself is right, and the ARIA-drift argument for extracting it is a good one. Mostly flagging that the two commits answer to different cards and could land on different days.

For what it's worth, I checked whether the test additions are padding, and they're not — study-request.test.ts had 45 tests on main and not one asserted a server-side length cap. The 16 tests removed from lexical.test.ts are all word-counting tests for functions this PR deletes, so that file shrinking is correct rather than lost coverage. The at-cap / one-over pairs are the coverage this area was missing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair reading of the churn, and your three-way split is the right one: (1) and (2) are inseparable, (3) isn't.

Keeping it here, since you're not asking for a re-roll. The reason is the ARIA argument you already granted: this round added a fourth change to that shared wiring (the counter id in aria-describedby), and with two copies that's the fourth time it had to be made twice by hand, which is how it drifted in the first place. Splitting it now means either landing the conversion with the drift still in, or landing the extraction first and rebasing a 55-file PR behind it. Given the stack is already three deep behind #980 and #988, I'd rather not add a fourth rebase point.

Worth noting the review has since pulled a genuinely separable piece in rather than out: the lexical.ts shape consolidation. That one I'd have argued belonged on its own card, except the drift it fixes is live today, so same reasoning in reverse.

Point taken for next time though. This should have been two PRs from the start, and the dedup commit would have been the easy half to review.

And thanks for actually checking the test diff rather than taking the count at face value. You're right that study-request.test.ts had no server-side length assertion at all before this, which is why the 501-word proposal review fixture passed for the wrong reason at ~4000 characters.

Comment thread src/lib/lexical.ts
@@ -90,31 +70,32 @@ export function isValidLexicalState(json: string | undefined): boolean {
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Anchored here on isValidLexicalState because GitHub won't take a comment on line 52 — this is about the module as a whole.)

The note fields can arrive as either Lexical JSON or plain text, and right now every call site re-decides which one it's holding. Counting the shape checks at head:

  • edit-and-resubmit/schema.ts:18isValidLexicalState(v) ? countCharactersFromLexical(v) : countCharacters(v)
  • edit-and-resubmit/schema.ts:23(isValidLexicalState(v) ? extractTextFromLexical(v) : v).trim().length === 0
  • edit-and-resubmit/schema.ts:30isValidLexicalState(v) ? v : lexicalJson(v)
  • lexical.ts:89normalizeFeedbackToLexical hand-rolls its own looksLikeLexicalRoot for the same decision
  • editable-text.tsx:58 — checks the shape again before rendering
  • hasLexicalContent — doesn't check at all, and so reports plain text as empty

That's five branches and one omission for a single question. I'd rather see the shape decided once, at the boundary, so nothing downstream has to ask:

/** Every note/feedback value, whatever shape it arrived in, as plain text. */
export function lexicalToText(value: string | undefined): string {
    if (!value) return ''
    const state = tryParseLexicalRoot(value)
    return state ? extractTextFromLexicalNode(state) : value
}

Then the branches stop existing rather than getting tidier:

countCharacters(lexicalToText(v))          // replaces :18 and countCharactersFromLexical
!lexicalToText(v).trim()                   // replaces :23
hasLexicalContent = (...f) => f.some(x => !!lexicalToText(x).trim())

resubmissionNoteCharacterCount and resubmissionNoteIsBlank then have no bodies left worth keeping, and hasLexicalContent gets its plain-text bug fixed as a side effect rather than as a separate task.

Two reasons I think this is worth doing in this PR rather than a follow-up:

The duplicate predicates have already drifted. isValidLexicalState and normalizeFeedbackToLexical's looksLikeLexicalRoot are both "is this Lexical?", and they disagree:

input isValidLexicalState looksLikeLexicalRoot
{"root":{"children":[]}} false true
{"root":null} false true

So an empty-root state gets wrapped by one path and passed straight through by the other. Nothing visibly breaks today, but that's two normalizers for one concept already diverging — exactly what a third caller inherits.

This PR is what makes it urgent. Before OTTER-737 the branch appeared twice; the character conversion spreads the same decision across every capped field. One function now is cheaper than six branches to keep in sync later.

Happy to be told this is scope creep and belongs on its own card — but if so I'd want the card filed, because the drift above is real today.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took this, and the drift table is what sold it. Confirmed both rows: for {"root":{"children":[]}} and {"root":null}, isValidLexicalState says no and looksLikeLexicalRoot says yes, so the same value was wrapped on one path and passed through on the other.

The shape is now decided once, in asLexicalEnvelope, with lexicalToText on top of it:

import { countCharacters } from '@/lib/field-limits'
/** A parsed Lexical document envelope. `root` stays `unknown`: only the walker below inspects it. */
type LexicalEnvelope = { root: unknown }
/**
* The one place the app decides "is this value a Lexical document, or plain text?".
*
* That question used to be answered separately by {@link isValidLexicalState} and by an inline
* check inside {@link normalizeFeedbackToLexical}, and the two had already drifted: given
* `{"root":{"children":[]}}` the first said plain text and the second said Lexical, so an
* empty-root state was wrapped on one path and passed through on the other. Two normalizers for one
* concept is what a third caller would have inherited, so everything below now shares this one
* (OTTER-737).
*/
function asLexicalEnvelope(value: string | undefined): LexicalEnvelope | null {
if (!value) return null
try {
const parsed: unknown = JSON.parse(value)
if (!parsed || typeof parsed !== 'object' || !('root' in parsed)) return null
const { root } = parsed as LexicalEnvelope
return typeof root === 'object' ? { root } : null
} catch {
return null
}
}
/**
* Any note or feedback value, whichever shape it arrived in, as plain text.
*
* The editor-backed fields post Lexical JSON and the older plain-text callers post a bare string.
* Resolving that here is what lets the callers measure, test for emptiness and normalize without
* re-deciding the shape each time.
*/
export function lexicalToText(value: string | undefined): string {
if (!value) return ''
const envelope = asLexicalEnvelope(value)
return envelope ? extractTextFromLexicalNode(envelope.root) : value
}

normalizeFeedbackToLexical lost its inline predicate and isValidLexicalState no longer parses for itself, so the two can't disagree again. Both kept their exact previous behavior; I checked each branch, including {"root":null} and {"root":"hi"}. isValidLexicalState stays as a separate question on purpose, since an empty-root document is Lexical but is not a usable initial state for the editor, and three callers depend on that distinction. It just no longer owns a shape check of its own.

One piece I did not take: making hasLexicalContent read plain text. I tried it exactly as sketched and it broke proposalFormSchema > researchQuestions > rejects invalid JSON. The four proposal rich-text fields have no plain-text path, so they rely on a non-Lexical value reading as empty and failing their required rule. Shape-agnostic, 'not valid json' becomes valid prose and passes.

Checking all six callers, that "plain-text bug" turns out to be latent rather than live: every one of them holds an editor value or a value already through normalizeFeedbackToLexical. The one place both shapes genuinely arrive is the resubmission note, so that asks for both explicitly instead. Both helpers keep Lexical-only semantics with the reasoning written down:

/**
* Check if any of the fields have non-empty Lexical content.
*
* Lexical only, deliberately. Every caller holds an editor value or a value already put through
* {@link normalizeFeedbackToLexical}, and the four proposal rich-text fields rely on a non-Lexical
* value reading as empty so that garbage fails their required rule rather than passing it as prose.
* A field that accepts plain text as well tests `lexicalToText(value).trim()` instead.
*/
export function hasLexicalContent(...fields: (string | undefined)[]): boolean {
return fields.some((field) => !!extractTextFromLexical(field).trim())
}

No follow-up card needed, since the drift itself is gone. Tests for lexicalToText and the empty-root case are in src/lib/lexical.test.ts.

}

/** Whether the note has any content at all, ignoring surrounding whitespace. */
export function resubmissionNoteIsBlank(value: string): boolean {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three helpers (:18, :23, :30) all open with the same isValidLexicalState(value) ? … : … branch — three answers to one question, "which shape is this note in?", asked three times in twelve lines.

Rather than three ternaries kept in step by hand, I think this wants one normalizer at the boundary so the shape is resolved before any of these run. Sketched it in a comment on lexical.ts, along with evidence that the duplicate predicates have already drifted (isValidLexicalState and normalizeFeedbackToLexical's internal check disagree on an empty root).

With a lexicalToText helper, all three collapse and mostly stop needing to exist:

countCharacters(lexicalToText(value))   // :18
!lexicalToText(value).trim()            // :23

Worth reading the two together — the change belongs in lexical.ts, but this file is where the cost of not making it shows up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three ternaries are gone. The shape is resolved by lexicalToText before any of these run, so none of them branches on it any more:

/**
* The proposal flow submits Lexical JSON; the code flow still submits plain text.
*
* This is the one field pair in the app where both shapes really arrive, so all three helpers read
* the value through `lexicalToText` and none of them branches on the shape itself. The two note
* fields, their counters and the server rule then measure a note the same way whichever shape it
* came in.
*/
export function resubmissionNoteCharacterCount(value: string): number {
return countCharacters(lexicalToText(value))
}
/** Whether the note has any content at all, ignoring surrounding whitespace. */
export function resubmissionNoteIsBlank(value: string): boolean {
return !lexicalToText(value).trim()
}
// Empty drafts stay '' - Lexical rejects an empty-root state, so callers treat '' as "no initial
// value". Blankness is judged on the text, not the raw string, so a Lexical document that holds
// nothing is treated the same as an empty one rather than being wrapped and shown to the user as
// its own JSON.
export function resubmissionNoteToLexicalJson(value: string): string {
if (!lexicalToText(value).trim()) return ''
return normalizeFeedbackToLexical(value)

Kept the three names, since they have real callers across the components, the server actions and the tests, and this PR is already wide enough. The bodies are one line each now, which was the point.

:30 picked up a fix on the way past. It used isValidLexicalState as the shape check, so an empty-root document failed it and got wrapped as plain text, meaning the user would have been shown {"root":{"children":[]}} as the body of their own note. It now judges blankness on the text, so an empty document is treated the same as an empty string.

Details on the shared helper and the one part of your sketch I didn't take are in the reply on lexical.ts.

@therealmarv
therealmarv force-pushed the OTTER-737-character-limits branch from 5b5b07b to 598efdf Compare August 25, 2026 15:37
@therealmarv

Copy link
Copy Markdown
Contributor Author

Thanks @nathanstitt. Answering the question from the review body first: yes on Intl.Segmenter, it's in. Details in the thread on field-limits.ts, but the short version is that grapheme clustering also makes the NFC/NFD fix redundant, and pure ASCII skips the segmenter so the per-keystroke path stays about as cheap as it was.

Pushed as 29fe5ca.

One of these turned out not to be minor. The finalizeStudySubmissionAction title cap did recreate the dead end this PR set out to remove, just relocated from the autosave to the submit: a pre-OTTER-690 draft with a 70-character title reached Step 2, which renders no title field, and the submit failed against a field that isn't on the page. /proposal now routes such a draft to Step 1, which owns the field. Good catch, and the test you asked for (the omit path, not the explicit-title path) is in.

Comment Outcome
study-request.ts submit dead end Fixed, guard widened plus 3 tests
Intl.Segmenter / grapheme counting Fixed, new field-limits.test.ts
lexical.ts shape decided once Fixed, asLexicalEnvelope + lexicalToText
edit-and-resubmit/schema.ts ternaries Fixed, all three collapsed
"60 limit" copy Left as the card has it, taking it to design
Split the dedup commit Keeping it here, reasoning in-thread

One part of the lexical.ts sketch I declined: making hasLexicalContent read plain text breaks the four proposal rich-text fields, which have no plain-text path and need 'not valid json' to fail their required rule rather than pass as prose. proposal/schema.test.ts catches it. Checking all six callers, that bug is latent rather than live, so both helpers stay Lexical-only and the resubmission note (the one field pair where both shapes really arrive) asks for both explicitly. Full reasoning in the thread.

pnpm run checks clean, 3070 unit tests passing across 297 files. PR description updated with a "Changes from code review" section. E2E still not run for this PR.

normalizeFeedbackToLexical reported a word count alongside the JSON it
normalized, which tied it to one unit. It now normalizes only, and each
caller measures the result in the unit its own field is capped in.

overCharacterLimitError moves out of the researcher proposal schema into
src/lib/field-limits, so the resubmission notes and the Data Partner
decision fields can raise the same message without importing from a
proposal page module.
The CHANGE-REQUESTED resubmit page renders the same study title and four
rich-text fields as the proposal, and was the last page still counting
words. It now uses the same caps: title 60, research questions 3000,
project summary 6000, impact 3000, additional notes 1800.

The title rule becomes a factory so both pages share one implementation.
Only the blank message differs between them: Step 1 names the action
because it raises every empty-field message at once, while the resubmit
page keeps the generic wording it uses on its other fields.

With both flows on one cap, draftStudyApiSchema can enforce the title
length for every entry point. It had to stay permissive while the
resubmit autosave ran under a 20-word rule that allowed longer titles,
which is why step1DraftStudyApiSchema existed; that override is gone.
The proposal note and the code note were capped at 300 words. Both now
use 1800 characters and raise the shared over-limit message.

The schema switches from chained refines to superRefine so a blank note
reports only that it is blank. Emptiness is measured trimmed and the cap
is measured raw, matching the counter beside the field and the split the
proposal fields already use.

resubmissionNoteWordCount becomes resubmissionNoteCharacterCount and
keeps its Lexical-or-plain-text branch: the proposal note posts editor
JSON while the code note posts a raw textarea string.
The two review steps used different word caps, 500 on the proposal review
and 300 on the code review. The card gives both the same 1800 characters,
so the two constants collapse into REVIEW_FEEDBACK_MAX_CHARACTERS and the
code review page stops passing an override.

The hook renames wordCount to characterCount and maxWords to
maxCharacters, and reports the shared over-limit message. Emptiness is
measured trimmed so whitespace-only feedback still reads as missing,
while the cap is measured raw to match the counter.
The cap on this field used to depend on the run being reviewed, 300 words
for an errored run against 1500 for a completed one, on the reasoning
that explaining a failure needs less room than summarizing results. The
card gives one number for the field, so both outcomes now get 1800
characters.

Worth calling out at review: on a completed run this is a real reduction,
from roughly 9000 characters' worth of words down to 1800. The unit
change is not what drives it, the new number is.

With the cap no longer derived from job status, outputsFeedbackMaxWords
goes, and the maxWords prop it fed disappears from both reviewer screens,
the review panel, the decision section, the hook and the server action.
The counter also drops its "words" suffix, which was the only unit label
on any counter in the app.
No field counts words any more, so WordCounter, countWords,
countWordsFromLexical and EditableText's WordCountPlugin have no callers.
The plugin's onWordCount prop had none already: reviewer-preview is the
only place that renders EditableText and it never passed one.

CharacterCounter's doc comment loses its reference to the sibling it no
longer has.
The available-outputs screen pointed at outputsFeedbackMaxWords, which no
longer exists, and buildFeedback described itself against a 500-word
proposal-review ceiling that is now 1800 characters.
Follow-up on the word-to-character conversion, from review of this branch
against OTTER-737.

Legacy titles no longer break Edit Proposal. `draftStudyApiSchema` had gained
a 60-character title cap, and it is the params schema for the resubmit autosave
as well as for resubmission. A study created before OTTER-690 can hold a longer
title its owner never chose to edit, so every autosave on that page was rejected
inside `.params()`, which also left the footer's Back and "View as reviewer"
buttons doing nothing. The cap moves to the actions that submit, where it can be
reported on the field: create-draft, the DRAFT branch of the draft update,
resubmission, and final submission, which had no title cap at all before.

Counts now exclude whitespace at either end, as the card requires, and interior
whitespace still counts. One `countCharacters` helper backs every counter and
every rule, so the counter and its validator cannot disagree, which is what the
previous raw counting was reaching for.

The over-limit message matches the card's wording exactly, and the three Data
Partner fields call themselves "Decision", the name the card and the pages use.

Proposal Review and Code Review raise the over-limit error on the keystroke that
crosses the cap and drop it the moment the value is back under, instead of
waiting for a blur. `useField` neither validates on change nor keeps an error
across one, so the rule is derived rather than left in `validate`.

Accessibility: the counter carries an id and is named in every field's
`aria-describedby`, and the over-limit message is announced politely on the
Edit Proposal title, both resubmission notes and both review decisions. Outputs
Review already did both and now uses the counter id rather than borrowing the
description slot.

Adds the boundary coverage the card asks for: at the cap and one past it for the
notes and both review decisions on the server, whitespace handling on every
capped field, and counter association plus live-region assertions per field.
Follow-up to the character-cap work, from a duplication pass over the branch.

The blank check on a Lexical value was hand-rolled in six places as
extractTextFromLexical(x).trim().length === 0. hasLexicalContent already existed
in src/lib/lexical.ts, exported and unit-tested, so those call sites now use it.
Two are deliberately left alone: the resubmission-note check also accepts plain
text, where hasLexicalContent reports a non-empty note as blank, and
reviewer-preview.tsx is outside this branch.

The three review actions each carried their own normalize, then require, then
cap block. Two were byte-identical and the third differed only in which
constants it named. They now call assertDecisionFeedback, which takes the field
title and the cap as parameters so the two review domains keep their own
constants.

The proposal review and the code review had a copy each of the Decision editor,
differing in the document name, the ids and the copy. What they shared was the
error slot, the counter, and the ARIA wiring that ties both to the editor, and
that wiring had already drifted once: every change to it had to be made twice by
hand. DecisionFeedbackEditor holds it once. Each page keeps its own contentStyle,
which differ in height and font size.

This does not reduce line count. It costs about 47 lines, in the props type and
in the comments recording why each shared piece exists. What it buys is a single
definition in the two places that have already proved they drift.
@therealmarv
therealmarv force-pushed the OTTER-737-character-limits branch from 58b174a to 3b59a76 Compare August 26, 2026 21:47
@github-actions

Copy link
Copy Markdown
Contributor

Total coverage

Lines Branches Functions Statements
96.63% (+0.18%) 77.43% (+0.24%) 93.28% (+0.42%) 95.92% (+0.19%)

Detailed report

15 files with a coverage regression
File Lines Branches Functions Statements
src/app/[orgSlug]/admin/legal/org-study-agreements.tsx 92.10% 60.00% (+5.00%) 78.57% 90.47% (+2.38%)
🚫 src/app/[orgSlug]/admin/settings/code-envs.tsx 84.44% 73.91% (-2.90%) 76.92% 84.04%
🚫 src/app/[orgSlug]/study/[studyId]/_screens/reviewer-outputs-errored-screen.tsx 95.65% (-0.18%) 60.00% 100.00% 95.65% (-0.18%)
src/app/[orgSlug]/study/[studyId]/edit-and-resubmit/edit-initial-request-section.tsx 50.00% (+6.25%) 0.00% 0.00% 50.00% (+6.25%)
🚫 src/app/[orgSlug]/study/[studyId]/edit-and-resubmit/schema.test.ts 100.00% 50.00% (-50.00%) 100.00% 100.00%
src/app/[orgSlug]/study/[studyId]/proposal/collaborative-proposal-text-field.test.tsx 100.00% 75.00% (+25.00%) 100.00% 97.29% (+2.85%)
🚫 src/app/[orgSlug]/study/[studyId]/proposal/collaborative-proposal-text-field.tsx 96.29% (-3.71%) 80.00% (-20.00%) 83.33% (-16.67%) 96.29% (-3.71%)
src/app/[orgSlug]/study/[studyId]/proposal/field-ids.ts 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
src/app/[orgSlug]/study/[studyId]/proposal/footer.tsx 100.00% (+5.72%) 90.90% (+6.29%) 100.00% (+25.00%) 97.22% (+5.33%)
src/app/[orgSlug]/study/[studyId]/proposal/form.tsx 82.50% (+1.65%) 60.00% (+10.00%) 50.00% (+16.67%) 82.50% (+1.65%)
src/app/[orgSlug]/study/[studyId]/proposal/page.test.tsx 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
src/app/[orgSlug]/study/[studyId]/proposal/page.tsx 96.42% (+96.42%) 78.57% (+78.57%) 100.00% (+100.00%) 96.42% (+96.42%)
src/app/[orgSlug]/study/[studyId]/proposal/researcher-field.tsx 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
src/app/[orgSlug]/study/[studyId]/proposal/reviewer-preview.tsx 100.00% 80.00% (+10.00%) 75.00% 100.00%
src/app/[orgSlug]/study/[studyId]/proposal/use-proposal-submit-attempt.ts 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
🚫 src/app/[orgSlug]/study/[studyId]/review/code-review-client.tsx 100.00% 80.95% 100.00% 94.91% (-0.09%)
src/app/[orgSlug]/study/[studyId]/review/code-review-feedback-section.test.tsx 100.00% 100.00% 91.66% (+4.16%) 100.00%
src/app/[orgSlug]/study/[studyId]/review/decision-feedback-editor.tsx 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
src/app/[orgSlug]/study/[studyId]/review/review-feedback-section.test.tsx 100.00% 100.00% 94.44% (+2.78%) 100.00%
src/app/[orgSlug]/study/[studyId]/review/submitted-code-interactive.tsx 100.00% (+0.79%) 97.22% (+1.39%) 93.33% 98.54% (+0.73%)
src/app/[orgSlug]/study/request/fields/data-partner-field.tsx 100.00% (+100.00%) 88.88% (+88.88%) 100.00% (+100.00%) 100.00% (+100.00%)
src/app/[orgSlug]/study/request/fields/field-ids.ts 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
src/app/[orgSlug]/study/request/fields/programming-language-field.tsx 94.44% (+94.44%) 89.47% (+89.47%) 77.77% (+77.77%) 94.64% (+94.64%)
src/app/[orgSlug]/study/request/fields/study-title-field.tsx 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
🚫 src/app/[orgSlug]/study/request/form-schemas.test.ts 100.00% 66.66% (+16.66%) 100.00% 98.64% (-1.36%)
src/app/[orgSlug]/study/request/form-schemas.ts 82.14% (+11.56%) 57.14% (+32.14%) 37.50% (+20.84%) 79.31% (+12.65%)
src/app/[orgSlug]/study/request/proposal.tsx 100.00% (+23.08%) 100.00% (+25.00%) 85.71% (+52.38%) 100.00% (+23.08%)
src/app/[orgSlug]/study/request/setup-form.test.tsx 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
src/app/[orgSlug]/study/request/setup-form.tsx 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
src/app/[orgSlug]/study/request/use-setup-form.ts 100.00% (+100.00%) 87.50% (+87.50%) 100.00% (+100.00%) 100.00% (+100.00%)
src/app/admin/safeinsights/legal/tos-pn/acknowledgements-table.test.tsx 100.00% 50.00% 93.33% (+0.48%) 100.00%
src/components/character-counter.test.tsx 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
src/components/character-counter.tsx 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
src/components/dataset-multi-select.test.tsx 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
src/components/dataset-multi-select.tsx 100.00% 100.00% (+33.34%) 100.00% 100.00%
🚫 src/components/editable-text/collaborative-editor.tsx 87.90% (-4.22%) 65.38% (-12.95%) 72.41% (-6.90%) 83.94% (-6.06%)
src/components/editable-text/editor-surface.test.tsx 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
src/components/editable-text/editor-surface.tsx 95.12% (+95.12%) 75.00% (+75.00%) 87.50% (+87.50%) 91.11% (+91.11%)
🚫 src/components/editable-text/single-user-editor.tsx 100.00% 88.88% (-4.45%) 83.33% 96.66% (-0.30%)
src/components/editable-text.tsx 84.21% (+13.38%) 58.97% (+0.44%) 42.85% (+15.58%) 84.21% (+13.38%)
🚫 src/components/form-field.tsx 95.23% (-4.77%) 90.47% (-6.08%) 100.00% 91.66% (-5.77%)
src/components/modals/submit-confirmation-modal.test.tsx 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
🚫 src/components/panel.tsx 54.54% (-18.18%) 100.00% 0.00% (-33.33%) 54.54% (-18.18%)
src/components/read-only-field.test.tsx 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
src/components/read-only-field.tsx 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
src/components/study/proposal-step-header.test.tsx 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
🚫 src/components/study/proposal-step-header.tsx 100.00% 88.88% (-11.12%) 100.00% 93.75% (-6.25%)
src/lib/field-limits.test.ts 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
src/lib/field-limits.ts 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
🚫 src/lib/focus-first-invalid.ts 100.00% 80.00% (-10.00%) 100.00% 100.00%
🚫 src/lib/lexical.ts 100.00% 94.11% (-2.66%) 100.00% 97.61% (-0.46%)
src/lib/realtime/yjs-websocket-context.test.tsx 100.00% 100.00% 96.62% (+1.12%) 100.00%
src/server/actions/decision-feedback.test.ts 96.66% (+96.66%) 100.00% (+100.00%) 100.00% (+100.00%) 96.66% (+96.66%)
src/server/actions/decision-feedback.ts 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%) 100.00% (+100.00%)
src/server/actions/study-job.actions.test.ts 100.00% 100.00% 98.27% (+0.03%) 100.00%
🚫 src/server/actions/study-job.actions.ts 82.64% (-0.69%) 78.04% (-1.96%) 75.00% 80.15% (-0.76%)
src/server/actions/study-request.ts 96.88% (+0.22%) 78.30% (+2.70%) 92.30% 95.25% (+0.32%)
src/server/actions/study.actions.test.ts 100.00% 81.81% 98.90% (+0.03%) 100.00%
🚫 src/server/actions/study.actions.ts 94.81% (+0.27%) 80.24% (-0.65%) 100.00% 94.90% (+0.26%)

@therealmarv
therealmarv merged commit 9819eed into main Aug 26, 2026
14 checks passed
@therealmarv
therealmarv deleted the OTTER-737-character-limits branch August 26, 2026 21:53
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 26, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants