diff --git a/CLAUDE.md b/CLAUDE.md
index e0f7d33..5fd6504 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -46,6 +46,7 @@ ui/src/
Baseline.jsx Version management, baseline workbench, reference load
Forecast.jsx Perspective pivot, selection handling, operation dispatch
components/
+ DepthButtons.jsx One axis's collapse control; both axes are drawn from it
LayoutMenu.jsx The Layout ▾ control — Published / Mine, with the write actions
OperationPanel.jsx The adjustment workbench — ledger + scale/recode/clone forms
BridgeView.jsx Baseline → current waterfall by tag (exports buildSteps/layoutSteps)
@@ -231,11 +232,15 @@ gets written.
Both axes collapse the same way: a **depth in `ViewConfig`**, set through
`restore()`.
-- **Rows** — `group_by_depth`, driven by the `EXPAND 0 1 2 3` buttons via
- `applyDepth()`
-- **Columns** — `split_by_depth`, driven by the `COLUMNS` buttons via
+- **Rows** — `group_by_depth`, driven by the `Rows` buttons via `applyDepth()`
+- **Columns** — `split_by_depth`, driven by the `Columns` buttons via
`applySplitDepth()`
+Both sets are drawn by `components/DepthButtons.jsx` from the axis's own field
+list (`groupFull` / `splitFull`), so each button is named after the level it
+reveals. The row axis used to be a fixed `0 1 2 3`, which named nothing and
+offered levels a two-field `group_by` does not have.
+
Both are **1-based**: they count the levels to show, where the imperative
`view.set_depth()` counts the boundary below them. `server.cpp` does
`ctx1->set_depth(row_pivot_depth - 1)` and
@@ -367,6 +372,22 @@ layout, since `restore()` is all-or-nothing.
---
+## The Forecast toolbar is two zones
+
+Left, on its own tinted ground: **view** — `Layout ▾`, the `Rows` and `Columns`
+depth buttons, `Fit widths`. Nothing there reaches the server or changes the
+forecast; it is all `ViewConfig` and `plugin_config` against rows already loaded.
+
+Right: **forecast** — `Refresh data`, `Change log`, `Bridge`, `Operations`. These
+re-run the load, open something over the pivot, or write to the version.
+
+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. The split is the point; the tint is just what makes it
+visible.
+
+---
+
## Slice mechanics
When the user clicks a pivot cell, `perspective-click` fires. The handler in `Forecast.jsx` extracts `[col, '==', value]` filters from `detail.config.filter` — only `role = dimension` and `role = date` columns are kept as the slice. A plain click replaces the selection; ctrl/⌘/shift-click toggles a slice in or out of it, so the panel holds a **list** of slices sent as `slices` in operation POST bodies (the single `slice` object is still accepted server-side).
diff --git a/ui/src/components/DepthButtons.jsx b/ui/src/components/DepthButtons.jsx
new file mode 100644
index 0000000..c6fbd2b
--- /dev/null
+++ b/ui/src/components/DepthButtons.jsx
@@ -0,0 +1,26 @@
+// One axis's collapse control. Rows and columns are the same thing — a depth in
+// ViewConfig, counting the levels to show — so they are drawn by one component
+// rather than two that happen to look alike.
+//
+// The buttons are named after the level they reveal. The row axis used to be a
+// fixed 0 1 2 3: a number means nothing without already knowing what group_by
+// holds, and a fixed range offers levels that do not exist while hiding ones
+// that do.
+export default function DepthButtons({ label, levels, depth, onPick, totalLabel = 'Total' }) {
+ return (
+
+ )
+}
diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx
index ca9d015..318f828 100644
--- a/ui/src/views/Forecast.jsx
+++ b/ui/src/views/Forecast.jsx
@@ -1,6 +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 useAuth from '../auth.jsx'
import OperationPanel from '../components/OperationPanel.jsx'
import BridgeView from '../components/BridgeView.jsx'
@@ -115,6 +116,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// re-renders when either changes.
const [splitFull, setSplitFull] = useState([])
const [splitDepth, setSplitDepth] = useState(null)
+ // The row hierarchy, for the same reason: the depth buttons name the level
+ // they show rather than counting to a number nobody can map back to a field.
+ const [groupFull, setGroupFull] = useState([])
const [slices, setSlices] = useState([])
const [applyMode, setApplyMode] = useState('prorate') // 'prorate' | 'each'
@@ -920,6 +924,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// split_full is the legacy key, from when collapsing truncated split_by
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
cfg.split_by_depth != null ? cfg.split_by_depth - 1 : null)
+ setGroupFull(cfg.group_by || [])
// restore() has already applied group_by_depth; this only syncs the
// toolbar. expand_depth is the legacy key, from when depth was imperative
// and had to be stored beside the config rather than in it.
@@ -947,6 +952,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
}
await viewer.restore(cfg)
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length)
+ setGroupFull(cfg.group_by || [])
// Name what we landed on, so the menu says which layout this is and the
// dirty dot has something to compare against. Read back rather than
// reusing cfg: restore() normalises, so the live config is what a later
@@ -971,6 +977,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const live = await viewer.save()
adoptSplit(live.split_by || [],
live.split_by_depth != null ? live.split_by_depth - 1 : null)
+ setGroupFull(live.group_by || [])
// the plugin element is replaced when the plugin changes, so re-assert
hideSplitTotal()
const cfg = await captureConfig()
@@ -1515,6 +1522,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
await viewer.restore(cfg)
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
cfg.split_by_depth != null ? cfg.split_by_depth - 1 : null)
+ setGroupFull(cfg.group_by || [])
if (cfg.group_by_depth != null) setExpandDepth(cfg.group_by_depth - 1)
else if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth)
@@ -1899,73 +1907,68 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
{/* Toolbar */}
- patchLayout(l, { visibility: v },
- v === 'published' ? `Published “${l.name}”` : `“${l.name}” is private again`)}
- onSetDefault={l => patchLayout(l, { is_default: true },
- `“${l.name}” opens this forecast`)}
- onRename={(l, name) => patchLayout(l, { name }, 'Renamed')}
- onDelete={deleteLayout}
- onReset={resetLayout} />
+ {/* ── 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. ── */}
+
-
+ patchLayout(l, { visibility: v },
+ v === 'published' ? `Published “${l.name}”` : `“${l.name}” is private again`)}
+ onSetDefault={l => patchLayout(l, { is_default: true },
+ `“${l.name}” opens this forecast`)}
+ onRename={(l, name) => patchLayout(l, { name }, 'Renamed')}
+ onDelete={deleteLayout}
+ onReset={resetLayout} />
-
+ {/* Rows and Columns are the same control on the two axes — a depth in
+ ViewConfig — so they are built from one renderer and read alike.
+ The buttons name the level they reveal: "2" meant nothing without
+ already knowing what group_by held, and the old fixed 0–3 range
+ offered levels that did not exist and hid ones that did. */}
+ {groupFull.length > 0 && (
+ <>
+
+
+ >
+ )}
-
+ {splitFull.length > 0 && (
+ <>
+
+
+ >
+ )}
- {/* Expand group */}
-
- Expand
- {[0, 1, 2, 3].map(d => (
-
- ))}
+
+
+
- {splitFull.length > 0 && (
- <>
-
-
- {/* Column hierarchy group — the split_by equivalent of Expand */}
-