From bd4733a11db4254a3a309ab8931933b863016d3e Mon Sep 17 00:00:00 2001
From: BigSimmo <87357024+BigSimmo@users.noreply.github.com>
Date: Sat, 22 Aug 2026 00:07:36 +0800
Subject: [PATCH] design(dictionary): measure the row's intrinsic width, not
its laid-out width
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Design-scratch mockup only; no production route or component changes.
Addresses the Codex review finding on the fit strip. Summing the children's
rendered rectangles measured what the flexbox had already handed out, not
what the row wanted. `ml-auto` on Filter absorbed the slack in the idle
state and `flex-1` on the query chip absorbed it in the search state, then
truncated its own text to whatever it had been given — so every query width
reported "one row · 0px spare" while the query was silently being cut off,
which is precisely the failure the strip exists to expose.
The verdict now probes the track under `max-content` for one synchronous
read, so every child sizes to its content and `scrollWidth` is what the row
actually wants. Truncation is reported as its own figure beside the fit,
because a row can seat every control and still fail the reader.
Verified in Chromium against the running dev server. Idle is unchanged in
substance (390 px: one row, 55 px spare; 320 px wraps, 15 px short). The
query states now tell the truth instead of reporting a dead heat: with
"tardive" the row holds from 360 px up and 320 px is 31 px short with the
query cut by 30 px; with "tardive dyskinesia" it is 28 px short at 390 px
and only 430 px seats it. That is the measurement behind giving the search
words their own line, and it was previously invisible.
Co-Authored-By: Claude Opus 5
---
.../dictionary-control-row-mockups.tsx | 63 +++++++++++--------
1 file changed, 37 insertions(+), 26 deletions(-)
diff --git a/src/components/dictionary-control-row-mockups.tsx b/src/components/dictionary-control-row-mockups.tsx
index 27fd8d2388..c794711244 100644
--- a/src/components/dictionary-control-row-mockups.tsx
+++ b/src/components/dictionary-control-row-mockups.tsx
@@ -530,37 +530,43 @@ const fitWidths = [
function FitRow({ width, note, searching }: { width: number; note: string; searching: boolean | "long" }) {
const state = useRowState(searching);
const trackRef = useRef(null);
- const [verdict, setVerdict] = useState<{ fits: boolean; slack: number } | null>(null);
+ const [verdict, setVerdict] = useState<{ fits: boolean; slack: number; queryShortfall: number } | null>(null);
useEffect(() => {
const track = trackRef.current?.querySelector("[data-fit-track]");
if (!track) return;
- // Sum what the controls intrinsically want, against the track's content
- // box. Comparing scrollWidth to clientWidth looks like the same question
- // and is not: `ml-auto` on Filter absorbs every spare pixel, so that
- // comparison reports a dead-heat zero at every width and hides both the
- // real slack and, once the row wraps, the real shortfall.
+ // Intrinsic width, probed under `max-content`, never the laid-out width.
+ //
+ // Summing the children's rendered rectangles looks like the same question
+ // and is not, and it fails in both directions. `ml-auto` on Filter absorbs
+ // every spare pixel in the idle state; `flex-1` on the query chip absorbs
+ // it in the search state and then truncates its own text to whatever it was
+ // given. Either way the children sum to the track width and the verdict
+ // reads a dead-heat zero — "one row, 0px spare" while the query is being
+ // cut off, which is the exact failure this strip exists to expose.
+ //
+ // Forcing `max-content` for one synchronous read makes every child size to
+ // its content, flex or not, so `scrollWidth` is what the row actually
+ // wants. Both figures are border-box, so they compare directly.
const measure = () => {
- const style = getComputedStyle(track);
- const gap = Number.parseFloat(style.columnGap) || 0;
- const padding = Number.parseFloat(style.paddingLeft) + Number.parseFloat(style.paddingRight);
- const available = track.getBoundingClientRect().width - padding;
- const children = Array.from(track.children);
- const needed =
- children.reduce((total, child) => total + child.getBoundingClientRect().width, 0) +
- gap * Math.max(0, children.length - 1);
- // QueryChip is `min-w-0 flex-1`, so it always reports the box the flex
- // track handed it, not the width its own text wants — a truncated query
- // silently sums to the available width and reads as "fits" even while
- // the words themselves are being clipped. Read the clipped amount off
- // the live text node (scrollWidth vs clientWidth) and add it back, so a
- // truncating query shows up as a real shortfall instead of 0px spare.
- const textOverflow = Array.from(track.querySelectorAll("[data-fit-text]")).reduce(
- (total, el) => total + Math.max(0, el.scrollWidth - el.clientWidth),
- 0,
- );
- const slack = Math.round(available - needed - textOverflow);
- setVerdict({ fits: slack >= 0, slack });
+ const previousWidth = track.style.width;
+ const previousMaxWidth = track.style.maxWidth;
+ track.style.width = "max-content";
+ track.style.maxWidth = "none";
+ const needed = track.scrollWidth;
+ track.style.width = previousWidth;
+ track.style.maxWidth = previousMaxWidth;
+ const available = track.getBoundingClientRect().width;
+ // Reported separately from the fit, because a row can seat every control
+ // and still be failing the reader: the chip yields its text before it
+ // yields its box.
+ const label = track.querySelector("[data-fit-text]");
+ const queryShortfall = label ? Math.round(label.scrollWidth - label.clientWidth) : 0;
+ setVerdict({
+ fits: available - needed >= 0,
+ slack: Math.round(available - needed),
+ queryShortfall: queryShortfall > 1 ? queryShortfall : 0,
+ });
};
measure();
const observer = new ResizeObserver(measure);
@@ -592,6 +598,11 @@ function FitRow({ width, note, searching }: { width: number; note: string; searc
{verdict.fits ? `one row · ${verdict.slack}px spare` : `wraps · ${Math.abs(verdict.slack)}px short`}
) : null}
+ {verdict?.queryShortfall ? (
+
+ query cut by {verdict.queryShortfall}px
+
+ ) : null}
{note}