blume 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cli/index.js +1715 -539
  3. package/dist/cli/index.js.map +40 -31
  4. package/dist/types/core/config-input.d.ts +131 -11
  5. package/dist/types/core/config.d.ts +9 -1
  6. package/dist/types/core/data.d.ts +24 -5
  7. package/dist/types/core/i18n-ui.d.ts +58 -799
  8. package/dist/types/core/schema.d.ts +534 -3305
  9. package/dist/types/theme/fonts.d.ts +55 -11
  10. package/docs/02-deployment.mdx +2 -0
  11. package/docs/07-faq.mdx +14 -14
  12. package/docs/advanced/skills.mdx +2 -2
  13. package/docs/configuration/ai.mdx +126 -2
  14. package/docs/configuration/index.mdx +19 -1
  15. package/docs/configuration/search.mdx +17 -0
  16. package/docs/configuration/seo.mdx +26 -3
  17. package/docs/configuration/theming.mdx +44 -2
  18. package/docs/content/syntax.mdx +18 -2
  19. package/docs/reference/cli.mdx +3 -3
  20. package/package.json +9 -8
  21. package/skills/blume/SKILL.md +6 -4
  22. package/skills/blume-migrate/SKILL.md +5 -3
  23. package/skills/blume-migrate/references/mintlify.md +5 -5
  24. package/skills/blume-migrate/references/monorepo.md +2 -1
  25. package/src/ai/agent-readability.ts +31 -1
  26. package/src/ai/api-catalog.ts +81 -0
  27. package/src/ai/ask-context.ts +7 -1
  28. package/src/ai/ask-data.ts +1 -0
  29. package/src/ai/link-headers.ts +52 -0
  30. package/src/ai/llms.ts +12 -1
  31. package/src/ai/markdown.ts +15 -2
  32. package/src/ai/mcp/data.ts +7 -0
  33. package/src/ai/mcp/discovery.ts +70 -15
  34. package/src/ai/mcp/server.ts +14 -8
  35. package/src/ai/mcp/stdio.ts +4 -1
  36. package/src/ai/skills.ts +193 -0
  37. package/src/ai/tar.ts +104 -0
  38. package/src/ai/web-bot-auth.ts +30 -0
  39. package/src/astro/generate.ts +116 -6
  40. package/src/astro/integration.ts +52 -14
  41. package/src/astro/templates.ts +191 -37
  42. package/src/audit/catalog.ts +20 -0
  43. package/src/audit/checks/dns-aid.ts +190 -0
  44. package/src/audit/report.ts +5 -0
  45. package/src/audit/run.ts +2 -0
  46. package/src/cli/commands/build.ts +178 -9
  47. package/src/cli/init/scaffold.ts +1 -1
  48. package/src/components/islands/ask-ai.tsx +4 -1
  49. package/src/components/islands/webmcp.ts +203 -0
  50. package/src/components/layout/NavTree.astro +4 -4
  51. package/src/components/layout/PageLayout.astro +2 -0
  52. package/src/components/layout/ReferenceLayout.astro +2 -0
  53. package/src/components/layout/RootLayout.astro +63 -11
  54. package/src/components/layout/Search.astro +2 -2
  55. package/src/components/layout/WebMcp.astro +49 -0
  56. package/src/components/layout/search/orama.ts +5 -2
  57. package/src/core/config-input.ts +143 -11
  58. package/src/core/config.ts +17 -1
  59. package/src/core/content-assets.ts +199 -0
  60. package/src/core/data.ts +21 -5
  61. package/src/core/diagnostics.ts +6 -5
  62. package/src/core/i18n-ui.ts +19 -28
  63. package/src/core/project-graph.ts +6 -0
  64. package/src/core/schema.ts +224 -71
  65. package/src/core/sources/normalize.ts +5 -5
  66. package/src/deploy/headers.ts +45 -3
  67. package/src/deploy/vercel-negotiation.ts +233 -0
  68. package/src/markdown/mermaid.ts +7 -1
  69. package/src/markdown/table-wrap.ts +33 -1
  70. package/src/og/card.ts +91 -22
  71. package/src/og/derive.ts +200 -0
  72. package/src/og/index.ts +6 -1
  73. package/src/search/orama-index.ts +151 -7
  74. package/src/theme/entry.ts +34 -13
  75. package/src/theme/fonts.ts +183 -30
  76. package/dist/types/og/card.d.ts +0 -63
  77. package/dist/types/og/dimensions.d.ts +0 -12
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Bridges `theme.fonts` into the OG card renderer. A site that explicitly
3
+ * picks its typefaces gets matching cards (and non-Latin coverage) without
4
+ * configuring `seo.og.fonts`; untouched defaults derive nothing, so plain
5
+ * sites keep Takumi's built-in font and gain no build-time font fetch.
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+
10
+ import { isAbsolute, join } from "pathe";
11
+
12
+ import type {
13
+ FontsConfig,
14
+ FontValue,
15
+ LocalFontConfig,
16
+ RemoteFontConfig,
17
+ } from "../theme/fonts.ts";
18
+ import { GOOGLE_FONTS, isFontSlug } from "../theme/fonts.ts";
19
+ import type { OgFont, OgFontFamilies, OgLocalFont } from "./card.ts";
20
+
21
+ /** Fonts plus per-role families for the generated OG endpoint. */
22
+ export interface DerivedOgFonts {
23
+ families?: OgFontFamilies;
24
+ fonts: OgFont[];
25
+ }
26
+
27
+ /** The weights the card actually renders at (title 600, everything else 400). */
28
+ const CARD_WEIGHTS = [400, 600];
29
+
30
+ /** Resolve a config path against the project root. */
31
+ const absoluteSrc = (root: string, src: string): string =>
32
+ isAbsolute(src) ? src : join(root, src);
33
+
34
+ /**
35
+ * The weight spec to fetch for a derived Google family: the declared weights
36
+ * the card uses, the declared numeric weights otherwise, a lone variable
37
+ * range as-is, or nothing (family default) as the last resort.
38
+ */
39
+ const googleWeights = (
40
+ weights: (number | string)[]
41
+ ): number[] | string | undefined => {
42
+ const numbers = weights.filter(
43
+ (weight): weight is number => typeof weight === "number"
44
+ );
45
+ const used = numbers.filter((weight) => CARD_WEIGHTS.includes(weight));
46
+ if (used.length > 0) {
47
+ return used;
48
+ }
49
+ if (numbers.length > 0) {
50
+ return numbers;
51
+ }
52
+ const [first] = weights;
53
+ return weights.length === 1 && typeof first === "string" ? first : undefined;
54
+ };
55
+
56
+ const googleOgFont = (name: string, weights: (number | string)[]): OgFont => {
57
+ const weight = googleWeights(weights);
58
+ return weight === undefined ? { name } : { name, weight };
59
+ };
60
+
61
+ /** Per-variant local entries for the renderer (paths made absolute). */
62
+ const localOgFonts = (font: LocalFontConfig, root: string): OgLocalFont[] =>
63
+ font.variants.map((variant) => ({
64
+ name: font.name,
65
+ src: absoluteSrc(root, variant.src),
66
+ ...(typeof variant.weight === "number" ? { weight: variant.weight } : {}),
67
+ // Takumi's per-face style is normal/italic; oblique falls back to the file.
68
+ ...(variant.style === "normal" || variant.style === "italic"
69
+ ? { style: variant.style }
70
+ : {}),
71
+ }));
72
+
73
+ /**
74
+ * The card fonts for one theme role, or null when the role can't flow into
75
+ * the renderer (an unknown slug string, or a provider Takumi can't fetch —
76
+ * `googleFonts` only speaks Google's css2 endpoint).
77
+ */
78
+ const roleFonts = (value: FontValue, root: string): OgFont[] | null => {
79
+ if (typeof value === "string") {
80
+ if (!isFontSlug(value)) {
81
+ return null;
82
+ }
83
+ const def = GOOGLE_FONTS[value];
84
+ return [googleOgFont(def.family, def.weights)];
85
+ }
86
+ if ("variants" in value) {
87
+ return localOgFonts(value, root);
88
+ }
89
+ const remote = value as RemoteFontConfig;
90
+ if ((remote.provider ?? "google") !== "google") {
91
+ return null;
92
+ }
93
+ return [googleOgFont(remote.name, remote.weights ?? CARD_WEIGHTS)];
94
+ };
95
+
96
+ /** The family name a theme role registers under. */
97
+ const roleFamily = (value: FontValue): string | null => {
98
+ if (typeof value === "string") {
99
+ return isFontSlug(value) ? GOOGLE_FONTS[value].family : null;
100
+ }
101
+ return value.name;
102
+ };
103
+
104
+ /**
105
+ * Derive the OG card fonts from the theme's display and body roles (the two
106
+ * the card renders), deduped, plus the per-role family names so the title
107
+ * keeps the display face and the body text the body face.
108
+ */
109
+ export const deriveOgFonts = (
110
+ fonts: FontsConfig,
111
+ root: string
112
+ ): DerivedOgFonts => {
113
+ const derived: OgFont[] = [];
114
+ const seen = new Set<string>();
115
+ const families: OgFontFamilies = {};
116
+
117
+ const roles = [
118
+ ["title", fonts?.display],
119
+ ["body", fonts?.body],
120
+ ] as const;
121
+ for (const [role, value] of roles) {
122
+ if (value === undefined) {
123
+ continue;
124
+ }
125
+ const roleEntries = roleFonts(value, root);
126
+ if (!roleEntries) {
127
+ continue;
128
+ }
129
+ families[role] = roleFamily(value) ?? undefined;
130
+ for (const entry of roleEntries) {
131
+ const key = JSON.stringify(entry);
132
+ if (!seen.has(key)) {
133
+ seen.add(key);
134
+ derived.push(entry);
135
+ }
136
+ }
137
+ }
138
+
139
+ return {
140
+ fonts: derived,
141
+ ...(families.title || families.body ? { families } : {}),
142
+ };
143
+ };
144
+
145
+ /** Explicit `seo.og.fonts` with local `src` paths resolved to absolute. */
146
+ export const resolveOgFontSources = (fonts: OgFont[], root: string): OgFont[] =>
147
+ fonts.map((font) =>
148
+ typeof font !== "string" && "src" in font
149
+ ? { ...font, src: absoluteSrc(root, font.src) }
150
+ : font
151
+ );
152
+
153
+ /**
154
+ * The fonts baked into the generated OG endpoint. An explicit `seo.og.fonts`
155
+ * always wins (including `[]` to opt out, keeping the card's role styling
156
+ * untouched); otherwise a site that explicitly set `theme.fonts` gets its
157
+ * display/body fonts derived so cards match the site without extra config.
158
+ */
159
+ export const resolveOgFonts = (
160
+ options: {
161
+ /** Explicit `seo.og.fonts`, or undefined when unset. */
162
+ ogFonts: OgFont[] | undefined;
163
+ themeFonts: FontsConfig;
164
+ /** Whether the config file set `theme.fonts` itself (gates derivation). */
165
+ themeFontsConfigured: boolean;
166
+ },
167
+ root: string
168
+ ): DerivedOgFonts => {
169
+ if (options.ogFonts) {
170
+ return { fonts: resolveOgFontSources(options.ogFonts, root) };
171
+ }
172
+ return options.themeFontsConfigured
173
+ ? deriveOgFonts(options.themeFonts, root)
174
+ : { fonts: [] };
175
+ };
176
+
177
+ /**
178
+ * Every configured local font file (theme roles and `seo.og.fonts`) that is
179
+ * missing on disk, as absolute paths. Generation fails on these up front — the
180
+ * alternative is Astro or the OG renderer crashing later with a bare ENOENT.
181
+ */
182
+ export const missingFontFiles = (
183
+ options: { ogFonts: OgFont[]; themeFonts: FontsConfig },
184
+ root: string
185
+ ): string[] => {
186
+ const sources: string[] = [];
187
+ for (const value of Object.values(options.themeFonts ?? {})) {
188
+ if (typeof value !== "string" && "variants" in value) {
189
+ sources.push(
190
+ ...value.variants.map((variant) => absoluteSrc(root, variant.src))
191
+ );
192
+ }
193
+ }
194
+ for (const font of options.ogFonts) {
195
+ if (typeof font !== "string" && "src" in font) {
196
+ sources.push(absoluteSrc(root, font.src));
197
+ }
198
+ }
199
+ return [...new Set(sources)].filter((path) => !existsSync(path));
200
+ };
package/src/og/index.ts CHANGED
@@ -1,2 +1,7 @@
1
1
  export { renderOgImage } from "./card.ts";
