Living reference for bruin-data/dac as we use it in this repo. Read this first before re-fetching upstream files. Append findings as new constraints / quirks are discovered.
DAC = "Dashboard-as-Code". Go CLI + embedded Vite/React frontend. You write
YAML (or .dashboard.tsx) plus .sql files, DAC validates/serves them, queries
go through bruin query so any Bruin-supported warehouse works (BigQuery here).
Brand brief from upstream CLAUDE.md: precise, confident, minimal. Linear /
Vercel / Hex aesthetic. Density over decoration. Dark mode is first-class.
Stack: React 19 + TS + Vite + Tailwind v4 + Recharts. Theme tokens live in
CSS custom properties prefixed --dac-*.
curl -fsSL https://raw.githubusercontent.com/bruin-data/dac/main/install.sh | bash
# in this repo, ~/.local/bin/dac is already on $PATH.bruin.yml discovery walks UP from --dir. The repo-root .bruin.yml
is the one DAC picks up — no need for a per-project copy. The connection
we use is bruin-playground-arsalan (Google Cloud Platform, ADC creds).
dac connections will show "✗" rows for the generic API-token connections
in .bruin.yml; those are noise — only the google_cloud_platform row
matters for the dashboard.
dac validate --dir . # schema + reference checks
dac check --dir . # validate + execute every query
dac query --dir . --dashboard NAME --widget NAME # debug single widget
dac serve --dir . --port 8321 # live-reload dev server (SSE)
dac connections # ping every connection
dac ls # list discovered dashboards--debug for verbose logs. --environment NAME switches .bruin.yml
environments. Cache invalidates on file change; queries cached 5 min.
polymarket-weather/dashboard-dac/
├── dashboards/
│ ├── polymarket-weather.yml
│ └── queries/
│ ├── spike_temps_apr06.sql
│ └── spike_temps_apr15.sql
└── semantic/ # unused (semantic layer not needed yet)
Top-level fields:
name(required),description,connection,model(semantic),theme,refresh: { interval: "5m" },filters: [],queries: {},rows: [].- 12-column grid. Each
widgets: []row hascol: 1..12per widget; sum should not exceed 12.
Widget query sources (mutually exclusive):
query: name(named inqueries:)sql: "..."inlinefile: queries/foo.sqlrelative to dashboardmetric: <semantic-metric>(needs amodel)- Direct semantic fields (
dimension,metrics,filters, etc.)
metric, chart, table, text, divider, image. From the v1 schema.
line, bar, area, pie, scatter, bubble, combo, histogram, boxplot, funnel, sankey, heatmap, calendar, sparkline, waterfall, xmr, dumbbell
alt, bins, chart, col, column, columns, connection, content, description, dimension, dimensions, file, filters, format, granularity, label, limit, lines, metric, metrics, model, name, prefix, query, segments, size, sort, source, sql, src, stacked, suffix, target, type, value, x, y, yMax, yMin
Notably absent: legend, tooltip, series, style, class, per-series
colour or stroke-width override.
These are the things that will bite you if you don't already know them.
In frontend/src/components/widgets/ChartWidget.tsx:
| chart type | <Legend /> rendered? |
|---|---|
line |
no |
bar |
only when stacked: true |
area |
no |
pie |
yes |
funnel |
yes |
combo |
yes |
calendar |
yes |
| heatmap, scatter, bubble, etc. | no |
If you need a legend on a multi-series chart, the cheapest workaround is
chart: combo.
It does NOT iterate both y and lines. It iterates y only, and for
each field:
- if the field is also in
lines→ render as<Line /> - else → render as
<Bar />
So to draw 7 lines on a combo chart, list all 7 in BOTH y and lines. To
mix bars and lines, list everything in y and put the line-fields in lines.
y cannot be empty (validator rejects y: [] for combo).
line and combo both instantiate exactly one <YAxis />. There is no per-
series yAxisId, no yLeft/yRight, no second <YAxis />. To overlay two
metrics with very different scales (e.g. temperature in °C vs price in
[0–1]), you scale one of them in SQL onto the same numeric range as the
other and call out the scaling in the chart's caption / footnote.
Example we use today: yes_price * 25 AS yes_price_x25 so a 0–1 implied
probability shares the 0–25 °C temperature axis.
formatAxisTick turns any string matching ^\d{4}-\d{2}-\d{2}T... into
"Apr 6" / "Apr 6 19"-style labels — the time of day is discarded. To
get hour-of-day labels, emit a non-ISO STRING in SQL:
FORMAT_TIMESTAMP('%H:%M', ts_local_paris) AS time_labeltime_label is opaque to the formatter and renders identity. Order is
preserved by the ORDER BY ts_local_paris in SQL.
DAC's y: ["Paris CDG (suspect)"] style with spaces / parens / dashes /
accents fails — bruin query rejects:
Invalid field name "Paris-CDG (suspect)". Fields must contain the allowed characters, and be at most 300 characters long.
Use snake_case (cdg_suspect, le_bourget, open_meteo_grid). The
chart legend (when combo) shows the column name verbatim — there is no
display-name override field in the widget schema. Pick names that look OK
unmangled.
Theme tokens (--dac-* CSS vars) cover background, surface, border, accent,
text, success/warning/error, and chart-1..chart-8 colours. Font sizes,
spacing, paddings are hardcoded Tailwind classes (e.g. the chart widget
title is text-[11px] in frontend/src/themes/bruin/WidgetFrame.tsx).
Themes can't change them.
TextWidget.tsx does <Markdown>{content}</Markdown> with no plugins. Raw
HTML is stripped. No rehype-raw. No GFM extensions. ### h3 is the
common heading; sizes baked into frontend/src/index.css:
.dac-prose { font-size: 13px; }
.dac-prose h1 { font-size: 1.5em; } /* 19.5 px */
.dac-prose h2 { font-size: 1.25em; } /* 16.25 px */
.dac-prose h3 { font-size: 1.1em; } /* 14.3 px */
.dac-prose h4 { font-size: 1em; } /* 13 px */
.dac-prose strong { font-weight: 600; }
.dac-prose code { font-family: "Geist Mono"; bg: var(--dac-surface-hover); }So the maximum heading size reachable from YAML is # h1 ≈ 19.5 px.
For anything larger you have to fork the frontend.
Every widget needs a name, even text widgets where you'd prefer no caption.
WidgetFrame.tsx renders the widget.name as a small uppercase tracking-wide
header above the chart for everything except text and divider. There's
no way to suppress it via YAML.
Playwright's wait_until="networkidle" will time out. Use
wait_until="domcontentloaded" plus a manual time.sleep(N) for queries
to settle.
Upstream DAC instantiates Recharts <XAxis> and <YAxis> without a label
prop and renders only one <YAxis> per chart type. Neither axis titles
nor true dual y-axis are reachable from YAML.
We maintain a fork at .context/dac-fork/ that adds three optional widget
fields for chart: line only and rebuilds the binary:
| YAML field | Type | Effect |
|---|---|---|
yLabel |
string | Renders an axis title on the LEFT y-axis. |
yRight |
string[] | Field names rendered on a SECOND y-axis on the RIGHT. Each gets its own dashed line styled colors[(y.length+i) % colors.length]. |
yRightLabel |
string | Renders an axis title on the RIGHT y-axis. |
seriesNames |
object | Map from data-column name → display label. Used as the name= prop on each <Line>, which is what the legend (and tooltip) shows. Keys with no entry fall through to the snake_case column name. |
hideName |
bool | Suppresses the small uppercase title strip rendered by WidgetFrame above the chart/table/metric. Useful when an adjacent text widget already serves as the section title and you want the chart frame to render just the data. Applies to any widget type. |
The line case also unconditionally renders a <Legend iconType="line" verticalAlign="bottom" /> (upstream rendered no legend at all). Chart height is bumped from 240 px to 280 px to give the legend ~30 px without squishing the data area.
Example:
- name: Temperature + price
type: chart
chart: line
x: time_label
y: [cdg, le_bourget, ...] # left axis (°C)
yLabel: Temperature (°C)
yRight: [yes_price] # right axis (0-1)
yRightLabel: Polymarket Yes-price (0–1)Build / install steps:
cd .context/dac-fork
cd frontend && npm ci --legacy-peer-deps && npm run build && cd ..
make build # produces ./bin/dac
cp bin/dac ~/.local/bin/dac
codesign --force --deep --sign - ~/.local/bin/dac # macOS GatekeeperFiles patched in the fork:
pkg/dashboard/model.go—YLabel/YRight/YRightLabelonWidget.pkg/dashboard/jsloader.go— same fields wired into the TSX prop loader.schemas/dac/dashboard/v1/schema.json— added the three properties.frontend/src/types/dashboard.ts— same fields on the TSWidgetinterface.frontend/src/components/widgets/ChartWidget.tsx— line case now renders<YAxis yAxisId="left">always plus<YAxis yAxisId="right" orientation="right">whenyRightis non-empty, and routes each<Line>through the rightyAxisId. Right-axis lines renderstrokeDasharray="4 2"to stay visually distinct from the left-axis lines.
Other chart types still have only one y-axis upstream-style. If we need
dual axis on combo or area, replicate the same change in those cases.
For non-line charts, fall back to the description-text-widget pattern from the upstream constraint:
- name: Foo header
type: text
col: 12
content: |
# Foo
**Y-axis:** temperature in °C.
**X-axis:** local hour-of-day (Europe/Paris, 00:00–23:00).document.scrollingElement.scrollHeight === window.innerHeight initially.
To scroll the dashboard in headless tests, find the largest descendant
with overflow-y: auto/scroll and call el.scrollBy(...) on that.
See VISUALIZATIONS.md § 2 for the mandatory per-chart 3-row structure
(header text → chart → footnote text) and the exact format of the
sources / tools / limitations footnote block.
- Project / dataset prefix:
bruin-playground-arsalan.polymarket_weather_* - Datasets:
polymarket_weather_raw,polymarket_weather_staging,polymarket_weather_report. - ADC is configured via
gcloud auth application-default login.
polymarket_weather_report.spike_evidence— hourly per-station readings on event days plus ±1 day of context (CDG flagged viais_cdg).polymarket_weather_staging.markets_enriched— market metadata (bucket_value_c,bucket_kind,series_slug,end_date).polymarket_weather_staging.market_resolutions— per-event resolved bucket includingwinning_bucket_observedandwinning_bucket_kind_observed(point/le/ge).polymarket_weather_staging.prices_enriched— tick-level Yes-side prices, joined tomarkets_enriched.market_id.
Append a new section (or extend the relevant one above) any time:
- A YAML field doesn't behave the way the schema implies.
- A chart type renders differently than expected.
- A theme token changes the wrong thing (or the right thing).
- An upstream commit changes a default we relied on.
Keep it terse: every paragraph here exists because we hit the wall it describes.