From b8f0b97f22e21f6a176b4ebe044312076aa8fa68 Mon Sep 17 00:00:00 2001
From: Oleh Omelchenko
Date: Fri, 12 Jun 2026 23:13:26 +0300
Subject: [PATCH] Chart builder: equal shelf heights, pill floor, flat field
rows, scroll affordance
---
.../components/ChartBuilderModal.module.css | 147 ++++++++----------
src/app/components/ChartBuilderModal.tsx | 48 +++---
2 files changed, 87 insertions(+), 108 deletions(-)
diff --git a/src/app/components/ChartBuilderModal.module.css b/src/app/components/ChartBuilderModal.module.css
index ec8ef6d..d9bf3d5 100644
--- a/src/app/components/ChartBuilderModal.module.css
+++ b/src/app/components/ChartBuilderModal.module.css
@@ -131,7 +131,8 @@
.valueInput {
flex: 1;
min-width: 0;
- padding: var(--space-1) var(--space-2);
+ height: var(--control-height);
+ padding: 0 var(--space-2);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
@@ -149,7 +150,8 @@
.exprInput {
flex: 1;
min-width: 0;
- padding: var(--space-2) var(--space-3);
+ height: var(--control-height);
+ padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
@@ -161,7 +163,8 @@
.calcName {
flex: 0 1 110px;
min-width: 0;
- padding: var(--space-2) var(--space-3);
+ height: var(--control-height);
+ padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
@@ -187,30 +190,7 @@
border-color: var(--support-error);
}
-/* Remove-row × — icon-only, quiet until hovered. */
-.removeRow {
- flex: none;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- border: none;
- background: transparent;
- color: var(--text-placeholder);
- cursor: pointer;
- border-radius: var(--radius);
-}
-
-.removeRow:hover {
- background: var(--layer-01);
- color: var(--text);
-}
-
-.removeRow:focus-visible {
- outline: 2px solid var(--focus);
- outline-offset: 1px;
-}
+/* Remove-row × is the shared IconButton (sm). */
/* Expression escape-hatch toggle — a low-emphasis link, like the hint fixes. */
.modeToggle {
@@ -450,7 +430,8 @@
.dimInput {
width: 100px;
- padding: var(--space-2) var(--space-3);
+ height: var(--control-height);
+ padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
@@ -540,6 +521,7 @@
text-align: right;
}
+/* Cancel / Create are shared Buttons (secondary / primary, lg — modal footer). */
.actions {
display: flex;
justify-content: flex-end;
@@ -547,45 +529,6 @@
margin-top: auto;
}
-.action {
- height: 36px;
- padding: 0 var(--space-5);
- border: var(--border-width) solid var(--border-strong);
- border-radius: var(--radius);
- background: transparent;
- color: var(--text);
- font: inherit;
- font-weight: 500;
- cursor: pointer;
- transition: background var(--dur-fast) var(--ease);
-}
-
-.action:hover {
- background: var(--layer-01);
-}
-
-.action:focus-visible {
- outline: 2px solid var(--focus);
- outline-offset: 2px;
-}
-
-.primary {
- background: var(--accent);
- border-color: transparent;
- color: var(--accent-contrast);
- font-weight: 600;
-}
-
-.primary:hover {
- background: var(--accent-hover);
-}
-
-.primary:disabled {
- background: var(--layer-02, var(--layer-01));
- color: var(--text-placeholder);
- cursor: not-allowed;
-}
-
/* ── Right: live preview ─────────────────────────────────────────────── */
/* Right side = on-chart shelves stacked above the live preview (spec §06). */
@@ -694,6 +637,22 @@
gap: var(--space-2);
max-height: 280px;
overflow-y: auto;
+ /* Scroll shadows (background-attachment trick): a soft cue at the cut edge that
+ appears only while there is more list in that direction. The covers (first
+ two layers, attachment: local) ride with the content and hide the shadows at
+ the ends; rows are transparent so the shadows show through between them. */
+ background:
+ linear-gradient(var(--bg) 30%, transparent),
+ linear-gradient(transparent, var(--bg) 70%) 0 100%,
+ linear-gradient(rgb(0 0 0 / 0.1), transparent),
+ linear-gradient(transparent, rgb(0 0 0 / 0.1)) 0 100%;
+ background-repeat: no-repeat;
+ background-size:
+ 100% 24px,
+ 100% 24px,
+ 100% 8px,
+ 100% 8px;
+ background-attachment: local, local, scroll, scroll;
}
/* An armed channel makes the shelf the live target — say so where the next click
@@ -726,33 +685,39 @@
.shelfList {
display: flex;
flex-direction: column;
- gap: 2px;
}
+/* Field rows are FLAT — hairline dividers and a hover fill, not per-row boxes:
+ 25 outlined rectangles read as noise, and flat rows are denser, so more of a
+ wide dataset is visible before the shelf scrolls (arch 09 §4 — boxes are for
+ inputs and drop targets, not list rows). */
.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);
+ padding: var(--space-2) var(--space-2);
+ border: none;
+ background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
+ transition: background var(--dur-fast) var(--ease);
+}
+
+.shelfField + .shelfField {
+ border-top: var(--border-width) solid var(--border);
}
.shelfField:hover {
- border-color: var(--accent);
background: var(--layer-01);
}
.shelfField:focus-visible {
outline: 2px solid var(--focus);
- outline-offset: 1px;
+ outline-offset: -1px;
}
.shelfFieldUsed {
@@ -808,7 +773,12 @@
.pill {
display: inline-flex;
- align-items: center;
+ align-items: stretch;
+ /* One pill height, and a floor on width so "Count" and "Customer Name" read as
+ the same species; names stay content-sized above the floor (truncating to
+ equal widths would cost more than it buys). */
+ height: 24px;
+ min-width: 96px;
max-width: 100%;
background: var(--accent);
color: var(--accent-contrast);
@@ -821,7 +791,9 @@
}
.pillType {
- padding: var(--space-2);
+ display: inline-flex;
+ align-items: center;
+ padding: 0 var(--space-2);
border: none;
background: color-mix(in srgb, #000 18%, var(--accent));
color: var(--accent-contrast);
@@ -850,7 +822,9 @@
}
.pillTag {
- padding: var(--space-2);
+ display: inline-flex;
+ align-items: center;
+ padding: 0 var(--space-2);
color: var(--text-secondary);
font-size: 10px;
text-transform: uppercase;
@@ -861,7 +835,8 @@
/* Grow into any pill slack so the remove ✕ stays at the pill's right edge. */
flex: 1;
min-width: 0;
- padding: var(--space-2) var(--space-3);
+ align-self: center;
+ padding: 0 var(--space-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -872,7 +847,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
- padding: var(--space-2);
+ padding: 0 var(--space-2);
border: none;
background: transparent;
color: inherit;
@@ -922,7 +897,7 @@
display: flex;
align-items: center;
gap: var(--space-2);
- min-height: 32px;
+ min-height: var(--control-height);
padding: var(--space-1) var(--space-2);
border: var(--border-width) dashed var(--border-strong);
background: var(--layer-01);
@@ -999,7 +974,10 @@
.shelfStrip {
display: flex;
align-items: stretch;
- min-height: 42px;
+ /* One fixed minimum for BOTH strips — sized to the tallest occupant (a 32px
+ control + the slot padding), so Columns (which carries the Aggregate select)
+ and Rows stay the same height instead of each hugging its own content. */
+ min-height: 48px;
border: var(--border-width) solid var(--border);
}
@@ -1052,13 +1030,14 @@
.propInput {
width: 200px;
- padding: var(--space-1) var(--space-2);
+ height: var(--control-height);
+ padding: 0 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;
+ font-size: 13px;
}
.propInput:disabled {
@@ -1077,7 +1056,7 @@
align-items: center;
gap: var(--space-2);
min-width: 150px;
- min-height: 32px;
+ min-height: var(--control-height);
padding: var(--space-1) var(--space-3);
border: var(--border-width) dashed var(--border);
color: var(--text-placeholder);
diff --git a/src/app/components/ChartBuilderModal.tsx b/src/app/components/ChartBuilderModal.tsx
index d2b8be8..c02d106 100644
--- a/src/app/components/ChartBuilderModal.tsx
+++ b/src/app/components/ChartBuilderModal.tsx
@@ -71,6 +71,8 @@ import {
useChartBuilderStore,
} from '../stores/ChartBuilderStore';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
+import { Button } from './Button';
+import { IconButton } from './IconButton';
import { SelectControl } from './SelectControl';
import { Icon } from './Icon';
import styles from './ChartBuilderModal.module.css';
@@ -579,9 +581,11 @@ function FieldShelf() {
{split ? (
<>
- Dimensions
+ {/* The visible count says "there's more below the fold" — the grouped
+ shelf only appears for wide datasets, where the list scrolls. */}
+ Dimensions · {dimensions.length}
{dimensions.map(fieldButton)}
- Measures
+ Measures · {measures.length + 1}
{measures.map(fieldButton)}
{countButton}
@@ -618,6 +622,8 @@ function MarksCard() {
/** A reserved, non-interactive shelf slot for faceting (small multiples) — Phase 4.
* Shown so the layout telegraphs where row/column faceting will live. */
+// TODO: the "later" tag is internal roadmap language shown to users — hide the
+// affordance until faceting ships, or reword for users (docs/ux-second-pass.md).
function FacetSlot({ kind }: { kind: 'column' | 'row' }) {
return (
@@ -1303,16 +1303,16 @@ function NoDatasets() {
The Chart Builder turns a saved dataset into a chart — pick columns, watch the chart take
shape, and save it as a snippet. Add a dataset to start building.