2
- export type { OgCardOptions, OgCardPalette, OgFont } from "./card.ts";
2
+ export type {
3
+ OgCardOptions,
4
+ OgCardPalette,
5
+ OgFont,
6
+ OgFontFamilies,
7
+ } from "./card.ts";
@@ -1,5 +1,5 @@
1
1
  import { create, insertMultiple, search } from "@orama/orama";
2
- import type { AnyOrama } from "@orama/orama";
2
+ import type { AnyOrama, Tokenizer } from "@orama/orama";
3
3
 
4
4
  /**
5
5
  * The minimal document shape both the client-side search dialog and the
@@ -31,23 +31,161 @@ const SCHEMA = {
31
31
  /** Title and description outrank body text, matching the search dialog. */
32
32
  const BOOST = { description: 2, title: 3 };
33
33
 
34
+ /**
35
+ * Scripts written without spaces between words. Orama's default tokenizer
36
+ * splits on a Latin-centric delimiter class, so text in these languages
37
+ * collapses to zero tokens and every query silently returns no hits. Keyed by
38
+ * the primary language subtag of `i18n.defaultLocale`.
39
+ */
40
+ const SEGMENTED_LANGUAGES = new Set(["ja", "ko", "th", "zh"]);
41
+
42
+ /**
43
+ * Languages indexed as character bigrams rather than whole segments, and
44
+ * queried to match accordingly. Japanese and Chinese write compounds in
45
+ * {@link BIGRAM_SCRIPTS} without delimiters; Korean separates words with
46
+ * spaces and Thai has no comparable bigram convention, so both keep the plain
47
+ * segmented tokens even where a page mixes in Han or kana.
48
+ *
49
+ * Dictionary segmentation alone drops the adjacency that makes a compound term
50
+ * distinctive: 資金決済法 becomes 資金 / 決済 / 法, and because Orama scores a
51
+ * bag of words, a page that merely mentions each fragment somewhere outranks
52
+ * the page about the law itself. Bigrams put that adjacency back as index
53
+ * terms, and dropping the whole-segment tokens keeps a fragment as common as
54
+ * 法 from matching on its own.
55
+ */
56
+ const BIGRAM_LANGUAGES = new Set(["ja", "zh"]);
57
+
58
+ /**
59
+ * Segments written entirely in these scripts are the ones re-cut into bigrams,
60
+ * matching the scripts Lucene's CJK analyzer bigrams. Property escapes rather
61
+ * than ranges, so ideographs outside the basic plane are covered as well —
62
+ * 𠮟, the 常用漢字表 form of しかる, is one. The literals that follow belong to
63
+ * no script of their own but appear only inside such words: the iteration
64
+ * marks, the prolonged sound mark, and the halfwidth voiced sound marks.
65
+ */
66
+ const BIGRAM_SCRIPTS =
67
+ /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}々〆〇ー゙゚]+$/u;
68
+
69
+ /**
70
+ * Emit every overlapping 2-character window of `run`, or the lone character.
71
+ * Windows are cut by code point: an ideograph outside the basic plane is a
72
+ * surrogate pair, and slicing by code unit would split it into halves that
73
+ * match nothing.
74
+ *
75
+ * Dropping the whole-segment tokens means a single-character query reaches
76
+ * only pages where the character opens a bigram: a run-final 法 sits in 示法,
77
+ * which the query 法 does not prefix-match. Lucene's CJK analyzer shares this
78
+ * property; indexing lone characters alongside the bigrams would reinvite the
79
+ * fragment noise this file exists to remove.
80
+ */
81
+ const addBigrams = (run: string, tokens: Set<string>): void => {
82
+ const characters = [...run];
83
+ if (characters.length === 1) {
84
+ tokens.add(run);
85
+ return;
86
+ }
87
+ let previous = "";
88
+ for (const character of characters) {
89
+ if (previous) {
90
+ tokens.add(previous + character);
91
+ }
92
+ previous = character;
93
+ }
94
+ };
95
+
96
+ /**
97
+ * A word-segmenting tokenizer for languages the default splitter can't handle,
98
+ * built on `Intl.Segmenter` (the same engine `@orama/tokenizers` wraps).
99
+ * Input is lowercased before segmenting — unlike the upstream tokenizers —
100
+ * so Latin terms ("GDPR", English pages on a mixed-locale site) still match
101
+ * case-insensitively. Returns `undefined` for languages the default tokenizer
102
+ * already serves, and on runtimes without `Intl.Segmenter`, where the caller
103
+ * falls back to Orama's default.
104
+ *
105
+ * On a {@link BIGRAM_LANGUAGES} index, runs of adjacent
106
+ * {@link BIGRAM_SCRIPTS} segments are joined and re-cut into character
107
+ * bigrams; everything else (Latin, digits, and every segment on a Korean or
108
+ * Thai index) is emitted as the segmenter produced it. Punctuation and spaces
109
+ * are not word-like, so they end a run — 「クーリング・オフ」 bigrams either
110
+ * side of the interpunct rather than across it.
111
+ */
112
+ const segmentingTokenizer = (locale?: string): Tokenizer | undefined => {
113
+ const language = locale?.toLowerCase().split(/[-_]/u)[0] ?? "";
114
+ if (!SEGMENTED_LANGUAGES.has(language)) {
115
+ return;
116
+ }
117
+ if (typeof Intl.Segmenter !== "function") {
118
+ return;
119
+ }
120
+ const segmenter = new Intl.Segmenter(language, { granularity: "word" });
121
+ // Keyed off the same set the strict query pass reads, so an index is never
122
+ // built from bigrams that the query side then matches loosely.
123
+ const bigram = BIGRAM_LANGUAGES.has(language);
124
+ return {
125
+ language,
126
+ normalizationCache: new Map(),
127
+ tokenize: (raw: string): string[] => {
128
+ const tokens = new Set<string>();
129
+ let run = "";
130
+ const flush = (): void => {
131
+ if (run) {
132
+ addBigrams(run, tokens);
133
+ run = "";
134
+ }
135
+ };
136
+ for (const segment of segmenter.segment(raw.toLowerCase())) {
137
+ if (!segment.isWordLike) {
138
+ flush();
139
+ continue;
140
+ }
141
+ if (bigram && BIGRAM_SCRIPTS.test(segment.segment)) {
142
+ run += segment.segment;
143
+ continue;
144
+ }
145
+ flush();
146
+ tokens.add(segment.segment);
147
+ }
148
+ flush();
149
+ return [...tokens];
150
+ },
151
+ };
152
+ };
153
+
34
154
  /**
35
155
  * Build an in-memory Orama full-text index from search documents. Shared by the
36
- * Orama client loader (browser) and the MCP server (Node), so ranking is
37
- * identical wherever docs are queried.
156
+ * Orama client loader (browser), the MCP server, and Ask AI grounding (Node),
157
+ * so ranking is identical wherever docs are queried. `locale` — the site's
158
+ * `i18n.defaultLocale` — swaps in a word-segmenting tokenizer for languages
159
+ * written without spaces (Japanese, Chinese, Korean, Thai); the tokenizer
160
+ * belongs to the database, so on a mixed-locale site it applies to every
161
+ * document, which is safe because Latin words survive segmentation intact.
38
162
  */
