diff --git a/client/src/components/Studio/StudioInspector.tsx b/client/src/components/Studio/StudioInspector.tsx
index 7e9a163ec..7d27d96ad 100644
--- a/client/src/components/Studio/StudioInspector.tsx
+++ b/client/src/components/Studio/StudioInspector.tsx
@@ -6,7 +6,6 @@ import {
import { useMemo, useRef, useState } from 'react'
import { useStudioStore } from '../../store/studioStore'
import { photoSrc } from './bookRender'
-import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts'
import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes'
import { ShapeGlyph } from './StudioElementsPanel'
import { TravelInspector } from './StudioTravelInspector'
@@ -14,6 +13,7 @@ import { Swatches } from './StudioSwatches'
import { formatBookCoords } from './entryText'
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
+import { FontButtons, WeightLine } from './StudioTypeControls'
import type { JourneySource } from './StudioSidebar'
import { Tooltip } from '../shared/Tooltip'
@@ -320,41 +320,9 @@ export function StudioInspector({
nothing about a typeface — and grouped, because "which serif"
is a different question from "serif or sans".
*/}
-
- {BOOK_FONT_ORDER.map(id => {
- const font = BOOK_FONTS[id]
- return (
-
- )
- })}
-
+ set(patch)} />
-
- ({
- value: w,
- label: String(w),
- disabled: !hasWeight(el.font, w),
- title: hasWeight(el.font, w) ? undefined : t('journey.studio.weightMissing'),
- }))}
- onPick={weight => set({ weight })}
- />
-
+
set({ weight })} t={t} />
{([['left', AlignLeft], ['center', AlignCenter], ['right', AlignRight], ['justify', AlignJustify]] as const).map(([a, Icon]) => (
diff --git a/client/src/components/Studio/StudioTravelInspector.tsx b/client/src/components/Studio/StudioTravelInspector.tsx
index 8754ed238..50908e230 100644
--- a/client/src/components/Studio/StudioTravelInspector.tsx
+++ b/client/src/components/Studio/StudioTravelInspector.tsx
@@ -6,7 +6,7 @@ import type {
import { BOOK_METRICS } from '@trek/shared'
import { isStale, refreshPatch } from './travelRefresh'
import { Swatches } from './StudioSwatches'
-import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts'
+import { FontButtons, WeightLine } from './StudioTypeControls'
import { sourceIdOf, useMapSources } from './mapSources'
import { printDpi, tileView } from './mapTiles'
import { useCartoApiKey } from '../../hooks/useTileUrl'
@@ -82,41 +82,14 @@ export function TravelInspector({ el, stats, set, t }: TravelInspectorProps) {
* wrong typeface and no way to fix it.
*/}
-
- {BOOK_FONT_ORDER.map(id => {
- const font = BOOK_FONTS[id]
- return (
-
- )
- })}
-
+ set(patch as Partial)} />
-
- ({
- value: w,
- label: String(w),
- disabled: !hasWeight(el.font, w),
- title: hasWeight(el.font, w) ? undefined : t('journey.studio.weightMissing'),
- }))}
- onPick={weight => set({ weight } as Partial)}
- />
-
+
set({ weight } as Partial)}
+ t={t}
+ />
k;
+
+describe('FontButtons', () => {
+ it('offers every bundled family in the picker order and marks the current one', () => {
+ render();
+ const buttons = screen.getAllByRole('button');
+ expect(buttons).toHaveLength(BOOK_FONT_ORDER.length);
+ expect(screen.getByRole('button', { name: 'Lora' }).className).toContain('is-on');
+ expect(screen.getByRole('button', { name: 'Poppins' }).className).not.toContain('is-on');
+ });
+
+ it('keeps the weight when the new family ships it', () => {
+ const onPick = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'Inter' }));
+ expect(onPick).toHaveBeenCalledWith({ font: 'inter', weight: 700 });
+ });
+
+ it('moves the weight to one the new family really has', () => {
+ const onPick = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole('button', { name: 'Bebas Neue' }));
+ expect(onPick).toHaveBeenCalledWith({ font: 'bebas', weight: 400 });
+ });
+});
+
+describe('WeightLine', () => {
+ it('greys the weights the family does not ship and says why', () => {
+ render();
+ expect(screen.getByText('journey.studio.weight')).toBeTruthy();
+ const bold = screen.getByRole('radio', { name: '700' }) as HTMLButtonElement;
+ expect(bold.disabled).toBe(true);
+ expect(bold.title).toBe('journey.studio.weightMissing');
+ const regular = screen.getByRole('radio', { name: '400' }) as HTMLButtonElement;
+ expect(regular.disabled).toBe(false);
+ expect(regular.getAttribute('aria-checked')).toBe('true');
+ });
+
+ it('hands the picked weight back', () => {
+ const onPick = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole('radio', { name: '600' }));
+ expect(onPick).toHaveBeenCalledWith(600);
+ });
+});
diff --git a/client/src/components/Studio/StudioTypeControls.tsx b/client/src/components/Studio/StudioTypeControls.tsx
new file mode 100644
index 000000000..c46853b17
--- /dev/null
+++ b/client/src/components/Studio/StudioTypeControls.tsx
@@ -0,0 +1,81 @@
+import type { BookTextElement } from '@trek/shared';
+import type { BookFontId } from './bookFonts';
+import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts';
+import { Choice, Line } from './StudioControls';
+
+/**
+ * The typeface buttons and the weight line, shared by the text inspector and
+ * the travel inspector so both set type the same way.
+ */
+
+/** The weight set the book schema allows, taken from it so there is one list. */
+type BookWeight = BookTextElement['weight'];
+
+const WEIGHTS = [400, 500, 600, 700] as const;
+
+/** One button per bundled family, each set in its own face. */
+export function FontButtons({
+ font,
+ weight,
+ onPick,
+}: {
+ font: string;
+ weight: BookWeight;
+ onPick: (patch: { font: BookFontId; weight: BookWeight }) => void;
+}) {
+ return (
+
+ {BOOK_FONT_ORDER.map((id) => {
+ const face = BOOK_FONTS[id];
+ return (
+
+ );
+ })}
+
+ );
+}
+
+/** The four weights, with the ones the family does not ship greyed out. */
+export function WeightLine({
+ font,
+ weight,
+ onPick,
+ t,
+}: {
+ font: string;
+ weight: BookWeight;
+ onPick: (weight: BookWeight) => void;
+ t: (k: string) => string;
+}) {
+ return (
+
+ ({
+ value: w,
+ label: String(w),
+ disabled: !hasWeight(font, w),
+ title: hasWeight(font, w) ? undefined : t('journey.studio.weightMissing'),
+ }))}
+ onPick={onPick}
+ />
+
+ );
+}