Make the pivot toolbar read as three controls, not twelve

Each axis's depth stepper is now one joined control, styled like the
operation panel's Scale | Recode | Clone, so the arrows are visible and
tell enabled from disabled in dark mode. The Total checkboxes become
toggle chips beside their stepper. Operations shows its open state as
pressed rather than relabelling to a blue "Hide panel", keeping blue
for actions. The zone boxes are gone; the gap between the zones already
separates them. The layout name gets more room before it truncates.
color-scheme: dark on the dark palette fixes native controls app-wide.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-25 12:59:45 -04:00
parent 95c06047b3
commit 45e45bfa60
5 changed files with 111 additions and 53 deletions

View File

@ -615,22 +615,32 @@ layout, since `restore()` is all-or-nothing.
## The Forecast toolbar is two zones
Left, on its own tinted ground: **view** — `Layout ▾` and the `Rows` and
`Columns` depth buttons. Nothing there reaches the server or changes the
Left: **view** — `Layout ▾` and the `Rows` and `Columns` depth steppers, each
with its `Total` toggle. Nothing there reaches the server or changes the
forecast; it is all `ViewConfig` against rows already loaded.
Right, in a bordered panel of its own: **forecast** — `Refresh data`,
`Change log`, `Bridge`, `Operations`. These re-run the load, open something over
the pivot, or write to the version.
Right: **forecast** — `Refresh data`, `Change log`, `Bridge`, `Operations`.
These re-run the load, open something over the pivot, or write to the version.
The distance between them is the separation. Each zone used to be an outlined
box as well, the view one on a `gray-50` tint — too faint to see against white,
and in dark mode the two boxes read as siblings. Within the view zone each axis
is one joined control, `◂ | level | ▸`, in the style of the operation panel's
`Scale | Recode | Clone`, followed by its `Total` chip.
**Blue is for actions, pressed grey for state.** `Save` and `Post` are blue;
the `Total` chips and `Operations` while its panel is open use `.pf-pressed`.
`Operations` used to relabel itself `Hide panel` in a blue outline, which made
it look like a second Save.
They were one undifferentiated row separated by identical hairlines, which made
`Fit` and `Bridge` read as peers when one is a column width and the other is the
version's history. Three weights do the separating now, and they were all once
the same: `.pf-section-label` names a group (`Layout`, `Rows`, `Columns`) and is
heavier and darker than the controls under it, `.pf-group-rule` separates groups
*within* a zone, and the zone borders separate the zones. A label that matched
its buttons, and a group rule that matched a zone border, is what made the row
read as one undifferentiated thing.
*within* a zone, and the gap between the zones separates them. A label that
matched its buttons, and a group rule that matched a zone border, is what made
the row read as one undifferentiated thing.
**`Fit widths` is gone**, button and `fitColumns` both, for three independent
reasons. Auto-size measures the leaf header over the rows *currently in the

View File