39
163
  export const buildOramaIndex = async (
40
- documents: OramaDoc[]
164
+ documents: OramaDoc[],
165
+ locale?: string
41
166
  ): Promise<AnyOrama> => {
42
- const db = create({ schema: SCHEMA });
167
+ const tokenizer = segmentingTokenizer(locale);
168
+ const db = create({
169
+ schema: SCHEMA,
170
+ ...(tokenizer ? { components: { tokenizer } } : {}),
171
+ });
43
172
  await insertMultiple(db, documents);
44
173
  return db;
45
174
  };
46
175
 
176
+ /** Orama keeps only documents matching every token at a threshold of 0. */
177
+ const ALL_TOKENS = 0;
178
+
47
179
  /**
48
180
  * Query the index, returning the matching documents (highest-ranked first).
49
181
  * When `locale` is given, results are filtered to that language via an exact
50
182
  * `where` match on the `locale` enum.
183
+ *
184
+ * On a bigrammed index the strict pass runs first: a term is only meant to
185
+ * match where its bigrams sit together, and scoring them independently lets a
186
+ * page sharing a couple of windows outrank the page the term is about. Terms
187
+ * spanning several words rarely appear in full on one page, so an empty strict
188
+ * result falls back to the default pass rather than reporting no matches.
51
189
  */
