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:
parent
95c06047b3
commit
45e45bfa60
26
CLAUDE.md
26
CLAUDE.md
@ -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
|
||||
|
||||
@ -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}
|
||||
title={`Collapse ${label.toLowerCase()} by one level`}
|
||||
onClick={() => step((depth ?? 0) - 1)}>◂</button>
|
||||
<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'}`}>
|
||||
{current}
|
||||
</button>
|
||||
<button onClick={() => setOpen(o => !o)}
|
||||
title={`${label}: showing ${current}`}
|
||||
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}
|
||||
title={`Expand ${label.toLowerCase()} by one level`}
|
||||
onClick={() => step((depth ?? 0) + 1)}>▸</button>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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"
|
||||
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>
|
||||
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">
|
||||
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"
|
||||
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>
|
||||
onPick={applySplitDepth} totalLabel="Total">
|
||||
<ToggleChip on={totals.col} onChange={on => toggleTotal('col', on)}
|
||||
title="Show the grand-total column across every column group">
|
||||
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>
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user