Skip to content

Commit a4e4c73

Browse files
authored
Add a compact render mode and a title to the markdown preview (#3882)
* Add a compact render mode and a title to the markdown preview `Preview` renders into an iframe served by `/render`, which pads the page by 1rem and lays out `main`/`p` as blocks. That is right for a document and wrong for a short string: a one-line summary arrives in a padded box, and the padding is counted into the measured frame height. Adds two props: - `compact` drops the page padding and lets the block wrappers flow, so a short string renders as a line of text. Wrapping stays on, so content of a sentence or two still fits its frame rather than being clipped. - `title` sets the frame's accessible name, which was hardcoded to "output". A page with several of these frames announced them all identically. Also sets `border-0` on the iframe, which otherwise picks up the UA default border in some browsers. Both props are additive and default to today's behaviour. The approach here comes from @Tidemand's work in #3865, which needs this same body-class plumbing for inline event summaries. This is deliberately narrower: no shrink-to-fit width measurement, so that PR's inline mode can layer on top rather than conflict with it. * Tighten compact mode so it matches the text beside the frame Measuring the first consumer in a browser turned up three things the initial pass missed. The reset sizes every element through a `*` selector, which beats inheritance, so `body.compact { font-size }` never reached `main`, `p` or the inline elements — content rendered at 12.25px next to 14px text outside the frame. The metrics are now restated for the elements a short string can contain. `main` and `p` go back to blocks with the margins zeroed rather than `display: inline`. `getRenderedHeight` measures `main`, and an inline box reports the text box rather than the line box: two pixels short of the leading, which clips descenders and the background of a code span. Inline code keeps its horizontal padding and drops the vertical, which otherwise grows the line box past the height the frame was sized to. Measured on a one-line string with bold, a code span and a link: the frame went from 65px to 23px, with nothing clipped and the type matching its surroundings. * Restore bold, and merge main The star selector at the top of the reset sets font-weight on every element, which beats the UA default for strong. Nothing restored it, so `**bold**` has been rendering at normal weight on every markdown surface — event summaries and incident summaries included, not just the compact mode this PR adds. Found while checking a consumer. Also merges main for the Io colour system. The reset now resolves code colours through --color-surface-secondary and --color-content-primary, and the compact rules here still hold against it: they set layout and metrics, not colour. * Break long unbroken tokens instead of overflowing The reset set no overflow-wrap anywhere, so a long URL in a summary ran past its container - and in compact mode, where the body clips, it simply disappeared off the edge. Same bug as the bold one: it affects every markdown surface, not just the compact mode this PR adds. A consumer hit it, was fixed there, and this is the half that belongs upstream. * Carry the compact render styles into the Go render route The ui-server serves /render from its own Go handler with the stylesheet inlined as a template literal, so the compact changes to src/markdown.reset.css only took effect under the SvelteKit dev server. A packaged binary kept the 1rem page padding and the opaque box the compact class exists to remove. Ports the compact block, reads the compact query param with the same exact-string parsing the SvelteKit route uses, and adds overflow-wrap so a long unbroken token in a 255-character description wraps instead of overflowing the frame. Also pins strong/b to 600. Bold already worked here via the UA default, since unlike the SvelteKit reset this star selector sets no font-weight, but the UA's 700 rendered heavier than the same markdown does in dev. The two stylesheets cannot be fully unified while this handler has no way to read the Io theme variables the SvelteKit copy resolves colors through, so a test holds the colorless compact block to parity and names any selector that goes missing from it. * Make the compact frame take the surface it is placed on The compact mode shipped here painted its own canvas, so an embedder on any surface other than the default got an opaque box: white on a dark page. The fix has two halves and needs both. body.compact drops the background, and the root declares a color-scheme matching the theme on body, because a mismatch between the document's scheme and the one the page gave the iframe element makes the browser paint an opaque canvas regardless of what the document asks for. Ports both halves to the Go render route as well, for the same reason the compact styles went there: a packaged ui-server serves /render from Go, not from SvelteKit.
1 parent 8ac7a8e commit a4e4c73

6 files changed

Lines changed: 381 additions & 13 deletions

File tree

‎server/server/route/ui.go‎

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ const pageTemplate = `
164164
{{.CSS}}
165165
</style>
166166
</head>
167-
<body class="prose" {{if .Theme}}data-theme="{{.Theme}}"{{end}}>
167+
<body class="{{.BodyClass}}" {{if .Theme}}data-theme="{{.Theme}}"{{end}}>
168168
<main>
169169
{{.Content}}
170170
</main>
@@ -188,20 +188,28 @@ func SetRenderRoute(e *echo.Echo, publicPath string) {
188188
finalTheme = fmt.Sprintf("%s-%s", theme, overrideTheme)
189189
}
190190

191+
// Matches the SvelteKit route's parsing: only the exact string opts in.
192+
bodyClass := "prose"
193+
if c.QueryParam("compact") == "true" {
194+
bodyClass = "prose compact"
195+
}
196+
191197
// Process markdown to HTML
192198
renderedHTML := processMarkdown(content)
193199

194200
nonce := generateNonce()
195201

196202
data := struct {
197-
Content template.HTML
198-
Nonce string
199-
Theme string
200-
CSS template.CSS
203+
Content template.HTML
204+
Nonce string
205+
Theme string
206+
BodyClass string
207+
CSS template.CSS
201208
}{
202-
Content: template.HTML(renderedHTML),
203-
Nonce: nonce,
204-
Theme: finalTheme,
209+
Content: template.HTML(renderedHTML),
210+
Nonce: nonce,
211+
Theme: finalTheme,
212+
BodyClass: bodyClass,
205213
CSS: template.CSS(`*,
206214
body {
207215
margin: 0;
@@ -214,11 +222,49 @@ func SetRenderRoute(e *echo.Echo, publicPath string) {
214222
body {
215223
overscroll-behavior: none;
216224
position: relative;
225+
overflow-wrap: break-word;
217226
padding: 1rem;
218227
white-space: pre-line;
219228
font-family: sans-serif;
220229
}
221230
231+
/* No page padding, so a short string renders as a line of text rather than
232+
a padded box. Wrapping stays on. */
233+
body.compact {
234+
padding: 0;
235+
overflow: hidden;
236+
white-space: normal;
237+
}
238+
239+
/* The reset above sizes every element with a star selector, which beats
240+
inheritance, so compact restates the metrics or the frame renders at
241+
16px beside the 14px text around it. */
242+
body.compact,
243+
body.compact main,
244+
body.compact p,
245+
body.compact strong,
246+
body.compact em,
247+
body.compact a {
248+
font-size: 14px;
249+
line-height: 20px;
250+
}
251+
252+
/* Blocks with the margins zeroed, not display: inline. The embedder
253+
measures the frame from main, and an inline box reports the text box
254+
rather than the line box, which clips descenders. */
255+
body.compact main,
256+
body.compact p {
257+
display: block;
258+
margin: 0;
259+
}
260+
261+
/* Vertical padding on an inline code span grows the line box. */
262+
body.compact code {
263+
padding: 0 0.375rem;
264+
font-size: 13px;
265+
line-height: 20px;
266+
}
267+
222268
h1 {
223269
font-size: 2em;
224270
}
@@ -243,6 +289,13 @@ func SetRenderRoute(e *echo.Echo, publicPath string) {
243289
font-size: 0.67em;
244290
}
245291
292+
/* Pinned rather than left to the UA default, so bold matches the 600 the
293+
SvelteKit stylesheet renders at instead of the UA's 700. */
294+
strong,
295+
b {
296+
font-weight: 600;
297+
}
298+
246299
blockquote,
247300
q {
248301
quotes: none;
@@ -374,6 +427,29 @@ func SetRenderRoute(e *echo.Echo, publicPath string) {
374427
body[data-theme='dark-primary'] a {
375428
color: #8098f9;
376429
}
430+
431+
/* A compact frame paints no background of its own. An iframe's canvas is
432+
transparent when the embedded document is, so the frame takes whatever
433+
surface the page put it on, in either colour scheme, without the page
434+
having to name that surface.
435+
436+
Last in the file on purpose: the theme rules above set background-color
437+
at the same specificity, so order decides. */
438+
body.compact {
439+
background-color: transparent;
440+
}
441+
442+
/* The transparency only holds if this document's colour scheme matches
443+
the one the page gave the iframe element. When they differ the browser
444+
paints an opaque canvas instead, so a dark page shows a white box. The
445+
theme is an attribute on body, so the root reads it from there. */
446+
html {
447+
color-scheme: light;
448+
}
449+
450+
html:has(body[data-theme^='dark']) {
451+
color-scheme: dark;
452+
}
377453
`),
378454
}
379455

‎server/server/route/ui_test.go‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package route
33
import (
44
"net/http"
55
"net/http/httptest"
6+
"os"
7+
"path/filepath"
8+
"strings"
69
"testing"
710
"testing/fstest"
811

@@ -254,3 +257,116 @@ func TestBuildUIIndexHandler_MissingIndexHTML(t *testing.T) {
254257
_, err := buildUIIndexHandler("", emptyFS)
255258
assert.Error(t, err)
256259
}
260+
261+
func renderRequest(t *testing.T, query string) string {
262+
t.Helper()
263+
264+
e := echo.New()
265+
SetRenderRoute(e, "/")
266+
267+
req := httptest.NewRequest(http.MethodGet, "/render?"+query, nil)
268+
rec := httptest.NewRecorder()
269+
e.ServeHTTP(rec, req)
270+
271+
assert.Equal(t, http.StatusOK, rec.Code)
272+
return rec.Body.String()
273+
}
274+
275+
// The SvelteKit route and this one are separate implementations of the same
276+
// page, so an embedder that asks for compact has to get it from both.
277+
func TestSetRenderRoute_CompactBodyClass(t *testing.T) {
278+
tests := []struct {
279+
name string
280+
query string
281+
want string
282+
}{
283+
{
284+
name: "compact opts in",
285+
query: "content=hello&compact=true",
286+
want: `class="prose compact"`,
287+
},
288+
{
289+
name: "absent stays padded",
290+
query: "content=hello",
291+
want: `class="prose"`,
292+
},
293+
{
294+
name: "false stays padded",
295+
query: "content=hello&compact=false",
296+
want: `class="prose"`,
297+
},
298+
{
299+
name: "only the exact string opts in",
300+
query: "content=hello&compact=TRUE",
301+
want: `class="prose"`,
302+
},
303+
}
304+
305+
for _, tt := range tests {
306+
t.Run(tt.name, func(t *testing.T) {
307+
assert.Contains(t, renderRequest(t, tt.query), tt.want)
308+
})
309+
}
310+
}
311+
312+
func TestSetRenderRoute_StylesheetCarriesCompactRules(t *testing.T) {
313+
body := renderRequest(t, "content=hello&compact=true")
314+
315+
// Without these the class on <body> selects nothing and the frame keeps
316+
// the 1rem page padding it has in the standalone view.
317+
assert.Contains(t, body, "body.compact {")
318+
assert.Contains(t, body, "body.compact main,")
319+
assert.Contains(t, body, "body.compact code {")
320+
}
321+
322+
// Regression: a star selector reset with no strong rule renders bold at normal
323+
// weight. This route's reset does not set font-weight, so the pinned rule is
324+
// what keeps it at the same 600 the SvelteKit stylesheet uses.
325+
func TestSetRenderRoute_BoldIsPinned(t *testing.T) {
326+
body := renderRequest(t, "content=**bold**")
327+
328+
assert.Contains(t, body, "<strong>bold</strong>")
329+
assert.Contains(t, body, "font-weight: 600;")
330+
}
331+
332+
// A 255-character description can hold an unbroken token longer than the frame.
333+
func TestSetRenderRoute_BreaksLongWords(t *testing.T) {
334+
assert.Contains(t, renderRequest(t, "content=hello"), "overflow-wrap: break-word;")
335+
}
336+
337+
// This route's stylesheet is a hand-maintained copy of src/markdown.reset.css,
338+
// and the two have drifted before. A full comparison is not possible — the
339+
// SvelteKit copy resolves colors through Io theme variables this server cannot
340+
// read, so it hardcodes hex instead. The compact block carries no colors, so
341+
// that much can be held to parity here.
342+
func TestSetRenderRoute_CompactRulesMatchCanonicalStylesheet(t *testing.T) {
343+
canonical, err := os.ReadFile(filepath.Join("..", "..", "..", "src", "markdown.reset.css"))
344+
if err != nil {
345+
t.Skip("canonical stylesheet not present; server module built standalone")
346+
}
347+
348+
var want []string
349+
for _, line := range strings.Split(string(canonical), "\n") {
350+
if line = strings.TrimSpace(line); strings.HasPrefix(line, "body.compact") {
351+
want = append(want, line)
352+
}
353+
}
354+
assert.NotEmpty(t, want, "no compact selectors found in canonical stylesheet")
355+
356+
body := renderRequest(t, "content=hello&compact=true")
357+
for _, selector := range want {
358+
assert.Contains(t, body, selector,
359+
"src/markdown.reset.css has %q; this route's copy does not", selector)
360+
}
361+
}
362+
363+
// Regression: a compact frame that paints its own canvas shows as a white box
364+
// on a dark page. Transparency needs both halves — the background and a
365+
// color-scheme on the root that matches what the embedder gave the iframe.
366+
func TestSetRenderRoute_CompactFrameIsTransparent(t *testing.T) {
367+
body := renderRequest(t, "content=hello&compact=true")
368+
369+
assert.Contains(t, body, "background-color: transparent;")
370+
assert.Contains(t, body, "color-scheme: light;")
371+
assert.Contains(t, body, "html:has(body[data-theme^='dark'])")
372+
}

‎src/lib/holocene/markdown-editor/preview.svelte‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@
2020
frameId?: string;
2121
minHeight?: number;
2222
previewTheme?: 'dark' | 'light';
23+
/**
24+
* Render without the page padding, with the block wrappers flowing
25+
* inline, so a short string hugs its own text instead of sitting in a
26+
* padded box. The content still wraps, so it suits a label or a summary
27+
* of a sentence or two rather than a document.
28+
*/
29+
compact?: boolean;
30+
/**
31+
* Accessible name for the frame. Name what the content is, so a frame in
32+
* a list of them is distinguishable.
33+
*/
34+
title?: string;
2335
}
2436
2537
let {
@@ -30,6 +42,8 @@
3042
frameId = '',
3143
minHeight = 100,
3244
previewTheme,
45+
compact = false,
46+
title = 'output',
3347
}: Props = $props();
3448
3549
let iframe: HTMLIFrameElement | null = $state(null);
@@ -105,7 +119,7 @@
105119
);
106120
const previewPath = $derived(
107121
resolve(
108-
`/render?content=${encodeURIComponent(templatedContent)}&theme=${resolvedPreviewTheme}&overrideTheme=${overrideTheme}`,
122+
`/render?content=${encodeURIComponent(templatedContent)}&theme=${resolvedPreviewTheme}&overrideTheme=${overrideTheme}&compact=${compact}`,
109123
{},
110124
),
111125
);
@@ -115,8 +129,8 @@
115129
<iframe
116130
bind:this={iframe}
117131
onload={resizeIframe}
118-
title="output"
119-
class="block w-full"
132+
{title}
133+
class="block w-full border-0"
120134
src={previewPath}
121135
id={frameId}
122136
></iframe>

0 commit comments

Comments
 (0)