Files
astrolabe/src/app/components/ChartBuilderModal.module.css
T

1063 lines
23 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Chart Builder — two-pane modal body (spec §06). */
.builder {
display: grid;
grid-template-columns: minmax(320px, 360px) 1fr;
/* Fill the modal body and never exceed it, so each pane scrolls internally rather
than the whole modal growing past the viewport (otherwise a tall chart pushes the
Create/Cancel actions below the fold). The `minmax(0, 1fr)` row lets the panes
shrink below their content height so their own overflow kicks in. */
grid-template-rows: minmax(0, 1fr);
height: 100%;
min-height: 0;
min-width: 0;
}
/* ── No-dataset states: Carbon no-data empty state / dataset chooser ──── */
.emptyState {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
max-width: 48ch;
padding: var(--space-7) var(--space-6);
}
.emptyTitle {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.emptyBody {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: var(--text-secondary);
}
/* ── Left: configuration ─────────────────────────────────────────────── */
.configPane {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-5);
border-right: var(--border-width) solid var(--border);
overflow-y: auto;
min-width: 0;
min-height: 0;
}
/* The dataset picker pinned at the top of the config pane. */
.datasetRow {
display: flex;
align-items: center;
gap: var(--space-3);
}
/* Edit-in-place banner (spec §06 → Open in builder): names the snippet the builder
is editing, so the primary "Save changes" action reads unambiguously. */
.editingBanner {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.editingBanner strong {
color: var(--text-primary);
font-weight: 600;
}
/* ── Intent front door (spec §06 → Intent) ───────────────────────────────
A persistent "what do you want to show?" strip under the dataset picker. A
quiet accent-soft wash marks it as the guided on-ramp without competing with
the primary Create action (--accent stays reserved for primary actions). */
.intentStrip {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4);
border: var(--border-width) solid var(--border);
background: var(--accent-soft);
}
.intentQ {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.intentSub {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
}
.intentChips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-1);
}
.intentChip {
appearance: none;
height: var(--control-height);
border: var(--border-width) solid var(--border-strong);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 12px;
padding: 0 var(--space-3);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
box-shadow var(--dur-fast) var(--ease);
}
.intentChip:hover {
background: var(--field);
}
/* The chip whose layout the live chart matches (council-reserved: selection uses an
accent ring, never an accent fill — fill stays the primary-action signal). */
.intentChip[aria-pressed='true'] {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
/* Disabled = the dataset can't satisfy this intent: perceivable but inert (the chip's
accessible name carries the reason). */
.intentChip[aria-disabled='true'] {
color: var(--text-placeholder);
border-color: var(--border);
background: var(--bg);
cursor: not-allowed;
}
.intentChip:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
/* ── Data section: filters, calculated fields, row preview (spec §06 → Data) ──── */
.dataSection {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.transformGroup {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.transformGroupHead {
display: flex;
align-items: center;
justify-content: space-between;
}
/* "Add filter" / "Add field" — a low-emphasis ghost action, like Swap X/Y. */
.addRow {
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: transparent;
color: var(--accent);
font: inherit;
font-size: 12px;
cursor: pointer;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius);
}
.addRow:hover {
background: var(--layer-01);
}
.addRow:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
/* A filter / calculated-field row, bordered like a channel block for visual rhyme. */
.transformBlock {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
}
.transformBlockTop {
display: flex;
align-items: center;
gap: var(--space-2);
}
.filterPredicate {
display: flex;
align-items: center;
gap: var(--space-2);
}
.filterPredicate .mini {
flex: none;
}
.valueInput {
flex: 1;
min-width: 0;
height: var(--control-height);
padding: 0 var(--space-2);
font-size: 13px;
}
.rangeDash {
flex: none;
color: var(--text-placeholder);
}
/* Expression inputs (filter expression / calculate) — monospace, fill the row. */
.exprInput {
flex: 1;
min-width: 0;
height: var(--control-height);
padding: 0 var(--space-3);
font-family: var(--font-mono);
font-size: 12px;
}
.calcName {
flex: 0 1 110px;
min-width: 0;
height: var(--control-height);
padding: 0 var(--space-3);
font-family: var(--font-mono);
font-size: 12px;
}
.calcEquals {
flex: none;
color: var(--text-placeholder);
font-family: var(--font-mono);
}
.exprInput[aria-invalid='true'] {
border-color: var(--support-error);
}
/* Remove-row × is the shared IconButton (sm). */
/* Expression escape-hatch toggle — a low-emphasis link, like the hint fixes. */
.modeToggle {
align-self: flex-start;
border: none;
background: transparent;
color: var(--accent);
font: inherit;
font-size: 11px;
cursor: pointer;
padding: 2px var(--space-1);
border-radius: var(--radius);
}
.modeToggle:hover {
background: var(--layer-01);
}
.modeToggle:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.exprError,
.exprWarn {
display: flex;
align-items: flex-start;
gap: var(--space-2);
margin: 0;
font-size: 11px;
line-height: 1.4;
}
/* Severity reads from the glyph shape + colour, not colour alone (arch 10 §3); the
icon inherits the line's colour via currentColor (round error / triangle warning). */
.exprFeedbackIcon {
flex: none;
margin-top: 1px;
}
.exprError {
color: var(--support-error);
}
.exprWarn {
color: var(--support-warning-fg);
}
/* Contextual pointer to the Vega expression docs (shown when an expression exists). */
.exprHelp {
margin: 0;
font-size: 11px;
line-height: 1.4;
color: var(--text-secondary);
}
.exprHelpLink {
color: var(--accent);
text-decoration: none;
border-radius: var(--radius);
}
.exprHelpLink:hover {
text-decoration: underline;
}
.exprHelpLink:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
/* Row preview disclosure (1D). */
.dataPreview {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.previewToggle {
display: inline-flex;
align-items: center;
gap: var(--space-2);
align-self: flex-start;
border: none;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 12px;
cursor: pointer;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius);
}
.previewToggle:hover {
background: var(--layer-01);
}
.previewToggle:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.previewCaret {
font-size: 10px;
color: var(--text-placeholder);
}
.previewMeta {
color: var(--text-placeholder);
}
/* The per-column type chip in the source-preview header (the table structure and
cell styling now live in the shared DataTable). */
.previewColName {
font-weight: 600;
color: var(--text-secondary);
}
.previewColType {
color: var(--text-placeholder);
font-weight: 400;
}
.previewEmptyNote {
margin: 0;
font-size: 11px;
color: var(--text-secondary);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
/* The mark picker carries six segments — too many for one 320360px row. Override the
SegmentedControl's fixed single-row height so it wraps; each option keeps the control
height so the two rows read as even segments inside the shared border. */
.markPicker {
flex-wrap: wrap;
height: auto;
}
.markPickerOption {
min-height: var(--control-height);
}
.fieldLabel {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.swap {
border: none;
background: transparent;
color: var(--accent);
font: inherit;
font-size: 12px;
cursor: pointer;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius);
}
.swap:hover {
background: var(--layer-01);
}
.swap:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
/* Channel name label (Marks card rows + the on-chart Columns/Rows shelves). */
.channelLabel {
font-size: 12px;
font-weight: 600;
color: var(--text);
}
.transform {
display: inline-flex;
align-items: center;
gap: 6px;
}
.miniLabel {
font-size: 11px;
color: var(--text-placeholder);
}
.toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.chartControls {
display: flex;
gap: var(--space-5);
}
.dimInput {
width: 100px;
height: var(--control-height);
padding: 0 var(--space-3);
font-size: 13px;
}
.warnings {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin: 0;
padding: var(--space-3);
list-style: none;
background: var(--layer-01);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
}
/* These take focus only programmatically (tabIndex -1) after a hint fix is applied,
to keep focus off <body>; no visible ring for that script-driven move. */
.warnings:focus,
.configPane:focus {
outline: none;
}
.warning {
display: flex;
align-items: flex-start;
gap: var(--space-2);
font-size: 12px;
line-height: 1.4;
color: var(--text-secondary);
}
/* Filled status-warning glyph (arch 09 §5.2), in the contrast-safe amber. Replaces
the old ⚠ text character so the warning matches the toast status family. */
.warningIcon {
flex: none;
margin-top: 1px;
color: var(--support-warning-fg);
}
/* The hint text and its one-click remedies stacked, growing beside the icon. */
.warningBody {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
/* Actionable-hint remedies: each fix is an offer, never forced — low-emphasis
suggestions beside the advice, not commands. */
.warningFixes {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
/* Inline link-style actions (arch 09 §4): content-sized accent text, no box —
the smallness is the emphasis level. */
.warningFix {
font: inherit;
font-size: 12px;
cursor: pointer;
padding: var(--space-1) var(--space-2);
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--accent);
}
.warningFix:hover {
background: var(--control-hover-fill, var(--layer-01));
}
.warningFix:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
/* Explains the disabled Create action (contract 10: a disabled control must say
why). `margin-top: auto` pins it just above the actions so the two read as one. */
.createHint {
margin: auto 0 0;
font-size: 12px;
color: var(--text-secondary);
text-align: right;
}
/* Cancel / Create are shared Buttons (secondary / primary, lg — modal footer). */
.actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
margin-top: auto;
}
/* ── 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;
overflow: hidden;
background: var(--bg);
}
.previewHint {
margin: auto;
font-size: 13px;
color: var(--text-secondary);
text-align: center;
}
.previewFrame {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
/* A chart taller/wider than the pane scrolls *here*, inside a fixed viewport, so
the modal keeps its shape. `safe` centring aligns to the start instead of
clipping the top/left when the chart overflows. */
overflow: auto;
align-items: safe center;
justify-content: safe center;
}
.previewHost {
width: 100%;
}
.previewError {
margin: auto 0;
padding: var(--space-3);
}
.previewErrorHeadline {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--support-error);
}
.previewErrorDetails {
margin-top: var(--space-2);
}
.previewErrorSummary {
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
}
.previewErrorDetail {
margin: var(--space-2) 0 0;
padding: var(--space-3);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.4;
color: var(--text);
background: var(--layer-01);
border: var(--border-width) solid var(--border);
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);
}
/* Scroll viewport around the field lists: a wide dataset (Superstore: 25 columns)
must not push the Marks card and chart controls below the fold. Group headings
pin to the top while their list scrolls under them. */
.shelfScroll {
display: flex;
flex-direction: column;
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
happens (NN/g #1): an accent ring on the list plus the status line above it. */
.shelfArmed {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius);
}
.armedHint {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.armedHint strong {
color: var(--accent);
}
.shelfScroll .shelfGroupHead {
position: sticky;
top: 0;
z-index: 1;
margin-top: 0;
padding: var(--space-2) 0;
background: var(--layer-01);
}
.shelfList {
display: flex;
flex-direction: column;
}
/* 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-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 {
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);
/* A passive type tag, not a control — subtle border (borders mark function). */
border: var(--border-width) solid var(--border);
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). The per-channel transform
controls (Aggregate/Bin/Granularity) flow inline beside the pill, wrapping only
when the row truly runs out of room (a narrow window; the Marks card). */
.pillWrap {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
min-width: 0;
}
.pill {
display: inline-flex;
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);
border-radius: var(--radius);
}
.pillConst {
background: var(--layer-02);
color: var(--text);
}
.pillType {
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);
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[aria-expanded='true'] {
background: color-mix(in srgb, #000 30%, var(--accent));
}
.pillType:focus-visible {
outline: 2px solid var(--accent-contrast);
outline-offset: -2px;
}
.pillTag {
display: inline-flex;
align-items: center;
padding: 0 var(--space-2);
color: var(--text-secondary);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.pillName {
/* Grow into any pill slack so the remove ✕ stays at the pill's right edge. */
flex: 1;
min-width: 0;
align-self: center;
padding: 0 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: 0 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;
}
/* Sizing + chrome come from ColorField (size="sm"); only the pill margin is local. */
.constColor {
margin: 0 var(--space-2);
}
.constNumber {
width: 64px;
margin: 0 var(--space-2);
padding: var(--space-1) var(--space-2);
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: var(--control-height);
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;
}
/* Ghost button (Carbon: links navigate, buttons act — this one acts). */
.slotConst {
padding: var(--space-1) var(--space-2);
border: none;
border-radius: var(--radius);
background: none;
color: var(--accent);
font: inherit;
font-size: 11px;
white-space: nowrap;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.slotConst:hover {
background: var(--layer-02);
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;
}
/* Swap sits beside the heading — an action flushed to the far edge of a very wide
pane reads as unrelated to the shelves it acts on. */
.shelvesHead {
display: flex;
align-items: baseline;
gap: var(--space-4);
}
.shelfStrip {
display: flex;
align-items: stretch;
/* 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);
}
.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);
}
/* Empty slots keep a minimum presence; a filled pill hugs its content so the
remove ✕ sits at its right edge rather than adrift in dead pill background. */
.shelfSlots .slot {
min-width: 190px;
}
/* Chart properties — title/subtitle/size as a slim strip pinned under the preview
(chart-level properties live on the chart side; council 2026-06-12). */
.chartProps {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-5);
border-top: var(--border-width) solid var(--border);
}
.propField {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.propInput {
width: 200px;
height: var(--control-height);
padding: 0 var(--space-2);
font-size: 13px;
}
.propInput:disabled {
opacity: 0.5;
cursor: not-allowed;
}