From af9e6de88e7424d9cf1dbef12d37615725cae7de Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Thu, 17 Sep 2026 22:15:25 -0400 Subject: [PATCH 01/26] Give a segment an editable label and bucket, at creation and after Two gaps you hit. There was nowhere to set "counts toward" while defining a segment -- only in the list afterwards -- and the Edit buttons disappear entirely once any adjustment exists. That guard is right in principle and too broad in practice. Editing a segment's filters or date offset after a scale would silently recalibrate a distribution that was sized against the old rows, so it stays gated. But the label and the bucket are presentation: they change what the pivot shows and what the segment counts toward, never which rows were loaded. Those are now editable in the list at any time, and settable on the create form. pf.log.label is new: the segment's display name, falling back to tag then note. Separate from both because those have jobs already -- tag groups adjustments into initiatives for the bridge, note is commentary -- and because the label is where sort order lives. Perspective orders column groups by the value string, so a leading "01 - " is how ordering gets expressed, and putting that in the note would put it in every note. The load routes do not yet carry bucket and label through: they return only rows_affected, with no log id to attach them to. That comes with the switch away from computed prefixes. Co-Authored-By: Claude Opus 5 (1M context) --- routes/log.js | 13 +++++--- setup_sql/01_schema.sql | 9 ++++++ ui/src/views/Baseline.jsx | 68 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/routes/log.js b/routes/log.js index 130dd41..917f4ff 100644 --- a/routes/log.js +++ b/routes/log.js @@ -131,9 +131,12 @@ module.exports = function(pool) { // a closed version, where relabelling history is still legitimate. router.patch('/log/:logid', async (req, res) => { const logId = parseInt(req.params.logid); - const { note, tag, bucket, seq } = req.body; - if (note === undefined && tag === undefined && bucket === undefined && seq === undefined) { - return res.status(400).json({ error: 'Nothing to update — send note, tag, bucket and/or seq' }); + const { note, tag, bucket, seq, label } = req.body; + if (note === undefined && tag === undefined && bucket === undefined + && seq === undefined && label === undefined) { + return res.status(400).json({ + error: 'Nothing to update — send note, tag, bucket, label and/or seq' + }); } try { // COALESCE on the flag, not the value: an explicit null or '' must be @@ -143,7 +146,8 @@ module.exports = function(pool) { note = CASE WHEN $2::bool THEN $3::text ELSE note END, tag = CASE WHEN $4::bool THEN $5::text ELSE tag END, bucket = CASE WHEN $6::bool THEN $7::text ELSE bucket END, - seq = CASE WHEN $8::bool THEN $9::int ELSE seq END + seq = CASE WHEN $8::bool THEN $9::int ELSE seq END, + label = CASE WHEN $10::bool THEN $11::text ELSE label END WHERE id = $1 RETURNING *`, [ logId, @@ -152,6 +156,7 @@ module.exports = function(pool) { bucket !== undefined, bucket === undefined ? null : (String(bucket).trim() || null), seq !== undefined, (seq === undefined || seq === null || seq === '') ? null : parseInt(seq), + label !== undefined, label === undefined ? null : (String(label).trim() || null), ] ); if (!result.rows.length) return res.status(404).json({ error: 'Log entry not found' }); diff --git a/setup_sql/01_schema.sql b/setup_sql/01_schema.sql index a2e7b9c..cd66384 100644 --- a/setup_sql/01_schema.sql +++ b/setup_sql/01_schema.sql @@ -92,6 +92,15 @@ WHERE TRUE -- bucket_order lives on the version rather than the source because the Baseline -- page, where it is maintained, is version-scoped. log.seq orders the segments -- within that. +-- The segment's display name in the pivot, falling back to tag then note. +-- +-- Separate from both because those have jobs already -- tag groups adjustments +-- into initiatives for the bridge, note is free commentary -- and because the +-- label carries the sort order. Perspective orders column groups by the value +-- string, so a leading "01 - " is how ordering is expressed; putting that in the +-- note would put it in every note. +ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS label text; + ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS bucket_order jsonb; ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS seq integer; diff --git a/ui/src/views/Baseline.jsx b/ui/src/views/Baseline.jsx index a41b18e..d193697 100644 --- a/ui/src/views/Baseline.jsx +++ b/ui/src/views/Baseline.jsx @@ -103,6 +103,10 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio const [rawSql, setRawSql] = useState('') const [offset, setOffset] = useState('0 days') const [segNote, setSegNote] = useState('') + // Presentation, not definition: what the segment counts toward and how it is + // labelled in the pivot. Safe to set at any time, unlike its filters. + const [segBucket, setSegBucket] = useState('') + const [segLabel, setSegLabel] = useState('') const [submitting, setSubmitting] = useState(false) const [editingLogId, setEditingLogId] = useState(null) const [showAddForm, setShowAddForm] = useState(false) @@ -140,6 +144,25 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio const [seqs, setSeqs] = useState({}) const [bucketOrder, setBucketOrder] = useState([]) + // Label and bucket are presentation, not definition: they change what the pivot + // shows and what the segment counts toward, never which rows were loaded. So + // they stay editable after adjustments exist, unlike the filters and offset, + // where an edit would silently recalibrate scales sized against the old rows. + async function saveLogField(entry, field, value) { + const next = value.trim() + if (next === (entry[field] || '')) return + try { + const res = await fetch(`/api/log/${entry.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [field]: next }), + }) + if (!res.ok) { const d = await res.json(); flash(d.error, 'error'); return } + loadLog() + flash('Saved') + } catch (err) { flash(err.message, 'error') } + } + async function saveBucket(entry, value) { const next = value.trim() if (next === (entry.bucket || '')) return @@ -236,6 +259,8 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio where_clause: clause, note: description || segNote, date_offset: offsetStr, + ...(segBucket.trim() ? { bucket: segBucket.trim() } : {}), + ...(segLabel.trim() ? { label: segLabel.trim() } : {}), ...(useRaw ? { raw_where: clause } : { filters }), } setSubmitting(true) @@ -462,6 +487,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio # seq + label note counts toward rows @@ -473,11 +499,11 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio {log.length === 0 && ( - No segments loaded yet + No segments loaded yet )} {!showAddForm && !editingLogId && ( - + - {/* Bucket column order. Up/down rather than drag: the list is four or - five items that change once a quarter, and a keyboard-reachable - pair of buttons beats a drag target nobody can hit on a laptop - trackpad. */} - {bucketOrder.length > 1 && ( -
- column order - {bucketOrder.map((b, i) => ( - - {String(i + 1).padStart(2, '0')} - - {b} - - - - ))} -
- )} - - @@ -538,7 +472,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio value={buckets[entry.id] ?? entry.bucket ?? ''} list="pf-bucket-options" onChange={e => setBuckets(b => ({ ...b, [entry.id]: e.target.value }))} - onBlur={e => saveBucket(entry, e.target.value)} + onBlur={e => saveLogField(entry, 'bucket', e.target.value)} placeholder="—" className="w-full border border-transparent hover:border-gray-200 focus:border-blue-400 rounded px-1 py-0.5 text-xs focus:outline-none bg-transparent" /> From 3299bfe10b21234aed50366afd9b1f2ce373f7e3 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Thu, 17 Sep 2026 22:45:56 -0400 Subject: [PATCH 06/26] Be exact about what the 99 fallback does It puts adjustments after every *numbered* segment, not last outright: on the existing version, whose segments are still named AOP and YTD Sales with no prefix, "99 - Adjustments" sorts first, because digits precede letters. That is the scheme working as designed rather than an edge case, so the note says so. Also corrects the regeneration claim: editing a label is a PATCH and needs nothing regenerated. Generate SQL is a one-time thing per source, so its stored load templates write label and bucket at all. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9eea694..846e2e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,11 +140,15 @@ single definition, shared with the `/data` cursor in `routes/operations.js` — `/agg` is generated, `/data` is not, and the two have to agree. The one hardcoded ordinal is `ADJUSTMENT_SEGMENT` = `'99 - Adjustments'`, which -keeps unlabelled adjustments last. Labelling an adjustment's own log row -overrides it, which is how one kind of adjustment is split out from the rest. -Unlabelled loads read plain `Unlabeled` and need no ordinal, since letters follow -digits in ASCII — unlike the old `'(adjustment)'`, where `(` is `0x28` against -digits from `0x30` and so sorted *first*. +keeps unlabelled adjustments after every *numbered* segment. That proviso is the +whole scheme, not a caveat on it: ordering is string ordering, so `99` only lands +last once the loads carry `01`–`0n`, and an unnumbered segment sorts after it +(digits precede letters — `9` is `0x39`, `A` is `0x41`). The old `'(adjustment)'` +sorted *first* for the same reason read the other way, `(` being `0x28`. +Unlabelled loads read plain `Unlabeled` and so land at the very end, which is +where a segment nobody has named belongs. Labelling an adjustment's own log row +overrides the fallback, which is how one kind of adjustment is split out from the +rest. **What this replaced.** The prefix used to be computed client-side, as Perspective expression columns (`pf_bucket_ord`, `pf_segment_ord`) built from @@ -158,10 +162,11 @@ two bytes, of which `isprint(0xC2)` is false). `DEAD_ORDER_EXPRS` in scheme. `pf.log.seq` and `pf.version.bucket_order` are no longer read; the columns remain. -Reordering now needs a page reload to show, because the label is part of the -aggregated row rather than something the pivot can re-derive. Changing labels on -an existing source also needs **Generate SQL** re-run — the load templates that -write `label` and `bucket` onto the log row are stored in `pf.sql`. +Relabelling now needs a page reload to show, because the label is part of the +aggregated row rather than something the pivot can re-derive. Editing a label +afterwards is a `PATCH /api/log/:logid` and needs nothing regenerated, but a +source registered before this change needs **Generate SQL** run once, so its +stored load templates write `label` and `bucket` onto the log row at all. ### Forecast operations POST to `/api/versions/:id/{scale|recode|clone}` → SQL executed with `RETURNING *` → new rows returned as JSON → `pspTable.update(rows)` — no full reload. In grain mode the operation's final CTE aggregates its own new rows to grain first; since `pf_logid` is part of `pf_gkey` those keys are always new, so `update()` **appends** and the view re-sums. From 98322a608023c75d38bfcc7e08d2c682d9a20950 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Thu, 17 Sep 2026 22:51:34 -0400 Subject: [PATCH 07/26] Close the write-only surface left behind by the ordinals bucket_order and log.seq were still settable -- PUT /versions/:id took the first, PATCH /log/:logid the second -- with nothing left to read either. A field that only ever gets written is worse than a missing one: the call succeeds, so the caller has no way to find out it did nothing. The columns themselves stay, marked vestigial where they are declared. Dropping a column is not worth a migration to reclaim two that cost nothing. pf.log.bucket is untouched and stays exactly as it was -- what a row counts toward, read first by BUCKET_EXPR. It is the bucket *order* that no longer needs storing, the text having become the order. Also removes a comment head in Forecast.jsx that survived its function and had glued itself onto fitColumns. Co-Authored-By: Claude Opus 5 (1M context) --- routes/log.js | 13 +++++-------- routes/versions.js | 16 +++++++--------- setup_sql/01_schema.sql | 14 +++++--------- ui/src/views/Forecast.jsx | 4 ---- 4 files changed, 17 insertions(+), 30 deletions(-) diff --git a/routes/log.js b/routes/log.js index 917f4ff..3f4614c 100644 --- a/routes/log.js +++ b/routes/log.js @@ -131,11 +131,11 @@ module.exports = function(pool) { // a closed version, where relabelling history is still legitimate. router.patch('/log/:logid', async (req, res) => { const logId = parseInt(req.params.logid); - const { note, tag, bucket, seq, label } = req.body; - if (note === undefined && tag === undefined && bucket === undefined - && seq === undefined && label === undefined) { + const { note, tag, bucket, label } = req.body; + if (note === undefined && tag === undefined + && bucket === undefined && label === undefined) { return res.status(400).json({ - error: 'Nothing to update — send note, tag, bucket, label and/or seq' + error: 'Nothing to update — send note, tag, bucket and/or label' }); } try { @@ -146,16 +146,13 @@ module.exports = function(pool) { note = CASE WHEN $2::bool THEN $3::text ELSE note END, tag = CASE WHEN $4::bool THEN $5::text ELSE tag END, bucket = CASE WHEN $6::bool THEN $7::text ELSE bucket END, - seq = CASE WHEN $8::bool THEN $9::int ELSE seq END, - label = CASE WHEN $10::bool THEN $11::text ELSE label END + label = CASE WHEN $8::bool THEN $9::text ELSE label END WHERE id = $1 RETURNING *`, [ logId, note !== undefined, note === undefined ? null : (String(note).trim() || null), tag !== undefined, tag === undefined ? null : (String(tag).trim() || null), bucket !== undefined, bucket === undefined ? null : (String(bucket).trim() || null), - seq !== undefined, (seq === undefined || seq === null || seq === '') - ? null : parseInt(seq), label !== undefined, label === undefined ? null : (String(label).trim() || null), ] ); diff --git a/routes/versions.js b/routes/versions.js index 7087bed..d5a4a17 100644 --- a/routes/versions.js +++ b/routes/versions.js @@ -308,27 +308,25 @@ ${colDefs}, }); // update version name, description, or exclude_iters + // + // bucket_order is deliberately not settable: the bucket column order is the + // text in pf.log.bucket now, so a stored order would be a second answer to + // the same question, and a silent one -- nothing reads it. router.put('/versions/:id', async (req, res) => { - const { name, description, exclude_iters, bucket_order } = req.body; + const { name, description, exclude_iters } = req.body; try { - // bucket_order is a flag-and-value pair rather than COALESCE: an empty - // array is a meaningful value (no ordering), and COALESCE could not tell - // it from "not mentioned". const result = await pool.query(` UPDATE pf.version SET name = COALESCE($2, name), description = COALESCE($3, description), - exclude_iters = COALESCE($4, exclude_iters), - bucket_order = CASE WHEN $5::bool THEN $6::jsonb ELSE bucket_order END + exclude_iters = COALESCE($4, exclude_iters) WHERE id = $1 RETURNING * `, [ req.params.id, name || null, description || null, - exclude_iters ? JSON.stringify(exclude_iters) : null, - bucket_order !== undefined, - bucket_order === undefined ? null : JSON.stringify(bucket_order || []) + exclude_iters ? JSON.stringify(exclude_iters) : null ]); if (result.rows.length === 0) { return res.status(404).json({ error: 'Version not found' }); diff --git a/setup_sql/01_schema.sql b/setup_sql/01_schema.sql index cd66384..29f2365 100644 --- a/setup_sql/01_schema.sql +++ b/setup_sql/01_schema.sql @@ -83,15 +83,6 @@ WHERE TRUE -- Display order for the pivot's segment and bucket columns. -- --- Perspective orders column groups by the value string, and SortDir's col asc / --- col desc only reverses that -- so no sort setting can produce --- Prior Year -> Plan -> Actual -> Forecast, which is alphabetical in neither --- direction. The order has to be carried in the value itself, as a "01 · " style --- prefix applied when the rows are served. --- --- bucket_order lives on the version rather than the source because the Baseline --- page, where it is maintained, is version-scoped. log.seq orders the segments --- within that. -- The segment's display name in the pivot, falling back to tag then note. -- -- Separate from both because those have jobs already -- tag groups adjustments @@ -101,6 +92,11 @@ WHERE TRUE -- note would put it in every note. ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS label text; +-- Vestigial, both of them. They held the ordinal when the "01 - " prefix was +-- computed for the pivot rather than typed into label and bucket: bucket_order +-- sequenced the bucket columns, log.seq the segments within them. Nothing reads +-- either now, and nothing writes them -- kept only because dropping a column is +-- not worth a migration to reclaim two that cost nothing. ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS bucket_order jsonb; ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS seq integer; diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 390cafa..c014557 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -841,10 +841,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio } } - // Keep the ordering expressions in step with the version's bucket_order and the - // log's seq values. Merged into the live config rather than replacing - // expressions, so anything the user defined themselves survives. - // // Size every column to its contents. // // Values fit on their own: draw() calls regular_table.resetAutoSize(), which From 98f7bbef34e3e733172c8a241866772fcff58a4c Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Thu, 17 Sep 2026 22:57:20 -0400 Subject: [PATCH 08/26] Stop the note column wrapping the whole row It was the only one of eleven columns with no width, so it lived on whatever slack was left once kind, label and counts-toward took theirs -- and it holds the longest text of any of them. Before label and bucket existed it also held the operation badge and the segment name, and had the room for them. Now one line with an ellipsis, the full text on hover, and unclipped in the expand panel underneath, which already renders it. w-full with max-w-0 is what lets a cell in an auto-layout table absorb the slack and still clip; with only w-full the column grows to fit and nothing truncates. Co-Authored-By: Claude Opus 5 (1M context) --- ui/src/views/Baseline.jsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/ui/src/views/Baseline.jsx b/ui/src/views/Baseline.jsx index c58b441..23611bd 100644 --- a/ui/src/views/Baseline.jsx +++ b/ui/src/views/Baseline.jsx @@ -409,7 +409,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio - + @@ -464,8 +464,17 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio focus:border-blue-400 rounded px-1 py-0.5 text-xs focus:outline-none bg-transparent" /> - - - - + + + @@ -464,16 +480,16 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio focus:border-blue-400 rounded px-1 py-0.5 text-xs focus:outline-none bg-transparent" /> - {/* One line, clipped. The note is provenance -- it can run - long -- and this row is eleven columns wide, so it used - to wrap and push every row to two or three lines. w-full - plus max-w-0 is what lets a cell in an auto-layout table - absorb the slack and still clip: without the max-w-0 the - column simply grows to fit the text. Expanding the row - shows it in full. */} -
# kind labelnotenote counts toward rows {log[0]?.value_col || 'value'} - {entry.note || } + {/* One line, clipped. The note is provenance -- it can run + long -- and this row is eleven columns wide, so it used + to wrap and push every row to two or three lines. w-full + plus max-w-0 is what lets a cell in an auto-layout table + absorb the slack and still clip: without the max-w-0 the + column simply grows to fit the text. Expanding the row + shows it in full. */} + + {entry.note + ?
{entry.note}
+ : }
e.stopPropagation()}> Date: Thu, 17 Sep 2026 23:00:27 -0400 Subject: [PATCH 09/26] Give the segment table room, and size its text columns by content The page was capped at max-w-4xl. Eleven columns in 896px meant something was always smashed, and the previous fix just moved which one -- w-full on the note cell let it claim the slack, and w-48 on label is only a hint in an auto-layout table, so the browser shrank the label input to min-content. The cap is gone; the blocks that read better narrow keep their own. label, note and counts-toward are now measured off the longest value in the log, in ch, with floors so an empty table keeps its headers and ceilings so one long note cannot push the numbers off the side. The note's one-line clip moved onto its inner div: a max-width on a table cell is only a hint too, so pinning it to the cell could collapse the column to min-content or let it grow past the measurement. Co-Authored-By: Claude Opus 5 (1M context) --- ui/src/views/Baseline.jsx | 42 +++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/ui/src/views/Baseline.jsx b/ui/src/views/Baseline.jsx index 23611bd..c145c03 100644 --- a/ui/src/views/Baseline.jsx +++ b/ui/src/views/Baseline.jsx @@ -165,6 +165,19 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio } catch (err) { flash(err.message, 'error') } } + // Column widths read off the content rather than guessed. ch is the width of a + // '0', so for proportional text it runs slightly generous -- which is what is + // wanted for an input you are about to type a longer name into. The floors keep + // an empty table from collapsing its headers; the ceilings keep one long note + // from pushing the numbers off the side. + function widthCh(values, min, max) { + const longest = values.reduce((n, v) => Math.max(n, String(v || '').length), 0) + return `${Math.min(max, Math.max(min, longest + 2))}ch` + } + const labelW = widthCh(log.map(e => e.label || e.tag || e.note), 18, 40) + const bucketW = widthCh(log.map(e => e.bucket), 16, 28) + const noteW = widthCh(log.map(e => e.note), 24, 60) + function loadLog() { fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => { setLog(data.filter(e => e.operation === 'baseline' || e.operation === 'reference')) @@ -341,7 +354,10 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio return (
-
+ {/* Uncapped: the segment table is eleven columns, and at max-w-4xl (896px) + something always got smashed no matter how the widths were divided. The + blocks that read better narrow keep their own caps. */} +
{msg && (
@@ -408,9 +424,9 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio
# kindlabelnotecounts towardlabelnotecounts toward rows {log[0]?.value_col || 'value'} by + {/* One line, clipped against the measured width above. The + note is provenance and can run long, so left to itself it + wrapped and pushed every row to two or three lines. + Expanding the row shows it in full. The cap is on the div, + not the cell: a max-width on a cell in an auto-layout table + is only a hint, and the column can still collapse to + min-content or grow past it. */} + {entry.note - ?
{entry.note}
+ ?
{entry.note}
: }
e.stopPropagation()}> From 3162759f93b815b74f88550342dc8bc747733d54 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Thu, 17 Sep 2026 23:02:38 -0400 Subject: [PATCH 10/26] Size the table to its content, not to the window Measuring the columns was not enough while the table itself was w-full inside an uncapped page: it stretched to the window and handed the slack back out, so the measurements only decided who got squeezed. The table now sizes to its content and the page uses items-start, so each block is as wide as it needs. Ceilings pulled in a little too -- with nothing competing for slack they are the actual width, not a limit on a fight. Co-Authored-By: Claude Opus 5 (1M context) --- ui/src/views/Baseline.jsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/ui/src/views/Baseline.jsx b/ui/src/views/Baseline.jsx index c145c03..104bc37 100644 --- a/ui/src/views/Baseline.jsx +++ b/ui/src/views/Baseline.jsx @@ -174,9 +174,9 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio const longest = values.reduce((n, v) => Math.max(n, String(v || '').length), 0) return `${Math.min(max, Math.max(min, longest + 2))}ch` } - const labelW = widthCh(log.map(e => e.label || e.tag || e.note), 18, 40) - const bucketW = widthCh(log.map(e => e.bucket), 16, 28) - const noteW = widthCh(log.map(e => e.note), 24, 60) + const labelW = widthCh(log.map(e => e.label || e.tag || e.note), 18, 34) + const bucketW = widthCh(log.map(e => e.bucket), 16, 24) + const noteW = widthCh(log.map(e => e.note), 22, 44) function loadLog() { fetch(`/api/versions/${versionId}/log`).then(r => r.json()).then(data => { @@ -354,10 +354,11 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio return (
- {/* Uncapped: the segment table is eleven columns, and at max-w-4xl (896px) - something always got smashed no matter how the widths were divided. The - blocks that read better narrow keep their own caps. */} -
+ {/* No page-wide cap and no stretching: at max-w-4xl (896px) the eleven-column + segment table always had something smashed, and uncapped it ran to the + window. items-start makes each block as wide as its own content needs, + which for the table is the measured column widths below. */} +
{msg && (
@@ -406,7 +407,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio {versionId && <> {/* Segments loaded */} -
+
Segments loaded @@ -418,7 +419,7 @@ export default function Baseline({ sources = [], sourceId, versions = [], versio