diff --git a/docs/architecture/05-rendering-theming-preview.md b/docs/architecture/05-rendering-theming-preview.md
index c849343..2979ca5 100644
--- a/docs/architecture/05-rendering-theming-preview.md
+++ b/docs/architecture/05-rendering-theming-preview.md
@@ -426,6 +426,12 @@ renderer service. Builder flow: `chart-builder.ts` (pure spec assembler) → `Ch
(config + create) → `ChartBuilderModal`'s `BuilderPreview`. Reach for a reusable preview
component only if a _third_ surface appears.
+The builder's **X/Y axis controls live in the preview pane, not the config pane**: the
+on-chart Columns/Rows shelves (`OnChartShelves`) sit _above_ `BuilderPreview`, because axis
+position is a property of the chart (Tableau's Columns/Rows metaphor). The field shelf and
+the Colour/Size Marks card stay in the config pane. A reserved faceting slot in each shelf
+is a placeholder only.
+
---
## 6. Rendering Contract Lives Upstream (reference)
diff --git a/docs/chart-builder-enhancement-scope.md b/docs/chart-builder-enhancement-scope.md
index 4cae93f..1a071b7 100644
--- a/docs/chart-builder-enhancement-scope.md
+++ b/docs/chart-builder-enhancement-scope.md
@@ -25,6 +25,29 @@
Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is
the quick "where are we" — read it first.
+- **2026-06-11 (Phase 2)** — **2A + 2B shipped: the field-first interaction substrate.** Up next: **3A intent-first front door**.
+ - **2A · Value-or-field channels (the Property model).** `ChannelMapping` gained a constant
+ `value` arm; the assembler emits `{ value }` and the measure/stack/area-split/prune logic
+ all treat a constant Colour as "no series." New pure helpers (`isValueMapping`,
+ `channelAcceptsValue`, `defaultChannelValue`, `coerceChannelValue`,
+ `isColumnAllowedOnChannel`). `type` stays required (a constant carries a preserved-but-
+ ignored type, so a field↔constant toggle round-trips). Tested.
+ - **2B · Field-first builder.** The channel-first dropdown rows are replaced by a **field
+ shelf** (columns as type-glyphed chips, auto Dimensions/Measures split past a column
+ threshold), **click-to-assign** (armed channel, else first empty that accepts it —
+ `assignField`/`focusChannel`), Tableau-style **pills** (a type chip that **cycles** the
+ field type in-place, transforms beneath), and the **on-chart Columns/Rows shelves** above
+ the preview (X/Y as a property of the chart) with a **reserved faceting placeholder** in
+ each. Colour/Size live in a left **Marks card**, each switchable to a **constant** (2A).
+ - **Decisions taken (overridable):** drag deferred (click/keyboard-first, fully tested);
+ field grouping auto; aggregate stays a control under the pill (no pill context-menu yet).
+ - **Spec §06** rewritten for the field-first model (Layout, Encoding channels, faceting
+ placeholder, constants, validation/output).
+ - **Verified:** `typecheck` + `test` (762, +20 core/store/modal) + `eslint` + `build`.
+ **Owed:** a manual/visual pass against the live builder; a **`/council`** look at the
+ type-cycle chip (a cycling button gives no direct type pick — explicit menu vs cycle is a
+ real a11y trade-off) and the constant affordance.
+
- **2026-06-11 (later)** — **Owed debts on the Data section closed. Up next: Phase 2 (2A value-or-field channels, then 2B field shelf).**
- **Council pass** on the new error/disclosure copy (the previously-deferred auto-fire
surface). Three a11y conformance gaps against `architecture/10` were fixed: the inline
@@ -393,9 +416,9 @@ Phase 1 1A actionable hints ✓ done
1C filter (+ calculate) ✓ done
1D data preview ✓ done
1E expr-validate ✓ done (syntax + unknown-field; completion popup deferred)
-Phase 2 2A value-or-field channels (Property model) ← next
- 2B field shelf + in-place type cycling ← Tier-C substrate
-Phase 3 3A intent-first front door (Tier C) ← built on 2B; the defining feature
+Phase 2 2A value-or-field channels (Property model) ✓ done
+ 2B field shelf + in-place type cycling ✓ done (field-first + on-chart shelves)
+Phase 3 3A intent-first front door (Tier C) ← next; built on 2B; the defining feature
3B starter examples
Phase 4 (gated) theta/facets/styling-overrides/undo/lookup — decide after Phase 3
diff --git a/docs/spec/06-chart-builder.md b/docs/spec/06-chart-builder.md
index 027c09e..624e83d 100644
--- a/docs/spec/06-chart-builder.md
+++ b/docs/spec/06-chart-builder.md
@@ -2,7 +2,7 @@
The Chart Builder is a visual, no-JSON way to compose a Vega-Lite chart from a selected dataset. The user picks a mark type and maps the dataset's columns to encoding channels; the builder produces a complete Vega-Lite spec and saves it as a new snippet that references the dataset. It is intended for users who want to start a chart quickly without hand-writing JSON in the _Spec Editor & Draft/Published Workflow_.
-> **Design level — "smart + guarded" (Tier B).** The builder is mark-first and stays within the inputs below, but it is not a dumb composer: it picks a sensible default mark for the data shape, offers only field types valid for each column, keeps unsuitable channel mappings out of reach, and surfaces non-blocking guidance for encodings that render poorly. These behaviors are derived from cross-source chart-choice research recorded in [`docs/chart-builder-research.md`](../chart-builder-research.md) (the convergence of Draco, Voyager, the FT Visual Vocabulary, and Datawrapper). The richer "intent-first" front door (ask _what do you want to show?_ and recommend a chart) is explicitly out of scope for now and noted there as a future tier.
+> **Design level — "smart + guarded" (Tier B).** The builder is field-first — the user works from a shelf of the dataset's columns and drops them onto encoding channels — and stays within the inputs below, but it is not a dumb composer: it picks a sensible default mark for the data shape, offers only field types valid for each column, keeps unsuitable channel mappings out of reach, and surfaces non-blocking guidance for encodings that render poorly. These behaviors are derived from cross-source chart-choice research recorded in [`docs/chart-builder-research.md`](../chart-builder-research.md) (the convergence of Draco, Voyager, the FT Visual Vocabulary, and Datawrapper). The richer "intent-first" front door (ask _what do you want to show?_ and recommend a chart) is explicitly out of scope for now and noted there as a future tier.
## Opening
@@ -14,8 +14,8 @@ The Chart Builder is a visual, no-JSON way to compose a Vega-Lite chart from a s
A two-pane modal:
-- **Left — configuration:** dataset name, a **Data** section (row filters, calculated fields, and a collapsible row preview — see _Data_ below), mark type selector, one row per encoding channel, optional width/height inputs, and a "Create Snippet" action.
-- **Right — live preview:** a rendered chart that updates as the configuration changes, with a placeholder/error area.
+- **Left — configuration:** dataset name, a **Data** section (row filters, calculated fields, and a collapsible row preview — see _Data_ below), mark type selector, a **field shelf** (the dataset's columns, field-first), a **Marks** card (the Colour and Size encodings), optional width/height inputs, and a "Create Snippet" action.
+- **Right — live preview:** the **Columns** (X) and **Rows** (Y) shelves stacked above a rendered chart that updates as the configuration changes, with a placeholder/error area. Position is a property of the chart, so its controls sit on the chart (Tableau's Columns/Rows metaphor).
## Data (preview, filters, calculated fields)
@@ -59,17 +59,19 @@ The section is ordered **input → shaping** so the distinction reads at a glanc
### Encoding channels
-- Exactly four channels are offered, in this order: **X, Y, Color, Size**.
-- For each channel the user:
- - Picks a dataset column from a dropdown of the dataset's detected columns (see _Datasets_ for column detection). A "None" option leaves the channel unmapped. Each column option shows a small type indicator alongside the column name.
- - Optionally overrides the channel's **field type**. The override appears only once a column is selected, and offers only the **types valid for that column** (Tier B valid-type locking) — a string/boolean column never offers Quantitative, and only a date column offers Temporal. Concretely: number → {Quantitative (default), Ordinal, Nominal}; date → {Temporal}; text → {Nominal (default), Ordinal}; boolean → {Nominal}. When a column admits only one valid type, no override control is shown.
-- When a column is chosen, its field type defaults from the dataset's inferred column type (numeric → Quantitative, date → Temporal, otherwise Nominal); the user may change it within the valid set above.
-- **Size discipline:** the **Size** channel accepts only Quantitative or Ordinal columns — size implies an ordered magnitude, so categorical (Nominal) and Temporal columns are not offered for Size (they remain available on X/Y/Color). A column that can't go on Size is shown disabled there with a brief reason.
-- The column dropdown also offers a field-less **"Count of records"** measure (Vega-Lite `count`) — a quantitative count of the rows, with no column.
-- Clearing a channel back to "None" leaves it out of the produced spec.
-- A **Swap X/Y** control exchanges the X and Y mappings (field and type) in one click, for quickly flipping the axes of the pre-populated default without re-selecting both columns.
+Exactly four channels are offered: **X, Y** (the positional axes, on the on-chart Columns/Rows shelves) and **Colour, Size** (the Marks card). Assignment is **field-first**.
-The field-type override is presented as a fixed **`N | O | Q | T`** segmented control (abbreviations with full-name tooltips, after _Datasets_' Nominal/Ordinal/Quantitative/Temporal), always showing all four with the column's invalid types **disabled** rather than hidden — so the control keeps one shape on every channel.
+- **The field shelf** lists the dataset's detected columns (see _Datasets_), each with a small type glyph, plus any calculated fields and a field-less **"Count of records"** measure (Vega-Lite `count`). Past a threshold of columns the shelf groups into **Dimensions** (categories/dates) and **Measures** (numerics); a small dataset stays a single flat list. A column already mapped somewhere is dimmed (it may still be placed on more than one channel).
+- **Assigning a field:** clicking a shelf field places it on the **armed** channel, else the first empty channel that accepts it (X→Y→Colour→Size). A channel slot is armed by clicking it (then a clicked field fills it); the armed slot shows a "pick a field" prompt. A field that no channel can take is a no-op.
+- **A mapped channel is a pill:** a leading type chip, the field (or "Count") label, and a remove (×). The type chip **is the field-type control** — activating it cycles the field's type within the set **valid for that column** and channel (Tier B valid-type locking): number → {Quantitative (default), Ordinal, Nominal}; date → {Temporal}; text → {Nominal (default), Ordinal}; boolean → {Nominal}. When only one type applies (e.g. a date), the chip is inert. A fresh mapping defaults its type from the inferred column type (numeric → Quantitative, date → Temporal, otherwise Nominal).
+- **Constant values (the Property model):** the **Colour** and **Size** channels may instead hold a **fixed constant** — a literal colour or size applied to every mark, emitted as Vega-Lite `{ value }` rather than a field binding. An empty Colour/Size slot offers an "or constant" affordance; the bound constant shows a colour picker (Colour) or a number (Size). X and Y stay field-only (a constant position is not useful). Switching a channel between a field and a constant is reversible, and the prior field type is preserved across the toggle.
+- **Size discipline:** the **Size** channel accepts only columns whose natural type is a magnitude (numeric) — size implies an ordered magnitude, so categories and dates are not placed on Size by assignment (they remain available on X/Y/Colour). A constant size is always allowed.
+- **Clearing** a channel (the pill's ×) leaves it out of the produced spec.
+- A **Swap X/Y** control, by the on-chart shelves, exchanges the X and Y bindings (field/constant and type) in one click — for quickly flipping the axes of the pre-populated default.
+
+### Faceting placeholder (reserved)
+
+Each on-chart shelf shows, beside its axis slot, a **non-interactive placeholder** for **faceting → small multiples** (a future capability). It only signals where row/column faceting will live; it does nothing yet.
### Transforms (per channel)
@@ -118,14 +120,14 @@ A clean configuration shows no hints.
## Validation
-- A chart requires **at least one** channel mapped to a column.
-- While no channel is mapped, the "Create Snippet" action is disabled and the preview shows the configuration prompt.
+- A chart requires **at least one** channel bound — a field, a count, or a constant value.
+- While nothing is bound, the "Create Snippet" action is disabled and the preview shows the configuration prompt.
## Output / Create
Selecting "Create Snippet" produces the final artifact:
-- Builds a complete Vega-Lite spec containing: the schema reference, a named data reference to the dataset, any top-level `transform` (calculated fields first, then row filters — see _Data_), the chosen mark (with tooltips enabled), the mapped encodings (each with its field and field type, plus any aggregate / bin / `timeUnit` transform), chart-level sort and stacking where set, and any explicit width/height.
+- Builds a complete Vega-Lite spec containing: the schema reference, a named data reference to the dataset, any top-level `transform` (calculated fields first, then row filters — see _Data_), the chosen mark (with tooltips enabled), the bound encodings (a field encoding carries its field and field type plus any aggregate / bin / `timeUnit` transform; a constant encoding is a `{ value }`), chart-level sort and stacking where set, and any explicit width/height.
- Channels left unmapped are omitted; if no encodings exist the spec omits the encoding block entirely (prevented by validation here).
- Creates a new snippet from that spec with an auto-generated descriptive name, adds it to the snippet library, and records that it was built from the dataset.
- Links the snippet to the dataset by recording the dataset reference, so the bidirectional snippet↔dataset relationship is established (see _Datasets_).
diff --git a/docs/ux-second-pass.md b/docs/ux-second-pass.md
new file mode 100644
index 0000000..2cdd794
--- /dev/null
+++ b/docs/ux-second-pass.md
@@ -0,0 +1,30 @@
+# UX second pass — batched council review
+
+A running parking lot of small UX / interaction decisions deferred for a **batched
+[`/council`](../.claude/skills/council/SKILL.md) review**, rather than gating each one the
+moment it surfaces. Append quirks here as they come up; resolve them together in one pass,
+record the resolution into the contract (`docs/architecture/09`+`10` and the relevant
+`docs/spec/`), then delete the row.
+
+## Open
+
+### Chart Builder — encoding type control: cycle vs. explicit pick
+
+- **Where:** `src/app/components/ChartBuilderModal.tsx` → `ChannelPill` (the `pillType` chip).
+- **What:** a mapped channel's field type is shown as a **cycling chip** — activating it steps
+ N → O → Q → T within the set valid for the column/channel. It is compact and matches the
+ approved field-first pill design, but a _cycling_ button gives a screen-reader / keyboard
+ user no way to jump straight to a type; they step through the cycle. The pre-2B control was
+ an explicit `N | O | Q | T` segmented radio (direct pick, APG radio pattern).
+- **Decision to make:** keep the cycle, restore the explicit segmented control, or a hybrid
+ (chip for a quick mouse cycle + an accessible radio/menu for direct selection).
+- **Lens:** WAI-ARIA APG (radio vs. toggle button), IBM Carbon, the project's keyboard-first
+ accessibility bar.
+
+### Chart Builder — empty Colour/Size "or constant" affordance
+
+- **Where:** `ChannelPill` / `ChannelSlot` — the constant-value entry on Colour/Size (2A).
+- **What:** an empty Colour/Size slot offers a small underlined "or constant" text button to
+ switch the channel from a field binding to a fixed `{ value }`. Confirm the copy, emphasis,
+ and discoverability read well alongside the field-assign affordance.
+- **Lens:** Carbon / GOV.UK content + control emphasis.
diff --git a/src/app/components/ChartBuilderModal.module.css b/src/app/components/ChartBuilderModal.module.css
index 51c6762..7eddaf4 100644
--- a/src/app/components/ChartBuilderModal.module.css
+++ b/src/app/components/ChartBuilderModal.module.css
@@ -390,18 +390,6 @@
color: var(--text-secondary);
}
-.channels {
- display: flex;
- flex-direction: column;
- gap: var(--space-3);
-}
-
-.channelsHeader {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
-}
-
.swap {
border: none;
background: transparent;
@@ -422,56 +410,13 @@
outline-offset: 1px;
}
-/* Each channel is a small block: a top row (label + column) and, when mapped, a
- controls row (N|O|Q|T type + contextual transforms). */
-.channel {
- display: flex;
- flex-direction: column;
- gap: var(--space-2);
- padding: var(--space-3);
- border: var(--border-width) solid var(--border);
- border-radius: var(--radius);
-}
-
-.channelTop {
- display: grid;
- grid-template-columns: 44px 1fr;
- align-items: center;
- gap: var(--space-2);
-}
-
+/* Channel name label (Marks card rows + the on-chart Columns/Rows shelves). */
.channelLabel {
font-size: 12px;
font-weight: 600;
color: var(--text);
}
-.channelControls {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: var(--space-2);
- padding-left: 52px;
-}
-
-.select {
- padding: var(--space-2) var(--space-3);
- border: var(--border-width) solid var(--border-strong);
- border-radius: var(--radius);
- background: var(--bg);
- color: var(--text);
- font: inherit;
- font-size: 13px;
- width: 100%;
-}
-
-/* The N|O|Q|T type control: monospace abbreviations so the four segments line up. */
-.typeSeg button {
- font-family: var(--font-mono);
- font-size: 11px;
- padding: var(--space-1) var(--space-2);
-}
-
.transform {
display: inline-flex;
align-items: center;
@@ -506,7 +451,6 @@
gap: var(--space-5);
}
-.select:focus-visible,
.mini:focus-visible,
.dimInput:focus-visible {
outline: 2px solid var(--focus);
@@ -672,9 +616,19 @@
/* ── Right: live preview ─────────────────────────────────────────────── */
+/* Right side = on-chart shelves stacked above the live preview (spec §06). */
+.previewSide {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ min-height: 0;
+ background: var(--bg);
+}
+
.previewPane {
display: flex;
flex-direction: column;
+ flex: 1;
padding: var(--space-5);
min-width: 0;
min-height: 0;
@@ -741,3 +695,334 @@
white-space: pre-wrap;
word-break: break-word;
}
+
+/* ── Encoding: field shelf + Tableau-style shelves/pills (spec §06 → Encoding) ─── */
+
+/* Field shelf — the dataset's columns as clickable, type-glyphed chips (field-first). */
+.fieldShelf {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.shelfGroupHead {
+ margin-top: var(--space-2);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-secondary);
+}
+
+.shelfList {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.shelfField {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ width: 100%;
+ padding: var(--space-2) var(--space-3);
+ border: var(--border-width) solid var(--border);
+ border-radius: var(--radius);
+ background: var(--bg);
+ color: var(--text);
+ font: inherit;
+ font-size: 13px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.shelfField:hover {
+ border-color: var(--accent);
+ background: var(--layer-01);
+}
+
+.shelfField:focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: 1px;
+}
+
+.shelfFieldUsed {
+ opacity: 0.55;
+}
+
+.shelfGlyph {
+ min-width: 22px;
+ padding: 1px var(--space-2);
+ border: var(--border-width) solid var(--border-strong);
+ background: var(--layer-01);
+ color: var(--text-secondary);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ text-align: center;
+}
+
+.shelfFieldName {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Marks card — the non-positional encodings (Colour, Size). */
+.marksCard {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-3);
+ border: var(--border-width) solid var(--border);
+ border-radius: var(--radius);
+}
+
+.marksRow {
+ display: grid;
+ grid-template-columns: 44px 1fr;
+ align-items: start;
+ gap: var(--space-2);
+}
+
+/* Pill — a bound channel (field/count or constant). */
+.pillWrap {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ min-width: 0;
+}
+
+.pill {
+ display: inline-flex;
+ align-items: center;
+ max-width: 100%;
+ background: var(--accent);
+ color: var(--accent-contrast);
+ border-radius: var(--radius);
+}
+
+.pillConst {
+ background: var(--layer-02);
+ color: var(--text);
+}
+
+.pillType {
+ padding: var(--space-2);
+ border: none;
+ background: color-mix(in srgb, #000 18%, var(--accent));
+ color: var(--accent-contrast);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.pillType:hover:not(:disabled) {
+ background: color-mix(in srgb, #000 30%, var(--accent));
+}
+
+.pillType:disabled {
+ cursor: default;
+ opacity: 0.85;
+}
+
+.pillType:focus-visible {
+ outline: 2px solid var(--accent-contrast);
+ outline-offset: -2px;
+}
+
+.pillTag {
+ padding: var(--space-2);
+ color: var(--text-secondary);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.pillName {
+ min-width: 0;
+ padding: var(--space-2) var(--space-3);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12px;
+}
+
+.pillRemove {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-2);
+ border: none;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+}
+
+.pillRemove:hover {
+ background: color-mix(in srgb, var(--text) 14%, transparent);
+}
+
+.pillRemove:focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: -2px;
+}
+
+.constColor {
+ width: 28px;
+ height: 22px;
+ margin: 0 var(--space-2);
+ padding: 0;
+ border: var(--border-width) solid var(--border-strong);
+ background: var(--bg);
+ cursor: pointer;
+}
+
+.constNumber {
+ width: 64px;
+ margin: 0 var(--space-2);
+ padding: var(--space-1) var(--space-2);
+ border: var(--border-width) solid var(--border-strong);
+ border-radius: var(--radius);
+ background: var(--bg);
+ color: var(--text);
+ font: inherit;
+ font-size: 12px;
+}
+
+.pillControls {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+/* Empty assign target — armed by click, then filled by a clicked field. */
+.slot {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-height: 32px;
+ padding: var(--space-1) var(--space-2);
+ border: var(--border-width) dashed var(--border-strong);
+ background: var(--layer-01);
+}
+
+.slotActive {
+ border-style: solid;
+ border-color: var(--accent);
+ box-shadow: inset 0 0 0 1px var(--accent);
+}
+
+.slotAssign {
+ flex: 1;
+ min-width: 0;
+ padding: var(--space-1);
+ border: none;
+ background: transparent;
+ color: var(--text-placeholder);
+ font: inherit;
+ font-size: 12px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.slotAssign:hover {
+ color: var(--text-secondary);
+}
+
+.slotAssign:focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: 1px;
+}
+
+.slotConst {
+ padding: var(--space-1);
+ border: none;
+ background: none;
+ color: var(--accent);
+ font: inherit;
+ font-size: 11px;
+ white-space: nowrap;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+.slotConst:hover {
+ color: var(--accent-hover);
+}
+
+.slotConst:focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: 1px;
+}
+
+/* On-chart Columns / Rows shelves (X/Y), stacked above the preview. */
+.shelves {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-5) var(--space-5) 0;
+}
+
+.shelvesHead {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+}
+
+.shelfStrip {
+ display: flex;
+ align-items: stretch;
+ min-height: 42px;
+ border: var(--border-width) solid var(--border);
+}
+
+.shelfName {
+ flex: 0 0 84px;
+ display: flex;
+ align-items: center;
+ padding: 0 var(--space-3);
+ border-right: var(--border-width) solid var(--border);
+ background: var(--layer-01);
+ color: var(--text-secondary);
+ font-size: 12px;
+}
+
+.shelfSlots {
+ flex: 1;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-3);
+ min-width: 0;
+ padding: var(--space-2) var(--space-3);
+}
+
+.shelfSlots .slot,
+.shelfSlots .pillWrap {
+ min-width: 190px;
+}
+
+/* Reserved faceting placeholder (Phase 4) — non-interactive, signals where it'll live. */
+.facetSlot {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 150px;
+ min-height: 32px;
+ padding: var(--space-1) var(--space-3);
+ border: var(--border-width) dashed var(--border);
+ color: var(--text-placeholder);
+ font-size: 12px;
+ user-select: none;
+}
+
+.facetTag {
+ padding: 0 var(--space-2);
+ border: var(--border-width) solid var(--border);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
diff --git a/src/app/components/ChartBuilderModal.test.tsx b/src/app/components/ChartBuilderModal.test.tsx
index 6f218ec..b6cda59 100644
--- a/src/app/components/ChartBuilderModal.test.tsx
+++ b/src/app/components/ChartBuilderModal.test.tsx
@@ -296,4 +296,74 @@ describe('ChartBuilderModal', () => {
expect(refLink()).toBeDefined();
expect(refLink()!.getAttribute('href')).toContain('vega.github.io');
});
+
+ test('clicking a field in the shelf assigns it to a channel as a pill (field-first, 2B)', async () => {
+ const ds = createDataset({
+ name: 'Shop',
+ data: [{ region: 'E', sales: 5 }],
+ format: 'json',
+ source: 'inline',
+ now: T,
+ });
+ useDatasetStore.getState().add(ds);
+ const id = useDatasetStore.getState().datasets[0].id;
+ const store = useChartBuilderStore.getState();
+ store.init(id);
+ // Clear the smart-default axes so the click lands on the first empty channel (X).
+ store.setChannelColumn('x', null);
+ store.setChannelColumn('y', null);
+
+ await act(async () => {
+ root.render();
+ await Promise.resolve();
+ });
+
+ const fieldButton = Array.from(container.querySelectorAll('button')).find((b) =>
+ b.textContent?.includes('region'),
+ );
+ expect(fieldButton).toBeDefined();
+
+ await act(async () => {
+ fieldButton!.click();
+ await Promise.resolve();
+ });
+
+ expect(useChartBuilderStore.getState().config.encodings.x).toEqual({
+ field: 'region',
+ type: 'nominal',
+ });
+ });
+
+ test('Colour can be switched to a constant value (the Property model, 2A/2B)', async () => {
+ const ds = createDataset({
+ name: 'Shop',
+ data: [{ region: 'E', sales: 5 }],
+ format: 'json',
+ source: 'inline',
+ now: T,
+ });
+ useDatasetStore.getState().add(ds);
+ const id = useDatasetStore.getState().datasets[0].id;
+ useChartBuilderStore.getState().init(id);
+
+ await act(async () => {
+ root.render();
+ await Promise.resolve();
+ });
+
+ // The empty Colour slot offers an "or constant" affordance (Colour is first in Marks).
+ const constButton = Array.from(container.querySelectorAll('button')).find(
+ (b) => b.textContent === 'or constant',
+ );
+ expect(constButton).toBeDefined();
+
+ await act(async () => {
+ constButton!.click();
+ await Promise.resolve();
+ });
+
+ expect(useChartBuilderStore.getState().config.encodings.color?.value).toBeDefined();
+ // A colour picker renders for the constant.
+ expect(container.querySelector('input[type="color"]')).not.toBeNull();
+ });
});
diff --git a/src/app/components/ChartBuilderModal.tsx b/src/app/components/ChartBuilderModal.tsx
index 8312ee3..9df9c54 100644
--- a/src/app/components/ChartBuilderModal.tsx
+++ b/src/app/components/ChartBuilderModal.tsx
@@ -19,15 +19,17 @@ import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed';
import {
CHANNELS,
- FIELD_TYPES,
MARK_TYPES,
TIME_UNITS,
builderWarnings,
+ channelAcceptsValue,
+ defaultChannelValue,
defaultFieldType,
effectiveColumns,
filterOpArity,
isBuilderConfigValid,
isChannelTypeAllowed,
+ isValueMapping,
supportsAggregate,
supportsBin,
supportsSort,
@@ -114,8 +116,7 @@ const CHANNEL_LABELS: Record = {
size: 'Size',
};
-/** The fixed N | O | Q | T field-type segments (terse, with full-name tooltips). */
-const TYPE_ORDER: readonly FieldType[] = ['nominal', 'ordinal', 'quantitative', 'temporal'];
+/** Terse N | O | Q | T abbreviations for a field type (with full-name tooltips). */
const TYPE_ABBR: Record = {
nominal: 'N',
ordinal: 'O',
@@ -205,86 +206,126 @@ function typeBadge(type: ColumnType): string {
}
}
-/** Whether a column may be placed on a channel at all (Size discipline, §06). */
-function columnAllowedOnChannel(channel: ChannelName, colType: ColumnType): boolean {
- return isChannelTypeAllowed(channel, defaultFieldType(colType));
-}
-
/** True when the mapping is the field-less Count-of-records measure. */
function isCount(mapping: ChannelMapping | null): boolean {
return !!mapping && mapping.aggregate === 'count' && mapping.field === undefined;
}
-function ChannelBlock({ channel }: { channel: ChannelName }) {
- const baseColumns = useChartBuilderStore((s) => s.columns);
- const calculates = useChartBuilderStore((s) => s.config.calculates);
- // Effective columns = the dataset's columns plus any calculated fields, so a derived
- // field is selectable on a channel like any real column.
- const columns = useMemo(
- () => effectiveColumns(baseColumns, calculates),
- [baseColumns, calculates],
- );
- const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null);
+/** Past this many columns the field shelf splits into Dimensions / Measures groups;
+ * below it (and unless both groups are non-empty) it stays a single flat list. */
+const FIELD_SHELF_SPLIT_MIN = 7;
+
+/** The field types a mapping may cycle through on its channel (its column's valid
+ * types, narrowed by the channel — e.g. Size keeps only the measure types). */
+function channelTypeOptions(
+ channel: ChannelName,
+ mapping: ChannelMapping,
+ columns: BuilderColumns,
+): FieldType[] {
+ if (mapping.field === undefined) return [];
+ const colType = columns.columnTypes.find((c) => c.name === mapping.field)?.type ?? 'string';
+ return validFieldTypes(colType).filter((t) => isChannelTypeAllowed(channel, t));
+}
+
+/**
+ * A bound channel rendered as a Tableau-style **pill**: a leading type chip, the field
+ * (or "Count") label, and a remove (×). The type chip *is the control* — clicking it
+ * cycles the field's type within the set valid for this channel (disabled when only one
+ * type applies, e.g. a date). Any applicable transforms (aggregate / bin / granularity)
+ * sit in a compact row beneath the pill. A **constant** binding (Colour/Size only)
+ * shows the value editor instead — a colour picker or a number — emitting `{ value }`.
+ */
+function ChannelPill({
+ channel,
+ mapping,
+ columns,
+}: {
+ channel: ChannelName;
+ mapping: ChannelMapping;
+ columns: BuilderColumns;
+}) {
const setChannelColumn = useChartBuilderStore((s) => s.setChannelColumn);
const setChannelType = useChartBuilderStore((s) => s.setChannelType);
const setChannelAggregate = useChartBuilderStore((s) => s.setChannelAggregate);
const setChannelBin = useChartBuilderStore((s) => s.setChannelBin);
const setChannelTimeUnit = useChartBuilderStore((s) => s.setChannelTimeUnit);
+ const setChannelConstant = useChartBuilderStore((s) => s.setChannelConstant);
- const colTypeOf = (name: string): ColumnType =>
- columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
+ const clearLabel = `Remove ${CHANNEL_LABELS[channel]}`;
+ const clear = () => setChannelColumn(channel, null);
- // The fixed N|O|Q|T control: a column's invalid types and types disallowed on this
- // channel (e.g. a category on Size) are disabled, never hidden, so the control keeps
- // one shape on every channel (APG radio with disabled options).
- const typeSegments: ReadonlyArray> = useMemo(() => {
- const valid = mapping?.field !== undefined ? validFieldTypes(colTypeOf(mapping.field)) : [];
- return TYPE_ORDER.filter((t) => FIELD_TYPES.includes(t)).map((t) => ({
- value: t,
- label: TYPE_ABBR[t],
- title: titleCase(t),
- disabled: !(valid.includes(t) && isChannelTypeAllowed(channel, t)),
- }));
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [channel, mapping?.field, columns]);
+ // A constant value (the Property model) — Colour or Size only.
+ if (isValueMapping(mapping)) {
+ const isColor = channel === 'color';
+ return (
+