kit · power bi

Power BI setup

Report themes, PBIR fragments, a drop-in page shell, and the DAX module behind the in-report light/dark toggle. For styling an individual chart, see the Power BI tab on that chart's page under Visualizations.

TUX for Power BI

Drop-in report themes and PBIR fragments, generated from design/tokens.json. A Power BI report built with these renders the same system as a TUX web page — same palette, same type lanes, same chart foundations.

Everything here is generated and committed. Do not hand-edit: edit design/tokens.json, run npm run build:kit, commit both. tests/tux-kit-targets.test.ts fails CI if a committed file drifts from its generator.


What's in the box

kit/powerbi/
├── tti-theme.json              light  — import into Desktop or Fabric
├── tti-theme-dark.json         dark
├── tti-theme-hc.json           high contrast
└── pbir/
    ├── schema-lock.json        pinned $schema URLs + format versions
    ├── geometry.json           canvas, chrome bands, pills, z-order
    ├── tmdl/                   ThemeMode + TuxThemeColors (light/dark)
    ├── dax/                    same module for Desktop's UI
    ├── shell/                  drop-in page chrome, per canvas variant
    │   ├── classic/            15 visuals (10 shell + 5 pill templates)
    │   └── fluent2/
    └── fragments/
        ├── tti/                static colours, light
        ├── tti-dark/           static colours, dark
        ├── tti-hc/             static colours, high contrast
        └── themed/             measure-bound — follows an in-report toggle

Themes are the zero-tooling path: import one file, every native visual inherits the system. They carry dataColors, the structural colour set, sentiment and divergent ramps, textClasses, and visualStyles for 26 visual types.

PBIR fragments are JSON objects you splice into a visual.json. They cover the part that repeats on every visual and that a theme file cannot express.

FragmentSplice asApplies to
card-chrome.jsonvisualContainerObjectsevery content visual
table-chrome.jsonvisual.objectstableEx
chart-cartesian.jsonvisual.objectsbar / column / line / area / scatter

Card chrome goes on charts, tables, slicers and cards — not on shell shapes, textboxes or buttons, which carry their own.


Applying a theme

Three edits, and all three are required. A theme file dropped into StaticResources/ without the other two is silently ignored.

  1. File at <Report>/StaticResources/RegisteredResources/tti-theme.json
  2. report.jsonresourcePackages entry:
{
  "name": "RegisteredResources",
  "type": "RegisteredResources",
  "items": [
    { "name": "tti-theme.json", "path": "tti-theme.json", "type": "CustomTheme" }
  ]
}
  1. report.jsonthemeCollection.customTheme:
"customTheme": {
  "name": "tti-theme.json",
  "type": "RegisteredResources",
  "reportVersionAtImport": { "visual": "2.9.0", "page": "2.3.1", "report": "3.3.0" }
}

The theme name, the resource item name, the item path, and the on-disk filename must all match including the extension. A mismatch is one of the most common causes of a report that opens with no styling and no error.

reportVersionAtImport is an object, not a string. Report schema 3.0.0 changed it, and Microsoft's own published example still shows the old flat "5.53" form. Copying from the docs produces a report.json that fails validation. The current values are in pbir/schema-lock.json.

During PBIR preview, a new theme cannot be registered by file drop alone. report.json and definition.pbir are not externally editable, and RegisteredResources can only be edited for already-registered resources. Introduce the theme once through Desktop (View → Theme → Browse for themes), then subsequent updates are a file replace.


Light and dark

Power BI has no native dark mode for report content. Verified 2026-08-31 against Microsoft's own documentation: Desktop dark mode is application chrome only ("Dark mode is not applied to the Report view Canvas, including the Filter Pane and Wallpaper"), mobile explicitly excludes report content, and the service has none. The theme schema has no colorScheme key and is additionalProperties: false, so it cannot be extended.

That leaves two real options, and they do different jobs:

Static — ship tti-theme.json or tti-theme-dark.json and use the matching fragments/<theme>/. The report has one fixed appearance. No semantic-model dependency. Use this unless you need a viewer-facing toggle.

Themed — use fragments/themed/, whose colours bind to DAX measures instead of literals. A slicer on a disconnected ThemeMode table drives every bound colour, so a viewer flips the whole report at view time. This is the only mechanism that gives a live in-report toggle.

The model module that backs it is generated from the same tokens:

pbir/tmdl/ThemeMode.tmdl          the two-row mode table
pbir/tmdl/TuxThemeColors.tmdl     30 measures, one per colour role
pbir/dax/theme-tables.dax         the same thing for Desktop's UI

TMDL projects — copy both .tmdl files into <Model>.SemanticModel/definition/tables/ and add a ref table line for each in model.tmdl. A table file without its ref table entry is silently ignored, with no error.

Desktop — open dax/theme-tables.dax and follow the header: two New table expressions, then one New measure per role with TuxThemeColors as the home table.

