@bendyline/squisq 1.5.1 → 1.5.2

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.
@@ -516,9 +516,11 @@ declare function drawingBlock(input: DrawingBlockInput, context: TemplateContext
516
516
  * - `markerPath(style, dir)` → the `<marker>` path for a line end-style, in the
517
517
  * standard 0–10 marker viewBox (shared by the SSR `PathLayer` renderer and the
518
518
  * editor's edge renderer).
519
- * - `connectorPath(routing, start, end)` + `clipEndpoints(a, b)` → the line/edge
519
+ * - `connectorPath(routing, start, end)` + `snapEndpoints(a, b)` → the line/edge
520
520
  * geometry between two shapes (straight / orthogonal / curved), lifted from the
521
- * diagram template so drawings and diagrams draw identical connectors.
521
+ * diagram template so drawings and diagrams draw identical connectors. The older
522
+ * `clipEndpoints(a, b)` helper remains for free-angle clipping, but rendered
523
+ * connectors use snapped ports so authored lines land on stable attachment points.
522
524
  */
523
525
 
524
526
  /** Connector routing styles. */
@@ -549,6 +551,12 @@ interface ClipBox {
549
551
  rx: number;
550
552
  ry: number;
551
553
  }
554
+ type ConnectorPort = 'top' | 'right' | 'bottom' | 'left' | 'top-left' | 'top-right' | 'bottom-right' | 'bottom-left';
555
+ interface ConnectorSnapPoint {
556
+ port: ConnectorPort;
557
+ x: number;
558
+ y: number;
559
+ }
552
560
  /** Intersection of the center-to-center line with `from`'s bounding box. */
553
561
  declare function clipPoint(from: ClipBox, to: ClipBox): {
554
562
  x: number;
@@ -565,6 +573,22 @@ declare function clipEndpoints(a: ClipBox, b: ClipBox): {
565
573
  y: number;
566
574
  };
567
575
  };
576
+ /** The stable connector ports exposed around a shape/card box. */
577
+ declare function snapPoints(box: ClipBox): ConnectorSnapPoint[];
578
+ /** Nearest stable connector port on `box` to an arbitrary point. */
579
+ declare function nearestSnapPoint(box: ClipBox, point: {
580
+ x: number;
581
+ y: number;
582
+ }): ConnectorSnapPoint;
583
+ /**
584
+ * Pick the pair of connector ports, one on each box, with the shortest
585
+ * distance between them. This gives stable attachments to side/corner handles
586
+ * instead of arbitrary center-ray intersections.
587
+ */
588
+ declare function snapEndpoints(a: ClipBox, b: ClipBox): {
589
+ start: ConnectorSnapPoint;
590
+ end: ConnectorSnapPoint;
591
+ };
568
592
  /** Build the SVG `d` for a connector between two clipped endpoints. */
569
593
  declare function connectorPath(routing: ConnectorRouting, start: {
570
594
  x: number;
@@ -1774,4 +1798,4 @@ declare function coerceTemplateParams(template: string | undefined, params: Reco
1774
1798
  */
1775
1799
  declare function lintTemplateParams(template: string, params: Record<string, string>): TemplateParamFinding[];
1776
1800
 
1777
- export { type AccentLayout, type AudioSegmentTiming, BASE_INPUT_DESCRIPTORS, CONTAINER_TEMPLATES, type ClipBox, type ConnectorRouting, type CoverBlockInput, DEFAULT_LAYOUT, type DataFenceParseResult, type DeriveTemplateInputsOptions, type DiagramEdge, type DiagramLayout, type DiagramLayoutOptions, type DiagramNodePosition, DocBlock, type DocStylePreset, type DrawingConnector, type DrawingLayout, type DrawingLayoutOptions, type DrawingShape, type DrawingShapeKind, type ExpandDocBlocksOptions, type ExtractedTableData, type FirstImage, type InputCoercion, type LayoutLayerDefaults, type LayoutLayersResult, type MarkdownToDocOptions, type MarkdownValidationResult, PATH_SHAPE_KINDS, PersistentLayerConfig, type RenderContext, type RuntimeTemplateRegistry, SHAPE_NAMES, TEMPLATE_ALIASES, TEMPLATE_INPUT_DESCRIPTORS, TEMPLATE_METADATA, TemplateBlock, TemplateContext, type TemplateInputDescriptor, type TemplateMetadata, type TemplateParamFinding, Theme, ThemeColorScheme, type ValidateOptions, ViewportConfig, adjustY, applyRenderStyleToLayers, buildRegistry, clipEndpoints, clipPoint, coerceTemplateParams, comparisonBar, computeDiagramLayout, computeDrawingLayout, computeLayoutLayers, connectorPath, countBlocks, coverBlock, createAccentLayers, cssFilterForTreatment, dataTable, dateEvent, decodeLayersFromFrontmatter, definitionCard, deriveTemplateInputs, diagramBlock, docToMarkdown, drawingBlock, encodeLayersForFrontmatter, expandCoverBlock, expandDocBlocks, expandPersistentLayers, expandTemplateBlock, extractBlockquoteText, extractBodyPlainText, extractFirstImage, extractImages, extractListItems, extractTableData, extractTableFromContents, factCard, fallbackBlockLayers, findFirstTable, flattenBlocks, flattenRenderableBlocks, fullBleedQuote, getAccentLayout, getAnimationProgress, getAnimationStyle, getAvailableTemplates, getBlockDepth, getDefaultAnimation, getDefaultAnimationDuration, getDocStyleConfig, getLayers, getOverlayOpacity, getPersistentLayersFromTheme, getTemplateHint, getThemeFont, getTransitionClass, hasTemplate, imageWithCaption, isContainerTemplate, isDataFence, isShapeName, layoutBlock, leftFeature, lineStyleDasharray, lintTemplateParams, listBlock, mapBlock, markdownToDoc, markerPath, normalizeShapeKind, parseDataFence, parseYamlSubset, photoGrid, pullQuote, quoteBlock, readCustomTemplatesFromFrontmatter, readCustomThemesFromFrontmatter, replaceDataFence, resolveAudioMapping, resolveColorScheme, resolvePersistentLayers, resolveTemplateName, resolveThemeForDoc, rightFeature, scaleAnimationDuration, scoreTextSimilarity, sectionHeader, shapePath, shouldUseShadow, statHighlight, templateRegistry, themeWantsAmbientMotion, themedEntrance, themedFontSize, themedImageTreatment, themedScrim, themedSurfaceGradient, titleBlock, twoColumn, validateMarkdownDoc, validateMarkdownSource, videoPullQuote, videoWithCaption, wrapWithPersistentLayers, writeCustomTemplatesToFrontmatter, writeCustomThemesToFrontmatter };
1801
+ export { type AccentLayout, type AudioSegmentTiming, BASE_INPUT_DESCRIPTORS, CONTAINER_TEMPLATES, type ClipBox, type ConnectorPort, type ConnectorRouting, type ConnectorSnapPoint, type CoverBlockInput, DEFAULT_LAYOUT, type DataFenceParseResult, type DeriveTemplateInputsOptions, type DiagramEdge, type DiagramLayout, type DiagramLayoutOptions, type DiagramNodePosition, DocBlock, type DocStylePreset, type DrawingConnector, type DrawingLayout, type DrawingLayoutOptions, type DrawingShape, type DrawingShapeKind, type ExpandDocBlocksOptions, type ExtractedTableData, type FirstImage, type InputCoercion, type LayoutLayerDefaults, type LayoutLayersResult, type MarkdownToDocOptions, type MarkdownValidationResult, PATH_SHAPE_KINDS, PersistentLayerConfig, type RenderContext, type RuntimeTemplateRegistry, SHAPE_NAMES, TEMPLATE_ALIASES, TEMPLATE_INPUT_DESCRIPTORS, TEMPLATE_METADATA, TemplateBlock, TemplateContext, type TemplateInputDescriptor, type TemplateMetadata, type TemplateParamFinding, Theme, ThemeColorScheme, type ValidateOptions, ViewportConfig, adjustY, applyRenderStyleToLayers, buildRegistry, clipEndpoints, clipPoint, coerceTemplateParams, comparisonBar, computeDiagramLayout, computeDrawingLayout, computeLayoutLayers, connectorPath, countBlocks, coverBlock, createAccentLayers, cssFilterForTreatment, dataTable, dateEvent, decodeLayersFromFrontmatter, definitionCard, deriveTemplateInputs, diagramBlock, docToMarkdown, drawingBlock, encodeLayersForFrontmatter, expandCoverBlock, expandDocBlocks, expandPersistentLayers, expandTemplateBlock, extractBlockquoteText, extractBodyPlainText, extractFirstImage, extractImages, extractListItems, extractTableData, extractTableFromContents, factCard, fallbackBlockLayers, findFirstTable, flattenBlocks, flattenRenderableBlocks, fullBleedQuote, getAccentLayout, getAnimationProgress, getAnimationStyle, getAvailableTemplates, getBlockDepth, getDefaultAnimation, getDefaultAnimationDuration, getDocStyleConfig, getLayers, getOverlayOpacity, getPersistentLayersFromTheme, getTemplateHint, getThemeFont, getTransitionClass, hasTemplate, imageWithCaption, isContainerTemplate, isDataFence, isShapeName, layoutBlock, leftFeature, lineStyleDasharray, lintTemplateParams, listBlock, mapBlock, markdownToDoc, markerPath, nearestSnapPoint, normalizeShapeKind, parseDataFence, parseYamlSubset, photoGrid, pullQuote, quoteBlock, readCustomTemplatesFromFrontmatter, readCustomThemesFromFrontmatter, replaceDataFence, resolveAudioMapping, resolveColorScheme, resolvePersistentLayers, resolveTemplateName, resolveThemeForDoc, rightFeature, scaleAnimationDuration, scoreTextSimilarity, sectionHeader, shapePath, shouldUseShadow, snapEndpoints, snapPoints, statHighlight, templateRegistry, themeWantsAmbientMotion, themedEntrance, themedFontSize, themedImageTreatment, themedScrim, themedSurfaceGradient, titleBlock, twoColumn, validateMarkdownDoc, validateMarkdownSource, videoPullQuote, videoWithCaption, wrapWithPersistentLayers, writeCustomTemplatesToFrontmatter, writeCustomThemesToFrontmatter };
@@ -105,7 +105,7 @@ import {
105
105
  wrapWithPersistentLayers,
106
106
  writeCustomTemplatesToFrontmatter,
107
107
  writeCustomThemesToFrontmatter
108
- } from "../chunk-LH7I6SH7.js";
108
+ } from "../chunk-5TYS5V3G.js";
109
109
  import "../chunk-UY7KGQ5R.js";
110
110
  import "../chunk-V543AI2F.js";
111
111
  import "../chunk-FVTQSNR7.js";
@@ -116,8 +116,11 @@ import {
116
116
  connectorPath,
117
117
  lineStyleDasharray,
118
118
  markerPath,
119
- shapePath
120
- } from "../chunk-RUDYOTA6.js";
119
+ nearestSnapPoint,
120
+ shapePath,
121
+ snapEndpoints,
122
+ snapPoints
123
+ } from "../chunk-LTN6I7QW.js";
121
124
  import {
122
125
  DEFAULT_THEME,
123
126
  FRONTMATTER_CUSTOM_TEMPLATES_KEY,
@@ -136,7 +139,7 @@ import {
136
139
  isTemplateBlock,
137
140
  scaledFontSize2 as scaledFontSize
138
141
  } from "../chunk-DLXZMS5K.js";
139
- import "../chunk-TEEEILMP.js";
142
+ import "../chunk-BEQODJWV.js";
140
143
  import "../chunk-HWVFJAAH.js";
141
144
  import "../chunk-DBS6RIFL.js";
142
145
  import "../chunk-QE7TWGVT.js";
@@ -230,6 +233,7 @@ export {
230
233
  mapBlock,
231
234
  markdownToDoc,
232
235
  markerPath,
236
+ nearestSnapPoint,
233
237
  normalizeShapeKind,
234
238
  parseDataFence,
235
239
  parseYamlSubset,
@@ -252,6 +256,8 @@ export {
252
256
  sectionHeader,
253
257
  shapePath,
254
258
  shouldUseShadow,
259
+ snapEndpoints,
260
+ snapPoints,
255
261
  statHighlight,
256
262
  templateRegistry,
257
263
  themeWantsAmbientMotion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "Headless utilities for doc/block rendering, spatial math, Markdown, and storage",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -85,6 +85,7 @@ describe('coverBlock with hero image', () => {
85
85
  it('does not produce theme background layers', () => {
86
86
  const layers = coverBlock(input, landscapeContext);
87
87
  expect(findLayer(layers, 'cover-bg')).toBeUndefined();
88
+ expect(findLayer(layers, 'cover-bg-tint')).toBeUndefined();
88
89
  expect(findLayer(layers, 'cover-accent')).toBeUndefined();
89
90
  });
90
91
 
@@ -110,12 +111,16 @@ describe('coverBlock without hero image', () => {
110
111
  subtitle: 'Subtitle text',
111
112
  };
112
113
 
113
- it('produces theme background instead of hero', () => {
114
+ it('produces theme background and soft tint instead of hero', () => {
114
115
  const layers = coverBlock(input, landscapeContext);
115
116
  const bg = findShape(layers, 'cover-bg');
117
+ const tint = findShape(layers, 'cover-bg-tint');
116
118
  expect(bg).toBeDefined();
117
119
  expect(bg!.type).toBe('shape');
118
- expect(bg!.content.fill).toContain('radial-gradient');
120
+ expect(bg!.content.fill).toBe(DEFAULT_THEME.colors.background);
121
+ expect(tint).toBeDefined();
122
+ expect(tint!.content.fill).toContain('radial-gradient');
123
+ expect(tint!.content.fill).toContain('rgba(37, 99, 235, 0.16)');
119
124
  });
120
125
 
121
126
  it('produces decorative accent line', () => {
@@ -144,6 +149,31 @@ describe('coverBlock without hero image', () => {
144
149
  const subtitle = findLayer(layers, 'cover-subtitle');
145
150
  expect(subtitle!.position.y).not.toBe('82%');
146
151
  });
152
+
153
+ it('uses high-contrast Standard Light cover text', () => {
154
+ const layers = coverBlock(input, landscapeContext);
155
+ const title = findText(layers, 'cover-title');
156
+ const subtitle = findText(layers, 'cover-subtitle');
157
+ expect(title!.content.style.shadow).toBe(false);
158
+ expect(subtitle!.content.style.fontWeight).toBe('bold');
159
+ expect(subtitle!.content.style.color).toBe('rgba(15, 23, 42, 0.78)');
160
+ });
161
+
162
+ it('keeps non-Standard themes on the existing dramatic cover background', () => {
163
+ const nonStandardContext = createTemplateContext(
164
+ { ...DEFAULT_THEME, id: 'custom-light' },
165
+ 0,
166
+ 1,
167
+ VIEWPORT_PRESETS.landscape,
168
+ );
169
+ const layers = coverBlock(input, nonStandardContext);
170
+ const bg = findShape(layers, 'cover-bg');
171
+ const title = findText(layers, 'cover-title');
172
+ expect(findShape(layers, 'cover-bg-tint')).toBeUndefined();
173
+ expect(bg!.content.fill).toContain('radial-gradient');
174
+ expect(bg!.content.fill).toContain(`${DEFAULT_THEME.colors.primary} 0%`);
175
+ expect(title!.content.style.shadow).toBe(true);
176
+ });
147
177
  });
148
178
 
149
179
  // ── coverBlock title-only (no subtitle) ─────────────────────────────
@@ -273,6 +303,7 @@ describe('expandCoverBlock', () => {
273
303
  const config: StartBlockConfig = { title: 'No Hero' };
274
304
  const layers = expandCoverBlock(config, landscapeContext);
275
305
  expect(layers.some((l) => l.id === 'cover-bg')).toBe(true);
306
+ expect(layers.some((l) => l.id === 'cover-bg-tint')).toBe(true);
276
307
  expect(layers.some((l) => l.id === 'cover-title')).toBe(true);
277
308
  expect(layers.some((l) => l.id === 'cover-hero')).toBe(false);
278
309
  });
@@ -394,14 +394,15 @@ describe('template annotation in markdownToDoc', () => {
394
394
  expect(output).toContain('{[factCard style=minimal]}');
395
395
  });
396
396
 
397
- it('docToMarkdown injects transition attributes from block metadata', () => {
397
+ it('docToMarkdown injects transition params into the squiggly annotation', () => {
398
398
  const md = parseMarkdown('## Section\n\nBody');
399
399
  const doc = markdownToDoc(md);
400
400
  doc.blocks[0].transition = { type: 'wipe', duration: 0.8, direction: 'left' };
401
401
 
402
402
  const output = stringifyMarkdown(docToMarkdown(doc));
403
403
 
404
- expect(output).toContain('{transition=wipe transitionDuration=0.8 transitionDirection=left}');
404
+ expect(output).toContain('{[transition=wipe transitionDuration=0.8 transitionDirection=left]}');
405
+ expect(output).not.toContain('{transition=wipe');
405
406
  });
406
407
 
407
408
  it('a programmatic transition survives the string round trip', () => {
@@ -445,6 +446,8 @@ describe('template annotation in markdownToDoc', () => {
445
446
 
446
447
  const output = stringifyMarkdown(docToMarkdown(doc));
447
448
  expect(output).toContain('transition=wipe');
449
+ expect(output).toContain('{[transition=wipe transitionDirection=left]}');
450
+ expect(output).not.toContain('{transition=wipe');
448
451
  const reparsed = markdownToDoc(parseMarkdown(output));
449
452
  expect(reparsed.blocks[0].transition).toEqual({ type: 'wipe', direction: 'left' });
450
453
  });
@@ -4,8 +4,11 @@ import {
4
4
  markerPath,
5
5
  connectorPath,
6
6
  clipEndpoints,
7
+ nearestSnapPoint,
7
8
  lineStyleDasharray,
8
9
  PATH_SHAPE_KINDS,
10
+ snapEndpoints,
11
+ snapPoints,
9
12
  } from '../doc/utils/shapeGeometry.js';
10
13
 
11
14
  describe('shapePath', () => {
@@ -79,6 +82,40 @@ describe('clipEndpoints', () => {
79
82
  });
80
83
  });
81
84
 
85
+ describe('snapEndpoints', () => {
86
+ const a = { cx: 50, cy: 50, rx: 30, ry: 20 };
87
+ const b = { cx: 180, cy: 50, rx: 30, ry: 20 };
88
+
89
+ it('exposes side and corner ports around a box', () => {
90
+ expect(snapPoints(a).map((p) => p.port)).toEqual([
91
+ 'top',
92
+ 'right',
93
+ 'bottom',
94
+ 'left',
95
+ 'top-left',
96
+ 'top-right',
97
+ 'bottom-right',
98
+ 'bottom-left',
99
+ ]);
100
+ });
101
+
102
+ it('snaps horizontal connections to side ports', () => {
103
+ const r = snapEndpoints(a, b);
104
+ expect(r.start).toMatchObject({ port: 'right', x: 80, y: 50 });
105
+ expect(r.end).toMatchObject({ port: 'left', x: 150, y: 50 });
106
+ });
107
+
108
+ it('snaps diagonal connections to nearest corner ports', () => {
109
+ const r = snapEndpoints(a, { cx: 180, cy: 140, rx: 30, ry: 20 });
110
+ expect(r.start).toMatchObject({ port: 'bottom-right', x: 80, y: 70 });
111
+ expect(r.end).toMatchObject({ port: 'top-left', x: 150, y: 120 });
112
+ });
113
+
114
+ it('finds the nearest port toward a free pointer', () => {
115
+ expect(nearestSnapPoint(a, { x: 100, y: 10 }).port).toBe('top-right');
116
+ });
117
+ });
118
+
82
119
  describe('lineStyleDasharray', () => {
83
120
  it('maps line styles to dash patterns', () => {
84
121
  expect(lineStyleDasharray('dashed')).toBe('8 6');
@@ -39,6 +39,8 @@ import {
39
39
  writeCustomThemesToFrontmatter,
40
40
  } from './customThemesFrontmatter.js';
41
41
 
42
+ const TRANSITION_PARAM_KEYS = ['transition', 'transitionDuration', 'transitionDirection'] as const;
43
+
42
44
  /**
43
45
  * Convert a Doc with heading-driven blocks back to a MarkdownDocument.
44
46
  *
@@ -134,64 +136,81 @@ function synthesizeAnnotationParagraph(block: Block): MarkdownParagraph {
134
136
  * template and templateOverrides. Returns a (possibly cloned) heading.
135
137
  */
136
138
  function ensureAnnotation(block: Block, heading: MarkdownHeading): MarkdownHeading {
137
- const attrs = ensureTransitionAttributes(block, heading);
139
+ const { attributes, transitionParams } = ensureTransitionMetadata(block, heading);
138
140
 
139
141
  // If the block has a non-default template or overrides, inject an annotation
140
142
  const hasExplicitTemplate =
141
143
  block.template && block.template !== 'sectionHeader' && block.autoTemplate !== true;
142
144
  const hasOverrides = block.templateOverrides && Object.keys(block.templateOverrides).length > 0;
143
145
 
144
- // If the heading already has an annotation, trust it (it came from parsing).
145
- // Transition attrs may still have been injected above if the block changed.
146
- if (heading.templateAnnotation) {
147
- return attrs === heading.attributes ? heading : { ...heading, attributes: attrs };
146
+ let templateAnnotation = heading.templateAnnotation;
147
+ if (!templateAnnotation && (hasExplicitTemplate || hasOverrides || transitionParams)) {
148
+ templateAnnotation = {
149
+ ...(hasExplicitTemplate ? { template: block.template ?? 'sectionHeader' } : {}),
150
+ ...(hasOverrides ? { params: block.templateOverrides } : {}),
151
+ };
148
152
  }
149
153
 
150
- if (!hasExplicitTemplate && !hasOverrides) {
151
- return attrs === heading.attributes ? heading : { ...heading, attributes: attrs };
154
+ if (transitionParams) {
155
+ templateAnnotation = {
156
+ ...(templateAnnotation ?? {}),
157
+ params: {
158
+ ...(templateAnnotation?.params ?? {}),
159
+ ...transitionParams,
160
+ },
161
+ };
152
162
  }
153
163
 
154
- // Clone to avoid mutating the original
155
- return {
156
- ...heading,
157
- children: [...heading.children],
158
- attributes: attrs,
159
- templateAnnotation: {
160
- template: block.template ?? 'sectionHeader',
161
- ...(hasOverrides ? { params: block.templateOverrides } : {}),
162
- },
163
- };
164
+ if (attributes === heading.attributes && templateAnnotation === heading.templateAnnotation) {
165
+ return heading;
166
+ }
167
+
168
+ return { ...heading, children: [...heading.children], attributes, templateAnnotation };
164
169
  }
165
170
 
166
- function ensureTransitionAttributes(
171
+ function ensureTransitionMetadata(
167
172
  block: Block,
168
173
  heading: MarkdownHeading,
169
- ): MarkdownHeading['attributes'] {
174
+ ): {
175
+ attributes: MarkdownHeading['attributes'];
176
+ transitionParams: Record<string, string> | undefined;
177
+ } {
170
178
  const transition = block.transition;
171
- if (!transition) return heading.attributes;
172
-
173
- // The transition is deliberately written to BOTH channels, which serve
174
- // different consumers:
175
- // - `params` is what `stringifyMarkdown` serializes into the Pandoc `{…}`
176
- // attribute text (serializePandocAttributes reads only id/classes/params).
177
- // - `blockMeta` is what `markdownToDoc` reads back directly (makeBlock,
178
- // pinnedHeadingMeta) an in-memory Doc → MarkdownDocument → Doc round
179
- // trip never re-parses params, so a stale/missing `blockMeta.transition`
180
- // would lose or resurrect the wrong transition.
181
- const attrs = heading.attributes ?? {};
182
- return {
179
+ if (!transition) {
180
+ return { attributes: heading.attributes, transitionParams: undefined };
181
+ }
182
+
183
+ // The transition is written to the squisq-native `{[]}` params for
184
+ // author-facing markdown, and to `attributes.blockMeta` for the in-memory
185
+ // Doc MarkdownDocument Doc path (which does not re-parse string params).
186
+ const attrs = removeTransitionParams(heading.attributes) ?? {};
187
+ const attributes = {
183
188
  ...attrs,
184
- params: {
185
- ...(attrs.params ?? {}),
189
+ blockMeta: {
190
+ ...(attrs.blockMeta ?? {}),
191
+ transition,
192
+ },
193
+ };
194
+ return {
195
+ attributes,
196
+ transitionParams: {
186
197
  transition: transition.type,
187
198
  ...(transition.duration !== undefined
188
199
  ? { transitionDuration: String(transition.duration) }
189
200
  : {}),
190
201
  ...(transition.direction ? { transitionDirection: transition.direction } : {}),
191
202
  },
192
- blockMeta: {
193
- ...(attrs.blockMeta ?? {}),
194
- transition,
195
- },
203
+ };
204
+ }
205
+
206
+ function removeTransitionParams(
207
+ attrs: MarkdownHeading['attributes'],
208
+ ): MarkdownHeading['attributes'] {
209
+ if (!attrs?.params) return attrs;
210
+ const params = { ...attrs.params };
211
+ for (const key of TRANSITION_PARAM_KEYS) delete params[key];
212
+ return {
213
+ ...attrs,
214
+ ...(Object.keys(params).length > 0 ? { params } : { params: undefined }),
196
215
  };
197
216
  }
@@ -18,6 +18,7 @@ import type { Layer } from '../../schemas/Doc.js';
18
18
  import type { TemplateContext } from '../../schemas/BlockTemplates.js';
19
19
  import type { StartBlockConfig } from '../../schemas/Doc.js';
20
20
  import { getThemeFont, themedFontSize, themedImageTreatment } from '../utils/themeUtils.js';
21
+ import { relativeLuminance, withAlpha } from '../../schemas/colorUtils.js';
21
22
  import { mapAmbientMotion } from './accentImage.js';
22
23
 
23
24
  /**
@@ -61,6 +62,11 @@ export function fitCoverTitleSize(title: string): number {
61
62
  return Math.max(COVER_TITLE_MIN_BASE_PX, Math.round(scaled));
62
63
  }
63
64
 
65
+ function isStandardLightCover(context: TemplateContext): boolean {
66
+ const { theme } = context;
67
+ return theme.id === 'standard' && relativeLuminance(theme.colors.background) > 0.85;
68
+ }
69
+
64
70
  /**
65
71
  * Generate cover block layers from StartBlockConfig.
66
72
  */
@@ -68,6 +74,7 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
68
74
  const treatment = themedImageTreatment(context, input.imageTreatment);
69
75
  const { heroSrc, heroAlt, title, subtitle, ambientMotion, heroCredit, heroLicense } = input;
70
76
  const { theme, layout } = context;
77
+ const standardLightCover = !heroSrc && isStandardLightCover(context);
71
78
 
72
79
  // Scale font sizes for viewport - cover titles are larger than regular title blocks.
73
80
  // The cover title wraps within maxTextWidth but does not otherwise shrink, so a long
@@ -113,16 +120,42 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
113
120
  },
114
121
  );
115
122
  } else {
116
- // No hero image: use a rich theme-driven background with radial gradient
117
- layers.push({
118
- type: 'shape',
119
- id: 'cover-bg',
120
- content: {
121
- shape: 'rect',
122
- fill: `radial-gradient(ellipse at 50% 40%, ${theme.colors.primary} 0%, ${theme.colors.background} 100%)`,
123
- },
124
- position: { x: 0, y: 0, width: '100%', height: '100%' },
125
- });
123
+ if (standardLightCover) {
124
+ // Standard Light needs a restrained cover-poster treatment; a
125
+ // full-strength primary radial hotspot overwhelms dark serif text
126
+ // and makes the subtitle muddy.
127
+ layers.push(
128
+ {
129
+ type: 'shape',
130
+ id: 'cover-bg',
131
+ content: {
132
+ shape: 'rect',
133
+ fill: theme.colors.background,
134
+ },
135
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
136
+ },
137
+ {
138
+ type: 'shape',
139
+ id: 'cover-bg-tint',
140
+ content: {
141
+ shape: 'rect',
142
+ fill: `radial-gradient(ellipse at 50% 40%, ${withAlpha(theme.colors.primary, 0.16)} 0%, ${withAlpha(theme.colors.primary, 0.07)} 38%, ${withAlpha(theme.colors.primary, 0)} 78%)`,
143
+ },
144
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
145
+ },
146
+ );
147
+ } else {
148
+ // Non-Standard themes keep the existing dramatic radial cover look.
149
+ layers.push({
150
+ type: 'shape',
151
+ id: 'cover-bg',
152
+ content: {
153
+ shape: 'rect',
154
+ fill: `radial-gradient(ellipse at 50% 40%, ${theme.colors.primary} 0%, ${theme.colors.background} 100%)`,
155
+ },
156
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
157
+ });
158
+ }
126
159
 
127
160
  // Subtle decorative accent line below title
128
161
  layers.push({
@@ -130,7 +163,9 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
130
163
  id: 'cover-accent',
131
164
  content: {
132
165
  shape: 'rect',
133
- fill: 'rgba(255, 255, 255, 0.2)',
166
+ fill: standardLightCover
167
+ ? withAlpha(theme.colors.primary, 0.24)
168
+ : 'rgba(255, 255, 255, 0.2)',
134
169
  },
135
170
  position: {
136
171
  x: '35%',
@@ -153,7 +188,7 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
153
188
  fontWeight: 'bold',
154
189
  color: theme.colors.text,
155
190
  textAlign: 'center',
156
- shadow: true,
191
+ shadow: !standardLightCover,
157
192
  },
158
193
  },
159
194
  position: {
@@ -176,7 +211,8 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
176
211
  style: {
177
212
  fontSize: subtitleFontSize,
178
213
  fontFamily: getThemeFont(context, 'body'),
179
- color: theme.colors.textMuted,
214
+ fontWeight: standardLightCover ? 'bold' : 'normal',
215
+ color: standardLightCover ? withAlpha(theme.colors.text, 0.78) : theme.colors.textMuted,
180
216
  textAlign: 'center',
181
217
  lineHeight: 1.5,
182
218
  },
@@ -17,7 +17,7 @@
17
17
  import type { Layer, ShapeLayer, TextLayer, PathLayer, MarkerStyle } from '../../schemas/Doc.js';
18
18
  import type { DiagramBlockInput, TemplateContext } from '../../schemas/BlockTemplates.js';
19
19
  import { resolveColorScheme, getThemeFont, themedFontSize } from '../utils/themeUtils.js';
20
- import { clipEndpoints, connectorPath, lineStyleDasharray } from '../utils/shapeGeometry.js';
20
+ import { connectorPath, lineStyleDasharray, snapEndpoints } from '../utils/shapeGeometry.js';
21
21
  import { computeDiagramLayout, type DiagramNodePosition } from './diagramLayout.js';
22
22
 
23
23
  const NODE_WIDTH = 180;
@@ -124,7 +124,7 @@ export function diagramBlock(input: DiagramBlockInput, context: TemplateContext)
124
124
  const a = positions.get(edge.source);
125
125
  const b = positions.get(edge.target);
126
126
  if (!a || !b) continue;
127
- const { start, end } = clipEndpoints(a, b);
127
+ const { start, end } = snapEndpoints(a, b);
128
128
  const pathLayer: PathLayer = {
129
129
  type: 'path',
130
130
  id: `edge-${edge.id}`,
@@ -18,7 +18,7 @@ import type { Layer, ShapeLayer, TextLayer, PathLayer } from '../../schemas/Doc.
18
18
  import type { DrawingBlockInput, TemplateContext } from '../../schemas/BlockTemplates.js';
19
19
  import { resolveColorScheme, getThemeFont, themedFontSize } from '../utils/themeUtils.js';
20
20
  import { computeDrawingLayout, type DrawingShape, type DrawingConnector } from './drawingLayout.js';
21
- import { shapePath, connectorPath, clipEndpoints } from '../utils/shapeGeometry.js';
21
+ import { shapePath, connectorPath, snapEndpoints } from '../utils/shapeGeometry.js';
22
22
 
23
23
  const PADDING = 80;
24
24
 
@@ -102,7 +102,7 @@ export function drawingBlock(input: DrawingBlockInput, context: TemplateContext)
102
102
  const a = boxes.get(conn.from);
103
103
  const b = boxes.get(conn.to);
104
104
  if (!a || !b) continue;
105
- const { start, end } = clipEndpoints(a, b);
105
+ const { start, end } = snapEndpoints(a, b);
106
106
  const path: PathLayer = {
107
107
  type: 'path',
108
108
  id: `connector-${conn.id}`,
@@ -9,9 +9,11 @@
9
9
  * - `markerPath(style, dir)` → the `<marker>` path for a line end-style, in the
10
10
  * standard 0–10 marker viewBox (shared by the SSR `PathLayer` renderer and the
11
11
  * editor's edge renderer).
12
- * - `connectorPath(routing, start, end)` + `clipEndpoints(a, b)` → the line/edge
12
+ * - `connectorPath(routing, start, end)` + `snapEndpoints(a, b)` → the line/edge
13
13
  * geometry between two shapes (straight / orthogonal / curved), lifted from the
14
- * diagram template so drawings and diagrams draw identical connectors.
14
+ * diagram template so drawings and diagrams draw identical connectors. The older
15
+ * `clipEndpoints(a, b)` helper remains for free-angle clipping, but rendered
16
+ * connectors use snapped ports so authored lines land on stable attachment points.
15
17
  */
16
18
 
17
19
  import type { MarkerStyle } from '../../schemas/Doc.js';
@@ -411,6 +413,22 @@ export interface ClipBox {
411
413
  ry: number;
412
414
  }
413
415
 
416
+ export type ConnectorPort =
417
+ | 'top'
418
+ | 'right'
419
+ | 'bottom'
420
+ | 'left'
421
+ | 'top-left'
422
+ | 'top-right'
423
+ | 'bottom-right'
424
+ | 'bottom-left';
425
+
426
+ export interface ConnectorSnapPoint {
427
+ port: ConnectorPort;
428
+ x: number;
429
+ y: number;
430
+ }
431
+
414
432
  /** Intersection of the center-to-center line with `from`'s bounding box. */
415
433
  export function clipPoint(from: ClipBox, to: ClipBox): { x: number; y: number } {
416
434
  const dx = to.cx - from.cx;
@@ -430,6 +448,76 @@ export function clipEndpoints(
430
448
  return { start: clipPoint(a, b), end: clipPoint(b, a) };
431
449
  }
432
450
 
451
+ /** The stable connector ports exposed around a shape/card box. */
452
+ export function snapPoints(box: ClipBox): ConnectorSnapPoint[] {
453
+ const left = box.cx - box.rx;
454
+ const right = box.cx + box.rx;
455
+ const top = box.cy - box.ry;
456
+ const bottom = box.cy + box.ry;
457
+ return [
458
+ { port: 'top', x: box.cx, y: top },
459
+ { port: 'right', x: right, y: box.cy },
460
+ { port: 'bottom', x: box.cx, y: bottom },
461
+ { port: 'left', x: left, y: box.cy },
462
+ { port: 'top-left', x: left, y: top },
463
+ { port: 'top-right', x: right, y: top },
464
+ { port: 'bottom-right', x: right, y: bottom },
465
+ { port: 'bottom-left', x: left, y: bottom },
466
+ ];
467
+ }
468
+
469
+ /** Nearest stable connector port on `box` to an arbitrary point. */
470
+ export function nearestSnapPoint(
471
+ box: ClipBox,
472
+ point: { x: number; y: number },
473
+ ): ConnectorSnapPoint {
474
+ let best = snapPoints(box)[0];
475
+ let bestScore = Number.POSITIVE_INFINITY;
476
+ for (const candidate of snapPoints(box)) {
477
+ const score = distanceSq(candidate, point) + portTieBreak(candidate.port);
478
+ if (score < bestScore) {
479
+ best = candidate;
480
+ bestScore = score;
481
+ }
482
+ }
483
+ return best;
484
+ }
485
+
486
+ /**
487
+ * Pick the pair of connector ports, one on each box, with the shortest
488
+ * distance between them. This gives stable attachments to side/corner handles
489
+ * instead of arbitrary center-ray intersections.
490
+ */
491
+ export function snapEndpoints(
492
+ a: ClipBox,
493
+ b: ClipBox,
494
+ ): { start: ConnectorSnapPoint; end: ConnectorSnapPoint } {
495
+ let bestStart = snapPoints(a)[0];
496
+ let bestEnd = snapPoints(b)[0];
497
+ let bestScore = Number.POSITIVE_INFINITY;
498
+ for (const start of snapPoints(a)) {
499
+ for (const end of snapPoints(b)) {
500
+ const score = distanceSq(start, end) + portTieBreak(start.port) + portTieBreak(end.port);
501
+ if (score < bestScore) {
502
+ bestStart = start;
503
+ bestEnd = end;
504
+ bestScore = score;
505
+ }
506
+ }
507
+ }
508
+ return { start: bestStart, end: bestEnd };
509
+ }
510
+
511
+ function distanceSq(a: { x: number; y: number }, b: { x: number; y: number }): number {
512
+ const dx = a.x - b.x;
513
+ const dy = a.y - b.y;
514
+ return dx * dx + dy * dy;
515
+ }
516
+
517
+ function portTieBreak(port: ConnectorPort): number {
518
+ return port.includes('-') ? 0.02 : 0;
519
+ }
520
+
433
521
  /** Build the SVG `d` for a connector between two clipped endpoints. */
434
522
  export function connectorPath(
435
523
  routing: ConnectorRouting,