@bycrux/editor 1.2.0 → 1.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
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.1",
20
+ "@bycrux/timeline-core": "^0.2.2",
21
21
  "class-variance-authority": "^0.7",
22
22
  "clsx": "^2",
23
23
  "lucide-react": "^0.400",
@@ -514,6 +514,41 @@ describe('transportEndFor', () => {
514
514
  })
515
515
  expect(transportEndFor(p)).toBe(6)
516
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
+ })
517
552
  })
518
553
 
519
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
 
@@ -548,7 +548,15 @@ export function transportEndFor(project: Project): number {
548
548
  (m: number, s) => Math.max(m, s.end ?? 0),
549
549
  0,
550
550
  )
551
- return Math.max(visualEnd, 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)
552
560
  }
553
561
 
554
562
  /**
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export type {
37
37
  RenderPhase,
38
38
  CaptionEvent,
39
39
  GenerateCaptionsOptions,
40
+ CaptionProfileDefaults,
40
41
  MediaScope,
41
42
  MediaItem,
42
43
  GlobalOverlay,
@@ -169,6 +170,38 @@ export {
169
170
  FontSizePicker,
170
171
  } from './text/FontPicker'
171
172
  export type { FontOption } from './text/FontPicker'
173
+ // Two independent Google Fonts base-URL setters, one per loader — see each
174
+ // file's own header for why there are two. `setFontsBaseUrl` (from
175
+ // `lib/google-fonts.ts`) covers the broader surface: captions, overlays and
176
+ // the timeline/overlay preview. `setPickerFontsBaseUrl` (renamed at this
177
+ // export boundary only — the source file still calls it `setFontsBaseUrl`,
178
+ // same as the other loader) covers just the font-family picker's own
179
+ // preview list. A host must call BOTH to eliminate Google Fonts egress
180
+ // entirely; calling only one still leaves the other loader fetching from
181
+ // fonts.googleapis.com.
182
+ //
183
+ // Both take the same two arguments: `(url, families)`. `families` is the list
184
+ // of families the stylesheet at `url` declares — the host reads its own
185
+ // `families.json` (or equivalent) ONCE at app init and passes the result to
186
+ // both setters. It is deliberately not fetched inside the loaders: they are
187
+ // synchronous and called from effects, and a pending fetch would leave them
188
+ // unable to decide the partition at the moment they have to act on it.
189
+ //
190
+ // **Passing a url with no families means NOTHING is treated as vendored** —
191
+ // the vendored stylesheet is not linked and every family is fetched from
192
+ // Google. That is the safe direction: glyphs stay correct and preview and
193
+ // render still agree, at the cost of the egress the base exists to remove.
194
+ // Assuming the stylesheet covers everything would instead preview unvendored
195
+ // families as a system fallback while the renderer — reading its own manifest
196
+ // off local disk — got them right, and nothing on screen would say so.
197
+ //
198
+ // `vendoredFamiliesDigest()` returns a short fingerprint of the family list in
199
+ // force. The renderers log the same digest for the manifest they read; two
200
+ // different strings mean the two sides are partitioning against different
201
+ // vendored sets, and captions will differ between editing and export.
202
+ export { setFontsBaseUrl, vendoredFamiliesDigest } from './lib/google-fonts'
203
+ export { setFontsBaseUrl as setPickerFontsBaseUrl } from './text/FontPicker'
204
+ export { fontFamilyKey, familiesDigest, partitionFontSpecs, vendoredKeySet } from './lib/font-families'
172
205
  export { InlineTextEditor } from './text/InlineTextEditor'
173
206
  export type { InlineTextEditorProps } from './text/InlineTextEditor'
174
207
  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
+ })
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { render, cleanup } from '@testing-library/react'
3
+ import { FontFamilyPicker, setFontsBaseUrl as setPickerBase } from '../../text/FontPicker'
4
+ import { ensureGoogleFontsLoaded, setFontsBaseUrl as setOverlayBase } from '../google-fonts'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // TWO LOADERS, ONE PARTITION
8
+ // ---------------------------------------------------------------------------
9
+ //
10
+ // This package ships two independent Google Fonts loaders — `lib/google-fonts`
11
+ // (captions, overlays, the timeline/overlay preview) and `text/FontPicker`
12
+ // (the picker's own preview list). They keep separate bases and separate
13
+ // injection state on purpose. They must NOT keep separate opinions about which
14
+ // families a vendored stylesheet covers: one loader serving a family locally
15
+ // while the other fetches it from Google is a caption in one face in the
16
+ // picker and another on the canvas, with nothing on screen to explain it.
17
+ //
18
+ // The render package pins its two duplicated copies as identical SOURCE TEXT,
19
+ // because its two renderers are separate CLI entry points that cannot import
20
+ // each other. These two can import, and do — both partition through
21
+ // `lib/font-families`. Textual parity would therefore be a test that cannot
22
+ // fail. So this pins the property that actually matters instead: given the
23
+ // same family list, the two loaders put the same families on the same side.
24
+ //
25
+ // Driving them is asymmetric because their inputs are: the overlay loader
26
+ // takes the specs it is asked for, while the picker always requests its own
27
+ // fixed FONT_OPTIONS set. So feed the picker's specs to the overlay loader and
28
+ // compare what each one emits.
29
+
30
+ function injectedHrefs(): string[] {
31
+ return Array.from(document.head.querySelectorAll('link[rel="stylesheet"]')).map(
32
+ (l) => (l as HTMLLinkElement).href,
33
+ )
34
+ }
35
+
36
+ /** The googleapis URL a loader emitted, reduced to its `family=` specs. */
37
+ function googleSpecs(hrefs: string[]): string[] {
38
+ const url = hrefs.find((h) => h.includes('fonts.googleapis.com'))
39
+ if (!url) return []
40
+ return [...url.matchAll(/family=([^&]+)/g)].map((m) => decodeURIComponent(m[1]))
41
+ }
42
+
43
+ /** Whether a loader linked the vendored stylesheet. */
44
+ const linkedVendored = (hrefs: string[]) => hrefs.some((h) => h.endsWith('/fonts.css'))
45
+
46
+ // The picker's own spec list, read out of the module rather than restated, so
47
+ // a change to FONT_OPTIONS cannot leave this comparing a stale set.
48
+ async function pickerSpecs(): Promise<string[]> {
49
+ const { FONT_OPTIONS } = await import('../../text/FontPicker')
50
+ return FONT_OPTIONS.filter((f) => f.isGoogleFont).map(
51
+ (f) => f.spec ?? `${f.label.replace(/ /g, '+')}:wght@400;700`,
52
+ )
53
+ }
54
+
55
+ beforeEach(() => {
56
+ document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
57
+ vi.spyOn(console, 'info').mockImplementation(() => {})
58
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
59
+ })
60
+ afterEach(() => {
61
+ cleanup()
62
+ setPickerBase(undefined)
63
+ setOverlayBase(undefined)
64
+ vi.restoreAllMocks()
65
+ })
66
+
67
+ // Each case uses a base unique to itself: both loaders dedupe on the URLs they
68
+ // have already injected, and that state outlives a test, so a repeated base
69
+ // would make a loader look silent when it had merely already spoken.
70
+ describe('the picker loader and the overlay loader partition identically', () => {
71
+ const CASES: [string, (all: string[]) => string[]][] = [
72
+ ['every picker family vendored', (all) => all.map(familyOf)],
73
+ ['none vendored', () => []],
74
+ ['one family missing from the vendored list', (all) => all.slice(1).map(familyOf)],
75
+ ['half vendored', (all) => all.slice(0, 10).map(familyOf)],
76
+ ['vendored list spelled Google-style, with +', (all) => all.map((s) => s.split(':')[0])],
77
+ ['vendored list in a different case', (all) => all.map((s) => familyOf(s).toUpperCase())],
78
+ ]
79
+
80
+ function familyOf(spec: string) {
81
+ return spec.split(':')[0].replace(/\+/g, ' ')
82
+ }
83
+
84
+ for (const [label, listFor] of CASES) {
85
+ it(label, async () => {
86
+ const specs = await pickerSpecs()
87
+ const families = listFor(specs)
88
+ const base = `https://example.com/fonts/parity-${label.replace(/[^a-z]+/gi, '-')}`
89
+
90
+ setPickerBase(base, families)
91
+ render(<FontFamilyPicker value="" onChange={() => {}} />)
92
+ const picker = injectedHrefs()
93
+
94
+ document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
95
+
96
+ setOverlayBase(base, families)
97
+ ensureGoogleFontsLoaded(specs)
98
+ const overlay = injectedHrefs()
99
+
100
+ expect(googleSpecs(overlay)).toEqual(googleSpecs(picker))
101
+ expect(linkedVendored(overlay)).toBe(linkedVendored(picker))
102
+ })
103
+ }
104
+
105
+ it('and a host that sets only ONE of the two setters still leaves the other on Google', () => {
106
+ // Not a defect to fix here — it is the documented contract on
107
+ // `src/index.ts` — but it is the mistake a host will actually make, so
108
+ // pin that it stays visible rather than silently half-working.
109
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
110
+ setPickerBase('https://example.com/fonts/only-picker', ['Inter'])
111
+ // The overlay loader was never pointed anywhere, so it has no base at all.
112
+ ensureGoogleFontsLoaded(['Inter:wght@400'])
113
+ const hrefs = injectedHrefs()
114
+ expect(hrefs.some((h) => h.includes('fonts.googleapis.com'))).toBe(true)
115
+ // ...and the picker's own setter said out loud what it was given.
116
+ expect(warn.mock.calls.flat().join(' ')).not.toContain('treating NOTHING as vendored')
117
+ })
118
+ })
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // ...AND THEY AGREE AT FACE LEVEL TOO
122
+ // ---------------------------------------------------------------------------
123
+ //
124
+ // The partition is family-level AND face-level: a spec is vendored only when
125
+ // its family is declared and every face it requires is available. That second
126
+ // half is the one that can disagree quietly, because both loaders can name the
127
+ // same family while resolving different weights of it — and the picker is the
128
+ // loader that requests `Bebas+Neue:wght@400;700`, the one shipped spec whose
129
+ // answer depends on the `requested` map rather than on what is on disk.
130
+ //
131
+ // This is the TS half's parity mechanism. It is behavioural on purpose: both
132
+ // loaders import `lib/font-families`, so there is no second copy to compare
133
+ // textually and such a test could not fail. What can still diverge is how each
134
+ // loader WIRES that shared code — which argument it passes, and whether it
135
+ // passes the face index at all — and only running both can catch that.
136
+ describe('the two loaders agree on the FACE partition, not just the family one', () => {
137
+ function familyOf(spec: string) {
138
+ return spec.split(':')[0].replace(/\+/g, ' ')
139
+ }
140
+
141
+ /** The faces the picker's own specs resolve to, with `broken` letting a case
142
+ * remove something and check both loaders react the same way. */
143
+ function faceDataFor(specs: string[], broken?: (f: Record<string, { normal: number[] }>) => void) {
144
+ const faces: Record<string, { normal: number[] }> = {}
145
+ const requested: Record<string, { normal: number[] }> = {}
146
+ for (const spec of specs) {
147
+ const family = familyOf(spec)
148
+ const axes = spec.split(':')[1]
149
+ const weights = axes ? axes.slice('wght@'.length).split(';').map(Number) : [400]
150
+ requested[family] = { normal: weights }
151
+ // Bebas Neue publishes no 700, so it received only the 400 it asked for.
152
+ faces[family] = { normal: family === 'Bebas Neue' ? [400] : weights }
153
+ }
154
+ broken?.(faces)
155
+ return { faces, requested }
156
+ }
157
+
158
+ type Faces = Record<string, { normal: number[] }>
159
+ const CASES: [string, (specs: string[]) => { faces: Faces; requested?: Faces }][] = [
160
+ // Every picker spec satisfied — including Bebas Neue, and only because
161
+ // `requested` says Google was asked for the 700 and declined.
162
+ ['the real shipped manifest', (s) => faceDataFor(s)],
163
+ // Drop `requested` and Bebas Neue's missing 700 sends that spec to Google.
164
+ // Both loaders must send it, or neither.
165
+ ['faces only, no requested — Bebas Neue crosses to Google', (s) => ({ faces: faceDataFor(s).faces })],
166
+ // A weight removed from a family every picker spec asks for at 700.
167
+ ['a weight missing from Inter', (s) => faceDataFor(s, (f) => { f.Inter = { normal: [400] } })],
168
+ // Face information that covers nothing at all.
169
+ ['an empty faces map', () => ({ faces: {} })],
170
+ ]
171
+
172
+ for (const [label, dataFor] of CASES) {
173
+ it(label, async () => {
174
+ const specs = await pickerSpecs()
175
+ const families = specs.map(familyOf)
176
+ const faceData = dataFor(specs)
177
+ const base = `https://example.com/fonts/faceparity-${label.replace(/[^a-z]+/gi, '-')}`
178
+
179
+ setPickerBase(base, families, faceData)
180
+ render(<FontFamilyPicker value="" onChange={() => {}} />)
181
+ const picker = injectedHrefs()
182
+
183
+ document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
184
+
185
+ setOverlayBase(base, families, faceData)
186
+ ensureGoogleFontsLoaded(specs)
187
+ const overlay = injectedHrefs()
188
+
189
+ expect(googleSpecs(overlay)).toEqual(googleSpecs(picker))
190
+ expect(linkedVendored(overlay)).toBe(linkedVendored(picker))
191
+ })
192
+ }
193
+
194
+ // The property the whole feature is for, asserted on the real shape: with
195
+ // the shipped manifest neither loader touches Google at all.
196
+ it('with the shipped manifest, NEITHER loader reaches Google', async () => {
197
+ const specs = await pickerSpecs()
198
+ const families = specs.map(familyOf)
199
+ const faceData = faceDataFor(specs)
200
+ const base = 'https://example.com/fonts/faceparity-zero-egress'
201
+
202
+ setPickerBase(base, families, faceData)
203
+ render(<FontFamilyPicker value="" onChange={() => {}} />)
204
+ expect(googleSpecs(injectedHrefs())).toEqual([])
205
+ expect(linkedVendored(injectedHrefs())).toBe(true)
206
+
207
+ document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
208
+
209
+ setOverlayBase(base, families, faceData)
210
+ ensureGoogleFontsLoaded(specs)
211
+ expect(googleSpecs(injectedHrefs())).toEqual([])
212
+ expect(linkedVendored(injectedHrefs())).toBe(true)
213
+ })
214
+
215
+ // Both loaders log the same digest for the same manifest — the value that
216
+ // pins the TS↔JS seam. If one passed the face index and the other did not,
217
+ // the two lines would differ and this is where it would show.
218
+ it('both setters log the same digest for the same manifest', async () => {
219
+ const info = vi.spyOn(console, 'info').mockImplementation(() => {})
220
+ const specs = await pickerSpecs()
221
+ const families = specs.map(familyOf)
222
+ const faceData = faceDataFor(specs)
223
+
224
+ setPickerBase('https://example.com/fonts/digest-picker', families, faceData)
225
+ setOverlayBase('https://example.com/fonts/digest-overlay', families, faceData)
226
+
227
+ const digests = info.mock.calls
228
+ .map((c) => String(c[0]).match(/vendored set ([0-9a-f]{8})/)?.[1])
229
+ .filter(Boolean)
230
+ expect(digests).toHaveLength(2)
231
+ expect(digests[0]).toBe(digests[1])
232
+ // The same literal the render suite pins, so a divergence between the two
233
+ // languages surfaces here as well as there.
234
+ expect(digests[0]).toBe('1fcf41c1')
235
+ })
236
+ })