diff --git a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js
index 9858bfe498..9cd9e21092 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js
+++ b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.test.js
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { mount } from '@vue/test-utils';
-import { defineComponent, ref, KeepAlive } from 'vue';
+import { defineComponent, ref, nextTick, KeepAlive } from 'vue';
+import { EventEmitter } from 'eventemitter3';
import Toolbar from './Toolbar.vue';
const ToolbarKeepAliveHost = defineComponent({
@@ -12,8 +13,10 @@ const ToolbarKeepAliveHost = defineComponent({
template: '',
});
+// The real SuperToolbar is an EventEmitter; model that so Toolbar.vue can subscribe to
+// `toolbar-items-changed` (and tests can dispatch it).
function createMockToolbar() {
- return {
+ return Object.assign(new EventEmitter(), {
config: {
toolbarGroups: ['left', 'center', 'right'],
toolbarButtonsExclude: [],
@@ -26,7 +29,7 @@ function createMockToolbar() {
emitCommand: vi.fn(),
overflowItems: [],
activeEditor: null,
- };
+ });
}
describe('Toolbar', () => {
@@ -203,4 +206,45 @@ describe('Toolbar', () => {
expect(disconnect).toHaveBeenCalledTimes(1);
});
+
+ it('re-renders the toolbar DOM when SuperToolbar reports rebuilt items (toolbar-items-changed)', async () => {
+ // toolbarItems is a plain field SuperToolbar swaps on rebuild; only a re-render re-reads it. The mock
+ // returns whatever `center.items` currently holds, so a "rebuild" is just reassigning that array.
+ const center = { items: [{ name: { value: 'fontFamily' } }] };
+ const mockToolbar = createMockToolbar();
+ mockToolbar.config.toolbarGroups = ['center']; // render only the center group: one unambiguous ButtonGroup
+ mockToolbar.getToolbarItemByGroup = (position) => (position === 'center' ? center.items : []);
+
+ const ButtonGroupStub = defineComponent({
+ props: ['toolbarItems', 'overflowItems', 'compactSideGroups', 'uiFontFamily', 'position'],
+ template:
+ '
{{ i.name.value }}
',
+ });
+
+ const wrapper = mount(Toolbar, {
+ global: {
+ stubs: { ButtonGroup: ButtonGroupStub },
+ plugins: [
+ (app) => {
+ app.config.globalProperties.$toolbar = mockToolbar;
+ },
+ ],
+ },
+ });
+
+ const renderedItems = () => wrapper.findAll('.bg-item').map((w) => w.text());
+ expect(renderedItems()).toEqual(['fontFamily']);
+
+ // A rebuild swaps in a new array (e.g. a document font resolved). The swap alone is not reactive...
+ center.items = [{ name: { value: 'fontFamily' } }, { name: { value: 'Aptos' } }];
+ await nextTick();
+ expect(renderedItems()).toEqual(['fontFamily']); // ...so the DOM still shows the previously-built items.
+
+ // The notify event forces the re-render that re-reads the rebuilt array - the actual fix.
+ mockToolbar.emit('toolbar-items-changed');
+ await nextTick();
+ expect(renderedItems()).toEqual(['fontFamily', 'Aptos']);
+
+ wrapper.unmount();
+ });
});
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue
index 82e5165afa..2825f1df11 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue
+++ b/packages/super-editor/src/editors/v1/components/toolbar/Toolbar.vue
@@ -76,17 +76,29 @@ const onWindowResized = async () => {
};
const onResizeThrottled = throttle(onWindowResized, 300);
-function teardownWindowListeners() {
+/**
+ * Force a re-render when the toolbar's item arrays are rebuilt. `toolbarItems` / `overflowItems` are plain
+ * fields on the SuperToolbar instance, not a reactive source this component tracks, so a rebuild (a new
+ * active editor, or document fonts resolving via `fonts-changed`) is invisible until the render key changes.
+ * SuperToolbar emits `toolbar-items-changed` on rebuild; bumping the key re-reads the new items into the DOM.
+ */
+const onToolbarItemsChanged = () => {
+ toolbarKey.value += 1;
+};
+
+function teardownListeners() {
window.removeEventListener('resize', onResizeThrottled);
window.removeEventListener('keydown', onKeyDown);
+ proxy.$toolbar.off?.('toolbar-items-changed', onToolbarItemsChanged);
containerResizeObserver?.disconnect();
containerResizeObserver = null;
}
-function setupWindowListeners() {
- teardownWindowListeners();
+function setupListeners() {
+ teardownListeners();
window.addEventListener('resize', onResizeThrottled);
window.addEventListener('keydown', onKeyDown);
+ proxy.$toolbar.on?.('toolbar-items-changed', onToolbarItemsChanged);
if (
typeof ResizeObserver !== 'undefined' &&
proxy.$toolbar.config?.responsiveToContainer &&
@@ -100,10 +112,10 @@ function setupWindowListeners() {
updateCompactSideGroups();
}
-onMounted(setupWindowListeners);
-onActivated(setupWindowListeners);
-onDeactivated(teardownWindowListeners);
-onBeforeUnmount(teardownWindowListeners);
+onMounted(setupListeners);
+onActivated(setupListeners);
+onDeactivated(teardownListeners);
+onBeforeUnmount(teardownListeners);
const handleCommand = ({ item, argument, option }) => {
proxy.$toolbar.emitCommand({ item, argument, option });
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarDropdown.test.js b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarDropdown.test.js
index ae1f67b05d..0154665d38 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarDropdown.test.js
+++ b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarDropdown.test.js
@@ -57,3 +57,42 @@ describe('ToolbarDropdown keyboard focus', () => {
expect(document.activeElement).toBe(trigger.element);
});
});
+
+describe('ToolbarDropdown secondary label (document font support status)', () => {
+ it('renders the status separately from the pure label and folds it into the accessible name', async () => {
+ const Harness = defineComponent({
+ components: { ToolbarDropdown },
+ setup() {
+ const show = ref(false);
+ const options = [
+ { key: 'Calibri', label: 'Calibri', props: {} }, // a plain default
+ { key: 'Aptos', label: 'Aptos', secondaryLabel: 'Needs font', props: {} }, // a document font
+ ];
+ return { options, show };
+ },
+ template: `
+
+
+
+ `,
+ });
+
+ wrapper = mount(Harness, { attachTo: document.body });
+ wrapper.vm.show = true;
+ await nextTick();
+ await nextTick();
+
+ const options = document.body.querySelectorAll('.toolbar-dropdown-option');
+ expect(options).toHaveLength(2);
+
+ // Default: label only, no status span, no extra aria name.
+ expect(options[0].querySelector('.toolbar-dropdown-option__label').textContent.trim()).toBe('Calibri');
+ expect(options[0].querySelector('.toolbar-dropdown-option__secondary')).toBeNull();
+
+ // Document font: label stays pure, the status is a separate visible span AND in the accessible name.
+ const aptos = options[1];
+ expect(aptos.querySelector('.toolbar-dropdown-option__label').textContent.trim()).toBe('Aptos');
+ expect(aptos.querySelector('.toolbar-dropdown-option__secondary').textContent.trim()).toBe('Needs font');
+ expect(aptos.getAttribute('aria-label')).toBe('Aptos Needs font');
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarDropdown.vue b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarDropdown.vue
index 7ac0030fe5..f579ce5d8a 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/ToolbarDropdown.vue
+++ b/packages/super-editor/src/editors/v1/components/toolbar/ToolbarDropdown.vue
@@ -127,6 +127,9 @@ const onOptionClick = (option) => {
const isRenderOption = (option) => option?.type === 'render';
const isOptionNavigable = (option) => !option?.disabled && option?.type !== 'render';
const hasIcon = (option) => typeof option?.icon === 'function' || Boolean(option?.icon);
+// Fold the visible status (secondaryLabel) into the option's accessible name so screen readers don't
+// miss it, while option.label stays pure for active-state matching. Undefined leaves the default name.
+const ariaLabelFor = (option) => (option?.secondaryLabel ? `${option.label} ${option.secondaryLabel}` : undefined);
const renderIcon = (option) => {
if (typeof option?.icon === 'function') return option.icon(option);
return option?.icon || null;
@@ -403,6 +406,7 @@ onBeforeUnmount(() => {
tabindex="-1"
@click="onOptionClick(option)"
v-bind="{ ...option.props, ...getNodeProps(option) }"
+ :aria-label="ariaLabelFor(option)"
>
@@ -410,6 +414,9 @@ onBeforeUnmount(() => {
{{ option.label }}
+ {{
+ option.secondaryLabel
+ }}
@@ -470,6 +477,15 @@ onBeforeUnmount(() => {
height: 12px;
}
+/* Secondary annotation (e.g. a document font's support status). Pushed right, muted, never the label. */
+.toolbar-dropdown-option__secondary {
+ margin-left: auto;
+ padding-left: 12px;
+ font-size: var(--sd-ui-font-size-300, 12px);
+ color: var(--sd-ui-dropdown-text-muted, #8a8b8d);
+ white-space: nowrap;
+}
+
.toolbar-dropdown-option:hover {
background: var(--sd-ui-dropdown-hover-bg, #d8dee5);
color: var(--sd-ui-dropdown-hover-text, #47484a);
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/constants.js b/packages/super-editor/src/editors/v1/components/toolbar/constants.js
index fac666f16d..21ff770a29 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/constants.js
+++ b/packages/super-editor/src/editors/v1/components/toolbar/constants.js
@@ -1,4 +1,9 @@
-import { getDefaultFontOfferings, fontOfferingStack, fontOfferingRenderStack } from '@superdoc/font-system';
+import {
+ getDefaultFontOfferings,
+ fontOfferingStack,
+ fontOfferingRenderStack,
+ fontSupportStatusText,
+} from '@superdoc/font-system';
/**
* Built-in toolbar font dropdown options, DERIVED from the shared font-offering registry
@@ -20,6 +25,47 @@ export const TOOLBAR_FONTS = getDefaultFontOfferings().map((offering) => ({
},
}));
+/**
+ * The single seam that composes the font dropdown options: it turns the active document's
+ * {@link import('@superdoc/font-system').DocumentFontOption}s into toolbar font options and unions them
+ * with the bundled defaults. The toolbar only asks for the result; it does not know how a status becomes
+ * a `secondaryLabel`, or how a font previews.
+ *
+ * - A consumer-provided `configFonts` list is returned UNCHANGED (custom toolbars own their list).
+ * - With no document options, returns `undefined` so the caller keeps its fallback to {@link TOOLBAR_FONTS}.
+ * - Otherwise: the bundled defaults FIRST, then the document's own fonts appended, deduped by normalized
+ * logical family. `label`/`key` stay the pure logical family (active-state matching + the stored value),
+ * the preview renders in `previewFamily`, and a `secondaryLabel` is added only for a non-`available`
+ * status (`fontSupportStatusText` returns '' for available, so faithful fonts read as plain names).
+ *
+ * @param {ReadonlyArray} documentOptions
+ * @param {Array} [configFonts] - the consumer's `fonts` config, if any
+ * @returns {Array|undefined}
+ */
+export function composeToolbarFontOptions(documentOptions, configFonts) {
+ if (configFonts) return configFonts;
+ if (!documentOptions?.length) return undefined;
+ const seen = new Set(TOOLBAR_FONTS.map((option) => String(option.label).trim().toLowerCase()));
+ const appended = [];
+ for (const option of documentOptions) {
+ const dedupeKey = option.logicalFamily.trim().toLowerCase();
+ if (seen.has(dedupeKey)) continue; // already a bundled default (e.g. Calibri) -> not duplicated
+ seen.add(dedupeKey);
+ const statusText = fontSupportStatusText(option.status);
+ appended.push({
+ label: option.logicalFamily, // pure logical name: stored / exported + active-state matched
+ key: option.logicalFamily, // the logical family applied to the selection
+ fontWeight: 400,
+ ...(statusText ? { secondaryLabel: statusText } : {}),
+ props: {
+ style: { fontFamily: option.previewFamily || option.logicalFamily }, // preview in what paints
+ 'data-item': 'btn-fontFamily-option',
+ },
+ });
+ }
+ return appended.length ? [...TOOLBAR_FONTS, ...appended] : undefined;
+}
+
export const TOOLBAR_FONT_SIZES = [
{ label: '8', key: '8pt', props: { 'data-item': 'btn-fontSize-option' } },
{ label: '9', key: '9pt', props: { 'data-item': 'btn-fontSize-option' } },
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/constants.test.js b/packages/super-editor/src/editors/v1/components/toolbar/constants.test.js
index b5d54374a3..b79ad4c8fa 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/constants.test.js
+++ b/packages/super-editor/src/editors/v1/components/toolbar/constants.test.js
@@ -1,9 +1,15 @@
import { describe, it, expect } from 'vitest';
-import { TOOLBAR_FONTS } from './constants';
+import { TOOLBAR_FONTS, composeToolbarFontOptions } from './constants';
describe('TOOLBAR_FONTS (built-in font dropdown, derived from the font-offering registry)', () => {
it('advertises only the metric-safe bundled defaults, in order', () => {
- expect(TOOLBAR_FONTS.map((f) => f.label)).toEqual(['Calibri', 'Arial', 'Courier New', 'Times New Roman', 'Helvetica']);
+ expect(TOOLBAR_FONTS.map((f) => f.label)).toEqual([
+ 'Calibri',
+ 'Arial',
+ 'Courier New',
+ 'Times New Roman',
+ 'Helvetica',
+ ]);
});
it('does not leak non-bundled or qualified fonts into the default dropdown', () => {
@@ -32,3 +38,47 @@ describe('TOOLBAR_FONTS (built-in font dropdown, derived from the font-offering
}
});
});
+
+describe('composeToolbarFontOptions (document fonts unioned with the bundled defaults)', () => {
+ const doc = (logicalFamily, status, previewFamily) => ({
+ logicalFamily,
+ status,
+ previewFamily: previewFamily ?? logicalFamily,
+ });
+
+ it('returns a consumer-provided fonts list unchanged (custom toolbars own their list)', () => {
+ const custom = [{ label: 'My Font', key: 'My Font' }];
+ expect(composeToolbarFontOptions([doc('Aptos', 'needs_font')], custom)).toBe(custom);
+ });
+
+ it('returns undefined with no document fonts, so the caller keeps the bundled defaults', () => {
+ expect(composeToolbarFontOptions([], undefined)).toBeUndefined();
+ expect(composeToolbarFontOptions(undefined, undefined)).toBeUndefined();
+ });
+
+ it('puts defaults first, appends document fonts, and dedupes one already in the defaults', () => {
+ const options = composeToolbarFontOptions(
+ [doc('Calibri', 'available', 'Carlito'), doc('Aptos', 'needs_font'), doc('Georgia', 'pending')],
+ undefined,
+ );
+ // Defaults in their order, then the NON-default document fonts; Calibri (a default) is not duplicated.
+ expect(options.map((o) => o.label)).toEqual([...TOOLBAR_FONTS.map((f) => f.label), 'Aptos', 'Georgia']);
+ expect(options.filter((o) => o.label === 'Calibri')).toHaveLength(1);
+ });
+
+ it('maps a document font: pure logical label/key, preview in previewFamily, status as secondaryLabel', () => {
+ const options = composeToolbarFontOptions([doc('Aptos', 'needs_font', 'Aptos')], undefined);
+ expect(options.at(-1)).toMatchObject({
+ label: 'Aptos', // pure logical name (active-state match + the stored/exported value)
+ key: 'Aptos',
+ secondaryLabel: 'Needs font',
+ props: { style: { fontFamily: 'Aptos' }, 'data-item': 'btn-fontFamily-option' },
+ });
+ });
+
+ it('omits secondaryLabel for an available document font (it reads as a plain name)', () => {
+ const options = composeToolbarFontOptions([doc('BrandSans', 'available', 'BrandSans')], undefined);
+ expect(options.at(-1).label).toBe('BrandSans');
+ expect(options.at(-1).secondaryLabel).toBeUndefined();
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar-font-rebuild.test.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar-font-rebuild.test.js
new file mode 100644
index 0000000000..ac3bf7e9c0
--- /dev/null
+++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar-font-rebuild.test.js
@@ -0,0 +1,115 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { EventEmitter } from 'eventemitter3';
+import { SuperToolbar } from './super-toolbar.js';
+
+// super-toolbar.js pulls a broad import graph; mock the same heavy leaves the sibling super-toolbar.test.js
+// does, so the module imports cleanly in jsdom.
+vi.mock('prosemirror-history', () => ({ undoDepth: () => 0, redoDepth: () => 0 }));
+vi.mock('@core/helpers/getActiveFormatting.js', () => ({ getActiveFormatting: vi.fn(() => []) }));
+vi.mock('@helpers/isInTable.js', () => ({ isInTable: vi.fn(() => false) }));
+vi.mock('@extensions/linked-styles/index.js', () => ({ getQuickFormatList: vi.fn(() => []) }));
+vi.mock('@extensions/track-changes/permission-helpers.js', () => ({
+ collectTrackedChanges: vi.fn(() => []),
+ isTrackedChangeActionAllowed: vi.fn(() => true),
+}));
+
+// The font dropdown is rebuilt by re-running makeDefaultItems with the current document fonts. Replace it
+// with a light factory whose fontFamily item simply carries the toolbarFonts it was built with, so a
+// rebuild is observable as "the dropdown now lists this font". The composition (dedupe, ordering, status
+// labels) is covered in constants.test.js; here we only prove the TRIGGER threads the CURRENT document
+// options into a fresh build - the original bug was fonts-changed refreshing state without rebuilding.
+const { makeDefaultItemsSpy } = vi.hoisted(() => ({ makeDefaultItemsSpy: vi.fn() }));
+vi.mock('./defaultItems', () => ({ makeDefaultItems: makeDefaultItemsSpy }));
+
+const fontFamilyItem = (toolbarFonts) => ({
+ name: { value: 'fontFamily' },
+ options: { value: toolbarFonts },
+ resetDisabled: vi.fn(),
+ activate: vi.fn(),
+ deactivate: vi.fn(),
+ setDisabled: vi.fn(),
+ allowWithoutEditor: { value: false },
+});
+
+const aptos = { logicalFamily: 'Aptos', previewFamily: 'Aptos', status: 'needs_font' };
+
+describe('SuperToolbar font dropdown rebuild trigger', () => {
+ let toolbar;
+ let editor;
+ let documentOptions;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ makeDefaultItemsSpy.mockImplementation(({ toolbarFonts }) => ({
+ defaultItems: [fontFamilyItem(toolbarFonts)],
+ overflowItems: [],
+ }));
+
+ documentOptions = []; // a blank document: no document-specific fonts yet
+ // A non-resolving selector makes the constructor early-return after building the initial items, so
+ // there is no Vue mount or headless controller to stand up (the sibling toolbar tests use the same trick).
+ toolbar = new SuperToolbar({ selector: '#nope', role: 'editor' });
+ // The toolbar reads document fonts from the public read API; stand in a controllable one.
+ toolbar.superdoc = { fonts: { getDocumentFontOptions: () => documentOptions } };
+ // Isolate the rebuild from state refresh: updateToolbarState only re-reads existing item state, which is
+ // exactly what the original bug relied on. Stubbing it proves the rebuild path stands on its own.
+ vi.spyOn(toolbar, 'updateToolbarState').mockImplementation(() => {});
+ // A real emitter editor: setActiveEditor binds 'fonts-changed' on it, which each test fires.
+ editor = new EventEmitter();
+ toolbar.setActiveEditor(editor);
+ });
+
+ const fontOptions = () => toolbar.getToolbarItemByName('fontFamily').options.value ?? [];
+
+ it('rebuilds the dropdown options when fonts-changed reports a newly-resolved document font', () => {
+ // Before resolution the dropdown carries only the bundled defaults (no document font).
+ expect(fontOptions().some((o) => o.label === 'Aptos')).toBe(false);
+
+ // Fonts settle asynchronously after load: the document now uses Aptos (no open substitute).
+ documentOptions = [aptos];
+ editor.emit('fonts-changed');
+
+ // The dropdown was rebuilt (not merely state-refreshed): Aptos now appears, carrying its support status.
+ const option = fontOptions().find((o) => o.label === 'Aptos');
+ expect(option).toBeTruthy();
+ expect(option.secondaryLabel).toBe('Needs font');
+ });
+
+ it('rebuilds on active-editor change so a document that already resolved its fonts is reflected', () => {
+ // The next document already knows its fonts before it becomes active.
+ documentOptions = [aptos];
+ toolbar.setActiveEditor(new EventEmitter());
+
+ expect(fontOptions().some((o) => o.label === 'Aptos')).toBe(true);
+ });
+
+ it('emits toolbar-items-changed on active-editor change so a freshly attached editor is not left locked', () => {
+ const changed = vi.fn();
+ toolbar.on('toolbar-items-changed', changed);
+
+ toolbar.setActiveEditor(new EventEmitter());
+ expect(changed).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not rebuild when fonts-changed fires with the same options (signature guard)', () => {
+ documentOptions = [aptos];
+ editor.emit('fonts-changed'); // first change -> rebuild
+ const buildsAfterChange = makeDefaultItemsSpy.mock.calls.length;
+
+ editor.emit('fonts-changed'); // identical options -> no rebuild
+ expect(makeDefaultItemsSpy.mock.calls.length).toBe(buildsAfterChange);
+ });
+
+ it('emits toolbar-items-changed on rebuild (and not on the guarded no-op) so the view re-renders', () => {
+ // The rebuilt arrays are plain fields; Toolbar.vue only re-reads them on this event. Prove it fires.
+ const changed = vi.fn();
+ toolbar.on('toolbar-items-changed', changed);
+
+ documentOptions = [aptos];
+ editor.emit('fonts-changed'); // a real change -> one rebuild -> one notify
+ expect(changed).toHaveBeenCalledTimes(1);
+
+ editor.emit('fonts-changed'); // identical options -> guard skips both the rebuild and the notify
+ expect(changed).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js
index 0478506e6e..d00ef52bd6 100644
--- a/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js
+++ b/packages/super-editor/src/editors/v1/components/toolbar/super-toolbar.js
@@ -9,6 +9,7 @@ import Toolbar from './Toolbar.vue';
import { toolbarIcons } from './toolbarIcons.js';
import { toolbarTexts } from './toolbarTexts.js';
import {
+ composeToolbarFontOptions,
HEADLESS_TOOLBAR_COMMANDS,
HEADLESS_ITEM_MAP,
HEADLESS_EXECUTE_ITEMS,
@@ -295,8 +296,17 @@ export class SuperToolbar extends EventEmitter {
transaction: null,
selectionUpdate: null,
focus: null,
+ fontsChanged: null,
};
+ /**
+ * Signature of the last-built document font options. Fonts resolve in several steps after a document
+ * opens, so `fonts-changed` fires repeatedly; this skips rebuilding the dropdown when the options did
+ * not actually change.
+ * @private
+ */
+ this._lastFontOptionsSignature = '';
+
/**
* Timeout ID for restoring editor focus after toolbar command execution.
* Tracked for cleanup on destroy to prevent callbacks firing after toolbar is unmounted.
@@ -402,10 +412,12 @@ export class SuperToolbar extends EventEmitter {
this.activeEditor.off('transaction', this._boundEditorHandlers.transaction);
this.activeEditor.off('selectionUpdate', this._boundEditorHandlers.selectionUpdate);
this.activeEditor.off('focus', this._boundEditorHandlers.focus);
+ this.activeEditor.off('fonts-changed', this._boundEditorHandlers.fontsChanged);
// Clear bound handlers when removing editor
this._boundEditorHandlers.transaction = null;
this._boundEditorHandlers.selectionUpdate = null;
this._boundEditorHandlers.focus = null;
+ this._boundEditorHandlers.fontsChanged = null;
}
this.activeEditor = editor;
@@ -416,11 +428,21 @@ export class SuperToolbar extends EventEmitter {
this._boundEditorHandlers.transaction = this.onEditorTransaction.bind(this);
this._boundEditorHandlers.selectionUpdate = this.onEditorSelectionUpdate.bind(this);
this._boundEditorHandlers.focus = this.onEditorFocus.bind(this);
+ // Document fonts resolve asynchronously after load (the report can settle with no transaction), so
+ // the font list must rebuild on `fonts-changed`, not only on edits - otherwise status reads stale.
+ this._boundEditorHandlers.fontsChanged = this.onEditorFontsChanged.bind(this);
this.activeEditor.on('transaction', this._boundEditorHandlers.transaction);
this.activeEditor.on('selectionUpdate', this._boundEditorHandlers.selectionUpdate);
this.activeEditor.on('focus', this._boundEditorHandlers.focus);
+ this.activeEditor.on('fonts-changed', this._boundEditorHandlers.fontsChanged);
}
+
+ // Recompute on active-editor change: the new document has its own fonts, so rebuild the dropdown
+ // OPTIONS (updateToolbarState alone only refreshes item state), then refresh state.
+ this.#rebuildToolbarItems();
+ this._lastFontOptionsSignature = this.#fontOptionsSignature();
+ this.updateToolbarState();
}
/**
@@ -478,7 +500,7 @@ export class SuperToolbar extends EventEmitter {
superToolbar,
toolbarIcons: icons,
toolbarTexts: texts,
- toolbarFonts: fonts,
+ toolbarFonts: this.#resolveToolbarFonts(fonts),
hideButtons,
availableWidth,
role: this.role,
@@ -504,6 +526,54 @@ export class SuperToolbar extends EventEmitter {
this.overflowItems = overflowItems.filter((item) => allConfigItems.includes(item.name.value));
}
+ /**
+ * The font dropdown options. Lifecycle only: get the active document's font options from the read API
+ * and hand the composition (dedupe, ordering, status labels, preview styling) to the pure
+ * {@link composeToolbarFontOptions} seam. A consumer-provided `configFonts` list is returned untouched.
+ * @private
+ * @param {Array|undefined} configFonts - the consumer's `fonts` config, if any
+ * @returns {Array|undefined} the toolbar font options, or undefined to fall back to the bundled defaults
+ */
+ #resolveToolbarFonts(configFonts) {
+ return composeToolbarFontOptions(this.superdoc?.fonts?.getDocumentFontOptions?.() ?? [], configFonts);
+ }
+
+ /**
+ * Rebuild the toolbar items (and so the font dropdown OPTIONS) from current config + document fonts.
+ * `updateToolbarState()` only refreshes existing item state; this re-creates the items, which is what
+ * surfaces a newly-resolved document font in the dropdown.
+ *
+ * `toolbarItems` / `overflowItems` are plain instance fields, not a Vue reactive source, so swapping them
+ * is invisible to the mounted `Toolbar.vue` until it re-renders. Emit `toolbar-items-changed` so the view
+ * forces that re-render (it bumps its render key). Without it the rebuilt items never reach the DOM and the
+ * toolbar keeps showing the previously-built set - e.g. the initial disabled items before an editor attaches.
+ * The resize path rebuilds and bumps the key from the view itself, so it does not go through this method.
+ * @private
+ * @returns {void}
+ */
+ #rebuildToolbarItems() {
+ this.#makeToolbarItems({
+ superToolbar: this,
+ icons: this.config.icons,
+ texts: this.config.texts,
+ fonts: this.config.fonts,
+ hideButtons: this.config.hideButtons,
+ isDev: this.isDev,
+ });
+ this.emit('toolbar-items-changed');
+ }
+
+ /**
+ * A stable signature of the active document's font options, to detect when a `fonts-changed` event
+ * actually changed the dropdown (vs the same options resolving again).
+ * @private
+ * @returns {string}
+ */
+ #fontOptionsSignature() {
+ const options = this.superdoc?.fonts?.getDocumentFontOptions?.() ?? [];
+ return options.map((option) => `${option.logicalFamily}:${option.status}:${option.previewFamily}`).join('|');
+ }
+
/**
* Initialize default fonts from the editor
* @private
@@ -977,6 +1047,20 @@ export class SuperToolbar extends EventEmitter {
if (restored) this.updateToolbarState();
}
+ /**
+ * Rebuild the toolbar (and so the font dropdown) when the active document's font picture resolves.
+ * Fonts load asynchronously after a document opens, so support statuses settle with no edit/transaction.
+ * @returns {void}
+ */
+ onEditorFontsChanged() {
+ const signature = this.#fontOptionsSignature();
+ if (signature !== this._lastFontOptionsSignature) {
+ this._lastFontOptionsSignature = signature;
+ this.#rebuildToolbarItems(); // the document's fonts/statuses changed -> rebuild the dropdown options
+ }
+ this.updateToolbarState();
+ }
+
/**
* Handles editor focus events by flushing any pending mark commands.
* This is triggered by the editor's 'focus' event.
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts
index 1766f961b4..c0eaff48b2 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts
@@ -182,6 +182,7 @@ import { measureBlock } from '@superdoc/measuring-dom';
import {
createFontResolver,
type FontResolutionRecord,
+ type DocumentFontOption,
type FontLoadSummary,
type ResolvePhysicalFamily,
} from '@superdoc/font-system';
@@ -3114,6 +3115,16 @@ export class PresentationEditor extends EventEmitter {
return this.#fontGate?.getReport() ?? [];
}
+ /**
+ * The document's own fonts for the toolbar's document-specific picker: one option per LOGICAL family
+ * the document renders, each with the family to preview it in and a user-facing support status.
+ * DOCUMENT fonts only - the toolbar composes them with its defaults. Surfaced publicly as
+ * `superdoc.fonts.getDocumentFontOptions()`.
+ */
+ getDocumentFontOptions(): DocumentFontOption[] {
+ return this.#fontGate?.getDocumentFontOptions() ?? [];
+ }
+
/**
* Declared families with no faithful render font loaded (substitution-aware): the
* subset of {@link getFontReport} where `missing` is true - genuinely absent fonts
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts
index 014ea1a580..15a0e0de51 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts
@@ -6,6 +6,7 @@ import type {
FontLoadResult,
FontLoadStatus,
FontRegistry,
+ UsedFace,
} from '@superdoc/font-system';
import { FontReadinessGate, type FontEnvironment } from './FontReadinessGate';
@@ -502,3 +503,55 @@ describe('FontReadinessGate', () => {
});
});
});
+
+describe('FontReadinessGate.getDocumentFontOptions (document-used fonts, public read API)', () => {
+ // The full public chain needs a painted render plan, so this covers the gate seam that owns used-face wiring.
+ class FaceRegistry {
+ getStatus(): FontLoadStatus {
+ return 'unloaded';
+ }
+ getFaceStatus(): FontLoadStatus {
+ return 'unloaded';
+ }
+ hasFace(): boolean {
+ return false;
+ }
+ asRegistry(): FontRegistry {
+ return this as unknown as FontRegistry;
+ }
+ }
+
+ const regular = (logicalFamily: string): UsedFace => ({ logicalFamily, weight: '400', style: 'normal' });
+
+ function makeOptionsGate(getUsedFaces: () => UsedFace[], documentFonts: string[] = []) {
+ return new FontReadinessGate({
+ registry: new FaceRegistry().asRegistry(),
+ getDocumentFonts: () => documentFonts,
+ getUsedFaces,
+ requestReflow: vi.fn(),
+ invalidateCaches: vi.fn(),
+ getFontEnvironment: () => null,
+ });
+ }
+
+ it('returns one option per RENDERED font, ignoring declared-but-unused font-table rows and the defaults', () => {
+ const gate = makeOptionsGate(() => [regular('Aptos')], ['Calibri', 'Georgia', 'Aptos']);
+
+ const options = gate.getDocumentFontOptions();
+
+ expect(options.map((o) => o.logicalFamily)).toEqual(['Aptos']);
+ expect(options[0].status).toBe('needs_font');
+ });
+
+ it('returns [] when the document renders no fonts, even with declared families (no defaults leak in)', () => {
+ const gate = makeOptionsGate(() => [], ['Calibri', 'Arial']);
+ expect(gate.getDocumentFontOptions()).toEqual([]);
+ });
+
+ it('never throws (font UI must not break layout): returns [] when the used-faces read fails', () => {
+ const gate = makeOptionsGate(() => {
+ throw new Error('render plan unavailable');
+ });
+ expect(gate.getDocumentFontOptions()).toEqual([]);
+ });
+});
diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts
index 4cc3f057bf..484e93dd1c 100644
--- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts
+++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts
@@ -3,6 +3,7 @@ import {
bumpFontConfigVersion,
buildFontReport,
buildFaceReport,
+ buildDocumentFontOptions,
DEFAULT_FONT_LOAD_TIMEOUT_MS,
type FontRegistry,
type FontLoadResult,
@@ -10,6 +11,7 @@ import {
type FontFaceLoadResult,
type FontLoadSummary,
type UsedFace,
+ type DocumentFontOption,
type FontLoadStatus,
type FontResolutionRecord,
type FontResolver,
@@ -210,6 +212,24 @@ export class FontReadinessGate {
return [...faceRows, ...declaredRows];
}
+ /**
+ * The document's own fonts for the toolbar's document-specific picker: one option per LOGICAL family
+ * the document RENDERS, each with the family to preview it in and a user-facing support status. These
+ * are DOCUMENT fonts only - the toolbar composes them with its defaults. Built through the same
+ * registry + resolver + used faces as {@link getReport}, so the status reflects exactly what SuperDoc
+ * renders. Never throws (font UI must not break layout): returns [] before the plan exists.
+ */
+ getDocumentFontOptions(): DocumentFontOption[] {
+ try {
+ const usedFaces = this.#getUsedFaces?.() ?? [];
+ if (!usedFaces.length) return [];
+ const { registry } = this.#resolveContext();
+ return buildDocumentFontOptions(usedFaces, registry, this.#fontResolver ?? undefined);
+ } catch {
+ return [];
+ }
+ }
+
/**
* Await the faces the current document needs, then return their outcomes. Safe and
* cheap to call on every render: when the required set is unchanged and already fully
diff --git a/packages/super-editor/src/index.ts b/packages/super-editor/src/index.ts
index b41384e5b2..bf541e76a1 100644
--- a/packages/super-editor/src/index.ts
+++ b/packages/super-editor/src/index.ts
@@ -73,6 +73,8 @@ export type {
// Font report types (used to type `fonts-changed` payloads + the fonts read API)
export type {
FontResolutionRecord,
+ DocumentFontOption,
+ FontSupportStatus,
FontResolutionReason,
FontLoadStatus,
FontLoadSummary,
diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts
index d4c8579b77..ff9a70d0a1 100644
--- a/packages/superdoc/src/core/SuperDoc.ts
+++ b/packages/superdoc/src/core/SuperDoc.ts
@@ -1639,6 +1639,7 @@ export class SuperDoc extends EventEmitter {
this.#fontsApi = {
getReport: () => this.activeEditor?.presentationEditor?.getFontReport() ?? [],
getMissingFonts: () => this.activeEditor?.presentationEditor?.getMissingFonts() ?? [],
+ getDocumentFontOptions: () => this.activeEditor?.presentationEditor?.getDocumentFontOptions() ?? [],
getDocumentFonts: () => [
// Deduped by logical family: the report can now carry multiple FACE rows per family.
...new Set(
diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts
index 15a4ea8df5..1ec93c84bd 100644
--- a/packages/superdoc/src/core/types/index.ts
+++ b/packages/superdoc/src/core/types/index.ts
@@ -34,6 +34,7 @@ import type {
FontsResolvedPayload,
FontsChangedPayload,
FontResolutionRecord,
+ DocumentFontOption,
FontAssetUrlContext,
FontAssetUrlResolver,
ListDefinitionsPayload,
@@ -105,6 +106,11 @@ export interface SuperDocFontsApi {
getMissingFonts(): string[];
/** The document's declared logical font families, deduped. */
getDocumentFonts(): string[];
+ /**
+ * The document's own fonts as toolbar options: one per logical family the document renders, each with
+ * a preview family and a user-facing support status. Document fonts only - compose with the defaults.
+ */
+ getDocumentFontOptions(): DocumentFontOption[];
/**
* Observe the font report: replays the current report immediately if one has already
* resolved, then invokes `callback` on every future change. Use this rather than
diff --git a/packages/superdoc/src/public/index.ts b/packages/superdoc/src/public/index.ts
index e954dff67b..1ed3255f62 100644
--- a/packages/superdoc/src/public/index.ts
+++ b/packages/superdoc/src/public/index.ts
@@ -182,6 +182,7 @@ export type { FontConfig } from '@superdoc/super-editor';
export type { FontFaceConfig } from '@superdoc/super-editor';
export type { FontFamilyConfig } from '@superdoc/super-editor';
export type { FontResolutionRecord } from '@superdoc/super-editor';
+export type { DocumentFontOption, FontSupportStatus } from '@superdoc/super-editor';
export type { FontsChangedPayload } from '@superdoc/super-editor';
export type { FontsConfig } from '@superdoc/super-editor';
export type { FontsResolvedPayload } from '@superdoc/super-editor';
diff --git a/shared/font-system/src/document-font-options.test.ts b/shared/font-system/src/document-font-options.test.ts
new file mode 100644
index 0000000000..59743b989a
--- /dev/null
+++ b/shared/font-system/src/document-font-options.test.ts
@@ -0,0 +1,129 @@
+import { describe, it, expect } from 'vitest';
+import {
+ buildDocumentFontOptions,
+ fontSupportStatusText,
+ type FontSupportStatus,
+ type FontFaceRequest,
+ type FontLoadStatus,
+ type FontRegistry,
+ type UsedFace,
+} from './index';
+
+/** Face-aware fake registry: per-face load status + which faces are registered (hasFace). */
+class FaceRegistry {
+ readonly faceStatuses = new Map();
+ readonly registered = new Set();
+ #key(family: string, weight: string, style: string): string {
+ return `${family.toLowerCase()}|${weight}|${style}`;
+ }
+ getStatus(): FontLoadStatus {
+ return 'unloaded';
+ }
+ getFaceStatus(req: FontFaceRequest): FontLoadStatus {
+ return this.faceStatuses.get(this.#key(req.family, req.weight, req.style)) ?? 'unloaded';
+ }
+ hasFace(family: string, weight: '400' | '700', style: 'normal' | 'italic'): boolean {
+ return this.registered.has(this.#key(family, weight, style));
+ }
+ setFace(family: string, weight: '400' | '700', style: 'normal' | 'italic', status: FontLoadStatus): void {
+ this.registered.add(this.#key(family, weight, style));
+ this.faceStatuses.set(this.#key(family, weight, style), status);
+ }
+ asRegistry(): FontRegistry {
+ return this as unknown as FontRegistry;
+ }
+}
+
+const regular = (logicalFamily: string): UsedFace => ({ logicalFamily, weight: '400', style: 'normal' });
+
+/** A registry with the bundled clone pack loaded (so substitutes resolve) + one customer/embedded face. */
+function loadedRegistry(): FaceRegistry {
+ const reg = new FaceRegistry();
+ const faces = [
+ ['400', 'normal'],
+ ['700', 'normal'],
+ ['400', 'italic'],
+ ['700', 'italic'],
+ ] as const;
+ for (const clone of ['Carlito', 'Caladea', 'Liberation Sans', 'Liberation Serif', 'Liberation Mono']) {
+ for (const [w, s] of faces) reg.setFace(clone, w, s, 'loaded'); // the bundled clones are four-face fonts
+ }
+ reg.setFace('BrandSans', '400', 'normal', 'loaded'); // a document-embedded / customer-added real face
+ return reg;
+}
+
+describe('buildDocumentFontOptions (document-specific toolbar fonts + support status)', () => {
+ it('classifies each font the document uses by how faithfully SuperDoc renders it', () => {
+ const reg = loadedRegistry().asRegistry();
+ const options = buildDocumentFontOptions(
+ [
+ regular('Calibri'), // metric-safe bundled clone (Carlito)
+ regular('Cambria'), // bundled clone (Caladea) but qualified (visual_only)
+ regular('Calibri Light'), // category fallback (Carlito, non-metric)
+ regular('Georgia'), // candidate Gelasio exists but is not bundled yet
+ regular('Aptos'), // no open clone - the customer must supply it
+ regular('Cambria Math'), // math font - preserve, never substitute
+ regular('BrandSans'), // a real face the document embeds / adds
+ regular('Wingdings'), // docfonts has no record at all
+ ],
+ reg,
+ );
+ const byName = Object.fromEntries(options.map((o) => [o.logicalFamily, o.status])) as Record<
+ string,
+ FontSupportStatus
+ >;
+ expect(byName).toEqual({
+ Calibri: 'available',
+ Cambria: 'fallback',
+ 'Calibri Light': 'fallback',
+ Georgia: 'pending',
+ Aptos: 'needs_font',
+ 'Cambria Math': 'preserve_only',
+ BrandSans: 'available',
+ Wingdings: 'needs_font',
+ });
+ });
+
+ it('renders an available font through its bundled clone, and preserves the logical name', () => {
+ const reg = loadedRegistry().asRegistry();
+ const [calibri] = buildDocumentFontOptions([regular('Calibri')], reg);
+ expect(calibri).toMatchObject({ logicalFamily: 'Calibri', previewFamily: 'Carlito', status: 'available' });
+ });
+
+ it('dedupes a family used at multiple faces into one option (regular face represents it)', () => {
+ const reg = loadedRegistry().asRegistry();
+ const options = buildDocumentFontOptions(
+ [
+ { logicalFamily: 'Calibri', weight: '700', style: 'normal' },
+ { logicalFamily: 'Calibri', weight: '400', style: 'normal' },
+ { logicalFamily: 'Calibri', weight: '400', style: 'italic' },
+ ],
+ reg,
+ );
+ expect(options).toHaveLength(1);
+ expect(options[0]).toMatchObject({ logicalFamily: 'Calibri', status: 'available' });
+ });
+
+ it('reports the WORST status across used faces (available Regular + non-substitutable Bold = fallback)', () => {
+ const reg = new FaceRegistry();
+ reg.setFace('Carlito', '400', 'normal', 'loaded'); // ONLY Carlito Regular registered - no Bold
+ const options = buildDocumentFontOptions(
+ [
+ { logicalFamily: 'Calibri', weight: '400', style: 'normal' }, // available via Carlito Regular
+ { logicalFamily: 'Calibri', weight: '700', style: 'normal' }, // no Carlito Bold -> fallback_face_absent
+ ],
+ reg.asRegistry(),
+ );
+ expect(options).toHaveLength(1);
+ // Regular alone reads `available`; aggregating the unsubstitutable Bold honestly drops it to `fallback`.
+ expect(options[0]).toMatchObject({ logicalFamily: 'Calibri', previewFamily: 'Carlito', status: 'fallback' });
+ });
+
+ it('status text is user-facing and blank for available (faithful fonts read as plain names)', () => {
+ expect(fontSupportStatusText('available')).toBe('');
+ expect(fontSupportStatusText('fallback')).toBe('Fallback');
+ expect(fontSupportStatusText('pending')).toBe('Pending font');
+ expect(fontSupportStatusText('needs_font')).toBe('Needs font');
+ expect(fontSupportStatusText('preserve_only')).toBe('Preserve only');
+ });
+});
diff --git a/shared/font-system/src/document-font-options.ts b/shared/font-system/src/document-font-options.ts
new file mode 100644
index 0000000000..30288fd68f
--- /dev/null
+++ b/shared/font-system/src/document-font-options.ts
@@ -0,0 +1,170 @@
+/**
+ * Document-scoped font support for the toolbar: classify each font a loaded document actually uses by
+ * how faithfully SuperDoc can render it RIGHT NOW. Distinct from the static {@link ./font-offerings}
+ * (the bundled DEFAULTS): this is runtime + document-scoped (it needs the document's registry +
+ * resolver) and covers fonts beyond the defaults - embedded/customer fonts the document supplies,
+ * un-bundled candidates (Georgia), customer-supplied (Aptos), and preserve-only (math/symbol).
+ *
+ * The {@link FontSupportStatus} is a USER-FACING summary ("will SuperDoc render this faithfully,
+ * approximate it, or do I need to supply it?"), deliberately NOT the docfonts `FallbackDecision`: the
+ * toolbar must not expose evidence internals. It is derived by combining the runtime FACE report (does
+ * the document render this with a real / embedded / clone face?) with the docfonts evidence decision
+ * (for fonts with no runtime render: is a candidate pending an asset, customer-supplied, or preserve-
+ * only?). The evidence package is consulted by VALUE only, so this module's public types stay local and
+ * the published facade carries no `@docfonts/fallbacks` reference.
+ */
+import { getFallbackDecision } from '@docfonts/fallbacks';
+import { buildFaceReport, type FontResolutionRecord, type UsedFace } from './report';
+import type { FontRegistry } from './registry';
+import type { FontResolver } from './resolver';
+import { BUNDLED_MANIFEST } from './bundled-manifest';
+
+/**
+ * The user-facing answer to "how well can SuperDoc render this font?". Five tiers, no docfonts
+ * internals: a document font is shown in the toolbar with one of these so it never reads as a clean
+ * default when it is not.
+ */
+export type FontSupportStatus =
+ | 'available' // renderable now: the document's own embedded/customer face, a metric-safe clone, or an explicit mapping
+ | 'fallback' // an approved substitute renders, but not a faithful match (reflows / wrong weight)
+ | 'pending' // an open candidate exists, but SuperDoc does not bundle its asset yet (Georgia -> Gelasio)
+ | 'needs_font' // no open substitute: the real font must come from the customer / system (Aptos)
+ | 'preserve_only'; // math / symbol font: keep the name, never substitute (Cambria Math)
+
+/**
+ * One document font for the toolbar: the logical name (the dropdown label + the value stored and
+ * exported), the family to preview it in, and the support status.
+ */
+export interface DocumentFontOption {
+ /** The Word-facing logical family: the dropdown label and the value stored + exported (e.g. "Aptos"). */
+ logicalFamily: string;
+ /**
+ * The physical family to render the dropdown PREVIEW in (Calibri previews as Carlito; a document-
+ * provided font previews as itself). The REGULAR face's representative ONLY - NOT what every face of
+ * the family renders as, since {@link DocumentFontOption.status} is the worst across all used faces.
+ */
+ previewFamily: string;
+ status: FontSupportStatus;
+}
+
+const BUNDLED_FAMILIES: ReadonlySet = new Set(BUNDLED_MANIFEST.map((f) => f.family));
+/** A docfonts candidate activates only if SuperDoc ships its physical clone (matches the resolver gate). */
+const canRenderFamily = (family: string): boolean => BUNDLED_FAMILIES.has(family);
+
+/** Normalize a family for dedupe: trim, strip surrounding quotes, lowercase (matches the resolver key). */
+function normalizeKey(family: string): string {
+ return family
+ .trim()
+ .replace(/^["']|["']$/g, '')
+ .toLowerCase();
+}
+
+/** Derive the user-facing status for one family from its representative face record + the evidence. */
+function statusFor(rec: FontResolutionRecord): FontSupportStatus {
+ switch (rec.reason) {
+ case 'registered_face': // the document embeds it, or a customer `fonts.add` provides the real face
+ case 'custom_mapping': // an explicit `fonts.map` to a loadable family
+ // Available through a provider the app/customer supplied. "Available" means renderable, NOT
+ // necessarily a faithful match: a custom mapping's fidelity is unknown unless docfonts covers it.
+ return 'available';
+ case 'category_fallback': // non-metric family fallback (Calibri Light -> Carlito)
+ case 'fallback_face_absent': // a substitute exists, but not for this face
+ return 'fallback';
+ case 'bundled_substitute': {
+ // A bundled clone renders it. Faithful (metric_safe / near_metric, loaded) is `available`; a
+ // qualified substitute (visual_only / cell_width_only, e.g. Cambria) reads honestly as `fallback`.
+ const decision = getFallbackDecision(rec.logicalFamily, { canRenderFamily });
+ return decision.kind === 'fallback' && decision.fallback.lineBreakSafe && !rec.missing ? 'available' : 'fallback';
+ }
+ case 'as_requested':
+ default: {
+ // No runtime render - the evidence classifies why.
+ const decision = getFallbackDecision(rec.logicalFamily, { canRenderFamily });
+ switch (decision.kind) {
+ case 'asset_missing': // candidate exists, SuperDoc has not bundled it yet
+ return 'pending';
+ case 'face_missing': // a substitute exists but lacks the requested face - partial, not faithful
+ return 'fallback';
+ case 'preserve_only': // math / symbol - never a normal Latin fallback
+ return 'preserve_only';
+ case 'fallback': // evidence says renderable but the clone has not loaded; report the honest tier
+ return decision.fallback.lineBreakSafe ? 'available' : 'fallback';
+ case 'customer_supplied': // no open clone; the real font must come from the customer (Aptos)
+ case 'no_recommended_fallback': // a row exists but no recommended open family (Verdana, Tahoma)
+ case 'unknown': // docfonts has no record - a system/unknown font the user must supply
+ default:
+ return 'needs_font';
+ }
+ }
+ }
+}
+
+/**
+ * Best-to-worst severity for aggregating a family's used faces. The toolbar shows the MOST concerning
+ * status across the faces the document renders, so a family with a faithful Regular but an
+ * unsubstitutable Bold does not read as fully available.
+ */
+const STATUS_SEVERITY: Record = {
+ available: 0,
+ fallback: 1,
+ pending: 2,
+ needs_font: 3,
+ preserve_only: 4,
+};
+
+/**
+ * The document-specific font options for the toolbar: one per LOGICAL family the document renders,
+ * deduped, each with the family that actually paints and a user-facing {@link FontSupportStatus}.
+ * Resolved FACE-aware (via {@link buildFaceReport}) so an embedded / customer font the document supplies
+ * is detected (`registered_face` -> `available`) even when no bundled clone exists. The regular
+ * (400/normal) face is the family's representative; per-face weight/style nuance is a later pass.
+ */
+export function buildDocumentFontOptions(
+ usedFaces: Iterable,
+ registry: FontRegistry,
+ resolver?: FontResolver,
+): DocumentFontOption[] {
+ const faceRecords = buildFaceReport(usedFaces, registry, resolver);
+ // Aggregate the per-face rows by logical family: the status is the WORST across every used face (so a
+ // family whose Bold lacks a substitute is not advertised as available on the strength of its Regular),
+ // while the regular (400/normal) face is the render representative for the dropdown preview.
+ const agg = new Map();
+ for (const rec of faceRecords) {
+ const key = normalizeKey(rec.logicalFamily);
+ const status = statusFor(rec);
+ const isRegular = rec.face?.weight === '400' && rec.face?.style === 'normal';
+ const existing = agg.get(key);
+ if (!existing) {
+ agg.set(key, { rep: rec, worst: status });
+ continue;
+ }
+ if (STATUS_SEVERITY[status] > STATUS_SEVERITY[existing.worst]) existing.worst = status;
+ // Prefer the regular face as the render representative; otherwise keep the first seen.
+ const existingIsRegular = existing.rep.face?.weight === '400' && existing.rep.face?.style === 'normal';
+ if (isRegular && !existingIsRegular) existing.rep = rec;
+ }
+ const options: DocumentFontOption[] = [];
+ for (const { rep, worst } of agg.values()) {
+ options.push({ logicalFamily: rep.logicalFamily, previewFamily: rep.physicalFamily, status: worst });
+ }
+ return options;
+}
+
+/**
+ * The short inline status text for the toolbar dropdown - "" for `available`, so faithful fonts read as
+ * plain names. Kept here so the Vue and headless toolbars word the status identically.
+ */
+export function fontSupportStatusText(status: FontSupportStatus): string {
+ switch (status) {
+ case 'available':
+ return '';
+ case 'fallback':
+ return 'Fallback';
+ case 'pending':
+ return 'Pending font';
+ case 'needs_font':
+ return 'Needs font';
+ case 'preserve_only':
+ return 'Preserve only';
+ }
+}
diff --git a/shared/font-system/src/index.ts b/shared/font-system/src/index.ts
index dcfcf612d8..063e95f4a1 100644
--- a/shared/font-system/src/index.ts
+++ b/shared/font-system/src/index.ts
@@ -76,3 +76,6 @@ export {
fontOfferingStack,
fontOfferingRenderStack,
} from './font-offerings';
+
+export type { FontSupportStatus, DocumentFontOption } from './document-font-options';
+export { buildDocumentFontOptions, fontSupportStatusText } from './document-font-options';
diff --git a/tests/behavior/tests/toolbar/font-dropdown-document-options.spec.ts b/tests/behavior/tests/toolbar/font-dropdown-document-options.spec.ts
new file mode 100644
index 0000000000..bd83717da1
--- /dev/null
+++ b/tests/behavior/tests/toolbar/font-dropdown-document-options.spec.ts
@@ -0,0 +1,118 @@
+import { test, expect, type SuperDocFixture } from '../../fixtures/superdoc.js';
+
+test.use({ config: { toolbar: 'full', showSelection: true } });
+
+const FONT_OPTION = '[data-item="btn-fontFamily-option"]';
+const OPTION_LABEL = `${FONT_OPTION} .toolbar-dropdown-option__label`;
+
+async function openFontFamilyDropdown(superdoc: SuperDocFixture): Promise {
+ await superdoc.page.locator('[data-item="btn-fontFamily"]').click();
+ await superdoc.page.locator(FONT_OPTION).first().waitFor({ state: 'visible', timeout: 5000 });
+ await superdoc.waitForStable();
+}
+
+async function fontOptionLabels(superdoc: SuperDocFixture): Promise {
+ return (await superdoc.page.locator(OPTION_LABEL).allInnerTexts()).map((label) => label.trim());
+}
+
+async function expectFontFamilyDropdownClosed(superdoc: SuperDocFixture): Promise {
+ await expect(superdoc.page.locator(`${FONT_OPTION}:visible`)).toHaveCount(0);
+}
+
+async function selectFontOption(superdoc: SuperDocFixture, label: string): Promise {
+ await superdoc.page
+ .locator(FONT_OPTION)
+ .filter({ has: superdoc.page.getByText(label, { exact: true }) })
+ .click();
+ await superdoc.waitForStable();
+ await superdoc.page
+ .locator('.presentation-editor__viewport')
+ .first()
+ .click({ position: { x: 50, y: 50 } });
+ await superdoc.waitForStable();
+}
+
+async function stubDocumentFontsAndNotify(
+ superdoc: SuperDocFixture,
+ options: Array<{ logicalFamily: string; previewFamily: string; status: string }>,
+): Promise {
+ await superdoc.page.evaluate((opts) => {
+ const sd = (window as any).superdoc;
+ sd.fonts.getDocumentFontOptions = () => opts;
+ sd.toolbar.activeEditor.emit('fonts-changed');
+ }, options);
+ await superdoc.waitForStable();
+}
+
+test('font dropdown opens immediately with the clean default list and an enabled control', async ({ superdoc }) => {
+ const fontFamily = superdoc.page.locator('[data-item="btn-fontFamily"]');
+ await expect(fontFamily).not.toHaveClass(/sd-disabled/);
+
+ await openFontFamilyDropdown(superdoc);
+
+ const labels = await fontOptionLabels(superdoc);
+ for (const expected of ['Calibri', 'Arial', 'Courier New', 'Times New Roman', 'Helvetica']) {
+ expect(labels).toContain(expected);
+ }
+ for (const absent of ['Aptos', 'Georgia', 'Cambria', 'Calibri Light']) {
+ expect(labels).not.toContain(absent);
+ }
+});
+
+test('selecting a default font applies its logical Word-facing family to the selection', async ({ superdoc }) => {
+ await superdoc.type('Default font sample');
+ await superdoc.waitForStable();
+
+ const pos = await superdoc.findTextPos('Default font sample');
+ await superdoc.setTextSelection(pos, pos + 'Default font sample'.length);
+ await superdoc.waitForStable();
+
+ await openFontFamilyDropdown(superdoc);
+ await selectFontOption(superdoc, 'Helvetica');
+
+ await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .sd-button-label')).toHaveText('Helvetica');
+ await superdoc.assertTextMarkAttrs('Default font sample', 'textStyle', { fontFamily: 'Helvetica' });
+});
+
+test('a document-specific font reaches the live dropdown with its status and applies the logical family', async ({
+ superdoc,
+}) => {
+ await superdoc.type('Document font sample');
+ await superdoc.waitForStable();
+
+ await stubDocumentFontsAndNotify(superdoc, [
+ { logicalFamily: 'Aptos', previewFamily: 'Aptos', status: 'needs_font' },
+ ]);
+
+ const pos = await superdoc.findTextPos('Document font sample');
+ await superdoc.setTextSelection(pos, pos + 'Document font sample'.length);
+ await superdoc.waitForStable();
+
+ await openFontFamilyDropdown(superdoc);
+
+ const aptosOption = superdoc.page
+ .locator(FONT_OPTION)
+ .filter({ has: superdoc.page.getByText('Aptos', { exact: true }) });
+ await expect(aptosOption.locator('.toolbar-dropdown-option__label')).toHaveText('Aptos');
+ await expect(aptosOption.locator('.toolbar-dropdown-option__secondary')).toHaveText('Needs font');
+
+ await selectFontOption(superdoc, 'Aptos');
+
+ await expect(superdoc.page.locator('[data-item="btn-fontFamily"] .sd-button-label')).toHaveText('Aptos');
+ await superdoc.assertTextMarkAttrs('Document font sample', 'textStyle', { fontFamily: 'Aptos' });
+});
+
+test('the dropdown refreshes on fonts-changed without a resize', async ({ superdoc }) => {
+ await openFontFamilyDropdown(superdoc);
+ expect(await fontOptionLabels(superdoc)).not.toContain('Aptos');
+ await superdoc.page.keyboard.press('Escape');
+ await superdoc.waitForStable();
+ await expectFontFamilyDropdownClosed(superdoc);
+
+ await stubDocumentFontsAndNotify(superdoc, [
+ { logicalFamily: 'Aptos', previewFamily: 'Aptos', status: 'needs_font' },
+ ]);
+
+ await openFontFamilyDropdown(superdoc);
+ expect(await fontOptionLabels(superdoc)).toContain('Aptos');
+});
diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json
index b55d8d7dae..7bdf62aaa5 100644
--- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.json
+++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.json
@@ -1,5 +1,5 @@
{
- "generatedAt": "2026-06-05T18:14:02.432Z",
+ "generatedAt": "2026-06-06T01:57:50.052Z",
"ticket": "SD-3212 PR A0",
"package": "superdoc",
"rootExport": {
@@ -55,6 +55,7 @@
"DocRange",
"Document",
"DocumentApi",
+ "DocumentFontOption",
"DocumentMode",
"DocumentProtectionState",
"DocxFileEntry",
@@ -94,6 +95,7 @@
"FontFaceConfig",
"FontFamilyConfig",
"FontResolutionRecord",
+ "FontSupportStatus",
"FontsChangedPayload",
"FontsConfig",
"FontsResolvedPayload",
@@ -297,6 +299,7 @@
"DocRange",
"Document",
"DocumentApi",
+ "DocumentFontOption",
"DocumentMode",
"DocumentProtectionState",
"DocxFileEntry",
@@ -336,6 +339,7 @@
"FontFaceConfig",
"FontFamilyConfig",
"FontResolutionRecord",
+ "FontSupportStatus",
"FontsChangedPayload",
"FontsConfig",
"FontsResolvedPayload",
@@ -591,11 +595,11 @@
}
},
"counts": {
- "types.import": 236,
- "types.require": 236,
+ "types.import": 238,
+ "types.require": 238,
"import": 41,
"require": 41,
- "union": 236
+ "union": 238
},
"divergences": {
"typesImportVsRequire": {
@@ -643,6 +647,7 @@
"DocRange",
"Document",
"DocumentApi",
+ "DocumentFontOption",
"DocumentMode",
"DocumentProtectionState",
"DocxFileEntry",
@@ -679,6 +684,7 @@
"FontFaceConfig",
"FontFamilyConfig",
"FontResolutionRecord",
+ "FontSupportStatus",
"FontsChangedPayload",
"FontsConfig",
"FontsResolvedPayload",
diff --git a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md
index 01ec7ff81a..f5bccd6a68 100644
--- a/tests/consumer-typecheck/snapshots/superdoc-root-exports.md
+++ b/tests/consumer-typecheck/snapshots/superdoc-root-exports.md
@@ -1,17 +1,17 @@
# superdoc root export inventory (SD-3212 PR A0)
-Generated: 2026-06-05T18:14:02.432Z
+Generated: 2026-06-06T01:57:50.052Z
Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc`
## Counts
| Source | Path | Count |
|---|---|---|
-| types.import | `./dist/superdoc/src/public/index.d.ts` | 236 |
-| types.require | `./dist/superdoc/src/public/index.d.cts` | 236 |
+| types.import | `./dist/superdoc/src/public/index.d.ts` | 238 |
+| types.require | `./dist/superdoc/src/public/index.d.cts` | 238 |
| import | `./dist/superdoc.es.js` | 41 |
| require | `./dist/superdoc.cjs` | 41 |
-| **union** | | **236** |
+| **union** | | **238** |
## Divergences
@@ -19,7 +19,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc`
- types.require only (not in types.import): 0
- ESM only (not in CJS): 0
- CJS only (not in ESM): 0
-- typed but no runtime export (phantom risk): 195
+- typed but no runtime export (phantom risk): 197
- runtime export but not typed (silent shadow on root): 0
### Type-only names (no runtime)
@@ -59,6 +59,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc`
- `DocRange`
- `Document`
- `DocumentApi`
+- `DocumentFontOption`
- `DocumentMode`
- `DocumentProtectionState`
- `DocxFileEntry`
@@ -95,6 +96,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc`
- `FontFaceConfig`
- `FontFamilyConfig`
- `FontResolutionRecord`
+- `FontSupportStatus`
- `FontsChangedPayload`
- `FontsConfig`
- `FontsResolvedPayload`
@@ -260,11 +262,12 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc`
| `ContextMenuItem` | ✓ | ✓ | | | 2 | ✓ | 4 | 0 | 5 | |
| `ContextMenuSection` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | |
| `CoreCommandMap` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | ✓ |
-| `DOCX` | ✓ | ✓ | ✓ | ✓ | 2 | | 157 | 24 | 55 | ✓ |
+| `DOCX` | ✓ | ✓ | ✓ | ✓ | 2 | | 160 | 25 | 55 | ✓ |
| `DirectSurfaceRequest` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | |
| `DocRange` | ✓ | ✓ | | | 1 | ✓ | 0 | 0 | 0 | |
| `Document` | ✓ | ✓ | | | 2 | | 291 | 56 | 110 | ✓ |
| `DocumentApi` | ✓ | ✓ | | | 3 | ✓ | 0 | 11 | 4 | ✓ |
+| `DocumentFontOption` | ✓ | ✓ | | | 0 | | 0 | 0 | 0 | |
| `DocumentMode` | ✓ | ✓ | | | 3 | ✓ | 2 | 16 | 3 | |
| `DocumentProtectionState` | ✓ | ✓ | | | 1 | ✓ | 1 | 0 | 1 | |
| `DocxFileEntry` | ✓ | ✓ | | | 3 | ✓ | 0 | 0 | 0 | |
@@ -304,6 +307,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc`
| `FontFaceConfig` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | |
| `FontFamilyConfig` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | |
| `FontResolutionRecord` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | |
+| `FontSupportStatus` | ✓ | ✓ | | | 0 | | 0 | 0 | 0 | |
| `FontsChangedPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | |
| `FontsConfig` | ✓ | ✓ | | | 1 | | 0 | 0 | 0 | |
| `FontsResolvedPayload` | ✓ | ✓ | | | 2 | ✓ | 0 | 0 | 0 | |
@@ -385,7 +389,7 @@ Source: packed and installed `tests/consumer-typecheck/node_modules/superdoc`
| `SlashMenu` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 1 | |
| `StoryLocator` | ✓ | ✓ | | | 1 | ✓ | 123 | 0 | 3 | |
| `SuperConverter` | ✓ | ✓ | ✓ | ✓ | 1 | | 0 | 0 | 3 | ✓ |
-| `SuperDoc` | ✓ | ✓ | ✓ | ✓ | 22 | | 1046 | 187 | 250 | ✓ |
+| `SuperDoc` | ✓ | ✓ | ✓ | ✓ | 22 | | 1046 | 190 | 250 | ✓ |
| `SuperDocAwarenessUpdatePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | |
| `SuperDocCommentsUpdatePayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | |
| `SuperDocEditorPayload` | ✓ | ✓ | | | 2 | | 0 | 0 | 0 | |
diff --git a/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt b/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt
index 2b917a6d41..6448bf5baa 100644
--- a/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt
+++ b/tests/consumer-typecheck/snapshots/superdoc-super-editor.txt
@@ -30,6 +30,7 @@ ContextMenu
CoreCommandMap
CreateHeadlessToolbarOptions
DocumentApi
+DocumentFontOption
DocumentProtectionState
DocxEncryptionError
DocxEncryptionErrorCode
@@ -63,6 +64,7 @@ FontLoadStatus
FontLoadSummary
FontResolutionReason
FontResolutionRecord
+FontSupportStatus
FontsChangedPayload
FontsConfig
FontsResolvedPayload