notes: inline bold, italic, strikethrough and code - #32
Merged
Merged
Conversation
Deploying travel-planner with
|
| Latest commit: |
301e504
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://b3157013.travel-planner-7x6.pages.dev |
| Branch Preview URL: | https://feat-note-inline-formatting.travel-planner-7x6.pages.dev |
thkleinert
pushed a commit
that referenced
this pull request
Sep 9, 2026
A code span was not opaque to an emphasis closer scan. "*price `2*3` here*" closed the italic on the asterisk between the backticks: the code span vanished, its backticks rendered as literal characters, and an emphasis run appeared across a boundary nobody wrote. findCloser now steps over a code span exactly as it steps over a link, using the same bounds walk uses, so the two always agree on where a span is. Emphasis markers are out of TRAILING again. Trimming them there was unconditional, so it rewrote hrefs in notes with no emphasis anywhere in them — "https://example.com/a?b=1&c=*" lost its last character while the pill still read "example.com", which is a broken link wearing a working link's label. The trim only ever existed for the closer scan, so it now lives there, in skipPastLink, and nowhere else. walk had no depth bound, and trip_notes.body has no length limit: a pasted '*'.repeat(12000) + 'x' + '*'.repeat(12000) overflowed the stack, and since NoteBody renders inside the React tree that takes the page down for every viewer of the trip rather than only its author. Past MAX_DEPTH markers stop being markers, which is the same answer everything else here gives when a marker cannot be honoured. Declined the fourth finding, that "****bold****" and "**x***" lose characters. They do not: every asterisk in those is a delimiter of a matched pair or survives into the span ("**x***" renders x* — the odd one is kept). What the header comment promised was too strong, though, so it now states the guarantee it can actually keep: the only characters ever removed are markers that found a partner. Claude-Session: https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E
thkleinert
pushed a commit
that referenced
this pull request
Sep 9, 2026
A code span was not opaque to an emphasis closer scan. "*price `2*3` here*" closed the italic on the asterisk between the backticks: the code span vanished, its backticks rendered as literal characters, and an emphasis run appeared across a boundary nobody wrote. findCloser now steps over a code span exactly as it steps over a link, using the same bounds walk uses, so the two always agree on where a span is. Emphasis markers are out of TRAILING again. Trimming them there was unconditional, so it rewrote hrefs in notes with no emphasis anywhere in them — "https://example.com/a?b=1&c=*" lost its last character while the pill still read "example.com", which is a broken link wearing a working link's label. The trim only ever existed for the closer scan, so it now lives there, in skipPastLink, and nowhere else. walk had no depth bound, and trip_notes.body has no length limit: a pasted '*'.repeat(12000) + 'x' + '*'.repeat(12000) overflowed the stack, and since NoteBody renders inside the React tree that takes the page down for every viewer of the trip rather than only its author. Past MAX_DEPTH markers stop being markers, which is the same answer everything else here gives when a marker cannot be honoured. Declined the fourth finding, that "****bold****" and "**x***" lose characters. They do not: every asterisk in those is a delimiter of a matched pair or survives into the span ("**x***" renders x* — the odd one is kept). What the header comment promised was too strong, though, so it now states the guarantee it can actually keep: the only characters ever removed are markers that found a partner. Claude-Session: https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E
thkleinert
pushed a commit
that referenced
this pull request
Sep 9, 2026
A stray marker earlier in the line stole the emphasis the writer meant,
and ate itself doing it: "Bring adapters*, and *do not* forget" rendered
as "Bring adapters, and *do not forget" — the first asterisk paired with
the OPENING one of the real span, the emphasis landed on text nobody
wrote, and a character the user typed went missing. "Save as IMG*.jpg
then *print* it" the same. openerAt now applies CommonMark's
left-flanking rule: a marker facing punctuation only opens if what
precedes it is whitespace, punctuation, or the start of the span.
Intraword "5*3" is left alone, which is what CommonMark does too.
Bold and strikethrough were invisible on the only two things a note
contains besides prose. .note-link and .mention-chip set their own
font-weight, and an author rule on the element beats the UA's inherited
strong{bolder}; the line-through is not overridden but never drawn at
all, since .note-link is inline-flex and decoration does not propagate
into an inline-level atomic box. So "**@Café Korb**" and
"~~www.example.com~~" rendered pixel-identical to the unemphasised
versions — the markers did nothing and looked like a bug.
findCloser scanned to the end of the string for every opener that had no
partner, which is quadratic in marker density: a pasted bullet list
written "*Item" with no space is 2.4s of blocked main thread at 48KB and
a minute at 120KB, for every viewer of the trip. One failed search now
settles that marker kind for the rest of the string — a later opener
searches a suffix of a range already proven empty, and every test
findCloser applies is positional. 61.5s becomes 33ms.
skipPastLink handed a link's trailing backtick back to the scan, which
read it as a code opener and skipped to a backtick further down the
note, stepping over a real closer on the way: "*hi www.x.com` there* ok
`q` end*" lost its closer after "there". Those trailing markers may be
read as closers but never as openers, since walk keeps them inside the
URL — findCloser tracks the link's true end and suppresses only the code
branch inside it.
withMarks ended in a bare else that rendered any unrecognised mark as
<code>. A Record over NoteMark makes a fifth mark a compile error.
Also: urlAt absorbs the h/w gate both callers repeated, codeEnd makes
the two passes agree on a span's bounds structurally rather than by
comment, runLength replaces two copies of the same loop, and a plain
text segment renders as bare text — nothing styled that span, and plain
runs are the commonest segment by far.
Claude-Session: https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E
thkleinert
force-pushed
the
feat/note-inline-formatting
branch
from
September 9, 2026 11:19
8a1bcd9 to
2fbbe6c
Compare
parseNoteBody was already an inline tokenizer walking the string for mentions and links, so emphasis is four more branches in the same walk rather than a second system. Segments gain an optional `marks` set: flat, because emphasis crosses the other segment types instead of containing them — a bold span holds text, mentions and links alike, and a tree would make every consumer walk children to find a mention. Emphasis is tested after the URL branch, for the reason the comment there already gives about '@' and one worse: URLs are full of markers, and www.example.com/a*b*c would otherwise lose its middle to an <em> and stop being one link. _underscore_ is not supported at all, since that hazard is the common case rather than the rare one. An unmatched marker stays the character that was typed. "2 * 3 = 6" is arithmetic, "****" is four asterisks, "~20 min" is a tilde. That is the same guarantee mentions already make and the reason the storage format holds no tokens: a note always reads as what was typed, and a broken marker degrades to visible characters instead of corruption. Inline only. The notes ARE an outline — the row's depth carries the structure — so headings and list markers would be a second, contradictory hierarchy. NoteBody renders <strong>/<em>/<s>/<code>, so both surfaces that use it (the outliner and the place sheet) get this at once; nothing else renders a note body raw. Claude-Session: https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E
The example given did not hold: a marker at a link's very END is handed back by trimTrailingPunctuation and can close a span. That is wanted — it is what makes "*book www.example.com*" work, and it treats a trailing '*' exactly as a trailing '.' has always been treated. What the step-over actually guarantees is that nothing INSIDE a path can close, so the link is never cut in half. Claude-Session: https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E
A code span was not opaque to an emphasis closer scan. "*price `2*3` here*" closed the italic on the asterisk between the backticks: the code span vanished, its backticks rendered as literal characters, and an emphasis run appeared across a boundary nobody wrote. findCloser now steps over a code span exactly as it steps over a link, using the same bounds walk uses, so the two always agree on where a span is. Emphasis markers are out of TRAILING again. Trimming them there was unconditional, so it rewrote hrefs in notes with no emphasis anywhere in them — "https://example.com/a?b=1&c=*" lost its last character while the pill still read "example.com", which is a broken link wearing a working link's label. The trim only ever existed for the closer scan, so it now lives there, in skipPastLink, and nowhere else. walk had no depth bound, and trip_notes.body has no length limit: a pasted '*'.repeat(12000) + 'x' + '*'.repeat(12000) overflowed the stack, and since NoteBody renders inside the React tree that takes the page down for every viewer of the trip rather than only its author. Past MAX_DEPTH markers stop being markers, which is the same answer everything else here gives when a marker cannot be honoured. Declined the fourth finding, that "****bold****" and "**x***" lose characters. They do not: every asterisk in those is a delimiter of a matched pair or survives into the span ("**x***" renders x* — the odd one is kept). What the header comment promised was too strong, though, so it now states the guarantee it can actually keep: the only characters ever removed are markers that found a partner. Claude-Session: https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E
A second review pass flagged "www.example.com*bold*" collapsing into one link with a garbled href. Verified against main: byte-for-byte the same output there, so it is URL_PATTERN's existing greediness meeting new syntax rather than anything this branch introduced. Declined as a case nobody writes — a link and an emphasis run with no space between them — where the case people do write, a link wrapped in emphasis, is exactly what skipPastLink makes work. Written down so the next reader does not have to re-derive it. Claude-Session: https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E
A stray marker earlier in the line stole the emphasis the writer meant,
and ate itself doing it: "Bring adapters*, and *do not* forget" rendered
as "Bring adapters, and *do not forget" — the first asterisk paired with
the OPENING one of the real span, the emphasis landed on text nobody
wrote, and a character the user typed went missing. "Save as IMG*.jpg
then *print* it" the same. openerAt now applies CommonMark's
left-flanking rule: a marker facing punctuation only opens if what
precedes it is whitespace, punctuation, or the start of the span.
Intraword "5*3" is left alone, which is what CommonMark does too.
Bold and strikethrough were invisible on the only two things a note
contains besides prose. .note-link and .mention-chip set their own
font-weight, and an author rule on the element beats the UA's inherited
strong{bolder}; the line-through is not overridden but never drawn at
all, since .note-link is inline-flex and decoration does not propagate
into an inline-level atomic box. So "**@Café Korb**" and
"~~www.example.com~~" rendered pixel-identical to the unemphasised
versions — the markers did nothing and looked like a bug.
findCloser scanned to the end of the string for every opener that had no
partner, which is quadratic in marker density: a pasted bullet list
written "*Item" with no space is 2.4s of blocked main thread at 48KB and
a minute at 120KB, for every viewer of the trip. One failed search now
settles that marker kind for the rest of the string — a later opener
searches a suffix of a range already proven empty, and every test
findCloser applies is positional. 61.5s becomes 33ms.
skipPastLink handed a link's trailing backtick back to the scan, which
read it as a code opener and skipped to a backtick further down the
note, stepping over a real closer on the way: "*hi www.x.com` there* ok
`q` end*" lost its closer after "there". Those trailing markers may be
read as closers but never as openers, since walk keeps them inside the
URL — findCloser tracks the link's true end and suppresses only the code
branch inside it.
withMarks ended in a bare else that rendered any unrecognised mark as
<code>. A Record over NoteMark makes a fifth mark a compile error.
Also: urlAt absorbs the h/w gate both callers repeated, codeEnd makes
the two passes agree on a span's bounds structurally rather than by
comment, runLength replaces two copies of the same loop, and a plain
text segment renders as bare text — nothing styled that span, and plain
runs are the commonest segment by far.
Claude-Session: https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E
thkleinert
force-pushed
the
feat/note-inline-formatting
branch
from
September 9, 2026 11:21
2fbbe6c to
301e504
Compare
This was referenced Sep 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds markdown-like inline formatting to note bodies:
*italic*,**bold**,~~strike~~,`code`.Approach
parseNoteBodywas already an inline tokenizer walking the string for mentions and links, so emphasis is four more branches in the same walk rather than a parallel system. The editor is still a plain<textarea>— no rich-text dependency, users type the markers.Segment model: a flat
marks?: ReadonlySet<NoteMark>on each segment, not a tree. Emphasis crosses the other segment types instead of containing them — a bold span holds text,@mentionsand links alike, and***x***is one run wearing two marks. A tree would make every consumer walk children to find a mention; a mark set leaves the segment list exactly as flat as it was and lets the renderer wrap whatever it was going to draw anyway. Emphasis contents are parsed by re-entering the scanner with an extra mark, so the recursion accumulates marks and never nests segments.Ordering. Emphasis is tested after the URL branch, for the reason the existing comment gives about
@and one worse: URLs are full of markers, andwww.example.com/a*b*cwould otherwise lose its middle to an<em>and stop being one link. The emphasis closer scan also steps over links rather than into them._underscore_is not supported at all — that hazard is the common case (snake_case,example.com/a_b_c), not the rare one.An unmatched marker stays the character that was typed. Two rules do most of the work: an opener may not be followed by whitespace and a closer may not be preceded by it (this is what keeps
2 * 3 = 6 * 2arithmetic), and an empty span is rejected (****is four asterisks). This preserves the storage philosophy documented at the top ofmentions.ts: nothing in the database is a token, so a note always reads as what was typed and a broken marker degrades to visible characters rather than corruption.Inside
`code`nothing else is parsed — no mentions, no links, no nested emphasis.Inline only, no block-level markdown. The notes ARE an outline —
trip_notesrows carry adepthand the list structure is the structure — so headings, list markers and blockquotes would be a second, contradictory hierarchy.Rendering
NoteBodyemits semantic<strong>/<em>/<s>/<code>, not styled spans, in a fixed outermost-first order so the same mark set always produces the same DOM. It is shared by the outliner (NoteList) and the place sheet (PlaceDetailSheet), so both surfaces get this at once. I checked the other views —SharedTripView,TripNotesPage,TripTimeline,PlaceListView— and nothing else renders a note body raw, so there is nowhere that would newly show markers.CSS: only
.note-codegets a rule (neutralcolor-mixtint off--color-textso it doesn't compete with.mention-chip's primary tint,0.92emso monospace sits on the same optical line,box-decoration-break: clonelike the chip).<strong>,<em>and<s>deliberately ride on browser defaults. No new hex values.Parser results
Run against a throwaway script (not committed) with places
Café KorbandHotel Wandl. Format istype(marks) "value".Every unmatched or ambiguous case falls back to literal text, and the URL in
see www.example.com/a_b_c/x*y*z nowsurvives intact with both its underscores and its asterisks.npm run buildandnpm run lintare both clean.Boundary cases between emphasis and links
The interesting interaction is at a link's edge, since
URL_PATTERNtakes everything that isn't whitespace and would otherwise swallow a closing marker.findClosersteps over a link (and over a code span) rather than into one, stopping short of a trailing marker:A marker inside a path never closes a span, and a link in a note with no emphasis in it keeps its href byte for byte — the trim that makes the first two cases work is confined to the closer scan (
skipPastLink) rather than folded intotrimTrailingPunctuation, because a silently shortened href is a broken link wearing a working link's label.Fuzz
A throwaway property test (also not committed) ran 200,000 random strings built from an alphabet of
*,**,~,~~, backtick,@, a place name, a URL containing asterisks, parens, spaces and newlines, asserting that the concatenated segment values are a subsequence of the input dropping only marker characters:walkalso carries aMAX_DEPTH, past which markers stop being markers —trip_notes.bodyhas no length limit, and without the cap a pasted'*'.repeat(12000) + 'x' + '*'.repeat(12000)overflowed the stack inside the React tree, taking the page down for every viewer of the trip. It now parses at 100k characters in 24ms.Review
Three review passes,
higheffort.Round 1 — four findings. Fixed: a code span was not opaque to an emphasis closer scan, so
*price `2*3` here*destroyed it;TRAILINGgaining the markers rewrote hrefs unconditionally, and.note-linkonly shows the host so the user could not see the target had moved;walkhad no depth bound, and a pasted'*'.repeat(12000) + 'x' + '*'.repeat(12000)overflowed the stack inside the React tree. Declined: the claim that****bold****and**x***lose characters — every asterisk there is either a delimiter of a matched pair or survives into the span. The header comment was overclaiming, so it now states the guarantee it can keep: the only characters ever removed are markers that found a partner.Round 2 — five findings across two reviewers, all fixed:
Bring adapters*, and *do not* forgetrendered asBring adapters, and *do not forget— the first asterisk paired with the opening marker of the real span.openerAtnow applies CommonMark's left-flanking rule: a marker facing punctuation only opens if what precedes it is whitespace, punctuation, or the start of the span. Intraword5*3is left alone, as CommonMark leaves it.<strong>and<s>were invisible on the only two things a note contains besides prose..note-linkand.mention-chipset their ownfont-weight, and an author rule on the element beats the UA's inheritedstrong { bolder }; the line-through is not overridden but never drawn, because.note-linkisinline-flexand text decoration does not propagate into an inline-level atomic box. So**@Café Korb**and~~www.example.com~~rendered pixel-identical to the unemphasised versions. Two CSS rules fix it.findCloserscanned to the end of the string for every opener with no partner — quadratic in marker density, and the triggering input is not adversarial-looking: a pasted bullet list written*Itemwith no space. 48 KB was 2.4 s of blocked main thread and 120 KB was 61.5 s, for every viewer of the trip. One failed search now settles that marker kind for the rest of the string, since a later opener searches a suffix of a range already proven empty and every testfindCloserapplies is positional. 61.5 s becomes 33 ms.skipPastLinkhanded a link's trailing backtick back to the scan, which read it as a code opener and skipped to a backtick further down the note, stepping over a real closer:*hi www.x.com` there* ok `q` end*lost its closer afterthere. Those trailing markers may close a span but never open one, sincewalkkeeps them inside the URL;findClosertracks the link's true end and suppresses only the code branch inside it.withMarksended in a bareelsethat rendered any unrecognised mark as<code>. ARecord<NoteMark, …>makes a fifth mark a compile error.Also declined, from a parallel pass:
www.example.com*bold*collapsing into one link with a garbled href. Verified byte-for-byte identical onmain— it isURL_PATTERN's existing greediness meeting new syntax, not something this branch introduced, and it only fires when a marker abuts a link with no space. The case people do write, a link wrapped in emphasis, is exactly whatskipPastLinkmakes work; the boundary is written down next to the function.Reuse cleanups both reviewers raised:
urlAtabsorbs theh/wgate its two callers repeated,codeEndmakes the scanner and the closer scan agree on a span's bounds structurally rather than by comment,runLengthreplaces two copies of the same loop, and a plain-text segment renders as bare text — nothing styled that<span>, and plain runs are the commonest segment by far.https://claude.ai/code/session_01GTyi3SDwPUCdn6iXXPHu5E