From 41f5cb3b2aa8ebc352b654ad5dd5dfbed55ce5f3 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 24 Oct 2026 18:24:32 +0000 Subject: [PATCH 1/2] Generate toolbar rows split evenly on tablets and desktop From 640px up, a Generate toolbar row that fits on one line stays one natural, left-aligned line. A row that has to wrap is cut into lines whose button counts differ by at most one (3+2, not 4+1), and each line's buttons grow in proportion to their labels until the line is full. The Export tag keeps its own width at the start of its line. Phones keep the even pairs from #229. The split is measured, because where a row breaks depends on the language and on whether Save or "Saved to History" is showing. It stays behind the opt-in evenRows prop, so Attack, Brainstorm and Factoids are unchanged. Co-Authored-By: Claude Opus 5.5 --- src/components/features/ExportToolbar.tsx | 89 +++++++++++++++++++++-- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/src/components/features/ExportToolbar.tsx b/src/components/features/ExportToolbar.tsx index 167599956..9b6139333 100644 --- a/src/components/features/ExportToolbar.tsx +++ b/src/components/features/ExportToolbar.tsx @@ -1,4 +1,4 @@ -import { Children, type ReactNode } from 'react'; +import { Children, useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react'; import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { useTranslation } from 'react-i18next'; import { FileText, FileDown, MessageSquare } from 'lucide-react'; @@ -17,7 +17,10 @@ interface ExportToolbarProps { extraExports?: ReactNode; /** Buttons that appear on the top row (Save, Discuss, Flowcharts, Undo, etc.). */ secondaryActions?: ReactNode; - /** On a phone, both rows stretch their buttons into even pairs and the EXPORT tag takes its own line. */ + /** + * Rows that wrap fill evenly. On a phone both rows stretch into pairs and the EXPORT tag takes its + * own line; wider, a row that needs two lines splits its buttons evenly (3+2, 3+3) and stretches them. + */ evenRows?: boolean; } @@ -25,7 +28,77 @@ interface ExportToolbarProps { // label too long for half takes the whole line; nothing is left ragged. Buttons // set their own touch min-width, which would let a long label shrink into half // a row, so the width of the label wins. -const EVEN_ROW = 'max-sm:[&>*]:grow max-sm:[&>*]:basis-[calc(50%-0.25rem)] max-sm:[&>*]:whitespace-nowrap max-sm:[&>*]:min-w-fit!'; +const EVEN_ROW = 'max-sm:[&>*]:grow max-sm:[&>*]:basis-[calc(50%-0.25rem)] [&>*]:whitespace-nowrap [&>*]:min-w-fit!'; + +const WIDE = '(min-width: 40rem)'; + +type Split = { lines: number[][]; spread: number; widest: number }; + +// Every way to cut the items into consecutive lines that each fit. The best split uses the fewest +// lines, then keeps their counts closest (3+2 over 4+1), then the narrowest widest line. +function balancedSplit(widths: number[], room: number, gap: number): number[][] { + let best: Split | null = null; + const cuts = 1 << (widths.length - 1); + for (let mask = 0; mask < cuts; mask++) { + const lines: number[][] = [[0]]; + for (let i = 1; i < widths.length; i++) { + if (mask & (1 << (i - 1))) lines.push([i]); + else lines[lines.length - 1].push(i); + } + const lengths = lines.map((line) => line.reduce((sum, i) => sum + widths[i], 0) + gap * (line.length - 1)); + if (lengths.some((length) => length > room)) continue; + const counts = lines.map((line) => line.length); + const split = { lines, spread: Math.max(...counts) - Math.min(...counts), widest: Math.max(...lengths) }; + if (!best || split.lines.length < best.lines.length + || (split.lines.length === best.lines.length && (split.spread < best.spread + || (split.spread === best.spread && split.widest < best.widest)))) best = split; + } + return best?.lines ?? widths.map((_, i) => [i]); +} + +// From 640px up, a row that fits stays one natural line. One that wraps is cut into balanced lines, +// and each line's items grow in proportion to their labels until the line is full. An item marked +// data-row-fixed (the EXPORT tag) keeps its own width. Phones keep the CSS pairs above. +function useBalancedRow(ref: RefObject, enabled: boolean) { + useLayoutEffect(() => { + const row = ref.current; + if (!enabled || !row || typeof ResizeObserver === 'undefined') return; + const items = () => Array.from(row.children) as HTMLElement[]; + const reset = () => items().forEach((item) => { item.style.flexBasis = ''; item.style.flexGrow = ''; }); + const wide = window.matchMedia(WIDE); + let live = true; + const layout = () => { + if (!live) return; + reset(); + const room = row.clientWidth; + const all = items(); + if (!wide.matches || room === 0 || all.length < 2) return; + const gap = parseFloat(getComputedStyle(row).columnGap) || 0; + const widths = all.map((item) => item.getBoundingClientRect().width); + const lines = balancedSplit(widths, room, gap); + if (lines.length < 2) return; + for (const line of lines) { + const fixed = line.filter((i) => all[i].dataset.rowFixed !== undefined); + const fixedWidth = fixed.reduce((sum, i) => sum + widths[i], 0); + const growing = line.filter((i) => !fixed.includes(i)); + const labels = growing.reduce((sum, i) => sum + widths[i], 0); + const free = room - gap * (line.length - 1) - fixedWidth; + for (const i of line) { + // Half a pixel short of full, so rounding never pushes the next line's first item up. + const basis = fixed.includes(i) ? widths[i] : (free * widths[i]) / labels - 0.5; + all[i].style.flexBasis = `${basis}px`; + all[i].style.flexGrow = fixed.includes(i) ? '0' : '1'; + } + } + }; + layout(); + const observer = new ResizeObserver(layout); + observer.observe(row); + wide.addEventListener('change', layout); + void document.fonts?.ready.then(layout); + return () => { live = false; observer.disconnect(); wide.removeEventListener('change', layout); reset(); }; + }); +} // Unified export bar used by every feature's results view. // Two explicit rows, both left-aligned: @@ -44,6 +117,10 @@ export function ExportToolbar({ evenRows = false, }: ExportToolbarProps) { const { t } = useTranslation(); + const topRow = useRef(null); + const exportRow = useRef(null); + useBalancedRow(topRow, evenRows); + useBalancedRow(exportRow, evenRows); const hasSecondaryActions = Children.toArray(secondaryActions).filter(Boolean).length > 0; const hasTopRow = hasSecondaryActions || !!onDiscuss; @@ -56,7 +133,7 @@ export function ExportToolbar({ return (
{hasTopRow && ( -
+
{secondaryActions} {onDiscuss && (
)} -
- {evenRows ?
{exportTag}
: exportTag} +
+ {evenRows ?
{exportTag}
: exportTag} {extraExports}