# UI Styling

> Guide for the visual style, structure, and shared building blocks used by Semantic Link Labs interactive UI tools (HTML widgets and anywidget-based widgets). Use this when adding a new interactive UI, modifying an existing one, or adding shared visual components.

- Skill: `microsoft/ui-styling` (Agent Skill)
- Install (CLI): `npx skillmds@latest add microsoft/ui-styling`
- Raw SKILL.md: https://api.skillmd.com/api/skills/microsoft/ui-styling/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: Microsoft (https://skillmd.com/u/microsoft)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/microsoft/ui-styling

---


# UI Styling for Interactive Tools

This skill describes the styling conventions, shared building blocks, and architectural patterns used by all interactive UI tools in Semantic Link Labs so that every tool has a consistent, elegant design.

## When to Use This Skill

Use this skill when you need to:

- Add a **new interactive UI** function to the library.
- Modify an existing interactive UI (e.g. `vertipaq_analyzer`, `delta_analyzer`, `perspective_editor`).
- Add new **shared visual components** (icons, theme variables, headers, etc.) that should be reused across tools.
- Decide between a **static-HTML** widget and an **anywidget**-based widget.

---

## The Two UI Patterns

Semantic Link Labs has exactly two supported patterns for interactive UI tools. Always pick one based on whether the UI needs to call back into Python after the initial render.

| Pattern | When to use | Reference implementations |
|---------|-------------|---------------------------|
| **Static-HTML widget** (the *Vertipaq style*) | The UI is fully driven by the data computed in Python *before* render. All interactivity (filtering, sorting, tab switching, theme toggle, column resizing, etc.) is done in **pure browser-side JavaScript**. No Python code runs after `display(HTML(...))`. | `sempy_labs.semantic_model._vertipaq_analyzer.vertipaq_analyzer`, `sempy_labs._delta_analyzer.delta_analyzer` |
| **anywidget widget** (the *Perspective Editor style*) | The UI must run Python code in response to user actions (e.g. write back to a semantic model, refresh data from a REST API, perform long-running operations). State is synced between the JS frontend and the Python backend via `traitlets`. | `sempy_labs.semantic_model._perspective_editor.perspective_editor` |

> **Rule of thumb:** if the only thing the user does is view, sort, filter, or switch between pre-computed data, use the **static-HTML** pattern. If they can *change* something that must persist (model edits, refresh triggers, server calls), use **anywidget**.

---

## Shared Building Blocks: `sempy_labs._ui_components`

`src/sempy_labs/_ui_components.py` is the single source of truth for everything visual that should be consistent across tools. **Both patterns must source their visual primitives from this module.** If you need a new shared visual component (a new icon, a new helper, a new themed control), add it here so every tool can pick it up.

### What lives there today