Then put a slicer on ThemeMode[Mode], set it to tile mode, and sync it across pages.

Roles are grouped into display folders — Surface, Text, Accent, Table, Chart, Semantic, Shell, Tooltip. Two behaviours worth knowing, both of which fall out of the tokens rather than being special-cased:

  • Shell* roles are theme-invariant — the masthead stays maroon in dark mode, per the on-brand rule.
  • Tooltip* roles invert, because a tooltip wants the opposite surface from the page it sits on.

The measure names are a hard contract with the fragments. Renaming one breaks every report that has already spliced them in.

The measures return hex literals on purpose. Power BI's newer named theme colors feature — where a measure returns "background" instead of #FFFFFF — resolves against whichever theme is currently applied, and a report has exactly one. It makes a report theme-portable; it cannot drive a live toggle, because both branches of the IF collapse to the same colour. Do not refactor these measures onto it.

Two approaches that were tried and abandoned upstream, recorded so they aren't retried: bookmarks with duplicated per-mode pages (maintenance doubles with every page, slicer sync is fragile), and field parameters with GENERATESERIES (fine for two or three colours, doesn't scale to a full role set).

High contrast is not a toggle state — it's a separately applied theme. Use tti-theme-hc.json with the static fragments/tti-hc/ lane.


Geometry

pbir/geometry.json carries the report shell layout: chrome band heights, content area, nav pill stride, and the z-order scheme.

Two canvas variants are emitted because Fluent 2 (GA August 2026) moved the default canvas to 1920×1080 while existing reports stay at 1280×920:

VariantCanvasBase theme
classic1280 × 920CY26SU08
fluent21920 × 1080Fluent2-CY26SU08

Chrome bands are type-driven and do not scale with the canvas — only the content area does. Pick the variant matching your report's base theme.

The z-order scheme reserves 9000–15000 for content visuals. Everything outside that window is shell chrome. Keep content inside it and chrome never collides.


The report shell

pbir/shell/<canvas>/ holds ready-to-copy visuals — one folder per visual, matching PBIR's on-disk layout exactly:

