@bycrux/editor 1.1.0 → 1.2.1

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 (39) hide show
  1. package/package.json +2 -2
  2. package/src/ControlsInfoModal.tsx +1 -0
  3. package/src/engine/__tests__/scheduler.test.ts +56 -1
  4. package/src/engine/scheduler.ts +23 -5
  5. package/src/index.ts +36 -0
  6. package/src/lib/__tests__/font-faces.test.ts +244 -0
  7. package/src/lib/__tests__/font-loader-parity.test.tsx +236 -0
  8. package/src/lib/__tests__/google-fonts.test.ts +319 -2
  9. package/src/lib/font-families.ts +286 -0
  10. package/src/lib/google-fonts.ts +132 -10
  11. package/src/schema.ts +21 -0
  12. package/src/text/FontPicker.tsx +82 -11
  13. package/src/text/__tests__/FontPicker.baseUrl.test.tsx +112 -0
  14. package/src/video/VersionPanel.tsx +1 -1
  15. package/src/video/VideoEditor.tsx +98 -4
  16. package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
  17. package/src/video/__tests__/cuts.test.ts +84 -0
  18. package/src/video/__tests__/exportDurationSec.test.ts +60 -0
  19. package/src/video/__tests__/markerDropTime.test.ts +25 -0
  20. package/src/video/cuts.ts +30 -2
  21. package/src/video/preview/PreviewPlayer.tsx +52 -2
  22. package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
  23. package/src/video/preview/__tests__/useVideoPlayback.muted.test.ts +239 -0
  24. package/src/video/preview/useEnginePlayback.ts +58 -8
  25. package/src/video/preview/useVideoPlayback.ts +64 -17
  26. package/src/video/timeline/Timeline.tsx +6 -0
  27. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
  28. package/src/video/timeline/__tests__/markers.test.ts +125 -0
  29. package/src/video/timeline/canvas/TimelineCanvas.tsx +110 -12
  30. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +38 -7
  31. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
  32. package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
  33. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
  34. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +100 -1
  35. package/src/video/timeline/canvas/draw.ts +182 -8
  36. package/src/video/timeline/canvas/hit-test.ts +72 -1
  37. package/src/video/timeline/canvas/pointer-machine.ts +83 -4
  38. package/src/video/timeline/markers.ts +109 -0
  39. package/src/video/timeline/timeline-model.ts +19 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,7 +17,7 @@
17
17
  "lint": "eslint src"
18
18
  },