52
190
  export const queryOramaIndex = async (
53
191
  db: AnyOrama,
@@ -55,12 +193,18 @@ export const queryOramaIndex = async (
55
193
  limit: number,
56
194
  locale?: string
57
195
  ): Promise<OramaDoc[]> => {
58
- const found = await search(db, {
196
+ const params = {
59
197
  boost: BOOST,
60
198
  limit,
61
199
  properties: ["title", "description", "content"],
62
200
  term,
63
201
  ...(locale ? { where: { locale: { eq: locale } } } : {}),
64
- });
202
+ };
203
+ const bigrammed = BIGRAM_LANGUAGES.has(db.tokenizer?.language ?? "");
204
+ const strict = bigrammed
205
+ ? await search(db, { ...params, threshold: ALL_TOKENS })
206
+ : undefined;
207
+ const found =
208
+ strict && strict.hits.length > 0 ? strict : await search(db, params);
65
209
  return found.hits.map((hit) => hit.document as unknown as OramaDoc);
66
210
  };
@@ -452,19 +452,29 @@ blume-diff {
452
452
  even though its chrome wrapper is not-prose. */
453
453
  .prose :where(pre:not(.twoslash, .twoslash pre, blume-panel-tabs *) > code) {
454
454
  display: block;
455
- overflow-x: auto;
456
- padding: 0 1.25rem;
457
- /* The scroller is only as tall as the code, so an overlay scrollbar would
458
- draw on top of the last line; hide it (wheel/trackpad/keyboard scrolling
459
- still works). */
460
- scrollbar-width: none;
461
- }
462
-
463
- .prose
464
- :where(
465
- pre:not(.twoslash, .twoslash pre, blume-panel-tabs *) > code
466
- )::-webkit-scrollbar {
467
- display: none;
455
+ /* Tall blocks scroll vertically in place instead of taking the page. Both
456
+ axes live on the code element for the same reason: the pre stays static
457
+ so the header bar and copy button never drift with the scroll. */
458
+ max-height: 24rem;
459
+ overflow: auto;
460
+ /* The small bottom inset keeps the horizontal thumb off the last line's
461
+ descenders now that scrollbars are visible. */
462
+ padding: 0 1.25rem 0.375rem;
463
+ /* Thin theme-colored scrollbars, matching the sidebar treatment, so a
464
+ height-capped block reads as scrollable instead of simply ending.
465
+ Safari before 18.2 supports neither property and falls back to the
466
+ platform-default scrollbar — acceptable, since macOS overlays it. */
467
+ scrollbar-color: var(--blume-border) transparent;
468
+ scrollbar-width: thin;
469
+ }
470
+
471
+ /* The dark border token is too close to the page background to read as a
472
+ scrollbar thumb; derive a brighter one from the muted foreground instead. */
473
+ :root[data-theme="dark"]
474
+ .prose
475
+ :where(pre:not(.twoslash, .twoslash pre, blume-panel-tabs *) > code) {
476
+ scrollbar-color: color-mix(in oklab, var(--blume-muted-foreground) 55%, transparent)
477
+ transparent;
468
478
  }
469
479
 
470
480
  /* Word wrap (markdown.code.wrap): long lines wrap instead of scrolling. The
@@ -532,6 +542,10 @@ pre.blume-source {
532
542
 
533
543
  pre.blume-source > code {
534
544
  flex: 1;
545
+ /* The pane's measured height is authoritative (it can exceed the prose
546
+ 24rem cap), so undo the generic prose max-height: the code must fill
547
+ whatever height the tab was given. */
548
+ max-height: none;
535
549
  min-height: 0;
536
550
  overflow: auto;
537
551
  }
@@ -713,6 +727,13 @@ pre:has(.line.focused):hover .line:not(.focused) {
713
727
  display: none !important;
714
728
  }
715
729
 
730
+ /* Paper can't scroll: uncap the code scroller so long blocks print in
731
+ full, same as tab panels are force-expanded below. */
732
+ .prose :where(pre:not(.twoslash, .twoslash pre, blume-panel-tabs *) > code) {
733
+ max-height: none;
734
+ overflow: visible;
735
+ }
736
+
716
737
  #blume-content {
717
738
  padding: 0 !important;
718
739
  }