// Theme engine v3, step 9 (.omc/plans/theme-engine-v3.md 4.1): one bar component. Whether a bar's // items carry their labels is measured, never guessed from a width breakpoint: the sum of the whole // labels (text measured in the label's own font), icons, paddings and gaps against the room the items // have. The mode lands on `html[data-wb-bar="labels|icons"]`; the bar's CSS keys on it. A label is // therefore whole or absent, never cut ("H…", 1.0.2). export const BAR_ATTR = 'data-wb-bar'; export type BarMode = 'labels' | 'icons'; /** One item as the measurer sees it: its label text and font, and the fixed parts around the label. */ export type BarItem = { label: string; font: string; fixed: number }; /** * Pure: the mode for `items` in `available` px, with `gap` between items. Hysteresis: leaving icons * mode needs 16px more room than staying in labels mode, so a bar never flaps at the threshold. */ export function barMode(items: Array<{ labelWidth: number; fixed: number }>, available: number, gap: number, current: BarMode | null, hysteresis = 16): BarMode { if (!items.length || !items.some(item => item.labelWidth > 0)) return 'labels'; const need = items.reduce((sum, item) => sum + item.labelWidth + item.fixed, 0) + gap * Math.max(0, items.length - 1); const room = current === 'icons' ? available - hysteresis : available; return need <= room ? 'labels' : 'icons'; } export type BarMeasurerOptions = { /** The bar's item container (X: the nav). */ nav: string; /** Items inside it, and inside an item its icon box and label box. */ item: string; icon: string; label: string; /** Only while this matches (a bar layout is active). */ when?: string; }; const px = (value: string) => { const n = parseFloat(value); return Number.isFinite(n) ? n : 0; }; /** Measures on nav resize and font loads; writes the mode on . Returns a stop function. */ export function installBarMeasurer(win: Window, options: BarMeasurerOptions): () => void { const doc = win.document; const canvas = doc.createElement('canvas'); const ctx = canvas.getContext('2d'); const textWidth = (text: string, font: string) => { if (!ctx) return text.length * 8; ctx.font = font; return Math.ceil(ctx.measureText(text).width); }; let nav: Element | null = null; let frame = 0; const measure = () => { frame = 0; const html = doc.documentElement; if (options.when && !doc.querySelector(options.when)) { html.removeAttribute(BAR_ATTR); return; } if (!nav?.isConnected) return; const items = [...nav.querySelectorAll(`:scope > ${options.item}`)].filter(el => (el as HTMLElement).offsetParent !== null || el.getClientRects().length); const measured = items.map(item => { const label = item.querySelector(options.label) as HTMLElement | null; const icon = item.querySelector(options.icon) as HTMLElement | null; const pill = (item.firstElementChild as HTMLElement | null) ?? (item as HTMLElement); const ps = win.getComputedStyle(pill); const fixed = px(ps.paddingLeft) + px(ps.paddingRight) + px(ps.borderLeftWidth) + px(ps.borderRightWidth) + (icon ? Math.max(icon.getBoundingClientRect().width, 20) : 0); if (!label || !label.textContent?.trim()) return { labelWidth: 0, fixed }; const ls = win.getComputedStyle(label); // The label's own font, read even while the label is display:none (computed styles resolve it). const font = `${ls.fontStyle} ${ls.fontWeight} ${ls.fontSize} ${ls.fontFamily}`; return { labelWidth: textWidth(label.textContent.trim(), font) + px(ls.marginLeft) + px(ls.marginRight) + 2, fixed }; }); const ns = win.getComputedStyle(nav); const available = (nav as HTMLElement).clientWidth - px(ns.paddingLeft) - px(ns.paddingRight); const current = (html.getAttribute(BAR_ATTR) as BarMode | null); const mode = barMode(measured, available, px(ns.columnGap) || 0, current); if (current !== mode) html.setAttribute(BAR_ATTR, mode); }; const schedule = () => { if (!frame) frame = win.requestAnimationFrame(measure); }; const Resize = (win as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; const sized = Resize ? new Resize(schedule) : null; const attach = () => { const found = doc.querySelector(options.nav); if (found !== nav) { if (nav) sized?.unobserve(nav); nav = found; if (nav) sized?.observe(nav); } schedule(); }; attach(); // The nav mounts late and is replaced on some navigations; items and fonts change rarely. const timer = win.setInterval(attach, 1500); const fonts = (doc as Document & { fonts?: FontFaceSet }).fonts; fonts?.addEventListener?.('loadingdone', schedule); win.addEventListener('resize', schedule); return () => { win.clearInterval(timer); sized?.disconnect(); fonts?.removeEventListener?.('loadingdone', schedule); win.removeEventListener('resize', schedule); if (frame) win.cancelAnimationFrame(frame); doc.documentElement.removeAttribute(BAR_ATTR); }; }