19
19
  "dependencies": {
20
- "@bycrux/timeline-core": "^0.2.0",
20
+ "@bycrux/timeline-core": "^0.2.1",
21
21
  "class-variance-authority": "^0.7",
22
22
  "clsx": "^2",
23
23
  "lucide-react": "^0.400",
@@ -309,6 +309,7 @@ export const VIDEO_CONTROLS: ControlSection[] = [
309
309
  heading: 'Keyboard',
310
310
  entries: [
311
311
  { keys: ['S'], label: 'Split at the playhead' },
312
+ { keys: ['M'], label: 'Drop a marker at the playhead (or the preview axis)' },
312
313
  { keys: ['⌘/Ctrl', 'A'], label: 'Toggle the preview axis' },
313
314
  { keys: ['⇧', 'Delete'], label: 'Ripple-delete the selection' },
314
315
  { keys: ['⌘/Ctrl', 'Z'], label: 'Undo' },
@@ -483,7 +483,27 @@ describe('transportEndFor', () => {
483
483
  expect(transportEndFor(p)).toBe(8)
484
484
  })
485
485
 
486
- it('uses max(overlayEnd, captionEnd) for a canvas project — audio EXCLUDED', () => {
486
+ it('counts track 0 in a canvas project — an overlay-only single track is not a zero-length transport', () => {
487
+ // The shape an animations-workflow project actually has: ONE track, holding
488
+ // nothing but overlays, and no captions. Reading the ceiling off
489
+ // `tracks.slice(1)` made this 0, so play/space started the transport and
490
+ // stopped it in the same tick — the picture never moved.
491
+ const p = project([overlay('o1', 0, 5), overlay('o2', 5, 12)])
492
+ expect(p.tracks).toHaveLength(1)
493
+ expect(transportEndFor(p)).toBe(12)
494
+ })
495
+
496
+ it('counts track 0 images in a canvas project, even past the overlay tracks', () => {
497
+ // Same defect, other content kind: a background image outlasting every
498
+ // overlay used to be invisible to the ceiling.
499
+ const p = project(
500
+ [{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 9 }],
501
+ [overlay('o', 0, 4)],
502
+ )
503
+ expect(transportEndFor(p)).toBe(9)
504
+ })
505
+
506
+ it('uses max(visualEnd, captionEnd) for a canvas project — audio EXCLUDED', () => {
487
507
  // The legacy canvas rAF's ceiling is `canvasMaxEndRef`, which really does
488
508
  // leave audio out. Faithful, not unified.
489
509
  const p = project([{ id: 'img', type: 'image', src: '/a.png', start: 0, end: 3 }], [
@@ -494,6 +514,41 @@ describe('transportEndFor', () => {
494
514
  })
495
515
  expect(transportEndFor(p)).toBe(6)
496
516
  })
517
+
518
+ it('falls back to the audio end when NOTHING visual sets a ceiling', () => {
519
+ // Audio-only timeline: the shape an animations-workflow project has while
520
+ // the music is wired but no overlay exists yet. The ceiling was 0, so the
521
+ // transport started and stopped on the same tick and play did nothing at
522
+ // all — with no feedback saying why.
523
+ const p = project([], [], {
524
+ audio: { tracks: [{ id: 'm', src: '/m.wav', start: 0, end: 15 }] },
525
+ })
526
+ expect(transportEndFor(p)).toBe(15)
527
+ })
528
+
529
+ it('resolves an audio-only track carrying no explicit end to its natural length', () => {
530
+ const p = project([], [], {
531
+ // `AudioTrack` types `start`/`end` as required, but project.json files on
532
+ // disk routinely omit them — `resolveAudioWindow` exists precisely to cope
533
+ // at runtime. That gap between the type and the real shape is what this
534
+ // test pins, so the under-specified track is the point, not a shortcut.
535
+ // @ts-expect-error — deliberately the under-specified on-disk shape
536
+ audio: { tracks: [{ id: 'm', src: '/m.wav', sourceDuration: 15 }] },
537
+ })
538
+ expect(transportEndFor(p)).toBe(15)
539
+ })
540
+
541
+ it('skips muted tracks in the audio-only fallback', () => {
542
+ const p = project([], [], {
543
+ audio: {
544
+ tracks: [
545
+ { id: 'a', src: '/a.wav', start: 0, end: 30, muted: true },
546
+ { id: 'b', src: '/b.wav', start: 0, end: 15 },
547
+ ],
548
+ },
549
+ })
550
+ expect(transportEndFor(p)).toBe(15)
551
+ })
497
552
  })
498
553
 
499
554
  describe('engineSrcFor — proxy-only playback is structural', () => {
@@ -83,7 +83,7 @@ import type { ItemCrossfade, Scene, SourceWindow } from '@bycrux/timeline-core'
83
83
  import type { EditorProject as Project, VisualItem, VisualTrack } from '../schema'
84
84
  import type { ClipTimebase, MasterClock } from './audio-clock'
85
85
  import type { FrameServer } from './frame-server'
86
- import { effectiveItemAudio, enabledTrackItems, enabledTracks, withEnabledItemTracks } from '../video/timeline/timeline-model'
86
+ import { audioEnd, effectiveItemAudio, enabledTrackItems, enabledTracks, withEnabledItemTracks } from '../video/timeline/timeline-model'
87
87
 
88
88
  // ── Tuning constants ────────────────────────────────────────────────────────
89
89
 
@@ -517,9 +517,9 @@ function withTrackAudio(track: VisualTrack | undefined, item: VisualItem): Visua
517
517
  * Two formulas, because the legacy hook has two and they legitimately differ:
518
518
  *
519
519
  * - **Canvas projects** (no track-0 video) run the `isCanvasProject` rAF,
520
- * whose ceiling is `canvasMaxEndRef` = `max(overlayEnd, captionEnd)`. Note
520
+ * whose ceiling is `canvasMaxEndRef` = `max(visualEnd, captionEnd)`. Note
521
521
  * what is NOT in it: audio. A canvas project whose music outlasts its
522
- * overlays stops at the overlays today.
522
+ * visuals stops at the visuals today.
523
523
  * - **Video projects** use `projectEnd` = `max(videoEnd, overlayEnd,
524
524
  * audioEnd)` — captions excluded, audio included — which timeline-core
525
525
  * already ports verbatim (including its two documented faithfulness warts:
@@ -527,18 +527,36 @@ function withTrackAudio(track: VisualTrack | undefined, item: VisualItem): Visua
527
527
  *
528
528
  * Unifying them would be a behavior change in one mode or the other, so both
529
529
  * are kept and the divergence is named here rather than smoothed over.
530
+ *
531
+ * `visualEnd` spans EVERY enabled track, track 0 included — it is deliberately
532
+ * not the video path's `overlayEnd` (`tracks.slice(1)`). In canvas mode track 0
533
+ * is a content track like any other: it carries the background images, and an
534
+ * agent-authored project can put its overlays there too (an animations-workflow
535
+ * project is frequently ONE track holding nothing but overlays). Skipping it
536
+ * made the ceiling collapse to 0 for exactly those projects, so play/space
537
+ * started the transport and stopped it in the same tick. `OverlayItemsLayer`
538
+ * has always drawn track 0 in canvas mode (`isCanvasProject ? enabledTrackItems
539
+ * : overlayTracks`); this is the transport agreeing with what is on screen.
530
540
  */
531
541
  export function transportEndFor(project: Project): number {
532
542
  const clips = track0VideoItems(project)
533
543
  if (clips.length > 0) return timelineProjectEnd(withEnabledItemTracks(project))
534
- const overlayEnd = enabledTrackItems(project).slice(1)
544
+ const visualEnd = enabledTrackItems(project)
535
545
  .flat()
536
546
  .reduce((m, i) => Math.max(m, i?.end ?? 0), 0)
537
547
  const captionEnd = (project.captions?.segments ?? []).reduce(
538
548
  (m: number, s) => Math.max(m, s.end ?? 0),
539
549
  0,
540
550
  )
541
- return Math.max(overlayEnd, captionEnd)
551
+ const ceiling = Math.max(visualEnd, captionEnd)
552
+ if (ceiling > 0) return ceiling
553
+ // Nothing visual sets a ceiling: an audio-only timeline. Returning 0 here
554
+ // started the transport and stopped it in the same tick, so play did nothing
555
+ // at all. Audio is otherwise deliberately kept out of the ceiling (the
556
+ // canvas/video divergence over the audio tail, above) — this is the last
557
+ // resort that only fires when there is no visual content to measure.
558
+ // Mirrored in `canvasMaxEndRef` (video/preview/useVideoPlayback.ts).
559
+ return audioEnd(project)
542
560
  }
543
561
 
544
562
  /**
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export type {
19
19
  CarouselElement,
20
20
  Slide,
21
21
  EditorProject,
22
+ Marker,
22
23
  } from './schema'
23
24
 
24
25
  // ── Contracts (adapter, theme, render, media, component props) ────────────────
@@ -103,6 +104,9 @@ export {
103
104
  // "land where you dropped it, without stomping existing footage".
104
105
  export { placeDroppedClip, resolveDropTrackIndex } from './video/timeline/placement'
105
106
  export type { DroppedClipPlacement, PlacedClipResult } from './video/timeline/placement'
107
+ // Marker model — pure mutations over `project.markers` (see markers.ts's file
108
+ // header for the "same reference when unchanged" contract they all share).
109
+ export { addMarker, moveMarker, renameMarker, removeMarkers, nextMarkerLabel } from './video/timeline/markers'
106
110
 
107
111
  // ── Image tone (HDR image color mapping) ─────────────────────────────────────
108
112
  // The picker component is exported so hosts using `onProvideImageTone` can
@@ -165,6 +169,38 @@ export {
165
169
  FontSizePicker,
166
170
  } from './text/FontPicker'
167
171
  export type { FontOption } from './text/FontPicker'
172
+ // Two independent Google Fonts base-URL setters, one per loader — see each
173
+ // file's own header for why there are two. `setFontsBaseUrl` (from
174
+ // `lib/google-fonts.ts`) covers the broader surface: captions, overlays and
175
+ // the timeline/overlay preview. `setPickerFontsBaseUrl` (renamed at this
176
+ // export boundary only — the source file still calls it `setFontsBaseUrl`,
177
+ // same as the other loader) covers just the font-family picker's own
178
+ // preview list. A host must call BOTH to eliminate Google Fonts egress
179
+ // entirely; calling only one still leaves the other loader fetching from
180
+ // fonts.googleapis.com.
181
+ //
182
+ // Both take the same two arguments: `(url, families)`. `families` is the list
183
+ // of families the stylesheet at `url` declares — the host reads its own
184
+ // `families.json` (or equivalent) ONCE at app init and passes the result to
185
+ // both setters. It is deliberately not fetched inside the loaders: they are
186
+ // synchronous and called from effects, and a pending fetch would leave them
187
+ // unable to decide the partition at the moment they have to act on it.
188
+ //
189
+ // **Passing a url with no families means NOTHING is treated as vendored** —
190
+ // the vendored stylesheet is not linked and every family is fetched from
191
+ // Google. That is the safe direction: glyphs stay correct and preview and
192
+ // render still agree, at the cost of the egress the base exists to remove.
193
+ // Assuming the stylesheet covers everything would instead preview unvendored
194
+ // families as a system fallback while the renderer — reading its own manifest
195
+ // off local disk — got them right, and nothing on screen would say so.
196
+ //
197
+ // `vendoredFamiliesDigest()` returns a short fingerprint of the family list in
198
+ // force. The renderers log the same digest for the manifest they read; two
199
+ // different strings mean the two sides are partitioning against different
200
+ // vendored sets, and captions will differ between editing and export.
201
+ export { setFontsBaseUrl, vendoredFamiliesDigest } from './lib/google-fonts'
202
+ export { setFontsBaseUrl as setPickerFontsBaseUrl } from './text/FontPicker'
203
+ export { fontFamilyKey, familiesDigest, partitionFontSpecs, vendoredKeySet } from './lib/font-families'
168
204
  export { InlineTextEditor } from './text/InlineTextEditor'
169
205
  export type { InlineTextEditorProps } from './text/InlineTextEditor'
170
206
  export {
@@ -0,0 +1,244 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { ensureGoogleFontsLoaded, setFontsBaseUrl } from '../google-fonts'
3
+ import { requiredFaces, specFacesAvailable, vendoredFaceIndex } from '../font-families'
4
+
5
+ // THE FACE-LEVEL PARTITION (L1).
6
+ //
7
+ // The vendored set is family + STYLE + WEIGHT, not family. `fonts.css` carries
8
+ // only the faces the vendoring pass actually received — every face is
9
+ // `font-style: normal`, and the weights are only the ones it asked for. A
10
+ // family-level partition therefore gets two shapes silently wrong:
11
+ //
12
+ // Playfair+Display:ital@1 — family vendored, no italic face exists, so the
13
+ // browser synthesises an oblique from the upright.
14
+ // Inter:wght@300 — family vendored, no 300 face exists, so the
15
+ // browser synthesises a light weight from the 400.
16
+ //
17
+ // Neither produces a failed request or a console error, and both change what
18
+ // the user sees. `skills/write-overlay/SKILL.md` uses the first as its own
19
+ // documented example.
20
+ //
21
+ // Every assertion here has a twin on the render side
22
+ // (`montaj_assets/render/test/fonts-fallthrough.test.mjs`). The two sides MUST
23
+ // partition identically — a caption laid out in one face while editing and
24
+ // another at export is the Syne bug that case study documents.
25
+
26
+ const N = (weight: number) => ({ style: 'normal', weight })
27
+ const I = (weight: number) => ({ style: 'italic', weight })
28
+
29
+ describe('requiredFaces: a spec resolves to the faces it asks Google for', () => {
30
+ for (const [spec, want] of [
31
+ // No axis list — Google serves the family default, which is normal 400.
32
+ ['Anton', [N(400)]],
33
+ ['Playfair Display', [N(400)]],
34
+ ['Inter:wght@400;700', [N(400), N(700)]],
35
+ ['Inter:wght@300', [N(300)]],
36
+ ['Playfair+Display:ital@1', [I(400)]],
37
+ ['Playfair+Display:ital@0', [N(400)]],
38
+ // The axes are named in one list and their values in another, positionally.
39
+ ['Playfair+Display:ital,wght@1,700', [I(700)]],
40
+ ['Playfair+Display:ital,wght@0,400;1,700', [N(400), I(700)]],
41
+ ['Baloo+2:wght@400;500;600;700;800', [N(400), N(500), N(600), N(700), N(800)]],
42
+ ] as [string, ReturnType<typeof N>[]][]) {
43
+ it(`${spec} → ${JSON.stringify(want)}`, () => {
44
+ expect(requiredFaces(spec)).toEqual(want)
45
+ })
46
+ }
47
+
48
+ // `null` means "I cannot parse this confidently" and the caller must fall
49
+ // through. Fetching a font we happen to have costs one request; silently
50
+ // dropping one we lack costs the author a wrong face in a finished export.
51
+ for (const spec of [
52
+ 'Inter:', // a colon with no axis list
53
+ 'Inter:wght', // an axis with no '@'
54
+ 'Inter:wght@', // an '@' with no value
55
+ 'Inter:wght@400;', // a trailing ';' leaves an empty tuple
56
+ 'Inter:wght@100..900', // a variable RANGE is not a face
57
+ 'Inter:ital@0..1',
58
+ 'Inter:opsz@14', // an axis we do not model
59
+ 'Inter:slnt@-10',
60
+ 'Inter:GRAD@150',
61
+ 'Inter:wght,opsz@400,14', // one modelled axis, one not
62
+ 'Inter:wght,wght@400,700', // a duplicated axis
63
+ 'Inter:ital,wght@1', // arity mismatch: two axes, one value
64
+ 'Inter:wght@400@700', // two '@'
65
+ 'Inter:ital@2', // ital is 0 or 1, nothing else
66
+ 'Inter:wght@0', // out of range
67
+ 'Inter:wght@abc',
68
+ 'Inter:@400', // an empty axis name
69
+ ]) {
70
+ it(`${spec} → null, so the caller falls through`, () => {
71
+ expect(requiredFaces(spec)).toBeNull()
72
+ })
73
+ }
74
+ })
75
+
76
+ describe('vendoredFaceIndex: `faces` and `requested` together say what is available', () => {
77
+ it('merges both maps — a weight in either one counts as available', () => {
78
+ // Bebas Neue publishes no 700, so we asked for it and got only the 400.
79
+ // Falling through would fetch a stylesheet that omits it identically.
80
+ const index = vendoredFaceIndex(
81
+ { 'Bebas Neue': { normal: [400] } },
82
+ { 'Bebas Neue': { normal: [400, 700] } },
83
+ )!
84
+ expect(specFacesAvailable('Bebas+Neue:wght@400;700', index)).toBe(true)
85
+ expect(specFacesAvailable('Bebas+Neue:wght@500', index)).toBe(false)
86
+ })
87
+
88
+ it('distinguishes NO face information from face information covering nothing', () => {
89
+ // Nothing supplied at all: the partition has nothing to refine against and
90
+ // must stay at family level, which the loader signals with `undefined`.
91
+ expect(vendoredFaceIndex(undefined, undefined)).toBeUndefined()
92
+ // An empty map IS information: it says nothing is available.
93
+ const empty = vendoredFaceIndex({}, undefined)
94
+ expect(empty).toBeInstanceOf(Map)
95
+ expect(specFacesAvailable('Inter', empty!)).toBe(false)
96
+ })
97
+
98
+ it('normalises family keys the way the family list does', () => {
99
+ const index = vendoredFaceIndex({ 'Open+Sans': { normal: [400] } })!
100
+ expect(specFacesAvailable('Open+Sans', index)).toBe(true)
101
+ expect(specFacesAvailable('OPEN SANS', index)).toBe(true)
102
+ })
103
+
104
+ it('skips malformed entries rather than throwing inside a font load', () => {
105
+ const index = vendoredFaceIndex({
106
+ Inter: { normal: [400, 'x' as unknown as number, null as unknown as number] },
107
+ Bad: null as unknown as { normal: number[] },
108
+ Worse: { normal: 'nope' as unknown as number[] },
109
+ })!
110
+ expect(specFacesAvailable('Inter', index)).toBe(true)
111
+ expect(specFacesAvailable('Bad', index)).toBe(false)
112
+ expect(specFacesAvailable('Worse', index)).toBe(false)
113
+ })
114
+
115
+ it('a family with no index entry is not covered', () => {
116
+ const index = vendoredFaceIndex({ Inter: { normal: [400] } })!
117
+ expect(specFacesAvailable('Anton', index)).toBe(false)
118
+ })
119
+
120
+ it('a spec it cannot parse is not covered, however complete the index', () => {
121
+ const index = vendoredFaceIndex({ Inter: { normal: [100, 200, 300, 400, 700, 900] } })!
122
+ expect(specFacesAvailable('Inter:wght@100..900', index)).toBe(false)
123
+ })
124
+ })
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Through the loader
128
+ // ---------------------------------------------------------------------------
129
+
130
+ function injectedHrefs(): string[] {
131
+ return Array.from(document.head.querySelectorAll('link[rel="stylesheet"]')).map(
132
+ (l) => (l as HTMLLinkElement).href,
133
+ )
134
+ }
135
+
136
+ // The injected-URL Set is module state that outlives a single test, so every
137
+ // base and every unvendored family below must be unique or a later test is
138
+ // served a suppressed <link> and passes for the wrong reason.
139
+ const B = (name: string) => `https://example.com/faces/${name}`
140
+
141
+ const REAL = {
142
+ faces: {
143
+ 'Playfair Display': { normal: [400, 700] },
144
+ Inter: { normal: [400, 700] },
145
+ 'Bebas Neue': { normal: [400] },
146
+ },
147
+ requested: {
148
+ 'Playfair Display': { normal: [400, 700] },
149
+ Inter: { normal: [400, 700] },
150
+ 'Bebas Neue': { normal: [400, 700] },
151
+ },
152
+ }
153
+ const FAMILIES = ['Playfair Display', 'Inter', 'Bebas Neue']
154
+
155
+ beforeEach(() => {
156
+ document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
157
+ vi.spyOn(console, 'info').mockImplementation(() => {})
158
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
159
+ })
160
+ afterEach(() => {
161
+ setFontsBaseUrl(undefined)
162
+ vi.restoreAllMocks()
163
+ })
164
+
165
+ describe('ensureGoogleFontsLoaded: the face refinement', () => {
166
+ for (const [spec, vendored, why] of [
167
+ ['Playfair+Display:ital@1', false, 'no italic face is vendored'],
168
+ ['Playfair+Display:ital,wght@1,700', false, 'italic 700 is not on disk either'],
169
+ ['Playfair+Display:ital@0', true, 'ital@0 is normal 400, which IS on disk'],
170
+ ['Inter:wght@300', false, '300 was never vendored'],
171
+ ['Inter:wght@400;700', true, 'both weights are on disk'],
172
+ ['Bebas+Neue:wght@400;700', true, 'Google publishes no 700, so asking again cannot help'],
173
+ ['Bebas+Neue:wght@500', false, '500 was neither vendored nor asked for'],
174
+ ] as [string, boolean, string][]) {
175
+ it(`${spec} is ${vendored ? 'served locally' : 'fetched from Google'} — ${why}`, () => {
176
+ const base = B(spec.replace(/\W/g, ''))
177
+ setFontsBaseUrl(base, FAMILIES, REAL)
178
+ ensureGoogleFontsLoaded([spec])
179
+ const hrefs = injectedHrefs()
180
+ if (vendored) {
181
+ expect(hrefs).toEqual([`${base}/fonts.css`])
182
+ } else {
183
+ expect(hrefs).toEqual([`https://fonts.googleapis.com/css2?family=${spec}&display=swap`])
184
+ }
185
+ })
186
+ }
187
+
188
+ it('a partially vendored spec falls through WHOLE, exactly as the author wrote it', () => {
189
+ // Splitting would mean synthesising a new spec string, and a spec is the
190
+ // author's — ours to honour or pass on untouched, never to rewrite.
191
+ const base = B('partial')
192
+ setFontsBaseUrl(base, FAMILIES, REAL)
193
+ ensureGoogleFontsLoaded(['Inter:wght@400;300'])
194
+ expect(injectedHrefs()).toEqual([
195
+ 'https://fonts.googleapis.com/css2?family=Inter:wght@400;300&display=swap',
196
+ ])
197
+ })
198
+
199
+ it('a MIX puts each spec on exactly one side, by face and not by family', () => {
200
+ const base = B('mix')
201
+ setFontsBaseUrl(base, FAMILIES, REAL)
202
+ // Same family on both sides: the 700 is vendored, the italic is not.
203
+ //
204
+ // The italic spec here is spelled `ital,wght@1,400` rather than `ital@1`
205
+ // — the same FACE, a different URL. `__injectedFontUrls` dedupes on the
206
+ // full URL and is module state outliving a single test, so reusing the
207
+ // spelling an earlier case already injected would suppress this <link>
208
+ // and the assertion would fail for a reason that has nothing to do with
209
+ // the partition.
210
+ ensureGoogleFontsLoaded(['Playfair+Display:wght@700', 'Playfair+Display:ital,wght@1,400'])
211
+ const hrefs = injectedHrefs()
212
+ expect(hrefs).toEqual([
213
+ `${base}/fonts.css`,
214
+ 'https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@1,400&display=swap',
215
+ ])
216
+ })
217
+
218
+ it('omitting face data leaves the partition at family level', () => {
219
+ // A manifest vendored before the refinement existed carries no face
220
+ // information. That is a real shape, and it must degrade to the previous
221
+ // behaviour rather than treating every face as missing.
222
+ const base = B('nofaces')
223
+ setFontsBaseUrl(base, FAMILIES)
224
+ ensureGoogleFontsLoaded(['Playfair+Display:ital@1'])
225
+ expect(injectedHrefs()).toEqual([`${base}/fonts.css`])
226
+ })
227
+
228
+ it('faces cannot promote a family the list does not declare', () => {
229
+ const base = B('narrowing')
230
+ setFontsBaseUrl(base, ['Inter'], { faces: { Inter: { normal: [400] }, Zilch: { normal: [400] } } })
231
+ ensureGoogleFontsLoaded(['Zilch'])
232
+ expect(injectedHrefs()).toEqual(['https://fonts.googleapis.com/css2?family=Zilch&display=swap'])
233
+ })
234
+
235
+ it('names the fallen-through spec in the warning, and not the vendored one', () => {
236
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
237
+ const base = B('logged')
238
+ setFontsBaseUrl(base, FAMILIES, REAL)
239
+ ensureGoogleFontsLoaded(['Inter:wght@400', 'Quire:ital@1'])
240
+ expect(warn).toHaveBeenCalledOnce()
241
+ expect(warn.mock.calls[0][0]).toContain('Quire:ital@1')
242
+ expect(warn.mock.calls[0][0]).not.toContain('Inter')
243
+ })
244
+ })