cp -R kit/powerbi/pbir/shell/fluent2/* \
      MyReport.Report/definition/pages/<page>/visuals/

Ten of them are the page chrome, and they are identical on every page of a report — only nav_title_page, nav_title_context and nav_title_footer_support carry copy you'll want to change:

VisualTypeFollows the toggle?
shell_canvas_bgshapeyes — PageBackground
nav_btn_content_trayshapeyes — BorderColor @97%
nav_btn_footer_bgshapeno — brand
nav_btn_header_bgshapeno — brand
nav_btn_header_accent_ruleshapeno — brand
nav_title_footer_supporttextboxno — static on brand
nav_title_pagetextboxno — static on brand
shell_theme_slicerslicer— the control itself
nav_btn_nav_bgshapeyes — CardBackground @4%
nav_title_contexttextboxyes — titled, TextPrimary

Brand chrome deliberately does not respond to the toggle: the masthead stays maroon in dark mode. Anything sitting on that maroon (page title, footer label) is static white for the same reason — it is always readable there, so it needs no measure.

The remaining five visuals are pill templates, because the pill count is per-report. nav_pill_active* is positioned at index 0 and nav_pill_inactive* at index 1; replicate the inactive set per page and place pill n at:

x = pills.startX + n * pills.stride     (from geometry.json)

Each pill is three or four layered visuals, not one button. That looks redundant until you try the obvious thing: actionButton text rendering is unreliable in generated PBIR — labels vanish or misalign — and text.fontColor cannot take a measureRef at all. So chrome, label, and click target are separated:

LayerVisualWhy
shapenav_pill_*fill, radius, shadow
labelnav_pill_*_labelpredictable text rendering
hit targetnav_pill_inactive_hitinvisible actionButton, click only

The active pill needs no hit target — it is the current page.

Two calibrated offsets that look like typos and are not: the active label sits at pillY + 2 and the inactive at pillY + 8. Active uses paragraph text (internal top padding); inactive uses title text (renders at the top of its box). Without the different offsets they don't sit level.

nav_pill_inactive_hit still needs its page-navigation target. The invisible button is emitted with its chrome suppressed but no navigationSection binding — that value is the destination page's slug, which only exists once you have pages. Set it in Desktop, or via the injector when it lands.

Theme-responsive text

A textbox's paragraph textStyle.color is a plain string and cannot take a measureRef, so any label that must follow the toggle is rendered through the visual's title instead: paragraph text cleared to empty, copy in title.text, colour in title.fontColor. nav_title_context and nav_pill_inactive_label both use this.

Use textnot titleText, which is an embedded-JS-API property and fails PBIR schema validation outright.

Titled text needs more vertical room than paragraph text, or a bounding box appears around it. The calibrated minimums are 64px for 10pt content labels, 46px for nav labels, 55px for an 18pt page title.

The emitted themes pin baseTheme: "Fluent2-CY26SU08". A custom theme layers on top of a base theme and inherits anything it doesn't define, so leaving it unpinned means inheriting whatever the consuming report carries — and Fluent 2 vs Classic differ in padding, corner radius, canvas size and title defaults.


Deprecated visual types

schema-lock.json carries the migration map. Emitting the left-hand names produces reports that open today and break later:

Don't emitEmit
cardcardVisual
tabletableEx
matrixpivotTable
map, filledMapazureMap
multiRowCardcardVisual

Validating

npm run verify:pbir

re-checks every pinned schema URL against the live endpoint.

For structural validation of a whole report, use Microsoft's own offline validator rather than anything in this repo:

npx @microsoft/powerbi-report-authoring-cli validate <report-path>

Note that structural validity does not imply a correct render. Power BI silently ignores a property with the wrong literal type rather than rejecting it — 0L and 1D are different values, and the wrong one is a no-op, not an error. Open the report and look at it.


Parity with the web charts

Building the visualStyles emitter forced every axis, gridline and legend decision to become a literal, and those decisions are now the shared contract in design/chart-foundations.md §2. That is what makes "a Power BI report and a TUX page render the same system" a checkable claim rather than a slogan.

One deliberate divergence: web gridlines are --surface-border at 50% opacity, while this theme uses surface.border-subtle, because a theme's gridlineColor has no alpha channel. #EFEEED is within a hair of #E7E6E6 composited at 50% on white — same intent, two mechanisms.


Why the kit is split this way

Everything here is Tier 1 under the kit pipeline doctrine: every literal resolves from design/tokens.json through the same palette() the theme emitter uses, so a theme and its fragments cannot drift apart.

That's worth pausing on, because the shell looks like component-port work — geometry, layering, a nav system. But the geometry is derived and the colours are token-resolved, so the whole thing is a pure function of tokens.json. It gets deterministic emission and byte locks rather than the port-ledger treatment.

The theme/PBIR split isn't stylistic either. It falls directly out of the no-native-dark-mode constraint above: a theme file can carry colour, type and per-visual style, but nothing conditional. Chrome that responds to state has to live in the report's own PBIR JSON, bound to measures. Hence two halves.


Where this came from

Ported from the Power BI & Fabric documentation tree in docs-tti-tamu-edu — roughly 15,000 lines across 70 files, written against a real Tableau→Power BI migration that generated ~1,100 visuals. The port re-tokenised it from TAMU/AggieBI branding to TTI and re-pinned every schema version.

What was worth taking was the non-googleable part: that 0L and 1D are different values and the wrong one is silently ignored; that a slicer needs mode: 'Basic' and orientation: "1D" together to render as tiles; that syncGroup lives inside visual; that a nav pill must be three layered visuals because actionButton text rendering is unreliable and its fontColor can't take a measureRef at all; that paragraph text colour can't be measure-bound, so theme-responsive labels have to be rendered through the visual's title instead.

What was left behind: the PowerShell generator — the logic moved to a .mjs emitter, and Microsoft's own report-authoring guidance now recommends "a deterministic Node.js generator" — and everything data-platform-shaped (Graph ingestion, capacity sizing, semantic-model naming standards). That's a data-platform concern, not a design-system one.


Known gaps

  • Nothing here has been opened in Power BI Desktop. 30/30 shell visuals validate against the live visualContainer/2.9.0 schema with its five remote $refs resolved, and all three themes against reportThemeSchema-2.157 — but structural validity does not imply a correct render, precisely because Power BI ignores a wrong literal type rather than rejecting it. The smoke test needs Windows.
  • nav_pill_inactive_hit ships without its navigationSection binding. The value is a destination page slug that only exists once a report has pages, and the source docs never showed the property's JSON shape — so it's left to Desktop rather than invented.
  • No deep validation in CI. verify:pbir checks URL reachability only. Schema validation needs ajv, currently just a transitive dependency; wiring CI to an undeclared transitive dep is how builds break later.
  • Fragment coverage is partial. Three fragment types ship — card-chrome, chart-cartesian (bar / column / line / area / scatter), and table-chrome. Donut, gauge, heatmap, treemap and the rest have no fragment yet.
  • Not yet built: the report scaffold, a tux-pbir injector that mints conforming IDs and renumbers z-order on drop-in, and named style presets — the closest thing Power BI has to component variants.

Status

PBIR is still public preview as of 2026-08-31 — GA slipped past its Q3 2026 target. It is already the default format for new reports in the service (January 2026) and Desktop (March 2026), and Microsoft has stated it becomes the only supported format at GA, so building on it is the right bet. But schema versions moved roughly monthly before the current freeze; re-run npm run verify:pbir before each release.

Service-enforced limits worth knowing when generating at scale: 1,000 pages per report, 1,000 visuals per page, 1,000 resource files, 300 MB total. Windows MAX_PATH (260 chars) also bites, because PBIR nests a folder per visual.