@ -11,7 +11,7 @@ import { useEffect, useRef, useState } from 'react'
// A stepper is three controls whatever the depth, and says where you are rather
// than making you find it. The full list is still one click away, for jumping
// rather than stepping.
export default function DepthButtons({ label, levels, depth, onPick, totalLabel = 'Total' }) {
export default function DepthButtons({ label, levels, depth, onPick, totalLabel = 'Total', children }) {
const [open, setOpen] = useState(false)
const boxRef = useRef(null)
@ -36,28 +36,36 @@ export default function DepthButtons({ label, levels, depth, onPick, totalLabel
const current = !known ? '—' : (depth === 0 ? totalLabel : levels[depth - 1])
const step = (d) => onPick(Math.min(max, Math.max(0, d)))
const arrow = 'border border-gray-200 rounded px-1 py-0.5 text-gray-500 leading-none ' +
'hover:border-gray-400 disabled:opacity-30 disabled:hover:border-gray-200 transition-colors'
// One joined control, in the style of the operation panel's
// Scale | Recode | Clone. The arrows used to be separate outlined boxes a
// few pixels wide, which in dark mode were barely visible and looked the
// same enabled or disabled; joined, the axis reads as one object and the
// arrows are halves of it rather than specks beside it.
const seg = 'px-1.5 py-0.5 leading-none bg-white transition-colors ' +
'text-gray-600 hover:bg-gray-100 disabled:text-gray-300 disabled:hover:bg-white disabled:cursor-default'
return (
<div ref={boxRef} className="relative flex items-center gap-1">
<span className="pf-section-label mr-0.5">{label}</span>
<div ref={boxRef} className="relative flex items-center gap-1.5">
<span className="pf-section-label">{label}</span>
<button className={arrow} disabled={!known || depth <= 0}
<div className="inline-flex rounded border border-gray-300 divide-x divide-gray-300 overflow-hidden">
<button className={seg} disabled={!known || depth <= 0}
title={`Collapse ${label.toLowerCase()} by one level`}
onClick={() => step((depth ?? 0) - 1)}>◂</button>
<button onClick={() => setOpen(o => !o)}
title={`${label}: showing ${current}`}
className={`border rounded px-2 py-0.5 max-w-[10rem] truncate transition-colors
${open ? 'border-blue-300 text-blue-700 bg-blue-50'
: 'border-gray-200 text-gray-600 hover:border-gray-400'}`}>
className={`px-2 py-0.5 max-w-[10rem] truncate transition-colors
${open ? 'bg-blue-50 text-blue-700' : 'bg-white text-gray-700 hover:bg-gray-100'}`}>
{current}
</button>
<button className={arrow} disabled={!known || depth >= max}
<button className={seg} disabled={!known || depth >= max}
title={`Expand ${label.toLowerCase()} by one level`}
onClick={() => step((depth ?? 0) + 1)}>▸</button>
</div>
{children}
{open && (
<div className="absolute top-full left-0 mt-1 z-50 min-w-[10rem] bg-white border
@ -77,3 +85,17 @@ export default function DepthButtons({ label, levels, depth, onPick, totalLabel
</div>
)
}
// An on/off control that looks pressed when on — the same state the toolbar's
// Operations button uses for "open". Replaces the native checkboxes, which
// drew in the light style on the dark theme and were the brightest thing on
// the toolbar.
export function ToggleChip({ on, onChange, title, children }) {
return (
<button type="button" aria-pressed={on} title={title} onClick={() => onChange(!on)}
className={`border rounded px-2 py-0.5 transition-colors ${
on ? 'pf-pressed' : 'border-gray-300 bg-white text-gray-500 hover:bg-gray-100'}`}>
{children}
</button>
)
}

View File

@ -119,7 +119,7 @@ export default function LayoutMenu({
<span className="pf-section-label">Layout</span>
<button onClick={() => setOpen(o => !o)}
className={`flex items-center gap-1 border rounded px-2 py-0.5 max-w-[14rem] transition-colors
className={`flex items-center gap-1 border rounded px-2 py-0.5 max-w-[22rem] transition-colors
${open ? 'border-blue-300 text-blue-700 bg-blue-50'
: 'border-gray-200 text-gray-600 hover:border-gray-400'}`}>
<span className="truncate">{active ? active.name : 'Unsaved'}</span>

View File

@ -31,6 +31,9 @@
--accent-text: #4778c2;
--chart-ink-dim: #9ca3af;
--chart-axis: #61656e;
/* Native controls — checkboxes, scrollbars, date pickers — draw in the dark
style. Without it every checkbox in the app was a white square. */
color-scheme: dark;
}
body { margin: 0; background-color: var(--bg-primary); color: var(--text-primary); }
@ -76,6 +79,30 @@ body { margin: 0; background-color: var(--bg-primary); color: var(--text-primary
}
.dark .pf-group-rule { background: #5a616b; }
/* A toggle that is on — the Total chips, and Operations while its panel is
open. Pressed grey rather than blue: blue is Save and Post, actions, and
these are states. Its own class so the dark values are defined once rather
than hoping each utility has a dark override. */
.pf-pressed {
background-color: #e5e7eb;
border-color: #9ca3af;
color: #1f2937;
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.08);
}
.dark .pf-pressed {
background-color: var(--bg-tertiary);
border-color: #61656e;
color: var(--text-primary);
box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.3);
}
/* The joined controls — the depth steppers and the operation panel's
Scale | Recode | Clone — draw their inner rules with divide-*, which had no
dark override and stayed light grey. */
.dark .divide-gray-300 > :not(:last-child) { border-color: var(--border-color); }
.dark .disabled\:text-gray-300:disabled { color: var(--text-muted); }
.dark .disabled\:hover\:bg-white:disabled:hover { background-color: var(--bg-secondary); }
.dark .text-gray-300 { color: var(--text-muted); }
.dark .text-gray-400 { color: var(--text-muted); }
.dark .text-gray-500 { color: var(--text-muted); }

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import useTheme from '../theme.jsx'
import LayoutMenu from '../components/LayoutMenu.jsx'
import DepthButtons from '../components/DepthButtons.jsx'
import DepthButtons, { ToggleChip } from '../components/DepthButtons.jsx'
import useAuth from '../auth.jsx'
import OperationPanel from '../components/OperationPanel.jsx'
import BridgeView from '../components/BridgeView.jsx'
@ -2179,13 +2179,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
{/* ── View: everything here changes how the loaded rows are displayed.
Nothing in this zone talks to the server or alters the forecast,
which is the whole reason it is fenced off from the zone on the
right — the two used to sit in one undifferentiated row of
buttons separated by identical rules, so "Fit" and "Bridge"
read as peers when one is a column width and the other opens
the version's history. ── */}
<div className="flex items-center gap-3 flex-wrap bg-gray-50 border border-gray-200
rounded-md px-2.5 py-1">
which is why it sits apart from the zone on the right. The
distance between them is the separation: each zone used to be an
outlined box as well, the view one on a gray-50 tint, and the two
boxes read as siblings while the tint was too faint to see. ── */}
<div className="flex items-center gap-4 flex-wrap">
<LayoutMenu
layouts={layouts}
@ -2211,13 +2209,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
<>
<div className="pf-group-rule" />
<DepthButtons label="Rows" levels={groupFull} depth={expandDepth}
onPick={applyDepth} totalLabel="Total" />
<label className="flex items-center gap-1 text-gray-600 cursor-pointer select-none"
onPick={applyDepth} totalLabel="Total">
<ToggleChip on={totals.row} onChange={on => toggleTotal('row', on)}
title="Show the grand-total row at the top of the pivot">
<input type="checkbox" checked={totals.row}
onChange={e => toggleTotal('row', e.target.checked)} />
Total row
</label>
Total
</ToggleChip>
</DepthButtons>
</>
)}
@ -2225,13 +2222,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
<>
<div className="pf-group-rule" />
<DepthButtons label="Columns" levels={splitFull} depth={splitDepth}
onPick={applySplitDepth} totalLabel="Total" />
<label className="flex items-center gap-1 text-gray-600 cursor-pointer select-none"
onPick={applySplitDepth} totalLabel="Total">
<ToggleChip on={totals.col} onChange={on => toggleTotal('col', on)}
title="Show the grand-total column across every column group">
<input type="checkbox" checked={totals.col}
onChange={e => toggleTotal('col', e.target.checked)} />
Total column
</label>
Total
</ToggleChip>
</DepthButtons>
</>
)}
@ -2240,8 +2236,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
{/* ── Forecast: reaches the server, or opens something over the pivot.
Refresh discards the current view's state and re-runs the load;
Operations is where the version is actually written to. ── */}
<div className="flex items-center gap-1.5 flex-wrap ml-auto bg-white border border-gray-200
rounded-md px-2.5 py-1">
<div className="flex items-center gap-1.5 flex-wrap ml-auto">
<button onClick={() => initViewer(versionId, sourceId, layouts)} disabled={loading || !versionId}
title="Re-run the load from the database"
className="border border-gray-200 rounded px-2 py-0.5 text-gray-500 hover:bg-gray-50 disabled:opacity-40">
@ -2257,10 +2252,14 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
Bridge
</button>
<button onClick={() => setPanelOpen(o => !o)} disabled={!versionId}
aria-pressed={panelOpen}
title={panelOpen ? 'Hide the operations panel (Esc)' : 'Show the operations panel'}
className={`border rounded px-2 py-0.5 disabled:opacity-40 transition-colors ${
panelOpen ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:bg-gray-50'}`}>
{panelOpen ? 'Hide panel' : 'Operations'}
panelOpen ? 'pf-pressed' : 'border-gray-200 text-gray-500 hover:bg-gray-50'}`}>
{/* Open is shown as pressed, not by relabelling to "Hide panel" in
blue — blue outline is Save's colour, an action, and this is a
state. */}
Operations
{!panelOpen && hasSlice && (
<span className="ml-1 text-blue-500">({slices.length})</span>
)}