Skip to content

Show red, green or a label on every changed heading in the digest email #448

Description

@HMarzban

Summary

The Change digest email can list a changed heading with no green or red text under it. The reader then cannot tell what changed. Four code paths lead here. They share one fix: a compute guard, two payload fields (status, previousLevel), media words in runs, and a heading paint with a label. Both mail parts paint it. A refused compute gets one fixed line.

Shares files with #449 (packages/email-templates/src/types.ts, digestWalk.ts, templates/digest.eta, templates.ts) and with #452 (withSections). Land this issue first.

Report

A user received a digest for one document. The maintainer wrote:

"user reports they recive an email that does not those highlighet parts and they do not know what is the email about"

The screenshot shows three heading rows and no green or red text anywhere:

  1. "🧰 Resources", then a passage that is only "🧰" in plain ink.
  2. A heading that starts with an emoji and ends in text, then the same heading text again in plain ink.
  3. A plain-text heading, with nothing under it.

The status bar reads "Changed since you left, 4 days ago. 1 person contributed." fromLastLeft is true only on the success path (digestContentChanges.ts:191). So enrichment succeeded, and this is not the failed-enrichment fallback.

The causes below are read from code, not measured.

Where

apps/hocuspocus.server/src/modules/document-changes/domain/diffSections.ts

  • cleanRun (30-37) returns '' for whitespace-only text. JS \s also matches U+00A0. pushRun (43-53) then drops the run.
  • capRuns (88-90) drops a run a second time when it sanitises to ''.
  • runsAround (97-117) pushes the 'same' context run (line 109) before it cleans the removed and added text (110-113).
  • textBetween(..., '\n', ' ') (110-111) turns a leaf node (hard break, image, embed) into one space.
  • sectionDoc (119-120), with canonicalSection.ts:5-6, puts the heading first. So the context for an edit at the end of the heading, or just below it, is the heading text.
  • wholeSection (123-140) builds runs from section.nodes, which is the body only. An added or removed heading with an empty or media-only body gets no runs.
  • quantify (148-159, with its comment at 142-147) uses the default ChangeSet encoder. Its own comment says bold, a changed href and a heading level report zero changes, so it returns NOTHING.
  • diffSections (214-221) never quantifies a moved section. A quantify throw also gives NOTHING, and apps/hocuspocus.server/src/modules/document-changes/domain/computeDocumentChanges.ts:178-182 logs it at debug only.

Other compute files

  • apps/hocuspocus.server/src/modules/document-changes/types.ts:51-61: SectionChange has no previousLevel. SectionNode (63-65) extends it.
  • apps/hocuspocus.server/src/modules/document-conversion/domain/portableJson.ts:9-18: EMBED_NODE_TYPES lists the embed node names. mcp/domain/redactMedia.ts already imports it.

apps/hocuspocus.server/src/lib/email/digestContentChanges.ts

  • placeSections (77-115) reads node.status but never copies it to the payload.
  • Line 93 drops a changed section whose heading text is ''.
  • withSections line 173 returns the Change digest block unchanged when no section is placed, so the sheet has no rows.
  • Lines 160-162 (refused compute) and 240-243 (a throw) also return the seed block, which has no sections.

packages/email-templates/

  • src/types.ts:46-59: DigestChangedSection has no status.
  • src/digestWalk.ts: passageRuns (11-21) returns any non-empty runs, and falls back to the excerpts when there are none. The heading block (32, 63) carries only text and url.
  • templates/digest.eta:34-37: every heading is the same bold dark link.
  • src/templates.ts: the heading case of paintDigestBlock (97-98) has no marker. paintRuns (13-24) marks only removed and added runs.
  • src/helpers.ts:186-192: changeRunStyle returns '' for 'same', so context paints in plain ink.

apps/hocuspocus.server/API.md (around line 802): the section field table and the runs paragraph. diffSections also feeds the service-role GET /changes route.

apps/hocuspocus.server/src/api/email.ts:190: digestPreviewDocuments, the typed preview fixture.

What happens

All cases are read from code, not measured.

Path A: a passage with grey context only (rows 1 and 2).

  • The space after "🧰" becomes a no-break space, a tab or a second space. Both change runs clean to ''. The mail shows the heading, then a plain "🧰".
  • A trailing space is trimmed from a heading. The context is the whole heading. The mail repeats the heading in plain ink.
  • An empty paragraph, a horizontal rule or an image is added right under a heading that has no body. The mail shows the same heading echo.
  • A hard break, image or embed is added or removed inside a passage. The change text is ' ', and it is dropped.
  • A removed U+FE0F variation selector survives cleanRun as an invisible red run. So a guard that only tests for an empty string is not enough.

Path B: a heading with nothing under it (row 3 candidates).

  • A heading is added or removed, and its body is empty or media only. wholeSection has no text. The mail shows a plain bold heading.
  • A heading changes level (H2 to H3), a word is bolded, a link address changes, or a task item is checked. The encoder sees no change. The mail shows a bare heading.
  • A section is moved. It is never quantified. The mail shows a bare heading.
  • Enter is pressed inside a section whose body already has a paragraph. The context is '', and the added '\n' cleans to ''. The mail shows a bare heading.
  • quantify throws. The mail shows a bare heading.