| Export | Purpose |
|--------|---------|
| `ICONS` | Dict of monochrome SVG icons. All use `stroke="currentColor"` / `fill="currentColor"` so they adapt to light and dark themes automatically. Keys include tabular-object icons (`table`, `calculation_group`, `column`, `column_chunk`, `measure`, `hierarchy`, `calculation_item`, `partition`, `relationship`), tree/navigation icons (`caret_right`, `folder`, `level`), and UI/action icons (`sun`, `moon`, `search`, `plus`, `play`, `stop`, `refresh`, `swap`, `sort_asc`, `sort_desc`, `panel_collapse`, `panel_expand`, `builder`, `close`, `fullscreen`, `fullscreen_exit`). |
| `LIGHT_THEME_VARS`, `DARK_THEME_VARS` | CSS custom-property blocks defining the Apple-inspired light and dark palettes. Always reference colors via these `--ui-*` tokens, never hard-coded hex values. Includes semantic tokens for hover backgrounds (`--ui-bg-hover`), on-accent text (`--ui-on-accent`), and destructive/error states (`--ui-danger*`). |
| `SYNTAX_HIGHLIGHT_VARS` | Theme-independent `--ui-syntax-*` token block for colorizing DAX/code in an editor. Inject once into the widget's base scope (it is the same in light and dark). |
| `HEADER_CSS`, `scoped_header_css(root_selector)` | Standard widget header styles (title + dataset/workspace subtitle) **and the four standard header controls** (`.sl-theme-btn`, `.sl-change-btn`, `.sl-reload-btn`). `scoped_header_css` prefixes every rule with the root selector so the styles win against notebook host CSS (e.g. Jupyter's `.jp-RenderedHTMLCommon button`). Every tool must inject this, even when it builds its own header markup. |
| `render_header_html(title, dataset_name, workspace_name, theme_btn_id, dark_mode, fullscreen_btn_id)` | Renders the standard header markup. Pass `fullscreen_btn_id` to include a full-screen toggle button next to the theme toggle. |
| `theme_toggle_script(btn_id, root_selector, dark_class)` | Returns a `<script>` block that wires the theme toggle button to flip a `dark_class` on the root element and swap the sun/moon icon. |
| `fullscreen_css(root_selector, fullscreen_class, container_selector=None, bg_var)` | Returns the CSS for a widget's full-screen state (covers both the native `:fullscreen` pseudo-class and the `fullscreen_class` CSS-overlay fallback). Pass `container_selector` when an inner element carries the card styling; pass `bg_var` to match the widget's background token. |
| `fullscreen_toggle_script(btn_id, root_selector, fullscreen_class)` | Returns a `<script>` block that wires a full-screen toggle button for **static-HTML** widgets. Pair with `render_header_html(..., fullscreen_btn_id=...)` and `fullscreen_css`. |
| `fullscreen_setup_js(func_name="sllsSetupFullscreen")` | Returns a JS function definition that wires a full-screen toggle button for **anywidget** widgets. Embed once at the top of the ESM module, then call `func_name(root, btn, fullscreenClass, enterSvg, exitSvg)` after creating the button. |
| `display_html_widget(html, fallback=True)` | Renders a self-contained HTML string (styles + markup + inline `<script>`) via a lightweight anywidget so it lives in the notebook **webview's light DOM** instead of the sandboxed `srcdoc` iframe used for raw `display(HTML)`. Use this for static-HTML widgets so the full-screen toggle gets the native Fullscreen API (matching the anywidget tools). Falls back to `display(HTML(html))` when `anywidget` is unavailable. |
| `ATTRIBUTION_CSS`, `scoped_attribution_css(root_selector)`, `render_attribution_html(extra_links=None)` | "Powered by Semantic Link Labs" attribution shown at the bottom of every widget, with an optional list of extra `(label, url)` links. |
| `SEARCH_SELECT_CSS`, `SEARCH_SELECT_JS` | The standard **searchable single-select** picker (`createSearchSelect({ placeholder, searchPlaceholder, ariaLabel, emptyLabel, onChange })`). Every workspace / semantic model / item picker must use this control — never a plain `<select>` — so long lists can always be filtered by typing. Inject the CSS into the widget stylesheet and the JS into the widget's ESM module, then drive the returned controller with `setOptions(items, value)`, `setEmptyLabel(text)` and `setDisabled(flag)`. Reference implementations: `semantic_model._find_unused_objects`, `semantic_model._bpa`. |

### When to extend `_ui_components`

Add a new export to `_ui_components.py` whenever the same visual element appears (or *should* appear) in more than one tool. Typical candidates: a new icon, a shared button style, a status-pill style, a confirmation-dialog component, a toast/notification helper. Do **not** copy-paste CSS or SVGs between widgets — promote them to `_ui_components` instead.

---

## Standard Header Controls (mandatory)

Four controls recur in nearly every tool. They must look and behave identically
everywhere, so both their icon and their chrome come from `_ui_components` — a
tool never defines its own size, radius, border or icon for them. The
`sempy_labs.semantic_model.test` (DAX Perf Optimizer) widget is the reference
implementation.

| Control | Class | Icon | Footprint |
|---------|-------|------|-----------|
| Light/dark mode | `sl-theme-btn` | `ICONS["sun"]` / `ICONS["moon"]` | 32×32 circle, 18px icon |
| Full screen | `sl-theme-btn` | `ICONS["fullscreen"]` / `ICONS["fullscreen_exit"]` | 32×32 circle, 18px icon |
| Change model / workspace | `sl-change-btn` | `ICONS["swap"]` | 32×32, 8px radius, 18px icon |
| Reload (workspaces, models, lists) | `sl-reload-btn` | `ICONS["refresh"]` | 32×32 circle, 14px icon |

Rules:

- Inject `scoped_header_css(root_selector)` into the widget stylesheet, and use
  the class names above. Do **not** re-declare `.sl-theme-btn`,
  `.sl-change-btn` or `.sl-reload-btn` in a tool — `tests/test_ui_header_controls.py`
  fails if a tool restyles them.
- While a reload is in flight, add `sl-spinning` to the reload button; the
  shared CSS animates the icon.
- If the tool uses `--slls-*` (or other) palette names, alias the `--ui-*`
  tokens the shared CSS reads: `--ui-surface`, `--ui-surface-2`,
  `--ui-border-strong`, `--ui-text`, `--ui-text-secondary`,
  `--ui-text-tertiary`, `--ui-accent`, `--ui-accent-soft`.
- Tools rendering their header with `render_header_html` get these for free via
  `theme_btn_id`, `fullscreen_btn_id` and `picker_btn_id`. For an extra button
  that changes the model/workspace, pass `"base": "sl-change-btn"` in
  `extra_buttons`.
- Buttons that are *not* one of these four (e.g. expand/collapse, delete,
  tool-specific actions) keep their own tool-scoped classes.

### Workspace / semantic model pickers

Always resolve the workspace with `resolve_workspace_name_and_id(workspace)`
**even when the caller passed nothing**, and seed the picker with the result, so
the workspace dropdown opens pre-selected on the current workspace. Passing
`None` resolves to the attached lakehouse's workspace, or the notebook's.

---

## Design Tokens (the visual language)

All interactive UIs share one visual language. Stick to these tokens — do not introduce one-off colors, fonts, radii, or shadows.

### Typography

- Font stack: `-apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", Helvetica, Arial, sans-serif`.
- Enable font smoothing: `-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;`.
- Title (22px / 600 / `-0.01em` letter-spacing) and subtitle (≈12.5px / `--ui-text-secondary`) are produced by `render_header_html` — do not re-implement them.
- Use `font-variant-numeric: tabular-nums` for any numeric column or large numeric value.

### Color tokens (from `LIGHT_THEME_VARS` / `DARK_THEME_VARS`)

| Token | Use for |
|-------|---------|
| `--ui-bg`, `--ui-bg-secondary`, `--ui-bg-tertiary`, `--ui-bg-solid` | Surfaces, in increasing emphasis levels |
| `--ui-bg-hover` | Solid hover background for controls/buttons whose base is `--ui-bg` |
| `--ui-surface`, `--ui-surface-2` | Translucent overlays / hover backgrounds |
| `--ui-border`, `--ui-border-strong` | Subtle and strong borders |
| `--ui-text`, `--ui-text-secondary`, `--ui-text-tertiary` | Primary, secondary, tertiary text |
| `--ui-accent`, `--ui-accent-hover`, `--ui-accent-soft` | Brand accent (links, focus rings, active tab indicator, primary buttons, data bars) |
| `--ui-on-accent` | Text/icon color placed on top of an `--ui-accent` (or `--ui-danger`) fill — i.e. "white" |
| `--ui-danger`, `--ui-danger-hover` | Destructive button fill + hover (e.g. a Stop button) |
| `--ui-danger-bg`, `--ui-danger-border`, `--ui-danger-text` | Inline error/alert box background, border, and text (themed for light + dark) |
| `--ui-shadow-sm`, `--ui-shadow-md`, `--ui-shadow-lg` | Elevation |

Never hard-code `#fff`, red error colors, or any other literal — always go through a token. If a destructive action, error banner, or white-on-accent label is needed, use the danger / on-accent tokens above rather than inlining hex values.

### Code / DAX syntax-highlight palette (`SYNTAX_HIGHLIGHT_VARS`)

For colorizing DAX (or similar code) inside an editor/highlighter, inject the
theme-independent `SYNTAX_HIGHLIGHT_VARS` block once into the widget's **base**
scope (it intentionally renders the same in light and dark, so do not override
it in the dark block). Reference these `--ui-syntax-*` tokens — never the raw
hex values.

| Token | Token class it colors |
|-------|-----------------------|
| `--ui-syntax-keyword`, `--ui-syntax-function` | Keywords and function names |
| `--ui-syntax-variable` | Variables |
| `--ui-syntax-number` | Numeric literals |
| `--ui-syntax-virtual-column` | Virtual / measure-ref columns |
| `--ui-syntax-string` | String literals |
| `--ui-syntax-operator`, `--ui-syntax-punctuation` | Operators and punctuation |

See `sempy_labs.semantic_model._test_dax.test` for the reference usage (it
injects `SYNTAX_HIGHLIGHT_VARS` into its `.dtx` base block alongside
`LIGHT_THEME_VARS`).

If a tool needs its own derived names (e.g. `--vpx-text`), alias them to the `--ui-*` tokens inside the tool's scoped block — see `_vertipaq_analyzer.py` for the pattern. This keeps the palette centralized while letting tools use short, local names.

### Shape and motion

- Radii: `12px` (containers), `8px` (controls/cards) — exposed as `--vpx-radius` / `--vpx-radius-sm` style aliases when needed.
- Transitions: `0.25s cubic-bezier(0.4, 0, 0.2, 1)` for color/border/background; `120ms ease` for small button state changes; `80ms` for press scale.
- Subtle hover/active states only — no heavy animations.

---

## Anatomy of a Widget

Every interactive UI, regardless of pattern, should be composed of these regions in this order:

1. **Root container** with a per-instance unique class (e.g. `vpx-<uid>`) and an optional dark-mode modifier class (e.g. `vpx-dark`). The unique id (`uid = uuid.uuid4().hex[:8]`) keeps multiple widgets on the same page from colliding.
2. **Standard header** rendered via `render_header_html(...)` — title + dataset/workspace subtitle + theme toggle button.
3. **Summary cards** (optional) — a horizontal row of `(label, value)` cards summarizing the result set.
4. **Tab bar** (optional) — for multi-section views; active tab uses `--ui-accent` text + a 2px accent underline.
5. **Toolbar** (optional) — search box (using `ICONS["search"]`), row count, view toggles (e.g. data-bars on/off).
6. **Main content** — table, tree, form, etc.
7. **Attribution** rendered via `render_attribution_html(...)` at the bottom, with optional `extra_links` for upstream credit (e.g. SQLBI's Vertipaq Analyzer).

---

## Pattern A: Static-HTML Widget (Vertipaq / Delta Analyzer style)

Use this for **read-only / pre-computed** UIs. End-to-end recipe:

### 1. Compute data in Python, then render

Build the `pandas.DataFrame`(s) or dict of dataframes in Python. Pass them to a private `visualize_*` / `_render_*` helper that assembles HTML and JS strings and calls `IPython.display.display(HTML(...))`.

### 2. Generate a per-instance uid

```python
uid = uuid.uuid4().hex[:8]
root_selector = f".vpx-{uid}"            # used for scoping CSS + JS lookups
theme_btn_id = f"vpx-theme-{uid}"
```

Every CSS class, every DOM id, and every global JS function name **must** include `uid` so multiple instances on one notebook page never clash.

### 3. Import the shared building blocks

Pull from `sempy_labs._ui_components` — do not re-define icons, colors, headers, theme-toggle JS, or the attribution footer.

```python
from sempy_labs._ui_components import (
    ICONS as _UI_ICONS,
    LIGHT_THEME_VARS as _UI_LIGHT_VARS,
    DARK_THEME_VARS as _UI_DARK_VARS,
    scoped_header_css as _ui_scoped_header_css,
    scoped_attribution_css as _ui_scoped_attribution_css,
    render_header_html as _ui_render_header_html,
    render_attribution_html as _ui_render_attribution_html,
    theme_toggle_script as _ui_theme_toggle_script,
)
```

### 4. Scope all CSS under the root selector

Inline the light palette inside `.vpx-<uid> { ... }` and the dark palette inside `.vpx-<uid>.vpx-dark { ... }`, so dark mode is a class toggle on the root. Include `_ui_scoped_header_css(root_selector)` and `_ui_scoped_attribution_css(root_selector)` in your `<style>` block. Scoping is required so that high-specificity notebook host styles (Jupyter, VS Code, Fabric) do not override your widget.

### 5. Build the markup in order: header → cards → tabs → toolbar → content → attribution

Use `render_header_html(...)` for the header and `render_attribution_html(...)` for the footer. Re-use icons from `_UI_ICONS` (apply your own class via `.replace("<svg ", '<svg class="vpx-tab-icon" ', 1)` for sizing).

### 6. Wire all interactivity in inline JavaScript

Filter/sort/resize/tab-switch/bar-toggle logic lives in a `<script>` block whose function names are also uid-suffixed (e.g. `window.vpxSort_<uid>`). The theme toggle button is wired by appending `_ui_theme_toggle_script(btn_id=theme_btn_id, root_selector=root_selector, dark_class="vpx-dark")`.

### 7. Render

```python
display(HTML(styles + "\n".join(html_parts) + script + theme_script))
```

### Reference

- `src/sempy_labs/semantic_model/_vertipaq_analyzer.py` — `visualize_vertipaq` (full implementation: cards, tabs, toolbar, sortable/filterable/resizable table with optional data bars).
- `src/sempy_labs/_delta_analyzer.py` — same pattern applied to delta-table analysis output.

---

## Pattern B: anywidget Widget (Perspective Editor style)

Use this when the UI must call back into Python after render (e.g. to edit a semantic model, trigger a refresh, run an API call). The widget is implemented as a subclass of `anywidget.AnyWidget` with state synced via `traitlets`.

### 1. Guard the optional dependency

`anywidget` is **not** a hard dependency of the library — keep it that way. Import lazily inside the public function and raise a friendly `ImportError` if missing:

```python
try:
    import anywidget
    import traitlets
except ImportError as e:
    raise ImportError(
        "The '<my_tool>' function requires the 'anywidget' package. "
        "Install it with: pip install anywidget"
    ) from e
```

### 2. Subclass `anywidget.AnyWidget`

Define the widget with class attributes `_esm` (the JS module string — typically a top-level `function render({ model, el }) { ... }` block) and `_css` (the CSS string). Declare every piece of state that must cross the Python/JS boundary as a synced traitlet:

```python
class MyWidget(anywidget.AnyWidget):
    _esm = _WIDGET_JS
    _css = _WIDGET_CSS

    data = traitlets.Dict().tag(sync=True)
    selected = traitlets.Unicode("").tag(sync=True)
    status = traitlets.Dict().tag(sync=True)         # { "message": ..., "kind": "success"|"error" }
    pending_action = traitlets.Dict().tag(sync=True) # what JS asked Python to do
    run = traitlets.Int(0).tag(sync=True)            # bump to trigger the Python callback
    dataset_name = traitlets.Unicode("").tag(sync=True)
    workspace_name = traitlets.Unicode("").tag(sync=True)
    dark_mode = traitlets.Bool(False).tag(sync=True)
```

### 3. Use the "bump `run` + observe" callback pattern

JS triggers Python work by setting `pending_action` (a dict describing what to do) and then incrementing `run` and calling `model.save_changes()`. Python observes `run` and dispatches based on `pending_action["action"]`. On completion it writes results back to other traitlets (including a user-visible `status`) which the JS observer renders.

```python
def _on_run(change):
    data = dict(widget.pending_action or {})
    action = data.get("action")
    if not action:
        return
    try:
        ...  # do Python work, then update widget.status / other traitlets
    except Exception as e:
        widget.status = {"message": f"Error: {e}", "kind": "error"}

widget.observe(_on_run, names=["run"])
```

### 4. Display once, keep the reference alive

After `display(widget)`, keep the local `widget` reference inside the closure (Python's GC must not collect it, or observers stop firing). **Do not also `return widget`** — that causes Jupyter to render it a second time.

### 5. Visual conventions on the JS side

The frontend `render({ model, el })` function should:

- Create a root `<div>` with a stable namespace class (e.g. `slls-pe` for the perspective editor). Add `slls-pe-dark` when `dark_mode === true` and `slls-pe-auto` (which uses `@media (prefers-color-scheme: dark)`) when `dark_mode` is null/undefined.
- Build the header with the same title + dataset/workspace subtitle + sun/moon theme-toggle button shape used by the static widgets. The theme button toggles the `dark_mode` traitlet (`model.set("dark_mode", ...); model.save_changes();`) so the preference round-trips to Python.
- Use the **same color/typography/radius tokens** as `_ui_components` (light + dark palettes, Apple font stack, 12px / 8px radii). When you need an icon also used elsewhere, embed the SVG from `ICONS` (e.g. via a Python-side template-substitution placeholder like `__SLLS_ICON_TABLE__`) so there is one source of truth.
- Always render the "Powered by Semantic Link Labs" attribution at the bottom, matching `render_attribution_html`.

> If you need a new visual primitive in an anywidget tool (a new icon, a new button style, a new theme token), add it to `_ui_components` and substitute it into the `_WIDGET_JS`/`_WIDGET_CSS` strings — do not fork the design.

### Reference

- `src/sempy_labs/semantic_model/_perspective_editor.py` — `perspective_editor`. Full implementation showing widget class definition, traitlets, the `pending_action` + `run` callback pattern, dark-mode round-trip, and lazy-import guard.
- `src/sempy_labs/semantic_model/_direct_lake_manager.py` — `direct_lake_manager`. Multi-screen anywidget with model-selection / model-management screens, popover menus, modals, pending-change tracking, and a save bar. Demonstrates icon-template-substitution from `_ui_components.ICONS`.

### 6. Icon template-substitution recipe (anywidget)

Because `_WIDGET_JS` is a raw string passed to anywidget's `_esm`, you cannot directly call Python at JS render time. To keep icons centralized in `_ui_components.ICONS`, use **placeholder substitution at module-import time**:

1. In `_WIDGET_JS`, refer to icons through uppercase placeholders, e.g.:

    ```javascript
    const SUN_SVG = `__SLLS_ICON_SUN__`;
    const ICON_SVG = {
        table: `__SLLS_ICON_TABLE__`,
        column: `__SLLS_ICON_COLUMN__`,
        // ...
    };
    ```

2. Immediately after `_WIDGET_JS = r"""...""""`, substitute each placeholder from `ICONS`:

    ```python
    from sempy_labs._ui_components import ICONS as _UI_ICONS

    _WIDGET_JS = (
        _WIDGET_JS
        .replace("__SLLS_ICON_SUN__", _UI_ICONS["sun"])
        .replace("__SLLS_ICON_MOON__", _UI_ICONS["moon"])
        .replace("__SLLS_ICON_TABLE__", _UI_ICONS["table"])
        .replace("__SLLS_ICON_COLUMN__", _UI_ICONS["column"])
        # ...one .replace per icon used
    )
    ```

Do **not** inline raw SVG strings inside `_WIDGET_JS`. If you need an icon that isn't in `ICONS` yet, add it to `_ui_components.ICONS` first, then substitute it in.

### 7. Minimal anywidget template

```python
from typing import Optional
from uuid import UUID
from sempy._utils._log import log

_WIDGET_CSS = """
.my-widget { /* root container styles, using --ui-* tokens */ }
.my-widget.my-widget-dark { /* DARK_THEME_VARS-equivalent overrides */ }
"""

_WIDGET_JS = r"""
function render({ model, el }) {
    const root = document.createElement("div");
    root.className = "my-widget";

    function applyTheme() {
        root.classList.remove("my-widget-dark", "my-widget-auto");
        const dm = model.get("dark_mode");
        if (dm === true) root.classList.add("my-widget-dark");
        else if (dm == null) root.classList.add("my-widget-auto");
    }
    applyTheme();
    model.on("change:dark_mode", applyTheme);
    el.appendChild(root);

    const SUN = `__SLLS_ICON_SUN__`;
    const MOON = `__SLLS_ICON_MOON__`;

    // ... build header, body, attribution ...

    // Trigger a Python action:
    function runAction(payload) {
        model.set("pending_action", payload);
        model.set("run", model.get("run") + 1);
        model.save_changes();
    }
}
export default { render };
"""

from sempy_labs._ui_components import ICONS as _UI_ICONS  # noqa: E402

_WIDGET_JS = (
    _WIDGET_JS
    .replace("__SLLS_ICON_SUN__", _UI_ICONS["sun"])
    .replace("__SLLS_ICON_MOON__", _UI_ICONS["moon"])
)


@log
def my_widget_function(
    dataset: str | UUID,
    workspace: Optional[str | UUID] = None,
    dark_mode: bool = False,
):
    """One-line description.

    Parameters
    ----------
    dataset : str | uuid.UUID
        ...
    workspace : str | uuid.UUID, default=None
        The Fabric workspace name or ID. Defaults to the attached lakehouse
        workspace or the notebook workspace.
    dark_mode : bool, default=False
        If True, renders with a dark color theme.
    """
    try:
        import anywidget
        import traitlets
    except ImportError as e:
        raise ImportError(
            "The 'my_widget_function' function requires the 'anywidget' "
            "package. Install it with: pip install anywidget"
        ) from e

    from IPython.display import display
    from sempy_labs._helper_functions import (
        resolve_workspace_name_and_id,
        resolve_dataset_name_and_id,
    )

    ws_name, ws_id = resolve_workspace_name_and_id(workspace)
    ds_name, ds_id = resolve_dataset_name_and_id(dataset, ws_id)

    class _Widget(anywidget.AnyWidget):
        _esm = _WIDGET_JS
        _css = _WIDGET_CSS
        dataset_name = traitlets.Unicode("").tag(sync=True)
        workspace_name = traitlets.Unicode("").tag(sync=True)
        dark_mode = traitlets.Bool(False).tag(sync=True)
        status = traitlets.Dict().tag(sync=True)
        pending_action = traitlets.Dict().tag(sync=True)
        run = traitlets.Int(0).tag(sync=True)

    widget = _Widget(
        dataset_name=ds_name,
        workspace_name=ws_name or "",
        dark_mode=bool(dark_mode),
    )

    def _on_run(_change):
        action = (widget.pending_action or {}).get("action")
        if not action:
            return
        try:
            # ... dispatch on action, mutate traitlets ...
            widget.status = {"message": "Done.", "kind": "success"}
        except Exception as e:
            widget.status = {"message": f"Error: {e}", "kind": "error"}

    widget.observe(_on_run, names=["run"])
    display(widget)  # do NOT return widget
```

---

## The Full-Screen Toggle

Every interactive widget should offer a **full-screen button** in its header
(next to the theme toggle) that expands the tool to fill the screen and toggles
back. The behavior is centralized in `sempy_labs._ui_components` so all widgets
stay in sync — never re-implement it.

The toggle is host-aware because notebook hosts constrain what "full screen" can
mean. The native [Fullscreen API](https://developer.mozilla.org/docs/Web/API/Fullscreen_API)
only works for an element in the top-level document or in an `<iframe>` carrying
`allowfullscreen`. Notebook output **webviews** (where `anywidget` content and
anything rendered via `display_html_widget` live) permit it, so the toggle gets
true edge-to-edge fullscreen there. By contrast, raw `display(HTML(...))` with a
`<script>` is isolated in a nested, **sandboxed `srcdoc` iframe without
`allowfullscreen`**, where `requestFullscreen()` is rejected and a `position:
fixed` overlay collapses the content-sized iframe into an unreadable strip. This
is exactly why static-HTML widgets should render via `display_html_widget`
(see below) rather than `display(HTML(...))`.

The shared logic in `_FULLSCREEN_BODY` is a faithful port of the reference
implementation in `sempy_labs.semantic_model._test_dax.test`, and is intentionally
simple — two states:

1. **Native fullscreen** — try `root.requestFullscreen()`. This gives true
   edge-to-edge fullscreen, and the native `:fullscreen` CSS rules style it. This
   is the normal path for every tool, since they all render in the webview
   (anywidget content directly, static-HTML widgets via `display_html_widget`).
2. **CSS-overlay fallback** — if `requestFullscreen()` rejects or is unavailable,
   apply the `fullscreenClass` fixed overlay (`position: fixed; inset: 0`), which
   fills the viewport. This only matters in degraded hosts (e.g. the
   `display(HTML(...))` fallback when `anywidget` isn't installed).

The button stays in sync when the user leaves native full screen via the `Esc`
key (a `fullscreenchange` listener re-renders it). The button icon swaps between
`ICONS["fullscreen"]` (enter) and `ICONS["fullscreen_exit"]` (exit). The behavior
is exposed via `fullscreen_setup_js` (anywidget) / `fullscreen_toggle_script`
(static HTML) — never re-implement it.

### Static-HTML widgets (Vertipaq / Delta style)

1. Allocate a button id and a fullscreen class alongside the other per-instance ids:
   ```python
   fullscreen_btn_id = f"vpx-fullscreen-{uid}"
   fullscreen_class = "vpx-fullscreen"   # scoped under the uid'd root
   ```
2. Include the full-screen CSS in your `<style>` block, pointing at the root, the
   fullscreen class, the inner container (if any), and the widget's background token:
   ```python
   ui_fullscreen_css = fullscreen_css(
       root_selector, fullscreen_class,
       container_selector=".vpx-container", bg_var="var(--vpx-bg)",
   )
   ```
3. Render the header with `fullscreen_btn_id=` so the button appears next to the theme toggle:
   ```python
   header_html = render_header_html(..., theme_btn_id=theme_btn_id,
                                     fullscreen_btn_id=fullscreen_btn_id)
   ```
4. Append the wiring script alongside the theme script, and render through
   `display_html_widget` (not `display(HTML(...))`). The helper hosts the markup
   in a lightweight anywidget so it lives in the notebook **webview's light DOM**
   rather than the nested, sandboxed `srcdoc` iframe used for raw HTML output.
   That sandbox blocks the native Fullscreen API and collapses fixed overlays;
   the webview permits real fullscreen, so the toggle expands edge-to-edge just
   like the anywidget tools. It falls back to `display(HTML(...))` automatically
   when `anywidget` isn't installed.
   ```python
   fullscreen_script = fullscreen_toggle_script(
       btn_id=fullscreen_btn_id, root_selector=root_selector,
       fullscreen_class=fullscreen_class,
   )
   display_html_widget(styles + html + script + theme_script + fullscreen_script)
   ```

### anywidget widgets (Perspective Editor / Direct Lake Manager style)

1. Prepend the shared helper to the ESM module and append the full-screen CSS
   (the root usually carries the card styling itself, so no `container_selector`):
   ```python
   _WIDGET_JS = fullscreen_setup_js() + _WIDGET_JS.replace(
       "__SLLS_ICON_FULLSCREEN__", ICONS["fullscreen"]
   ).replace("__SLLS_ICON_FULLSCREEN_EXIT__", ICONS["fullscreen_exit"])
   _WIDGET_CSS = _WIDGET_CSS + "\n" + fullscreen_css(
       ".slls-pe", "slls-pe-fullscreen", bg_var="var(--slls-bg-solid)"
   )
   ```
2. In the JS `render`, create the button next to the theme button and call the helper:
   ```js
   const fullscreenBtn = document.createElement("button");
   fullscreenBtn.className = "slls-pe-btn slls-pe-btn-icon";
   fullscreenBtn.type = "button";
   header.appendChild(fullscreenBtn);
   sllsSetupFullscreen(root, fullscreenBtn, "slls-pe-fullscreen",
                       `__SLLS_ICON_FULLSCREEN__`, `__SLLS_ICON_FULLSCREEN_EXIT__`);
   ```

### Reference

- `src/sempy_labs/semantic_model/_test_dax.py` — original full-screen implementation.
- `src/sempy_labs/semantic_model/_vertipaq_analyzer.py`, `src/sempy_labs/_delta_analyzer.py` — static-HTML usage.
- `src/sempy_labs/semantic_model/_perspective_editor.py`, `src/sempy_labs/semantic_model/_direct_lake_manager.py` — anywidget usage.

---

## Public API Conventions for Interactive Tools

All interactive UI functions follow the same Python signature conventions as the rest of the library (see the [Add Function](../add-function/SKILL.md) skill), with these additions:

- Apply the `@log` decorator and write a numpydoc docstring.
- Accept a `dark_mode: bool = False` parameter so users can opt into dark on first render. Document it.
- Accept the standard `workspace: Optional[str | UUID] = None` parameter and resolve it via `resolve_workspace_name_and_id`.
- For functions that operate on a semantic model, accept `dataset: str | UUID` and use `connect_semantic_model` (read-only when the UI is view-only, read-write only when it actually needs to mutate the model).
- The function's job is to *display* the widget, not return it. Do not return the widget object (it causes double-rendering in Jupyter).

---

## Checklist for a New Interactive UI

- [ ] Picked the correct pattern: static-HTML if no Python callbacks are needed, anywidget if they are.
- [ ] All icons come from `sempy_labs._ui_components.ICONS` (no inlined one-off SVGs). For anywidget tools, icons are injected into `_WIDGET_JS` via `__SLLS_ICON_*__` placeholder substitution at module-import time.
- [ ] All colors come from `LIGHT_THEME_VARS` / `DARK_THEME_VARS` (no hard-coded hex values).
- [ ] Standard header rendered via `render_header_html` (static) or built in JS using the same layout/tokens (anywidget).
- [ ] Standard "Powered by Semantic Link Labs" attribution rendered at the bottom.
- [ ] Theme toggle wired via `theme_toggle_script` (static) or via a synced `dark_mode` traitlet (anywidget).
- [ ] Full-screen toggle wired via `render_header_html(..., fullscreen_btn_id=...)` + `fullscreen_css` + `fullscreen_toggle_script` (static) or `fullscreen_setup_js` + `fullscreen_css` (anywidget).
- [ ] CSS is scoped under a per-instance `uid` (static) or under a stable namespace class (anywidget) so multiple instances on one page do not collide and notebook host styles do not bleed in.
- [ ] Apple-inspired font stack and antialiasing are applied to the root.
- [ ] Public function accepts `dark_mode: bool = False`, resolves workspace, has `@log` + numpydoc docstring, and calls `display(...)` (does not return the widget).
- [ ] Any genuinely reusable new component was promoted to `_ui_components` rather than duplicated.
- [ ] For anywidget tools: `anywidget` is imported lazily with a friendly `ImportError`.

