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
|
## The Forecast toolbar is two zones
|
||||||
|
|
||||||
Left, on its own tinted ground: **view** — `Layout ▾` and the `Rows` and
|
Left: **view** — `Layout ▾` and the `Rows` and `Columns` depth steppers, each
|
||||||
`Columns` depth buttons. Nothing there reaches the server or changes the
|
with its `Total` toggle. Nothing there reaches the server or changes the
|
||||||
forecast; it is all `ViewConfig` against rows already loaded.
|
forecast; it is all `ViewConfig` against rows already loaded.
|
||||||
|
|
||||||
Right, in a bordered panel of its own: **forecast** — `Refresh data`,
|
Right: **forecast** — `Refresh data`, `Change log`, `Bridge`, `Operations`.
|
||||||
`Change log`, `Bridge`, `Operations`. These re-run the load, open something over
|
These re-run the load, open something over the pivot, or write to the version.
|
||||||
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
|
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
|
`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
|
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
|
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
|
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
|
*within* a zone, and the gap between the zones separates them. A label that
|
||||||
its buttons, and a group rule that matched a zone border, is what made the row
|
matched its buttons, and a group rule that matched a zone border, is what made
|
||||||
read as one undifferentiated thing.
|
the row read as one undifferentiated thing.
|
||||||
|
|
||||||
**`Fit widths` is gone**, button and `fitColumns` both, for three independent
|
**`Fit widths` is gone**, button and `fitColumns` both, for three independent
|
||||||
reasons. Auto-size measures the leaf header over the rows *currently in the
|
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
|
// 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
|
// than making you find it. The full list is still one click away, for jumping
|
||||||
// rather than stepping.
|
// 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 [open, setOpen] = useState(false)
|
||||||
const boxRef = useRef(null)
|
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 current = !known ? '—' : (depth === 0 ? totalLabel : levels[depth - 1])
|
||||||
const step = (d) => onPick(Math.min(max, Math.max(0, d)))
|
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 ' +
|
// One joined control, in the style of the operation panel's
|
||||||
'hover:border-gray-400 disabled:opacity-30 disabled:hover:border-gray-200 transition-colors'
|
// 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 (
|
return (
|
||||||
<div ref={boxRef} className="relative flex items-center gap-1">
|
<div ref={boxRef} className="relative flex items-center gap-1.5">
|
||||||
<span className="pf-section-label mr-0.5">{label}</span>
|
<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">
|
||||||
title={`Collapse ${label.toLowerCase()} by one level`}
|
<button className={seg} disabled={!known || depth <= 0}
|
||||||
onClick={() => step((depth ?? 0) - 1)}>◂</button>
|
title={`Collapse ${label.toLowerCase()} by one level`}
|
||||||
|
onClick={() => step((depth ?? 0) - 1)}>◂</button>
|
||||||
|
|
||||||
<button onClick={() => setOpen(o => !o)}
|
<button onClick={() => setOpen(o => !o)}
|
||||||
title={`${label}: showing ${current}`}
|
title={`${label}: showing ${current}`}
|
||||||
className={`border rounded px-2 py-0.5 max-w-[10rem] truncate transition-colors
|
className={`px-2 py-0.5 max-w-[10rem] truncate transition-colors
|
||||||
${open ? 'border-blue-300 text-blue-700 bg-blue-50'
|
${open ? 'bg-blue-50 text-blue-700' : 'bg-white text-gray-700 hover:bg-gray-100'}`}>
|
||||||
: 'border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
{current}
|
||||||
{current}
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
<button className={arrow} disabled={!known || depth >= max}
|
<button className={seg} disabled={!known || depth >= max}
|
||||||
title={`Expand ${label.toLowerCase()} by one level`}
|
title={`Expand ${label.toLowerCase()} by one level`}
|
||||||
onClick={() => step((depth ?? 0) + 1)}>▸</button>
|
onClick={() => step((depth ?? 0) + 1)}>▸</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute top-full left-0 mt-1 z-50 min-w-[10rem] bg-white border
|
<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>
|
</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>
|
<span className="pf-section-label">Layout</span>
|
||||||
|
|
||||||
<button onClick={() => setOpen(o => !o)}
|
<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'
|
${open ? 'border-blue-300 text-blue-700 bg-blue-50'
|
||||||
: 'border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
: 'border-gray-200 text-gray-600 hover:border-gray-400'}`}>
|
||||||
<span className="truncate">{active ? active.name : 'Unsaved'}</span>
|
<span className="truncate">{active ? active.name : 'Unsaved'}</span>
|
||||||
|
|||||||
@ -31,6 +31,9 @@
|
|||||||
--accent-text: #4778c2;
|
--accent-text: #4778c2;
|
||||||
--chart-ink-dim: #9ca3af;
|
--chart-ink-dim: #9ca3af;
|
||||||
--chart-axis: #61656e;
|
--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); }
|
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; }
|
.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-300 { color: var(--text-muted); }
|
||||||
.dark .text-gray-400 { color: var(--text-muted); }
|
.dark .text-gray-400 { color: var(--text-muted); }
|
||||||
.dark .text-gray-500 { color: var(--text-muted); }
|
.dark .text-gray-500 { color: var(--text-muted); }
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import useTheme from '../theme.jsx'
|
import useTheme from '../theme.jsx'
|
||||||
import LayoutMenu from '../components/LayoutMenu.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 useAuth from '../auth.jsx'
|
||||||
import OperationPanel from '../components/OperationPanel.jsx'
|
import OperationPanel from '../components/OperationPanel.jsx'
|
||||||
import BridgeView from '../components/BridgeView.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.
|
{/* ── View: everything here changes how the loaded rows are displayed.
|
||||||
Nothing in this zone talks to the server or alters the forecast,
|
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
|
which is why it sits apart from the zone on the right. The
|
||||||
right — the two used to sit in one undifferentiated row of
|
distance between them is the separation: each zone used to be an
|
||||||
buttons separated by identical rules, so "Fit" and "Bridge"
|
outlined box as well, the view one on a gray-50 tint, and the two
|
||||||
read as peers when one is a column width and the other opens
|
boxes read as siblings while the tint was too faint to see. ── */}
|
||||||
the version's history. ── */}
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
<div className="flex items-center gap-3 flex-wrap bg-gray-50 border border-gray-200
|
|
||||||
rounded-md px-2.5 py-1">
|
|
||||||
|
|
||||||
<LayoutMenu
|
<LayoutMenu
|
||||||
layouts={layouts}
|
layouts={layouts}
|
||||||
@ -2211,13 +2209,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
<>
|
<>
|
||||||
<div className="pf-group-rule" />
|
<div className="pf-group-rule" />
|
||||||
<DepthButtons label="Rows" levels={groupFull} depth={expandDepth}
|
<DepthButtons label="Rows" levels={groupFull} depth={expandDepth}
|
||||||
onPick={applyDepth} totalLabel="Total" />
|
onPick={applyDepth} totalLabel="Total">
|
||||||
<label className="flex items-center gap-1 text-gray-600 cursor-pointer select-none"
|
<ToggleChip on={totals.row} onChange={on => toggleTotal('row', on)}
|
||||||
title="Show the grand-total row at the top of the pivot">
|
title="Show the grand-total row at the top of the pivot">
|
||||||
<input type="checkbox" checked={totals.row}
|
Total
|
||||||
onChange={e => toggleTotal('row', e.target.checked)} />
|
</ToggleChip>
|
||||||
Total row
|
</DepthButtons>
|
||||||
</label>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -2225,13 +2222,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
<>
|
<>
|
||||||
<div className="pf-group-rule" />
|
<div className="pf-group-rule" />
|
||||||
<DepthButtons label="Columns" levels={splitFull} depth={splitDepth}
|
<DepthButtons label="Columns" levels={splitFull} depth={splitDepth}
|
||||||
onPick={applySplitDepth} totalLabel="Total" />
|
onPick={applySplitDepth} totalLabel="Total">
|
||||||
<label className="flex items-center gap-1 text-gray-600 cursor-pointer select-none"
|
<ToggleChip on={totals.col} onChange={on => toggleTotal('col', on)}
|
||||||
title="Show the grand-total column across every column group">
|
title="Show the grand-total column across every column group">
|
||||||
<input type="checkbox" checked={totals.col}
|
Total
|
||||||
onChange={e => toggleTotal('col', e.target.checked)} />
|
</ToggleChip>
|
||||||
Total column
|
</DepthButtons>
|
||||||
</label>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -2240,8 +2236,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
{/* ── Forecast: reaches the server, or opens something over the pivot.
|
{/* ── Forecast: reaches the server, or opens something over the pivot.
|
||||||
Refresh discards the current view's state and re-runs the load;
|
Refresh discards the current view's state and re-runs the load;
|
||||||
Operations is where the version is actually written to. ── */}
|
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
|
<div className="flex items-center gap-1.5 flex-wrap ml-auto">
|
||||||
rounded-md px-2.5 py-1">
|
|
||||||
<button onClick={() => initViewer(versionId, sourceId, layouts)} disabled={loading || !versionId}
|
<button onClick={() => initViewer(versionId, sourceId, layouts)} disabled={loading || !versionId}
|
||||||
title="Re-run the load from the database"
|
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">
|
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
|
Bridge
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setPanelOpen(o => !o)} disabled={!versionId}
|
<button onClick={() => setPanelOpen(o => !o)} disabled={!versionId}
|
||||||
|
aria-pressed={panelOpen}
|
||||||
title={panelOpen ? 'Hide the operations panel (Esc)' : 'Show the operations panel'}
|
title={panelOpen ? 'Hide the operations panel (Esc)' : 'Show the operations panel'}
|
||||||
className={`border rounded px-2 py-0.5 disabled:opacity-40 transition-colors ${
|
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 ? 'pf-pressed' : 'border-gray-200 text-gray-500 hover:bg-gray-50'}`}>
|
||||||
{panelOpen ? 'Hide panel' : 'Operations'}
|
{/* 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 && (
|
{!panelOpen && hasSlice && (
|
||||||
<span className="ml-1 text-blue-500">({slices.length})</span>
|
<span className="ml-1 text-blue-500">({slices.length})</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user