Path C: a deleted or new heading never looks deleted or new.

  • A whole section is deleted. Its body is red and struck through. Its heading is a live-looking bold link to the top of the document.
  • A new section is added. Its body is green. Its heading is plain.
  • A heading rename already works. The old words are red and the new words green in the passage.

Path D: a sheet with no rows.

  • A changed heading whose text is '' is dropped at line 93. That covers a cleared heading, a new heading with no name yet, and the legacy preamble. If it is the only change, the mail has a title, a badge of 1, the status bar, and nothing else.

Expected

The maintainer ruling in AGENTS.md (Change digest email) sets four rules:

  • Each changed heading is bold and linked.
  • Added text is green.
  • Removed text is red and struck through.
  • Never drop a changed heading.

The maintainer also asked, in this report, for a deleted heading to be highlighted. The ruling comment of 2026-10-07 settles the rest.

  • Every heading row shows a visible change: a green or red run, a green or red heading, or a label on the heading row.
  • A passage never consists only of 'same' and 'gap' runs.
  • A removed heading stays bold and linked, and is also red-washed and struck through. In plain text it reads [-heading-].
  • An added heading stays bold and linked, and is also green-washed. In plain text it reads {+heading+}.
  • Modified and moved headings keep today's style.
  • A modified or moved section with no change run shows a label on its heading row: "Moved" or "Edited". A whitespace-only or blank-line edit counts here and shows "Edited".
  • A heading whose level changed shows "Heading level changed".
  • An added or removed image, video or embed reads as a green or red word.
  • A changed heading with no text reads "Untitled heading". It is never dropped.
  • A document whose compute is refused or throws shows one line: "This document changed. Open it to see the edits."
  • A changed result never mails a sheet with no rows.

Fix

