Skip to content

Commit 3235ec9

Browse files
authored
feat(core): Markdown fenced code wraps long lines, as RichText does (#7564)
## User impact A reader of `Markdown` on a narrow screen sees a whole code line instead of scrolling a code block sideways, and a long line is as tall in `Markdown` as in RichText, so a product that switches a document between reading (`Markdown`) and editing (`RichTextEditor`) no longer moves every block below a long code line. Only appearance changes: the code's text, line numbers, syntax colours and Copy output are unchanged. ## Authority `spec:AST-061` FR5 / DEC-9 (owner-approved, stacked spec pull request #7559): "Fenced code wraps long lines on every surface; a code line never scrolls or clips sideways." Merge after #7559. ## What changes - `Markdown` passes `isWrapped` to its default `CodeBlock`. A caller's own `components.code` renderer is untouched. - RichText's code block wraps with core `CodeBlock`'s wrapped rule (`white-space: pre-wrap`, `word-break: break-all`, `overflow-wrap: break-word`) instead of `overflow-wrap: anywhere`, so a line of the same width breaks at the same places on both surfaces; with different rules a long line could take a different number of lines in read and edit mode. - `Markdown.doc.mjs` anatomy says code wraps. - `component:Markdown` (current record) Intent and Compatibility record the wrap as a visual, non-breaking default-path change (a fence that no longer overflows also no longer takes the conditional scroll-region tab stop), and its `system_specs` cite `spec:AST-061/DEC-9`. This touches a record, so it needs spec-owner approval at its head. - Changeset: `@astryxdesign/core` patch, `[feat]` (visual-only under `spec:AST-017/DEC-17`), naming the old and new look and the `components.code` escape. - The parity sandbox Chromium spec asserts the record: no fence scrolls sideways on `Markdown`, and above phone width a wrapped long line is the same height on both surfaces (at 1440px `code-long` wraps to two lines on each). At phone width the editor's field inset leaves its frame 18px narrower, so a long line there may take one more line in the editor, which FR9's edit-mode inset allows. ## Evidence Real Chrome (stable, headless) against Storybook builds: | Arm | Width | Markdown code block | RichText code block | Markdown scrolls sideways | | --- | --- | --- | --- | --- | | before (current `main`) | 390 | 82px | 142px | yes | | before | 1440 | 82px | 102px | yes | | after | 390 | 122px | 142px | no | | after | 1440 | 102px | 102px | no | Light and dark give the same numbers. At 390px the remaining 20px is one extra wrapped line in the editor's narrower frame (340px vs 358px of room). - Red first: the updated parity case fails on `main` at 390px and 1440px ("code-fence scrolls", "code-long scrolls"); with this change the whole parity sandbox spec passes, 39 of 39. - `npx vitest run packages/core/src/Markdown packages/core/src/CodeBlock`: 48 files, 1070 tests pass. - `check:changesets`, Prettier, ESLint clean. ## Writing rubric results Run over the changed text of `docs/specs/AST-061-document-surface-parity/spec.md` (Current-state impact) and `packages/core/src/Markdown/Markdown.spec.md` (Intent, Compatibility and migration, FR29 row). - R1 narration: none. Each changed sentence states what Markdown and RichText do now (the wrap, the editor-frame gap, the two default-path changes) in the present tense. - R2 research in the record: none; the measurements stay in this description and the evidence artifact. - R3 evidence naming a change: none; no verification cell names a pull request. - R4 rejected alternatives over one line or outside a decision: none added. - R5 pull request or issue references remaining: none. - R6 ruled open questions still listing options: none; neither record gains an open question. ## Scope - [x] One change: fenced code wraps on both surfaces. - [x] No new public API; a visual default changes, with an escape. - [x] Public text and artifacts contain no internal Meta context.
1 parent 4a14542 commit 3235ec9

7 files changed

Lines changed: 62 additions & 20 deletions

File tree

‎.changeset/markdown-code-wraps.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@astryxdesign/core': patch
3+
---
4+
5+
[feat] `Markdown` fenced code blocks wrap long lines instead of scrolling them sideways. A reader on a narrow screen sees the whole line without a horizontal scroll, and where `Markdown` and RichText give a code block the same width, a long line takes the same number of lines in both. Only the appearance changes: the code's text, line numbers, syntax colours and what Copy returns are unchanged, and a code block whose lines no longer overflow also no longer takes the scroll region's Tab stop, as a code block that fits never has. To keep the old sideways-scrolling look, render code yourself through the `components.code` prop with a `CodeBlock` that leaves `isWrapped` off.
6+
7+
@cixzhang

‎docs/specs/AST-061-document-surface-parity/spec.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,11 @@ RichText adopts DEC-8: its package entry stops exporting `sharedEditorTheme`,
132132
gains `BaseProps` and a `ref` and renames `linkOpensInNewTab` to `hasNewTabLinks`; and its
133133
Lexical peer ranges move to the Lexical 0.52 minor.
134134
`Markdown` renders fenced code unwrapped, scrolling sideways, and adopts the
135-
FR5 wrap; RichText already wraps fenced code.
135+
FR5 wrap; RichText already wraps fenced code. RichText's editing surface is
136+
18px narrower than the reading surface at every width, the field's inset, so a
137+
line that wraps close to the available width can take one more line in edit
138+
mode than in read mode, against FR9; at phone width a long fenced code line
139+
does.
136140

137141
## Verification
138142

‎packages/core/src/Markdown/Markdown.doc.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const anatomy = [
2929
name: 'Code block',
3030
required: false,
3131
description:
32-
'Fenced code block; a custom code renderer replaces the default part.',
32+
'Fenced code block; long lines wrap rather than scroll sideways. A custom code renderer replaces the default part.',
3333
},
3434
{
3535
name: 'Blockquote',

‎packages/core/src/Markdown/Markdown.spec.md‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ system_specs:
5050
spec:AST-036/DEC-4,
5151
spec:AST-061/DEC-5,
5252
spec:AST-061/DEC-6,
53+
spec:AST-061/DEC-9,
5354
spec:AST-064/DEC-6,
5455
spec:AST-036/DEC-12,
5556
]
@@ -66,14 +67,15 @@ rendering, and native typed document-start frontmatter. They may separately opt
6667
renderer for both inline and display expressions. The parser accepts matching
6768
explicit options. Plugins and math change nothing when absent; the default Code
6869
block path renders `diff` and `diff-<language>` fences as CodeBlock's diff layer
69-
(FR29), and every other parsing, rendering, styling, and streaming behavior is
70-
the released one.
70+
(FR29) and wraps every fence's long lines (`spec:AST-061` FR5, DEC-9), and every
71+
other parsing, rendering, styling, and streaming behavior is the released one.
7172

7273
## Compatibility and migration
7374

74-
- Released default preserved: `yes`, except the default Code block path for `diff` fences (FR29, below)
75-
- Compatibility class: additive, opt-in public API, with one visible default-path
76-
rendering change (FR29, below); existing parser nodes, DOM, styling, targets,
75+
- Released default preserved: `yes`, except the default Code block path for `diff` fences (FR29, below) and its long-line wrap (below)
76+
- Compatibility class: additive, opt-in public API, with two visible
77+
default-path rendering changes (the FR29 diff layer and the fenced-code wrap,
78+
below); existing parser nodes, DOM, styling, targets,
7779
dollar-delimited text, `components`, and `inlinePlugins` remain unchanged unless
7880
the caller supplies `plugins`, supplies `components.math`, or passes the matching
7981
explicit parser option.
@@ -87,7 +89,13 @@ the released one.
8789
a `<language> diff` label; the header, copy, disclosure, and viewport name
8890
stay, and a supplied `code` renderer's contract is unchanged. The way back to
8991
plain text is a `text` fence or a custom `code` renderer. Every other fence
90-
is unchanged.
92+
is unchanged apart from the wrap below.
93+
- Default-path wrap change (`spec:AST-061` FR5, DEC-9), visual and not breaking:
94+
the default Code block wraps long lines instead of scrolling them sideways, so
95+
a fence whose lines no longer overflow also no longer takes the conditional
96+
scroll-region tab stop. Text, line numbers, syntax colours, copy output, and a
97+
supplied `code` renderer's contract are unchanged. The way back to sideways
98+
scrolling is a custom `code` renderer.
9199
- Default-path layout change (FR26–FR28): a default table's columns may become
92100
wider than the released fixed buckets, never narrower; header labels wrap
93101
instead of truncating; a table that squashed its columns may now scroll.
@@ -240,7 +248,7 @@ unions. Enabled calls return the explicit `InlineNodeWithMath` and
240248
| FR26 | On the default table path each column has a content-derived width floor: the larger of its own longest unbreakable token and a readable minimum derived from that column's content, bounded so a table of short columns still fits its container. The floor applies to the cell's text, not to its padded box, so it means the same amount of content at every density. A header label contributes its own readable minimum to that comparison. A table whose floors exceed the container grows and scrolls rather than dividing the container equally. |
241249
| FR27 | Default table header cells never truncate: no ellipsis and no width clamp. A header reads in full, on one line up to a bounded width and wrapping past it. Inline code inside a default table cell keeps its token whole rather than breaking mid-token; a supplied `inlineCode` renderer owns its own wrapping. |
242250
| FR28 | Markdown's Table block owns spacing, sizing, and alignment. The nested Table's own Scroll region remains the sole owner of the table's horizontal overflow, its accessible name, and its conditional keyboard focusability; Markdown adds no second scroll container, name, or tab stop of its own. |
243-
| FR29 | On the default Code block path the fence's full first word selects CodeBlock's diff layer (`component:CodeBlock` FR9): `diff` renders the layer over plaintext, `diff-<language>` the layer over `<language>` (the language read by the same leading-word rule as any other fence); every other fence is unchanged. A supplied `code` renderer's contract is unchanged: it keeps receiving the leading word characters as `language` — `diff` for both `diff` and `diff-<language>` fences — and nothing new. |
251+
| FR29 | On the default Code block path the fence's full first word selects CodeBlock's diff layer (`component:CodeBlock` FR9): `diff` renders the layer over plaintext, `diff-<language>` the layer over `<language>` (the language read by the same leading-word rule as any other fence); every other fence takes no diff layer. A supplied `code` renderer's contract is unchanged: it keeps receiving the leading word characters as `language` — `diff` for both `diff` and `diff-<language>` fences — and nothing new. |
244252

245253
FR23 includes CommonMark-compatible lazy paragraph continuation inside blockquotes
246254
and ordered, unordered, or task-list items. Omitting a repeated quote marker or

‎packages/core/src/Markdown/Markdown.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1523,6 +1523,10 @@ function renderBlock(
15231523
language={language}
15241524
isDiff={isDiff}
15251525
isCollapsible
1526+
// Long lines wrap rather than scroll sideways, as they do in
1527+
// RichText, so a reader sees whole lines and the read/edit switch
1528+
// moves no block (spec:AST-061 FR5, DEC-9).
1529+
isWrapped
15261530
xstyle={[
15271531
contentWidthValue != null
15281532
? dynamicStyles.blockWidth(contentWidthValue)

‎packages/richtext/src/__tests__/MarkdownParitySandbox.a11y.chromium.spec.ts‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -670,9 +670,8 @@ test('task items own their checkboxes, keep link tab order, and toggle by keyboa
670670
// both surfaces, so the code sits at the same place; the header is not part
671671
// of the editable text.
672672
/**
673-
* Whether core Markdown scrolls a fence's lines sideways. RichText wraps them
674-
* instead (a scrolling region inside the editable text cannot take focus), so
675-
* a fence whose lines do not fit is taller in RichText by its wrapped lines.
673+
* Whether core Markdown scrolls a fence's lines sideways. Under spec:AST-061
674+
* FR5 / DEC-9 it never does: both surfaces wrap long code lines.
676675
*/
677676
async function markdownFenceScrolls(page: Page, key: string): Promise<boolean> {
678677
return page.evaluate(
@@ -695,12 +694,12 @@ for (const viewport of [PHONE, DESKTOP, {width: 2200, height: 900}] as const) {
695694
await waitForDocument(page, RICH_TEXT);
696695
const markdown = await surfaceGeometry(page, MARKDOWN);
697696
const richText = await surfaceGeometry(page, RICH_TEXT);
698-
const code = (geometry: SurfaceGeometry) =>
699-
geometry.blocks.find(block => block.key === 'code-fence');
700-
if (!(await markdownFenceScrolls(page, 'code-fence'))) {
701-
expect(
702-
Math.abs((code(richText)?.height ?? 0) - (code(markdown)?.height ?? 0)),
703-
).toBeLessThanOrEqual(2);
697+
// Fenced code wraps on both surfaces (spec:AST-061 FR5, DEC-9): no fence
698+
// scrolls sideways.
699+
for (const key of ['code-fence', 'code-long']) {
700+
expect(await markdownFenceScrolls(page, key), `${key} scrolls`).toBe(
701+
false,
702+
);
704703
}
705704
// Each frame is sized like core Markdown's: as wide as its longest line,
706705
// at least the 680px measure (or the whole width when narrower), and no
@@ -747,6 +746,24 @@ for (const viewport of [PHONE, DESKTOP, {width: 2200, height: 900}] as const) {
747746
keys: ['code-fence', 'code-plain', 'code-unknown', 'code-long'],
748747
},
749748
);
749+
// The editor's frame is 18px narrower than the reader's at every width
750+
// (the field inset). Above phone width `code-long` still wraps to the same
751+
// number of lines on both surfaces (two at 1440px), so the block is as
752+
// tall in read as in edit mode (DEC-9). At phone width it takes one more
753+
// line in the editor: the known editor-frame gap that AST-061's
754+
// Current-state impact records, so the check skips that width.
755+
if (viewport.width !== PHONE.width) {
756+
for (const key of ['code-fence', 'code-long']) {
757+
const block = (geometry: SurfaceGeometry) =>
758+
geometry.blocks.find(candidate => candidate.key === key);
759+
expect(
760+
Math.abs(
761+
(block(richText)?.height ?? 0) - (block(markdown)?.height ?? 0),
762+
),
763+
`${key} height`,
764+
).toBeLessThanOrEqual(2);
765+
}
766+
}
750767
for (const frame of frames) {
751768
if (frame.key === 'code-long') {
752769
// A long line widens the frame past the measure, up to the room.

‎packages/richtext/src/editorTheme.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -410,9 +410,11 @@ const editorTheme = stylex.create({
410410
default: WIDE_BLOCK_SPACE,
411411
':last-child': spacingVars['--spacing-0'],
412412
},
413+
// Long lines wrap exactly as core CodeBlock's wrapped lines do, so a code
414+
// block is as tall here as in core Markdown (spec:AST-061 FR5, DEC-9).
413415
whiteSpace: 'pre-wrap',
414-
// An unbroken token wider than the block (a long URL or hash) wraps too.
415-
overflowWrap: 'anywhere',
416+
wordBreak: 'break-all',
417+
overflowWrap: 'break-word',
416418
},
417419
});
418420

0 commit comments

Comments
 (0)