It adds no new module and no new colour token. Compute gains one optional field, previousLevel.

  1. Compute guard (runsAround). Clean the removed and added text first. Push the 'same' context only when at least one change run has visible text. Visible means more than whitespace, U+00A0, U+FE0F, U+200B or U+200D. Keep this predicate as one private function in diffSections.ts. Call it inside cleanRun, so pushRun drops an invisible run. Also apply it to excerpt and removedExcerpt. Run the final check after capRuns. If no change run survives in the section, return runs: [], and clear excerpt and removedExcerpt with it.
  2. Media words. Add one private leafWord(node) in diffSections.ts. image reads ' image ', video reads ' video ', and any other name in EMBED_NODE_TYPES reads ' embed '. Every other leaf, such as a hard break, stays ' '. Import EMBED_NODE_TYPES from portableJson.ts. Do not add a second media list.
    • Pass leafWord as the fourth argument of the removed and added textBetween calls in runsAround (110-111). Leave the context call (105) and the quantify word counts (166-167) on ' ', so the counts do not change.
    • In wholeSection, build the body text once from sectionDoc(section) with textBetween and leafWord. Start after the heading node, or at 0 when section.heading === null. Use it for the run and for the excerpt that the status sets. Keep magnitude on blockText.
  3. Heading level. In diffSections, when the status is modified and pair.baseline.level !== pair.head.level, add previousLevel: pair.baseline.level to the result. Add previousLevel?: number to SectionChange. buildSectionTree already spreads it into SectionNode.
  4. Payload. Add status: 'added' | 'removed' | 'modified' | 'moved' | 'unchanged' and previousLevel?: number to DigestChangedSection in packages/email-templates/src/types.ts. Copy node.status and node.previousLevel in placeSections. A chatOnly row carries 'unchanged', which is true for it.
  5. Nameless headings. Drop the node.text.length > 0 gate on changed (line 93), and pass the empty text through. Keep the live chat gate as it is. Update the placeSections comment at line 75, "A nameless row is dropped".
  6. Walk. walkDigest makes every choice, and both parts only paint it. Add two optional fields to the heading block, and no new block kind for labels.
    • mark?: 'added' | 'removed', set from status added or removed.
    • label?: string. The first match wins: "Heading level changed" when previousLevel is set, with or without runs. "Moved" when the status is moved. "Edited" when the status is modified and passageRuns returns no runs.
    • "Edited" covers formatting, whitespace and a thrown quantify, which compute cannot tell apart.
    • The heading text is section.text || 'Untitled heading'. The legacy preamble reads the same, because the payload carries no level to tell them apart.
    • Leftover channel rows (# name, digestWalk.ts:71) set neither field and keep today's paint.
  7. No-detail line. In walkDigest, when doc.content_changes is set and its sections is absent or empty, push one { kind: 'note'; text: string } block right after the sheet. The text is "This document changed. Open it to see the edits." That state already means compute was refused, threw, or placed nothing, or enrichment failed (digestContentChanges.ts:160-162, 173, 230-233, 240-243; apps/hocuspocus.server/src/lib/email/pgmqConsumer.ts:196-200). So the server and the payload do not change for it.
  8. Paint. Add no status table to helpers.ts, because the walk already resolved mark.
    • In digest.eta, wrap the heading text in a <span> inside the link. When mark is set, put changeRunStyle(block.mark) on that span. The link keeps its bold and colour, because Gmail and Outlook can restyle a link.
    • In the same <p>, after the <a>, paint label as a <span> in the existing COLORS.textMuted, 12px, weight 400, with margin-left: 6px.
    • Paint note as one <p> in COLORS.textMuted.
    • In templates.ts, lift the two markers out of paintRuns into one private markRun(kind, text). paintDigestBlock uses it for the heading, so plain text reads [-heading-] or {+heading+}.
    • Plain text appends the label in parentheses, for example Intro (Edited). Add a note arm that prints the line indented.
    • digest.eta has no exhaustive check, so engine.test.ts must assert the mark, the label and the note in both parts.
  9. Raise the quantify onError log in computeDocumentChanges.ts:178-182 from debug to warn.
  10. API.md. In the runs paragraph, state that runs never holds only same and gap runs, and that the excerpts are cleared with it. State that an added or removed media leaf reads as "image", "video" or "embed" in runs and in an added or removed section's excerpt. Add a previousLevel row to the field table: "The baseline heading level. Set only on a modified section whose level changed."

wholeSection keeps its body-only runs. The heading paint now carries the heading, so the heading text must not repeat in the passage. digestFit needs no change, because it already keeps runless sections (apps/hocuspocus.server/CLAUDE.md §Digest Email Links And Counts).

Cost: the guard is linear in the runs one section already builds. No new decode or query. Read from code, not measured. Compute still runs once per document per reader (digestContentChanges.ts:211-213).

Out of scope

Acceptance criteria

  • No digest passage has only 'same' and 'gap' runs, in HTML or in plain text.
  • A space swapped for U+00A0 after an emoji in a heading gives no grey-only passage. The heading row shows "Edited".
  • An empty paragraph added under a heading with no body does not echo the heading. The heading row shows "Edited".
  • A removed section, with a body and without one, shows its heading bold, red-washed and struck through. Plain text shows [-heading-].
  • An added section with an empty body shows its heading bold and green-washed. Plain text shows {+heading+}.
  • An H2 to H3 change shows "Heading level changed" on the heading row, with or without text runs. The section carries previousLevel: 2.
  • A bold-only edit, a link-address-only edit and a checked task item each show "Edited" on the heading row.
  • A moved section shows "Moved" on the heading row.
  • An image added or removed inside a passage, or as a whole section body, reads as a green or red "image".
  • A heading rename still paints the old words red and the new words green.
  • A changed heading with empty text reads "Untitled heading".
  • A Change digest block with no sections shows "This document changed. Open it to see the edits." in HTML and in plain text.
  • A changed: true result never mails a sheet with no rows.
  • No changed heading is dropped, and digestFit behaviour is unchanged.
  • API.md documents the new runs rule, the media words and previousLevel.
  • HTML and plain text show the same rows, labels and markers, checked in Gmail.
  • No colour token is added or changed.

Verify

Each new test must fail on today's main before the fix.

  • apps/hocuspocus.server/src/modules/document-changes/__tests__/unit/diffSections.test.ts: add cases for a U+00A0 swap in a heading, a trailing heading space, and an empty paragraph under a heading-only section. Add cases for a hard break, an image leaf, and H2 to H3. Assert no runs list without 'added' or 'removed'. Assert that excerpt and removedExcerpt are cleared with it. Assert that the image leaf gives an added run reading image, and that H2 to H3 sets previousLevel: 2.
  • apps/hocuspocus.server/tests/unit/contentChangeDigest.test.ts: assert that status and previousLevel reach the payload. Assert that a nameless changed heading is kept with empty text; the preamble case (around line 306) changes with it.
  • packages/email-templates/src/__tests__/engine.test.ts: in both parts, cover removed and added heading paint and each of the three labels on the heading row. Also cover "Untitled heading" and the note line for a block with no sections. The snapshots in __snapshots__ change; review the diff before you update them.
  • Add one section per status, and one with previousLevel, to digestPreviewDocuments. Open GET /api/email/preview/digest with the service-role header, and check it in a browser. Then send one test digest through the local stack to a Gmail account. Check Gmail web, Gmail mobile and the text/plain part.

Activity

  1. HMarzban commented on Oct 7, 2026

    @HMarzban
    CollaboratorAuthor

    Maintainer ruling, 2026-10-07

    1. Whitespace-only and blank-line edits still show the heading, with the "Edited" label.
    2. Label words: "Moved" and "Edited". Each label shows as a visible indicator on the heading row (a small marker or tag), so the reader always sees that something happened.
    3. A heading level change gets its own "Heading level changed" label. Add the optional previousLevel field.
    4. An added or removed image reads as a green or red word ("image", "video", "embed").
    5. A heading with no name reads "Untitled heading".
    6. When compute is refused or throws, the mail adds one line: "This document changed. Open it to see the edits."
    7. The screenshot and its heading names may appear in this public issue.
  2. changed the title [-]Show red, green or a label row under every changed heading in the digest email[/-] [+]Show red, green or a label on every changed heading in the digest email[/+] on Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions