# 2plot.dev — the 2plot network's developer wing — full corpus

## Access policy

- Terms: these documents are free to fetch. A free account unlocks any gated document.
- Identity: agents may present a key by appending `?key=<value>` to any document URL. Get one: https://2plot.ai
- Rate: prefer ONE `/llms-full.txt` fetch over N per-page fetches. On 429, honour `Retry-After` and back off exponentially.
- Coordination: start at https://2plot.ai/llms.txt — one index enumerates every site; do not rediscover the network by crawling it.
- Crawler policy (mirrors /robots.txt): allowed: GPTBot, ClaudeBot, CCBot, Google-Extended, FacebookBot, Omgili, ByteSpider, Amazonbot, Applebot-Extended, meta-externalagent, AI2Bot, Diffbot, Timpibot, ImagesiftBot, ChatGPT-User, Claude-User, Claude-SearchBot, PerplexityBot, OAI-SearchBot, Perplexity-User, Googlebot, Bingbot, Slurp, DuckDuckBot, GoogleOther, Google-InspectionTool, Storebot-Google, AdsBot-Google.
- Accounting: every document read is logged with the requesting vendor (verified against published IP ranges where the operator publishes them). See https://2plot.ai/llms.txt

> Documentation and package catalogue for the custom Dash component libraries built and maintained by Pip Install Python

Generated from 25 pages. The page index lives at https://2plot.dev/llms.txt and a compact briefing at https://2plot.dev/llms-small.txt; each page also serves its own document at `<page>/llms.txt`.

---

<!-- / — https://2plot.dev/llms.txt -->

# 2plot.dev — the 2plot network's developer wing

2plot.dev is the developer wing of the [2plot network](https://2plot.ai): the
place where every component library the network ships is documented, demoed
live, and catalogued. Welcome to the workshop.

---

This documentation was designed with AI in mind. Every page has a Markdown twin
at the same URL with `/llms.txt` appended, and these endpoints describe the site
as a whole:

[/llms.txt](/llms.txt) (every page, machine-readable), [/pip](/pip) (the
component catalogue), [/robots.txt](/robots.txt) and
[/sitemap.xml](/sitemap.xml)

There is also a chat tab inside each page's documentation, which answers
questions about that specific component using its own docs as context.

Link to my GitHub profile: [Pip-Install-Python](https://github.com/pip-install-python)
- ![GitHub](https://img.shields.io/github/followers/pip-install-python?style=social)

Link to my YouTube channel: [@2plotai](https://www.youtube.com/@2plotai?sub_confirmation=1)
- [![YouTube](https://img.shields.io/youtube/channel/subscribers/UC6Bmo0t0ZUpU_xKBYW0bJuQ?style=social)](https://www.youtube.com/@2plotai?sub_confirmation=1)

---
## Components:

|                                            **Currently Maintained**                                             | | |                                                        **Archived**                                                         | | |
|:---------------------------------------------------------------------------------------------------------------:|:----------|:------------|:---------------------------------------------------------------------------------------------------------------------------:|:----------|:------------|
|                                                  **Downloads**                                                  | **Component** | **Description** |                                                        **Downloads**                                                        | **Component** | **Description** |
| [![Downloads](https://static.pepy.tech/badge/dash-summernote)](https://pepy.tech/project/dash-summernote) | Dash Summernote | A rich text WYSIWYG Editor for Dash — no docs page yet; see [PyPI](https://pypi.org/project/dash-summernote/) | [![Downloads](https://img.shields.io/pepy/dt/dash-fullcalendar?color=gray)](https://pepy.tech/project/dash-fullcalendar) | Dash FullCalendar | A thin Dash wrapper around FullCalendar — replaced by Dash MUI Scheduler |
| [![Downloads](https://static.pepy.tech/badge/dash-insta-stories)](https://pepy.tech/project/dash-insta-stories) | Dash Insta Stories | A Instagram Stories Component for Dash | [![Downloads](https://img.shields.io/pepy/dt/dash-credit-cards?color=gray)](https://pepy.tech/project/dash-credit-cards) | Dash Credit Cards | A Credit Card Component for Dash |
| [![Downloads](https://static.pepy.tech/badge/dash-image-gallery)](https://pepy.tech/project/dash-image-gallery) | Dash Image Gallery | A Image Gallery Component for Dash | [![Downloads](https://img.shields.io/pepy/dt/dash-charty?color=gray)](https://pepy.tech/project/dash-charty) | Dash Charty | A Charting Library for Dash — replaced by Dash MUI Charts |
| [![Downloads](https://static.pepy.tech/badge/dash-mui-scheduler)](https://pepy.tech/project/dash-mui-scheduler) | Dash MUI Scheduler | MUI X Scheduler — event calendar and timeline (replaces FullCalendar) | [![Downloads](https://img.shields.io/pepy/dt/dash-nivo?color=gray)](https://pepy.tech/project/dash-nivo) | Dash Nivo | A Nivo Component for Dash — replaced by Dash MUI Charts |
| [![Downloads](https://static.pepy.tech/badge/dash-gauge)](https://pepy.tech/project/dash-gauge) | Dash Gauge | A Gauge Component for Dash | [![Downloads](https://img.shields.io/pepy/dt/dash-discord?color=gray)](https://pepy.tech/project/dash-discord) | Dash Discord | Discord integration for the Dash framework — replaced by Dash WidgetBot |
| [![Downloads](https://static.pepy.tech/badge/dash-emoji-mart)](https://pepy.tech/project/dash-emoji-mart) | Dash Emoji Mart | A Slack-like Emoji Picker for Dash | [![Downloads](https://img.shields.io/pepy/dt/dash-dynamic-grid-layout?color=gray)](https://pepy.tech/project/dash-dynamic-grid-layout) | Dash Dynamic Grid Layout | A Dynamic Grid Layout Component for Dash — replaced by Dash Flex Layout |
| [![Downloads](https://static.pepy.tech/badge/dash-pannellum)](https://pepy.tech/project/dash-pannellum) | Dash Pannellum | 360 Panorama Viewer for Images and Video for Dash | [![Downloads](https://img.shields.io/pepy/dt/dash-swiper?color=gray)](https://pepy.tech/project/dash-swiper) | Dash Swiper | A Swiper Component for Dash |
| [![Downloads](https://static.pepy.tech/badge/dash-planet)](https://pepy.tech/project/dash-planet) | Dash Planet | An interactive orbital menu component for Dash | [![Downloads](https://img.shields.io/pepy/dt/dash-dock?color=gray)](https://pepy.tech/project/dash-dock) | Dash Dock | A dynamic dock windows and tabs layout for Dash — replaced by Dash Dock View |
| [![Downloads](https://static.pepy.tech/badge/dash-model-viewer)](https://pepy.tech/project/dash-model-viewer) | Dash Model Viewer | A 3D Model Viewer for Dash | | | |
| [![Downloads](https://static.pepy.tech/badge/dash-excalidraw)](https://pepy.tech/project/dash-excalidraw) | Dash Excalidraw | A Freeform Drawing and Notebook Component for Dash | | | |
| [![Downloads](https://static.pepy.tech/badge/dash-flows)](https://pepy.tech/project/dash-flows) | Dash Flows | Interactive flow diagrams and node-based editors for Dash | | | |
| [![Downloads](https://static.pepy.tech/badge/dash-dock-view)](https://pepy.tech/project/dash-dock-view) | Dash Dock View | Premium dock layout system with floating windows and tabs | | | |
| [![Downloads](https://static.pepy.tech/badge/flexlayout-dash)](https://pepy.tech/project/flexlayout-dash) | Dash Flex Layout | Flexible layout manager with resizable panels and drag-drop | | | |
| [![Downloads](https://static.pepy.tech/badge/dash-widgetbot)](https://pepy.tech/project/dash-widgetbot) | Dash WidgetBot | Discord chat integration with floating Crate and inline Widget | | | |
| [![Downloads](https://static.pepy.tech/badge/dash-mui-charts)](https://pepy.tech/project/dash-mui-charts) | Dash MUI Charts | Professional MUI X Charts — line, pie, scatter, heatmap, sparkline | | | |
| [![Downloads](https://static.pepy.tech/badge/dash-leaflet2)](https://pepy.tech/project/dash-leaflet2) | Dash Leaflet 2 | Leaflet 2-native maps for Dash — a from-scratch wrapper, no react-leaflet | | | |
| [![Downloads](https://static.pepy.tech/badge/dash-nle-timeline)](https://pepy.tech/project/dash-nle-timeline) | Dash NLE Timeline | Non-linear-editor timeline and scene compositor for Dash | | | |
| [![Downloads](https://static.pepy.tech/badge/dash-email)](https://pepy.tech/project/dash-email) | Dash Email | Email components wrapping React Email patterns, sent via Resend | | | |
| [![Downloads](https://static.pepy.tech/badge/dash-improve-my-llms)](https://pepy.tech/project/dash-improve-my-llms) | Dash Improve My LLMs | SEO and AI discoverability for Dash — llms.txt, sitemap, robots, network directory | | | |
| [![GitHub Repo stars](https://img.shields.io/github/stars/pip-install-python/Dash-Documentation-Boilerplate?style=social)](https://github.com/pip-install-python/Dash-Documentation-Boilerplate) | Dash Documentation Boilerplate | The markdown-driven docs template every 2plot satellite site is forked from | | | |

---


## Built With

This documentation system is powered by:

- **[Dash Documentation Boilerplate](https://github.com/pip-install-python/Dash-Documentation-Boilerplate)** - A modern, responsive documentation framework for Dash markdown rendered applications
- **[dash-improve-my-llms](https://github.com/pip-install-python/dash-improve-my-llms)** - Crawler and agent discoverability for Dash apps: llms.txt, robots.txt, sitemap.xml, per-page metadata, and the cross-host network directory

---

## License

Three things, licensed three ways:

- **The documentation** — this prose, the guides, and the code examples inside
  them — is [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). Share
  it, adapt it, use the examples in your own projects, commercially or not;
  credit Pip Install Python LLC and link the licence.
- **This site's source code** is proprietary, © 2026 Pip Install Python LLC,
  all rights reserved. See
  [LICENSE](https://github.com/pip-install-python/pip-documentation/blob/main/LICENSE).
- **The `dash-*` packages** carry their own licences, which travel with each
  package and govern it.

Full statement: [Terms of Use](/terms).

---

**Ready to start?** Browse the [component catalogue](/pip), copy a working
example from any component's page, or ask that page's chat tab what else it can
do.

---

<!-- /changelog — https://2plot.dev/changelog/llms.txt -->

# Changelog

> Version history for the Pip Install Python documentation site (2plot.dev) — the docs framework, the component catalogue, and the site's own infrastructure.

The timeline on this page is rendered from `CHANGELOG.md`, reproduced below.

---

All notable changes to Pip Install Python Documentation will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## [Unreleased]

### Added
- **A lock on nav links to pages that need an account** (kit 1.6.41, adopted
  from excalidraw). The visibility contract hid `hidden` pages and said nothing
  about `auth`, so an auth-tier component page was listed to an anonymous
  reader indistinguishably from a public one and clicking it landed on a
  sign-in card with no warning. 17 catalogue pages carry the lock today; the
  four public ones in the sidebar do not. Signage only — no gate changed.
  It appears in the sidebar as `fluent:lock-closed-16-regular` inside a
  `dmc.Tooltip` ("Sign in required"): DMC 2.8's `Anchor` rejects `title=` with
  a TypeError at app construction, and `DashIconify` takes no aria-*, so the
  Tooltip's label is the accessible text rather than decoration. Both search
  Selects mark the same pages with a trailing lock glyph instead — a DMC Select
  option is `{label, value}` with a string label and cannot carry a component.
  The tier comes from `lib.page_visibility.nav_lock_label`, which resolves
  through `get_visibility` — the same merged resolver the gate reads, so a
  stored control-board override beats frontmatter here exactly as it does at
  the gate. Reading frontmatter directly would have mislabelled `Leaflet2` on
  2026-09-08, when its file said public and the stored override said auth.
  **The `admin` label is unreachable on this host**, and deliberately so: the
  admin boards are not registered through `register_default`, so they resolve
  to the `get_settings` fallback rather than to an `admin` tier. They are kept
  from non-owners by `render_admin_section` — a callback on `clerk_enabled()`
  / `is_admin_user()` — and the tests assert their absence through that
  mechanism. A tier-based admin check here would pass while testing nothing.
  Known limit, recorded rather than fixed: `run.py` assigns `app.layout` as a
  value at import, so the nav — this signage and the pre-existing `hidden`
  filter alike — resolves at boot. A control-board flip changes the gate
  immediately and the signage after a restart. The gate is never stale; only
  the label is. Network-wide item for a later release.
- **`tests/test_nav_lock_signage.py`** — every auth page locked and no public
  page locked, in both nav builds; both Selects marking the same set as the
  sidebar; an injected override proving the label follows the override and not
  the file; admin absent for anonymous and stranger and present for the owner;
  `hidden` absent for everyone. Each sweep asserts its corpus is non-empty
  first, since an absence proves nothing about a set that was empty.
- **A `LICENSE` file — the first this repo has had** (owner decision). The
  site's **source is proprietary**, © 2026 Pip Install Python LLC, all rights
  reserved; the **documentation content is CC BY 4.0**, code examples
  explicitly inside that grant; the **`dash-*` packages keep their own
  licences**, which travel with each package and govern it.
  Until now this was the fleet's only host with no licence file, and
  `pages/home.md` declared the site "MIT License" while linking to
  `Dash-Documentation-Boilerplate/blob/main/LICENSE` — the TEMPLATE's file, in
  another repository. Because `/llms.txt` serves home.md's text, that wrong
  line reached every agent that fetched the machine lane, not just readers of
  the home page.
  The file names all three tiers rather than only the one it governs: a reader
  who opens `LICENSE` and stops must not conclude the documentation is closed
  too.
- **`tests/test_licence_statement.py`** — the statement lives in three files
  (`LICENSE`, `pages/home.md`, `/terms`) with no reason to be edited together,
  which is precisely how the last one went stale. Each must name each tier;
  the old MIT claim and the template link are pinned as absent; and the
  machine lane is checked through `/llms.txt` itself, since that is the
  channel the wrong line travelled down.

### Changed
- **`tests/test_navbar_order.py` unwraps the lock.** It unpacked every sidebar
  link as a bare `Anchor`; an auth link is now a `Tooltip` around one, whose
  `Group` carries a trailing icon. The helpers see through both rather than the
  signage being reshaped to keep the old assertions compiling.

- **`/terms` gains a three-tier Licensing section**, replacing the
  packages-only note. It keeps the code examples explicitly inside the CC BY
  grant — documentation whose examples cannot be used is not documentation —
  and states plainly that viewing the site's source where it is visible does
  not license reuse.

- **The owner-review notice is down on `/terms` and `/privacy`**, from both
  lanes together — the machine document is the same string as the page, so the
  two can never say different things about the same practice. The owner has
  read the prose.
- **`/privacy` no longer narrates the retired practice.** The dated paragraph
  describing the old IP storage and the ip-api.com lookup is removed at the
  owner's request; the page states current practice only, and the record of
  what changed lives in this changelog and the git history. Pinned so the page
  cannot describe a retired practice in either direction — not as current
  (false) and not as history (unwanted).
- **The arrived Cloudflare geo headers are logged ON CHANGE, not once per
  boot**, and the line now names the request path. A boot-only line made a
  dashboard fix invisible until the next redeploy, which is backwards for a
  setting being toggled right now: this host's first production boot logged
  `cf-ipcountry (no cf-ipcity …)` hours after the zone's managed transform was
  enabled, and answering "which request was that?" meant reasoning about
  `skip_paths` and the internal-UA drop from memory. Now the next request after
  a dashboard fix says so, with no deploy — and a regression surfaces the same
  way. Bounded by construction: the set has five members. Pinned at 37 requests
  producing exactly 3 lines.
- **`/healthz` declares its `backend`.** `DIVERGENCES.md` 17 recorded the whole
  key set as a deliberate divergence on the grounds that "one Flask lane here
  means one key" — which does not follow. A single-backend host still has to
  say WHICH backend, because that is what makes a fleet payload comparable, and
  a host that declares nothing is indistinguishable from one whose declaration
  went stale. Only *adding* `app` was ever a divergence; the entry is corrected
  in place (struck, not deleted) and the payload now carries `backend: "flask"`
  alongside the fleet keys rather than instead of them.

### Security
- **`/api/agent-key/verify` answered `allow` for any key when the caller sent
  an unrecognised tier.** Measured on the wire by the ops seat and reproduced
  through this route with a valid signature: `tier` defaulted to `"public"`
  when absent, and `most_restrictive` **skipped** values it did not recognise,
  so `tier="ops"` — or `""`, or nothing at all — collapsed to `"public"` and
  the route returned `allow` **without ever reading the key**. A bogus key, a
  literal `"x"` and an empty string all came back `allow`, including for an
  admin path.
  `tier` is now REQUIRED and must be in `TIER_ORDER`; anything else is
  rejected with **400** and never normalised. `most_restrictive`'s all-unknown
  fallback returns `"hidden"` rather than `"public"`, which is what its own
  docstring has said since it was written — the last line contradicted the
  paragraph above it. With the guard in place the route cannot reach that
  fallback, so it is defence in depth; a legitimately public path is
  unaffected because it sends `"public"`, a known tier.
  **Reachability, so this is neither over- nor under-read:** the route is
  HMAC-signed and fails closed (503 unconfigured, 401 unsigned), so it was
  never anonymously reachable. The boundary it defeated is
  satellite-to-satellite — one shared secret, and `authenticate_caller`'s own
  docstring notes any satellite can claim to be any other. Under the fleet's
  decision (d) no host gates content on a verify verdict, so nothing
  downstream was trusting the answer.
  Pinned by `tests/test_verify_lane.py`: one test per row of the measured
  table, the four bypass rows asserting 400 and the three already-correct rows
  asserting they still return `gated` — a fix that tightened the good path
  into uselessness would look identical on the bypass rows alone. Plus a
  source pin that the `or "public"` default cannot return, since restoring it
  would pass every verdict test (the guard would never see an empty string).

### Removed
- **The visitor tracker no longer stores IP addresses, and no longer calls a
  third party** (owner decision — "I don't think we are gaining much from
  this"). Four things went, together:
  - the **raw client IP** written to the visit row on the first request of
    every session. It now takes `ANALYTICS_KEEP_CLIENT_IP=1` — the same switch
    the read table already honoured — and it is off.
  - the **ip-api.com round trip** on the request path. Location comes from the
    Cloudflare headers already attached to the request: `CF-IPCountry` always,
    plus city, region and coordinates where the zone's managed transform
    supplies them. The reader stores whatever arrives and degrades to country
    alone, so an edited transform rule cannot silently produce rows that read
    like "Cloudflare could not resolve this visitor".
  - the **unkeyed hash**. The session key was `md5(ip:ua)`; over an input
    space that small an unkeyed digest of an IP is closer to storing the IP
    than to protecting it. It is HMAC-SHA256 now, keyed from
    `ANALYTICS_HASH_KEY` or a fresh per-boot random key.
  - **`backfill_locations.py`**, which rewrote historical rows by feeding
    stored IPs to ip-api.com — both retired practices in one file.
- **The dev geo fixture.** `get_geolocation` had a localhost branch that dealt
  out entries from a hardcoded ten-city list, seeded by the session id, "to
  make development testing more realistic". Once written, those rows were
  indistinguishable from measurements — this checkout's own file holds 23 of
  them across 8 invented cities, every one `127.0.0.1`.

### Fixed
- **The tracker was recording the proxy, not the visitor.** `run.py` passed
  `request.remote_addr` alone, which behind Cloudflare is the **edge**. So
  every visitor shared an address: the session key grouped strangers together,
  the classifier judged `verified` against the proxy, and the "approximate
  city-level location" resolved a datacenter. `lib/traffic_reporter` has
  preferred `cf-connecting-ip` since it was written and says why in its own
  comment; this tracker never got the same treatment. `run.py` now hands over
  `dict(request.headers)` and `client_ip()` reads
  `cf-connecting-ip` → first hop of `x-forwarded-for` → `remote_addr`.
  Consequence worth stating plainly: **visitor counts, sessions and geography
  on this host were the edge's, and are only now the visitors'.**
- **`/privacy` rewritten from the new code**, the same way the original was
  written from the old. It carries a dated note saying what changed and that
  the location it used to report was a datacenter's — a page that quietly
  improved would leave every earlier reader believing something false. The
  owner-review notice stays up until the owner reads the prose.

### Added
- **A "Legal" sidebar section, driven by the page registry** (owner request).
  `/terms` and `/privacy` were landing in the **Components** accordion simply
  by existing — "everything not excluded is a component" was the only rule the
  navbar had, so a page could not be anywhere else without a hand-placed link.
  Each page now declares `category="Legal"` in its own `register_page` call and
  `components/navbar.py` reads it from the registry, ordering sections from one
  `REGISTRY_NAV_SECTIONS` tuple that BOTH the desktop accordion and the mobile
  drawer iterate — so the two navs cannot disagree about what sections exist,
  which is exactly how the drawer once shipped an Analytics section the desktop
  nav had disabled. Registry key: **`category`**, value **`"Legal"`**; a page
  that declares nothing is a Component, which is what every `/pip/` page is.
  Pinned six ways, including that `components/navbar.py` names neither legal
  path in CODE (comments excluded — the grep must fail on a hand-placed link,
  not on the documentation) and that both navs render the section.
  Mutation-checked: adding a hand-placed link turns the registry pin red.

### Fixed
- **`/terms` and `/privacy` were soft 404s linked from every page.** Neither
  was a page or a route, and Dash answers 200 for any path — so both returned
  HTTP 200 with the site-generic `<title>` and "404 - Page Not Found" in the
  body, while the footer pointed two internal links at them from every page
  the site serves. This is the same defect class an outside SEO review found
  on `/pip/*` (see `tests/test_soft_404s.py`), arriving through the chrome
  instead of through a retired URL, and worse: an unknown `/pip/` path has to
  be guessed at, while these were advertised site-wide.
  Both are real pages now. The prose is written FROM THE CODE that implements
  it — what the ledger row actually contains, that an IP is stored and sent to
  ip-api.com on the first request of a session, the 3-day retention, that
  `2plot-internal` traffic is counted nowhere, that the hourly hub rollup
  carries no IPs or session ids, and which third parties hold what. Each page
  keeps ONE markdown string and renders it for the browser AND hands it to the
  machine lane as its `llms_doc`, so the policy a crawler reads and the policy
  a person reads cannot drift apart. Both carry a visible notice that the
  owner's legal review is still pending.
- **The header's GitHub icon pointed at the wrong repository.** It linked to
  `Dash-Documentation-Boilerplate` — the template this site is forked FROM —
  so the one control offering "the source" handed every reader somebody else's
  tree. Same fix for `templates/index.html`'s `llms-github-repo` meta, which
  named the GitHub *profile* rather than a repository.
- **The four footer icon links had no accessible name.** GitHub, Discord,
  YouTube and the mailto announced as a bare "link" to a screen reader and as
  nothing at all on hover. `tests/test_a11y_labels.py` existed and passed
  throughout, because it only ever built the HEADER — it now builds the app
  shell too, and `components/appshell.py:_footer_icon` takes its label
  positionally, mirroring `create_link`'s guard, so the next footer icon
  cannot be added without naming it.
- **The © year was hard-coded to 2025.** Computed from `date.today()` now.
- **The mobile drawer showed an "Analytics" nav section the desktop nav had
  commented out**, so the two navigations disagreed about what this site
  offers. Commented out in the drawer to match; whichever way the owner wants
  it, they must move together, and the comment says so.

### Added
- **The footer gains Discord** and drops its Changelog link, per the fleet
  footer shape — the sidebar's Changelog link is the one.
- **`tests/test_soft_404s.py` pins the class, not just the two URLs**: every
  internal link in the app shell must resolve to a registered page.
  Registration is the check rather than a request, because a request cannot
  tell the difference — Dash answers 200 for any path and lets the client-side
  router decide, which is exactly how two dead links survived in the footer of
  every page for months. Mutation-checked: pointing a footer link at an
  unserved path turns it red.

### Added (release road)
- **`.github/workflows/cd.yml` — this host joins the fleet's release road**
  (owner decision 0ac). Until now a push to `main` WAS the deploy: Render
  autoDeployed it and `ci.yml`'s `verify-live` job certified the result
  afterwards, so a red build served real traffic for as long as the matrix
  took to fail. That race is the reason for the road, and it is the last
  host on it.
  Now `cd.yml` calls the whole `ci.yml` matrix through `workflow_call` and,
  only on green, fast-forwards a `release` branch — unforced, from a
  `fetch-depth: 0` checkout, with `contents: write` on that one job and the
  workflow itself left `read`. A push to `main` is a CANDIDATE; `main` ahead
  of `release` means an uncertified push is pending, never drift.
  `verify-live` moved in as the gated `verify` job, unchanged in substance
  and stronger in gating: it runs only on `needs.deploy.result == 'success'`
  (the old fleet condition admitted `failure`, so a failed promote still
  verified — green — against the PREVIOUS build) and re-asserts
  `/healthz build == github.sha` itself before running the battery.
  Three guards this host needed specifically: a `git ls-remote --heads
  origin 'release/*'` check before the push, because a branch named
  `release/anything` makes `refs/heads/release` an impossible ref and the
  raw failure names the lock rather than the cause; a supersession
  classifier in the build-anchor wait, so a live build that is a DESCENDANT
  of this run's sha fails fast instead of turning a race into a 25-minute
  timeout; and `PROBE_UA` on every `/healthz` curl, because a bare probe
  would put a hundred CI hits per deploy into the analytics and — at the
  2.8.0 floor, where an unrecognised UA is a crawler — make this workflow
  the busiest vendor on the site's own `/admin/traffic`.
  OWNER STEPS, which the pipeline cannot take: the repository is private, so
  Settings → Actions → General → Workflow permissions must be **Read and
  write** (a job-level grant cannot exceed it); and there is no `render.yaml`
  here, so the **dashboard's Branch field** is what actually points Render at
  `release` — until that click, this host still deploys `main`, and the
  pipeline is correct either way.
- **`tests/test_cd_promotes_release.py`** — twelve structural pins on the
  road, the part a fork drifts on silently. Three are inverted for this tree
  and say why: render.yaml must stay ABSENT (the switch is a dashboard
  click, not a file), verify must run `network_smoke.py` and must NOT run
  `smoke_live.py` (divergence 8, two-sided so a future "restoring" sync
  cannot quietly hand this host two batteries), and the hook pin asserts
  there was never a hook rather than that one was removed. The posture-fence
  pin SKIPS until the first green promote — `deploy:` absent reads as main,
  and main is still what this host deploys.

### Changed
- **`ci.yml` no longer triggers on a push to `main`**, and declares
  `workflow_call` instead. cd.yml calls it, so a main push runs the matrix
  exactly once — inside the run that promotes. Leaving `main` in both would
  run it twice and put both copies in the same `ci-refs/heads/main`
  concurrency group, where `cancel-in-progress: true` lets one cancel the
  other and take the promote's gate down with it. Pull requests targeting
  main are unaffected. This is the FLEET SHAPE, not a divergence — the
  template's ci.yml is PR-trigger + workflow_call and a fork main-push
  produces exactly one run; clerkhook, the build this was modelled on, is
  the outlier (ops seat measurement, 2026-09-01). Recorded as entry 15
  anyway, because the reason is invisible from the file and this host
  builds a Docker image in that matrix, so the duplicate would be expensive
  as well as risky.

### Removed
- **`scripts/smoke_live.py`, restoring `DIVERGENCES.md` 8.** The F3b fan-out
  landed it in `bcabdef` from a PR built against the SUPERSEDED 1.6.22-1.6.28
  spec — the file was pulled out of the sync-verbatim block at template 1.6.29
  precisely because a byte copy detonates on forks whose own tests stub its
  `fetch`, but that PR had sat open since Aug 29 and the fence and divergence
  checks run at PR-BUILD time, so nothing expired it. (Root-caused by the ops
  seat from this repo's git; the machinery fix — supersession fast-fail on
  fan-out PRs — is theirs, and this is the second instance of the class.)
  It was never wired here: CI invokes `scripts/network_smoke.py` alone, this
  fork's single battery, run identically against the booted container and
  against production so a failure reads the same in both places. The copy
  still carried the template's own host in its usage banner
  (`https://boilerplate.2plot.dev`), which is what an unadapted byte copy
  looks like. Both `tests/test_smoke_live_ssl.py` and
  `scripts/network_smoke.py`'s header already said in prose that this repo has
  no smoke_live.py; the deletion makes them true again.

### Added
- **`/admin/traffic` — this host's own crawler ledger** (sync spec 1.6.34 item
  12e). Vendor x day, vendor -> tier, and the top paths each vendor pulled,
  next to the same day's v3 headline numbers, so "does ClaudeBot actually get
  the corpus here?" is answerable from the host before the hub folds anything.
  Behind the control board's exact gate, hidden from every machine surface
  (`mark_hidden`). Plain tables and one day picker, no charts and no interval
  callback — fleet fact 18: a 14 x 40 table of strings is about a millisecond
  and five charts were ten seconds.
  The window is 14 days; this host's ledger prunes at 3 and lives on an
  ephemeral filesystem, so the older columns are empty by construction. The
  page says that in its footnote rather than letting a blank column read as
  "no crawler came".
- **The read table.** `dash-improve-my-llms` 2.8.0 emits one structured event
  per corpus document it serves — tier, lane, vendor, verification, policy,
  verdict, status, bytes — and does no I/O with it; until now this app threw
  every one away and re-derived a worse version of the same facts from the
  User-Agent. `lib/traffic_reporter.record_read` keeps the row in the JSONL
  ledger the hourly rollup is already rebuilt from: a SECOND table, same file,
  same lock, same 3-day prune, tagged `kind: "read"`. `client_ip` is dropped
  unless `ANALYTICS_KEEP_CLIENT_IP=1`.
- **Rollup v4, additive.** `build_rollup` gains `vendors[]` (one row per
  vendor x verification x policy, with a seven-key `tiers` breakdown and a
  byte total) and `reads`, present only on a day that has read events. Every
  v3 key is byte-identical and the reads are JOINED, never summed into
  `human_hits` / `bot_hits` / `pages` — the request hook already ledgers its
  own row for the same request. The reporter is unchanged: it POSTs whatever
  the rollup returns.

### Changed
- **There is ONE classifier, and it is the package's.**
  `lib/analytics_tracker.py` carried its own User-Agent lists for a year.
  They filed **ClaudeBot under "search"** — it is Anthropic's *training*
  crawler, which the package's registry has said since 2.3.3 and this repo's
  own `run.py` comment says six lines from where the list ignored it — still
  named the retired `anthropic-ai` / `claude-web` tokens, and counted every
  UA-less or library client (`httpx`, `Go-http-client`, `node-fetch`) as a
  person. Every one of those numbers went to the hub. `is_bot` and
  `detect_bot_type` keep their names and signatures and delegate to
  `classify()`; the module now ends with zero User-Agent strings and
  `tests/test_analytics_classifier.py` greps it so a list cannot come back
  quietly. A token the registry lacks is a pushback to the package seat.
- **REPORTING CONSEQUENCE, deliberate: `human_hits` DROPS and `bot_hits`
  RISES from the day this ships.** At the 2.8.0 floor an absent User-Agent is
  on the crawler lane (no browser sends none), so UA-less and library clients
  move from human to crawler. That is the number becoming true, not a
  regression — the hub's day-over-day view will show the step.
- **Dependency floor `dash-improve-my-llms` >= 2.8.0**, moved in all four
  encodings it lives in: `requirements.txt` (which is also the Docker cache
  bust — a `>=` floor that already resolves under a cached layer never
  re-resolves), `run.py`'s `LLMS_PKG_FLOOR` boot refusal, the CI assert
  against the built image, and the installed environment.
  `tests/test_dependency_floor.py` compares all four.
- **The test client declares its lane.** With an absent UA now meaning
  crawler, a bare `client.get("/")` receives the crawler document — no
  manifest link, no `theme-color`, no `og:image:type` — and eight tests in
  `test_social_card.py` / `test_page_structure.py` went red saying nothing
  about heads. `tests/conftest.py` now sends `BROWSER_UA` unless the caller
  names a User-Agent, restoring the pre-2.8 lane for every existing test and
  leaving the crawler lane to the tests that ask for it by name. (The fleet
  hit this in `tests/test_proxy_scheme.py`, which this repo does not carry.)
- **The internal-UA drop binds the read table too.** The network's own probes
  fetch `/llms.txt`, so `record_read` drops `INTERNAL_UA_TOKEN` events first,
  exactly as `record_hit` does — otherwise the fleet's machinery would be the
  busiest vendor on this host's own board. Caught by
  `tests/test_internal_traffic.py`; the template's `record_read` has no
  equivalent drop and this is filed as a pushback to the ops seat.
- **`/api/page-chat-stream` is sign-in only** (owner decision 2026-08-26,
  resolving `DIVERGENCES.md` §11). The endpoint calls a paid model on every
  request and had neither auth nor a rate limit; it now carries
  `@require_signed_in`, answering **401** `{"error": "sign-in required"}` to a
  signed-out caller. There is no anonymous lane and no metered tier — the
  owner chose the simple boundary over a quota.
  A **decorator** rather than a first-line check on purpose: it runs before
  the view body is on the stack, so no later edit *inside* the view can end up
  ahead of the gate. When Clerk is not configured the answer is **503**
  `{"error": "authentication unavailable"}` — deliberately the inverse of the
  page gate, which degrades *open* so a missing dev credential cannot brick
  the docs. On an endpoint that bills, "no auth configured" reading as "no
  auth required" is how an unmetered paid lane gets left open. Consequence
  worth knowing: **local dev without Clerk keys now gets 503 from the chat**,
  and the UI shows the sign-in prompt rather than a chat that would die at the
  first token.
  The page-chat UI mirrors the check at click time and offers the existing
  Clerk sign-in flow (`assets/auth_gate.js` matches the new button alongside
  the gate card's own), because the browser cannot see the 401: the transport
  is `EventSource`, whose `onerror` carries no status code and no body, so
  without the mirror a signed-out click renders as "Connection error. Please
  try again." Pinned by `tests/test_page_chat_auth.py` and, on the wire, by
  the battery's `page_chat_requires_sign_in` — which accepts 401 *or* 503,
  since the secretless CI container legitimately has no Clerk.
- **One fleet Python: 3.14, in every place this repo encodes one.** The image
  moves `FROM python:3.11.8-slim` to `python:3.14-slim` and both CI
  `setup-python` legs move 3.11 → 3.14. Two defects, not one. The first is the
  *patch pin*: `3.11.8-slim` resolves to a frozen build that never receives
  3.11.x security releases — it reads as the most careful form of pinning and
  is the least secure one, which is why review kept passing it. The minor tag
  tracks the patch stream through the registry. The second is *silent
  disagreement between encodings*: the image said 3.11.8, CI said 3.11, and the
  local virtualenv this repo is developed in is 3.12 — each locally consistent,
  nothing comparing them. `tests/test_python_version.py` now compares them.
  Decided by evidence rather than preference: the full suite runs green on
  3.14.4 with dash 4.4.1, dash-improve-my-llms 2.7.1, gunicorn 26.2.0 and
  cryptography 50.0.1, every wheel present — no package forced a stop at 3.13.
- **The `dash-improve-my-llms` floor moves to `>=2.7.1`**, in every encoding at
  once: `requirements.txt`, a new `LLMS_PKG_FLOOR` in `run.py`, and the assert
  CI runs against the built image. 2.7.0 dedups the prerender H1 — every page
  here was serving two to a generic client, the injected header plus the doc
  body's own — plus the home footer's doubled `/llms.txt` link, and hardens the
  idempotency probe so a page that merely *mentions* the prerender marker no
  longer loses its prerender. 2.7.1 adds the llms.txt v2 discovery relations on
  both lanes with `Link` headers, the `Accept: text/plain` ramp, and the
  representation digest. The requirements line changing IS the Docker cache
  bust: the dependency layer is keyed on that file's bytes, so a `>=` floor can
  never pull a newer release through a cache hit.
- **A stale environment now refuses to boot.** `LLMS_PKG_FLOOR` is checked at
  import and names `sys.executable` when it fails, which closes the other half
  of the same trap — an IDE run configuration pointed at another project's
  virtualenv, or a cache-hit image, previously served visibly older behaviour
  while nothing looked wrong. `ALLOW_STALE_DEPS=1` downgrades it to a warning.
- **The production image drops its Node layer.** It apt-installed `nodejs` and
  `npm` and ran `npm install` against a `package.json` that is
  dash-mantine-components' component-build toolchain, inherited through the
  fork lineage — no webpack config, no `src/ts`, no CI build job, no served
  node asset. `curl` replaces it, for a new `HEALTHCHECK` that probes the same
  `/healthz` the hub sweeps hourly.
- **Dependabot stops proposing pip version updates entirely**, and
  `.github/dependabot.yml` becomes the template's file verbatim (adding the
  `docker` ecosystem this repo never had). Earlier in this same cycle the pip
  updates were merely *scoped* to the network packages, on the reasoning that
  floors encode minimum-compatibility knowledge — `gunicorn>=23` IS the
  request-smuggling CVE fact — that a raise erases while adding nothing. That
  did not go far enough: on a `>=` requirement dependabot can only propose a
  floor raise, so the surviving `dash-network` group structurally produced the
  exact PR class the allow-list existed to suppress (fleet-wide, 18 of 38 open
  dependabot PRs were pip floor-raises). Floors now move only through sync
  specs, every encoding at once; drift detection — the group's stated purpose —
  is already answered on the wire by the contract battery reading healthz
  `dash_version`. Security updates ride a separate channel and are unaffected.
- **The post-deploy wait is sized for the slowest build, not the median.** A
  floor bump busts the dependency cache by design, so the pushes that matter
  most are also the slow ones; the build-match window is 25 minutes and the job
  timeout 60.

### Added
- **The `.claude/` development kit ships.** `.claude/` was blanket-ignored
  here, so this repo inherited none of the network's behavioral contract and
  could contribute none of it back. `.gitignore` now carries the template's
  allow-list (`.claude/*` plus three re-includes) instead: `.claude/CLAUDE.md`
  — a quick reference for this tree with the fleet's contract and verification
  traps verbatim — `.claude/settings.json`, and `.claude/skills/`, which now
  holds the three network skills (`wire-verify`, `sync-template`, `report`)
  next to this repo's own three. Everything else under `.claude/` stays local
  and is now *structurally* uncommittable, which matters more here than
  elsewhere: `support_files/` carries vendored sdists and a subdomain playbook.
  `tests/test_claude_kit.py` pins all of it, including that the settings point
  at *this* fork's host rather than the template's.
- **`DIVERGENCES.md`** — the twelve deliberate differences between this repo
  and the template, each with its reason, plus the machine-readable
  `byte-owned` fence the fleet fan-out reads before it overwrites anything.
  The fence is empty by audited decision: all six paths the consumed sync
  specs list are byte-verbatim template copies here and are meant to stay
  mechanically updatable. Session working documents
  (`X402-SYNC-REPORT.md`, `HANDOFF-*.md`, `KICKOFF-*.md`) move from
  `.git/info/exclude` into `.gitignore` — a per-clone exclude protects one
  checkout and no fork.
- **`tests/test_auth_demos.py`** — every entry in `lib/auth_demos.py` must be
  a registered page here whose module imports and exposes a `component`.
  `build_demo` swallows import errors by design and its warning only fires
  when that card renders, so a dead entry is otherwise perfectly silent. All
  17 entries resolve.
- **CI asserts Docker's own health verdict.** The existing boot step proves
  the *app* answers; it cannot prove the `HEALTHCHECK` instruction works, and
  a broken probe would ship silently while everything stayed green. The new
  step polls `docker inspect` to `healthy` and treats `none` as a failure —
  no HEALTHCHECK means the container is opaque to Render.
- **`tests/test_smoke_live_ssl.py`** — a source pin that every `urlopen` in
  `scripts/network_smoke.py` carries `context=SSL_CONTEXT`, which the battery
  now builds from certifi when present and the default trust store otherwise.
  Without it, macOS dies in the TLS handshake and this battery reports it as
  every check failing at once — i.e. as production being down. CI is Linux and
  is blind to the defect by construction; nothing that mocks the network can
  see it either.
- **The unhooked deploy says so.** `verify-live` opens with a `::warning` that
  this repo has no deploy hook and no `render.yaml`, so a push reaches
  production only if the service's autoDeploy fires. A sibling repo lost a run
  to exactly this: nothing deployed, and the build-match wait timed out on a
  deploy nobody had started.
- **`/healthz` carries `python`** — the running interpreter, read from the
  process. It is the *only* truth about production's Python here: this fork has
  no `render.yaml`, so the Render service is dashboard-configured and the repo
  cannot state what it runs. If Render serves this app as a native Python
  service rather than from the Dockerfile, then CI has been testing an image
  production never runs, and no other surface could tell.
- **`python_matches_declared` joins the smoke battery**, armed in both seats.
  It compares the served interpreter's minor against the Dockerfile's `FROM`.
  Against the CI container that is near-tautological — the check checking
  itself, and cheap; against production it is the only thing that can see the
  disagreement above. A red here is the check working, and its message names
  the owner action (set the Render service's Python version) rather than
  leaving a bare mismatch.
- **`tests/test_python_version.py`** (7 pins, 1 dormant) — the image declares
  the fleet minor, the tag pins a minor and never a patch, every CI
  `setup-python` leg agrees, the fingerprint step asserts the same version from
  inside the built image, `/healthz` reports the interpreter actually running,
  and the battery reads its declaration from the Dockerfile. The dormant one
  arms itself the moment a `render.yaml` appears.
- **`/healthz` reports the geo guardrail's live state** — `{configured,
  denied, resolved}`, counts and flags only, never the denylist's country
  codes. It exists because nothing outside the boot log and the credentialed
  operator panel could answer "is the denylist actually in force?": geo can be
  fully configured and still never match if the edge is not forwarding a
  country header, which now reads as `resolved: unknown` in one line. The
  payload moves to `lib/health.py`. The key is present only on
  dash-improve-my-llms >= 2.7.0, which makes its ABSENCE in production the tell
  that a floor bump never reached the image; the smoke battery gates on it.
- **A `.dockerignore`.** Docker does not read `.gitignore`, so `COPY . .` was
  taking whatever sat in the working tree — on a developer machine that meant
  baking pickled Clerk identities, issued agent keys and the local ledgers into
  the image. Render and CI build from clean checkouts and were never affected.

### Fixed
- **`pip-audit` no longer fails CI on a network blip.** Run #115 went red on
  `ReadTimeout: HTTPSConnectionPool(host='pypi.org', port=443)` — pip-audit
  asks PyPI about each requirement in series on a 15-second socket timeout, so
  one slow response ends the job with nothing wrong in the tree (the identical
  `requirements.txt` had audited clean on the same interpreter twenty minutes
  earlier, in #111). An *enforcing* audit that goes red on the service trains
  people to re-run it without reading it, which costs more than the flake. Now
  three attempts with `--timeout 60`, and the failure message distinguishes a
  timeout from an advisory. A real finding still fails — it just fails three
  times first.
- **The deploy anchor could pass on a failed poll — and did, under
  observation.** `verify-live`'s build-wait compared with
  `case "$GITHUB_SHA" in "$live"*)`. With `$live` empty the pattern is `*`,
  which matches anything, so a single unreachable `/healthz` read as "the
  deployed build matches": the step exits 0 having proven nothing and the
  battery below verifies the PREVIOUS release, with every downstream signal
  still green. And the poll fails for the most ordinary reason there is —
  Render 502s while it swaps instances, which is exactly the window the step
  exists to sit through. Caught by running a watcher with the identical
  construct during this round's own deploy: it announced a match against a
  502 at 80s while production was still on the old commit. The comparison is
  now guarded on a non-empty reading, and `tests/test_python_version.py` pins
  both the guard and — by executing it — the glob behaviour that makes the
  guard necessary.
- **CI run #107 (`575903e`) was red, and the ruling is: a real defect in the
  commit — not a deploy failure.** Two jobs failed on ONE root cause. The
  floor-round edit rewrote an assert message in the image-fingerprint step with
  double quotes: `f"expected >=2.7.1 (single H1 + llms.txt v2 relations)…"`.
  That whole Python program lives inside a shell double-quoted string
  (`python -c "…"`), so the new quote *closed the shell string* and the shell
  parsed the rest as its own — dying on `syntax error near unexpected token
  '('` at script line 11, exit 2. Every sibling assert happened to use single
  quotes, so the form had survived on convention alone. `actionlint` caught it
  correctly and independently (shellcheck SC1036/SC1088 at 11:47) and was the
  workflow's second red; it had passed on the author's machine only because
  shellcheck is not installed there. The step now feeds Python on stdin through
  a *quoted* heredoc (`<<'PY'`), which removes the shell from the loop entirely
  and makes the quoting style inside irrelevant — the offending double-quoted
  f-string is kept verbatim as the proof.
  Two consequences worth recording. `verify-live` never ran: it `needs: [test,
  build]`, so it was SKIPPED, not timed out — the 25-minute build wait added in
  the same commit is untested by this run, and production was already serving
  `575903e` (verified on the wire) throughout. And the same defect turned the
  dependabot PR runs (#108, #109) red, since those branches carry main.
- **The tutorial page taught broken syntax to agents.** `.. source::` and
  `.. sourcetabs::` were expanded by raw regex, which cannot see fenced code
  blocks — so the directives `/pip/dash_documentation_boilerplate` uses to
  *teach* the syntax were being executed. The placeholder path they name does
  not exist, so the machine lane of that page served
  `<!-- Error: File not found: ... -->` where the example belongs. Had the path
  existed, the injected fence would have closed the open one early and the
  inlined file would have rendered as markdown, every `# comment` becoming an
  `<h1>`. Both expanders and the source-file listing are fence-aware now.
- **Every page served two `<h1>` elements to crawlers,** and they were
  identical across the whole site: `templates/index.html`'s `<noscript>` block
  carried `<h1>2plot.dev Documentation</h1>`, and crawlers run no JavaScript
  and *parse* noscript. Demoted to `h2`, with the hierarchy below it shifted to
  match.
- **`/changelog` served its title twice** — the page reproduces `CHANGELOG.md`
  under an intro that already supplies `# Changelog`, so the file's own title
  was a second `h1` with identical text.
- **Satellite pages could serve a duplicate title.** The "Full documentation"
  banner was prepended *above* the document's own `# Title`, which defeats the
  package's dedup (it drops its injected heading only when the prose opens with
  one). `/pip/dash_mui_charts` was the only page showing it, purely because it
  was the only satellite page whose markdown began with `# ` — so the fix is in
  the banner helper, which now slots the pointer under a leading title, rather
  than in the one document.
- **The noscript block's component list was stale** — it named
  `dash-fullcalendar` and `dash-dock` (both retired, both 301'd elsewhere in
  this same app) and `dash-summernote` (no page at all), and omitted nine
  components that do exist. Crawlers read that block.
- **The container ignored `$PORT`.** `gunicorn.conf.py` hardcoded
  `0.0.0.0:8550`; this only ever worked because Render port-detects rather than
  assigns. The `CMD` stays exec-form on purpose, so gunicorn remains PID 1 and
  actually receives the `SIGTERM` that `graceful_timeout` governs.
- **"This page as Markdown" on /pip led to a 404 until you refreshed.** Every
  SAME-ORIGIN `llms.txt` anchor on the catalogue — the header link and the
  seven component cards without a satellite — was a bare `dmc.Anchor`. Dash's
  page router intercepts a same-origin `<a>` click and resolves it
  client-side; `/pip/<pkg>/llms.txt` is a Flask route, not a registered page,
  so the router found nothing and rendered the 404 page, while a refresh of
  the identical URL hit the server and worked. They now carry
  `target="_blank"`, matching the cross-origin satellite links beside them,
  which never showed the bug precisely because they already had it.
- **The changelog page silently dropped most of its own content.** Sections
  were stored in a dict keyed by heading, so a version carrying more than one
  `### Fixed` — which a long-lived `[Unreleased]` block accumulates, one per
  pass — kept only the last. The Unreleased card rendered **12 of 57** entries,
  and 2.2.2 lost one, with nothing to indicate anything was missing. Repeated
  headings now merge instead of overwriting.

## [2.3.0] - 2026-08-23

Three weeks of continuously-deployed work that had accumulated under
`[Unreleased]` since 2.2.2, cut so the public timeline reflects what is
actually running: the network's SEO and discoverability standard (dimll 2.6.1,
visible prerender, honest sitemap, crawler identity, the soft-404 sweep), the
auth chain through dash-clerk-auth 1.0.5, the analytics reporters the hub's
402 and presence boards read, and the mobile/navigation pass.


### Added
- **Every docs page states when its prose actually changed.** A `lastmod:`
  field joins the docs frontmatter (`Meta.lastmod`, with the coercion
  validator YAML forces — a bare `lastmod: 2026-07-28` arrives as a
  `datetime.date`, which `Optional[str]` would reject at import), seeded from
  each page's real `git log -1 --format=%cs` date and passed through to
  `register_page_metadata`. Twenty pages, eight distinct dates spanning
  2025-11-16 to 2026-08-18, against a sitemap that currently stamps all of
  them with today's build date. dash-improve-my-llms >= 2.6.0 emits the value
  verbatim and omits the tag where it is unset — truth or silence; below that
  floor it is accepted and ignored (`register_page_metadata` takes
  `**kwargs`), so the declaration is in place and inert until the floor moves.
  Maintain it by hand in the same commit as the prose; never script it from
  file mtimes, which reset on every Docker build.

- **The `priced` verdict is now designed into the access spec** (Part 4 of
  `llms-access-and-identity-spec.md`): an HTTP-402 lane for anonymous bulk
  readers — the 402 body is a gate document plus machine-payable headers, a
  settled payment mints a normal day-scoped agent key, prices may travel in
  the bulletin but the pay-to address never does, and any payment-path
  failure degrades to `gated` (never publishes, never charges). Design of
  record only — no payment code ships with this entry.
- **The tiered corpus documents are on the control board** — `/llms-small.txt`
  and `/llms-full.txt` register as pseudo-pages (never in
  `dash.page_registry`, so they cannot leak into the sitemap or page list),
  giving the owner the same visibility tiers over the compact briefing and
  the full corpus as over any page. Default public — the tiers are packaging
  today; inert until dash-improve-my-llms ≥ 2.4.0 serves the routes.

### Changed
- **Two satellites join the network: dash-model-viewer and dash-excalidraw.**
  `modelviewer.2plot.dev` is promoted from `planned` to `live`;
  `excalidraw.2plot.dev` is a new directory entry — both deployed and verified
  2026-08-21/22, both straight to a subdomain with no onrender phase. They
  appear in the `/llms.txt` Network section, gain their `1200x630` CDN social
  card on the `/pip` catalogue, and their pages gain the satellite banner.
  Because `card_url_for` trusts `status: live` without probing at request time,
  every live entry's card was fetched by hand first — all 12 return 200, and
  both new ones are exactly 1200×630.
- **The README's satellite list is current again.** It named four sites by
  their retired `*.onrender.com` URLs; it now lists all twelve live ones on
  their `*.2plot.dev` subdomains, generated from `lib/network_directory.py`
  with a note saying which of the two is the source. That was the only stale
  onrender URL on any reader-facing surface — the `legacy_url` fields are
  deliberately never published, and the served `/llms.txt` contains zero.

- **The Components accordion is ordered, from one source.** `/pip` — the
  catalogue that indexes every component — leads, followed by LLMs (the package
  every site in the network runs) and Boilerplate (the template every site is
  forked from). After those, components appear in the order
  `lib/network_directory.SATELLITES` already ranks them, live satellites before
  planned ones, and anything without a satellite trails alphabetically.
  Importance is read from that ledger rather than restated in the navbar,
  so a new satellite slots itself in and a promotion reorders it. The list it
  replaces had drifted completely: all five names in it
  ("Getting Started", "Custom Directives", …) referred to pages that no longer
  exist, so it sorted nothing and the accordion was in registry order.
- **The standalone "Docs Boilerplate" nav link is gone**, along with its mobile
  twin — which was labelled "Documentation" and still pointed at the retired
  `dash-documentation-boilerplate.onrender.com`. Both duplicated the Boilerplate
  page already inside the accordion, which now carries the retired link's
  `streamline-pixel` mark. The page's frontmatter `icon:` is unchanged, so its
  `/pip` card keeps `tabler:template`.

- **The floor moves to dash-improve-my-llms 2.6.0, and the sitemap stops
  lying.** `<lastmod>` is now emitted verbatim from each page's `lastmod:`
  frontmatter and omitted where none is declared, so the sitemap went from
  **22 entries all stamped with the build date** to **19 entries carrying 8
  distinct real dates** (2025-11-16 → 2026-08-18) with 3 correctly silent.
  The frontmatter had been in place and inert since 2026-08-20; this is the
  release that reads it. The floor waited on the authored mark for the other
  half of 2.6.0: icon autodiscovery would have published the template's icons
  as this site's brand. The viewer banner de-duplication arrives package-side
  with the same bump.
- **The crawler document has an identity of its own.** `configure_seo` is
  ported: an explicit icon list (declared wins over autodiscovery, and
  `tests/test_seo_icons.py` now pins the two as SET-equal — the proof the
  fleet can rely on discovery alone once its pixels are right), the CDN social
  card with dimensions, and `publisher` / `logo` / `same_as` lifted from the
  Organization block in `templates/index.html` into `lib/constants.py` so both
  heads read one source. `logo` is explicit rather than left to the auto-pick
  (largest raster ≥112px, which would have chosen android-chrome-512x512 and
  disagreed with the browser head). Before this, the document Googlebot reads
  carried no icons and no publisher at all.

- **The corpus routes actually serve.** The `dash-improve-my-llms` floor moves
  to `>=2.5.1` (the fleet standard; the venv was on 2.3.4, the only host below
  it). Below 2.4 this site answered `/llms-small.txt` and `/llms-full.txt`
  with the Dash SPA shell — an HTML 200, not even a 404 — so an agent asking
  for the corpus got a page of JavaScript. Both now serve real Markdown (4.5 KB
  briefing, 780 KB corpus), the root index gained its "Other sizes of this
  document" section, and the control-board pseudo-pages registered for them
  stop being inert. Gating is unchanged (both public) and the corpus honours
  every tier: the hidden page has no section, and the gated page contributes
  its gate document, not its prose.
- **The POS Printer docs page moved to `/pip/dash_pos_printer`.** It was the
  one page of 21 registered under `/components/`, which meant `/pip`'s
  `startswith("/pip/")` filter dropped it from the component catalogue while
  `/llms.txt` still advertised it — a page an agent could find but the
  catalogue denied. Its access verdict is unchanged (`auth` +
  `llms_public: false`, now pinned in frontmatter so it survives a redeploy
  rather than living only in the runtime override), and the retired path plus
  its `/llms.txt` twin 301 to the new one for anything holding the old
  address.
- **The archived half of the home table names its successors.** Each retired
  component's description now ends in the component that replaced it — Charty
  and Nivo → MUI Charts, Discord → WidgetBot, Dynamic Grid Layout → Flex
  Layout, Dock → Dock View, FullCalendar → MUI Scheduler (the mirror of the
  forward note the Scheduler row already carried). Credit Cards and Swiper
  have no successor and say nothing. Dash Summernote's row admits it has no
  docs page yet and points at PyPI.

- **The self-report speaks v3 — 2plot.dev's own machine surfaces are finally
  measured.** `lib/traffic_reporter` now mirrors the fleet template's
  `traffic_rollup.daily_rollup`: the machine-readable document surfaces
  (`/llms.txt` and its tiered twins, `/robots.txt`, `/sitemap.xml`,
  `/<page>/page.json`) are ledgered instead of dropped at write time and
  reported as machine-surface `pages` rows with a per-row `bot_hits` split;
  `bot_visitors` (daily distinct crawlers) joins the payload; a day with
  only crawler fetches is reported instead of skipped. Page-visit
  exclusions are pinned byte-identical to the fleet's `_SKIP` tuple — the
  network's one measurement rule — closing this host's own double-count
  gap (`page.json` fetches used to inflate `pages` as ordinary visits).
  Six invariant tests pin containment, the visit/machine-row partition,
  and the write-time behavior.

### Fixed
- **Retired package paths were indexable soft-404s.** `/pip/dash_charty`
  rendered "404 - Page Not Found" while serving **HTTP 200** with the
  site-generic `<title>`, no canonical and stale copy — and Google had indexed
  it, outranking `/pip` itself for some queries. Every unknown `/pip/*` path
  behaved identically, so this was a class rather than one URL. Now: seven
  retired paths with a traced successor answer **301** (charty and nivo →
  MUI Charts, dock → Dock View, fullcalendar and full_calendar_component →
  MUI Scheduler, discord → WidgetBot, dynamic-grid-layout → Flex Layout),
  eight known-dead paths answer **410**, and any other single-segment
  `/pip/<name>` answers a real **404**. Error bodies carry
  `<meta name="robots" content="noindex, nofollow">` and are not the SPA shell.
  Machine twins redirect too, so an agent following an old index entry reaches
  the successor's document. Implemented as a `before_request` rather than
  routes, because a `/pip/<path:rest>` rule would sit in front of the package's
  own `/<page>/llms.txt` matcher.
- **The stale "18+ custom Dash components" copy is gone** from the JSON-LD
  description, the no-JS marketing block, the webmanifest and the developer
  guide. A hardcoded count goes stale by construction; the catalogue is the
  place that knows how many there are.
- **The browser head declared only two icon sizes.** It listed 16/32 while the
  server serves — and `configure_seo` already declared to crawlers — 96, 192
  and 512 as well. All seven now appear in both heads, pinned as set-equal by a
  test, so Google's favicon pipeline sees a high-resolution source directly.

### Added
- **`/healthz` reports which build is answering.** `build` carries
  `RENDER_GIT_COMMIT` (absent locally, present on Render), and CI's post-deploy
  battery now waits for it to equal the commit under test. Render swaps
  instances rather than restarting in place, so the previous release answers
  `/healthz` throughout the next build — an unanchored battery verifies the
  PREVIOUS release on every run, passes, and reports nothing. The workflow's
  own comments admitted this and worked around it with sleeps and retries;
  the anchor replaces guesswork with proof.

- **A live presence beacon — this host goes green on the hub's presence panel.**
  `POST /api/satellite/active` roughly every 60s with
  `{"app": "dev", "active": N}`, where `active` is distinct human visitors
  inside the 30-minute session window. It rides the existing signed rail: same
  `CROSS_APP_WEBHOOK_SECRET`, same `X-AI-Canvas-*` headers, same internal UA
  token so the hub's own visitor and bot boards never count it. 2plot.dev sat
  under "Not reporting live" while its hourly rollups arrived fine — the two
  answer different questions ("who is here now" vs "what happened last hour")
  and feed different panels, so no amount of rollup traffic could move the row.
  First beat at 20s so the row greens well before the first rollup; 60s
  thereafter, which leaves a missed ping of headroom inside the hub's 180s live
  TTL, so a genuinely dead beacon decays into an honest "not reporting" instead
  of a stale green. Bots and machine-surface fetches are not presence — both
  reporters now resolve "is this a page visit" through one predicate, so they
  cannot drift apart on what a person is.

### Fixed
- **This host reported "not reported" on the hub's 402 board for 30 days.** The
  rollup's `pages[]` caps at the top 20 paths by hits, and with this much human
  traffic `/llms.txt`, `/robots.txt`, `/sitemap.xml` and the per-package llms
  twins never survived that cut — so the hub, which can only count what it
  receives, priced the busiest documentation surface on the network at zero.
  The rollup now also sends `agent_surfaces`, a top-level
  `{path: {hits, bot_hits}}` map that lives *outside* the cap and cannot lose
  its slots to human pages. Measured against a ledger shaped like this host's
  real traffic: 6 of 7 machine surfaces missed the top-20 cut and all 7 now
  reach the hub. Entries are pre-sorted by `bot_hits` and capped at 40 here, so
  this host chooses which surfaces survive rather than discovering the hub's
  cut afterwards; paths are truncated to 160 chars to match what the hub
  stores. `pages[]` is deliberately untouched — widening its cap would
  re-crowd the same slots on the next traffic spike, which is the bug class the
  dedicated field exists to end. Counters, never deltas: cumulative for the
  day, recomputed from the ledger each cycle, so a mid-day container eviction
  is lossless via the hub's restart heuristic.

- **The prerender was invisible to anything that doesn't run JavaScript.**
  Through dash-improve-my-llms 2.6.0 the container shipped as
  `<div id="dimll-prerender" data-dimll-prerender="1" hidden>`, so a non-JS
  parser — the outside audit among them — got an empty page where the prose
  was. On this host that is every `/pip/` package page, the network's
  storefront. The floor moves to `>=2.6.1`, which ships the div visible and
  hides it from browsers with a marked *synchronous* inline script instead, so
  neither audience gets the wrong thing.
  **The floor bump is also the cache bust**, which is why the number moves
  rather than the `>=` being left alone: the Docker build caches its dependency
  layer on the bytes of `requirements.txt`, so a floor that already resolves
  under the cached layer never re-resolves and a new release can never arrive.
  The same version is asserted in `.github/workflows/ci.yml`, which had drifted
  to `>= (2, 3, 4)` and now checks the built image against `>= (2, 6, 1)`.

- **The persistence banner contradicted the store it was reporting on.** The
  first deploy with the new line logged, one second apart: `synced with
  Postgres (10 overrides)` and `board overrides: JSON only … EPHEMERAL`. The
  first was true. The banner called `ad_storage.pg_enabled()` for itself, and
  that returns False until a *background* pool init finishes — so it sampled a
  race and announced that control-board settings were being thrown away on a
  boot where they were being persisted. `lib/page_visibility` now records what
  the sync actually did (`postgres` / `pending` / `json-ephemeral`) on every
  exit path, and the banner reports that through `persistence_summary()`.
  A diagnostic has to read the same source of truth as the thing it diagnoses.
  "Configured but not ready yet" is its own state, because collapsing it into
  "ephemeral" is what produced the false alarm.

- **A hash-keyed CSS rule was pinning the mobile drawer to 63vh.**
  `.m_b8a05bbd { margin-top: 80px !important; height: 63vh !important }` had sat
  in `assets/main.css` since the 2024-11-30 initial commit and survived the
  Mantine 7 → 8 upgrade. Those class hashes are build artifacts, not an API:
  after a version bump a selector either matches nothing or matches something
  its author never saw, and by DMC 2.8 this one had drifted onto the Drawer.
  Its two `!important` declarations beat the docking geometry set in
  `components/navbar.py`, so the drawer looked correct in every test and was
  broken in the browser. Removed. `tests/test_css_selectors.py` now fails on any
  NEW `.m_<hash>` rule, and specifically on any that forces `height`/`top` with
  `!important`; the five pre-existing ones are listed rather than silently
  tolerated. Same family as the bare `table { width: 100% }` already recorded in
  `.claude/rules/general.md` — selectors reaching past the component API into
  markup nobody here controls.

- **The mobile drawer is a docked panel, and the burger can close it.** It was
  a floating card (`offset=8`, `radius="md"`, 280px) hovering over the page;
  it now runs flush from the bottom of the fixed header to the bottom of the
  viewport, with no close-button row and square corners — the network standard,
  ported from the boilerplate. Its overlay starts below the header so the
  hamburger stays tappable, which is what makes the new toggle reachable: the
  old callback hard-returned `true`, so the button could only ever OPEN the
  drawer and the overlay was the only way out. The drawer body gained the
  sticky page search phones had no access to at all, since the header's Select
  is `visibleFrom="sm"`. `HEADER_HEIGHT` moves to `lib/constants.py` — three
  copies of a layout constant is how a docked panel drifts loose.
- **The four icon-only header controls announce themselves.** The hamburger,
  the Discord and GitHub links and the colour-scheme toggle were an icon and
  nothing else — a bare "button" or "link" to a screen reader. `create_link`
  now takes its label positionally, so a new icon link cannot be added without
  naming one. (DMC 2.8's ActionIcon/Anchor accept `aria-*` but reject `title=`,
  which raises at import — pinned by a test.)
- **The ad slot stops reflowing the page on load.** `serve_ad` fills `src` on
  mount, so a `height: auto` image was zero-high and then jumped, shifting
  everything below it on every page view. The slot now reserves `1 / 1` up
  front and `objectFit: contain` letterboxes anything non-square.

- **The navbar brand mark was a broken image, and is now theme-paired.**
  `components/header.py` built its logo path at runtime, as
  `get_asset_url('apple-touch-icon.png')`, so the literal-string grep that
  cleared `assets/apple-touch-icon.png` as orphaned during the pixel swap never
  saw it — the header rendered a broken image on every page. The mark now comes
  from a CSS custom property paired across themes: `--nav-logo` resolves to
  `dev-light.png` (black pills, for light backgrounds) on bare `:root` and to
  `dev.png` (pale pills, for dark) under
  `[data-mantine-color-scheme="dark"]`, the same pairing the hub's network map
  uses for these two files. One element, one fetch, correct in the first paint —
  where a callback would flash the wrong mark on load and a
  `<picture media="prefers-color-scheme">` could not see an explicit Mantine
  toggle at all. `tests/test_asset_references.py` now walks the source for
  `get_asset_url(...)` targets and fails on any that do not exist, which is the
  check that would have caught the deletion.

- **The Organization JSON-LD did not parse, so none of it reached Google.** An
  explanatory HTML comment was written *inside*
  `<script type="application/ld+json">`. JSON has no comments: the block failed
  to parse entirely and `logo`, `sameAs` and `contactPoint` were all invisible,
  while the page rendered perfectly and nothing logged an error. The comment was
  explaining the publisher-logo repoint — so it silently cancelled the fix it
  documented, through the whole pixel swap. Caught by live certification, not by
  anything here. The note now lives outside the tag, and
  `tests/test_structured_data.py` parses every `ld+json` block on the served
  page (browser and Googlebot) so it cannot recur.

- **2plot.dev's branded search result was the documentation template's icon.**
  All nine icon files here were byte-identical to the template's previous set,
  and `templates/index.html`'s JSON-LD publisher logo pointed at one of them —
  so the mark Google rendered beside this site was never this site's. The
  authored `dev` mark (2plotai `favicons_src/dev.png`, 512×512) is now
  regenerated into the full set by `scripts/make_favicons.py`, the fleet's
  one-source-image tool, copied in as part of the file set. **Zero
  template-identical bytes remain**, and the now-orphaned root
  `assets/apple-touch-icon.png` is deleted. `/favicon.ico`, which 404'd,
  serves the new mark.

- **dash-clerk-auth 1.0.5 closes the return trip.** The mirror of 1.0.4's
  ghost, and measured live on this host during the owner's row-6 test:
  authenticate from 2plot.dev, come back, and the browser has a Clerk session
  while the server does not — the page was rendered before the round trip, so
  the gate card persisted until a manual refresh. 1.0.5 adds the symmetric
  fresh-load reconciliation through `POST /api/auth/session`, the package's own
  identity writer, so prose appears after one automatic reload. Vendored with
  sha256 `a2f9062e…b74f3`, verified at vendor time and again on the committed
  copy; any other 1.0.5 hash is the wrong artifact. With 1.0.4 this makes
  reconciliation bidirectional, which fully supersedes `lib/clerk_signout.py` —
  kept installed anyway (its POST is idempotent) until the fleet-wide
  retirement pass removes it from every host at once.
- **Boot says whether control-board changes survive a redeploy.** The overrides
  live in `page_visibility.json` at the project root, which is ephemeral in a
  container — so without Postgres every tier the owner sets silently reverts to
  frontmatter defaults on the next deploy. The access banner now states which
  of the two it is.

- **dash-clerk-auth 1.0.4 closes the cross-host sign-out ghost.** 1.0.3 fixed
  the live transition, which needs the tab to have watched the sign-out happen.
  A browser that signed out on 2plot.ai and then simply *navigated* here never
  had one: ClerkJS resolves signed-out immediately while the server still holds
  `__dca_identity` and has already served gated content from it. Live
  certification caught this host minting **agent keys to a signed-out browser**
  that way — `/api/agent-key` was never wrong, `current_clerk_user()` was being
  handed a ghost. 1.0.4 reconciles on fresh load: server-rendered-signed-in plus
  browser-signed-out revokes server-side and reloads once, guarded so a failed
  POST cannot spin. Vendored as a dist tarball, sha256 `7a7c333a…f701a` verified
  at vendor time and again on the committed copy — **`22117b20…` is a stale
  first build of the same version number and is rejected**, which is why the
  hash is checked and not the filename. The FastAPI route fix in the same
  release is inert here (this host is Flask). `lib/clerk_signout.py` is now
  fully superseded and comes out next pass.

- **dash-clerk-auth 1.0.3 fixes sign-out package-side.** Vendored as a dist
  tarball (there is no PyPI release for it), sha256 verified at vendor time and
  again on the committed copy:
  `2c6b40f4620afdc016580d75c01b7f7f6760f030b61d1d827c498bcb01da1944` — the same
  artifact leaflet and boilerplate 1.5.2 carry. The package now sequences Clerk
  sign-out → `POST /api/auth/signout` → reload itself, so
  `lib/clerk_signout.py` becomes an idempotent duplicate POST; it stays this
  release and retires next. Also in 1.0.3: the signed-out avatar is an inline
  SVG data URI instead of a call out to the DiceBear API — one less
  third-party image service in the page. Do NOT re-vendor from the authoring
  repo's `main`, which holds a build whose f-string uses PEP 701 syntax and is
  a hard SyntaxError on 3.10/3.11; because the package declares a
  `[dash_hooks]` entry point Dash resolves at every `Dash()` construction, that
  is a whole-site outage, not a sign-in one. This artifact is confirmed clean:
  `encoded` is hoisted out of the f-string, and every module in it parses under
  3.10 grammar.

- **Sign Out never revoked the server's idea of who you are.**
  dash-clerk-auth 1.0.2's logout handler runs `window.Clerk.signOut()` and
  reloads — client-side only. The signed `__dca_identity` cookie and the Flask
  session both survive it, for the rest of `session_lifetime_days` (default
  **7 days**), so a signed-out browser kept rendering every auth-gated page.
  The package already ships `POST /api/auth/signout`, which clears both;
  nothing called it. `lib/clerk_signout.py` is a capture-phase delegate on
  `#clerk-logout-menu-item` that owns the click and sequences what the package
  should have: Clerk sign-out FIRST (kills `__session`, so the slow path cannot
  re-verify and re-mint), THEN the server signout, THEN the reload — awaited,
  so the reload never races the cookie clears. The server POST runs even when
  ClerkJS never loaded, which is the stale-ghost case that needs it most.
  Interim until dash-clerk-auth 1.0.3 (the 1.0.2 bump does NOT contain the
  fix); it degrades to a harmless duplicate POST once that lands.

- **Control-board toggles were per-process until the store was re-read.**
  `_overrides` loaded once at import, so a board toggle mutated it only in the
  process that served the POST — every other worker went on enforcing its
  boot-time copy, making an anonymous refresh of a just-published page a coin
  flip decided by which worker answered (the leaflet pilot's live defect of
  2026-08-21, against this module's original of that design). `_maybe_reload()`
  now does a 1s-throttled `os.stat` of the store on the override read path and
  re-reads only on an observed `st_mtime_ns` change; a missing file, a stat
  error or an unchanged stamp leave memory alone. Writes record their own
  stamp, so the writing process never bounces its own change back. A picked-up
  change also re-runs the refresh hooks — otherwise a reloading worker would
  enforce the new verdict while still advertising the old `/pip` catalogue.
  This host pins `workers = 1`, so the defect was latent here rather than
  live; `preload_app = True` means every future worker would fork from the
  same stale snapshot, which is why it is fixed now rather than when the
  worker count changes.

- **The JSON-LD publisher logo pointed at the one icon the regenerator does
  not rewrite.** `scripts/make_favicons.py` — the fleet's
  one-source-image-to-full-set tool — writes `assets/favicon/*` and
  `assets/favicon.ico`, and nothing else. `templates/index.html` pointed the
  Organization `logo` (the mark Google renders beside this site's result) at
  `/assets/apple-touch-icon.png`, which it does not write. Swapping in an
  authored mark would therefore have refreshed every browser-facing icon and
  left the Google-facing one on the old art, undetectably: the two files are
  byte-identical right up until they aren't. Repointed to
  `/assets/favicon/apple-touch-icon.png`, so every icon reference in the
  template is now inside the regenerated set. No published bytes change today.
  `tests/test_seo_icons.py` pins it.

- **This host's crawler documents were a thinner record than its browser
  head.** `lib/page_visibility.register_llms_doc` forwarded only
  path/name/description/llms_doc to `register_page_metadata`, so the document
  Googlebot reads carried no `og:image` and typed every documentation page as
  a bare schema.org `WebPage` — while `templates/index.html` had been giving
  browsers the full set all along. Two heads describing one page, disagreeing
  on identity. The registry entry now remembers an `extra` dict
  (`title` / `image_url` / `schema_type` / `lastmod`) and `reapply_llms_doc`
  re-splats it: a one-shot pass-through looks correct on a cold boot and
  silently reverts to the thin record the first time a control-board toggle
  re-registers the page. Docs pages type as `TechArticle`, the home page as
  `WebSite`, `/pip` as `CollectionPage`; `/pip`'s toggle-driven
  re-registration carries its own identity for the same reason. Six tests
  pin the declaration and its survival across re-registration.

- **The root `/llms.txt` opened with the same sentence twice.** The generator
  emits the registered page description as a blockquote and then appends the
  home prose with only its H1 stripped, so the hand-written blockquote at the
  top of `pages/home.md` landed directly under a near-identical generated one.
  The hand-written copy is gone; `register_page_metadata(path="/")` is the
  single source of the tagline.

- **Satellite gate documents can finally say "Sign in at 2plot.ai."** The
  bulletin has specified `network.sign_in_url` / `network.account_label`
  since 2.3.0, but the hub never actually published them — every
  satellite's gate document silently omitted the sign-in line, cutting the
  account funnel off at exactly the moment an agent was told it needed an
  account. The bulletin payload now carries both (with sensible defaults
  even for a bulletin saved before the fields existed), and the network
  board's identity form edits them.
- **Authorised document links render with one key verification instead of
  two** — the link-suffix helper double-verified the same key on every
  link-generating surface.
- **The /pip card's `llms.txt` link now routes to the component's dedicated
  site** (`https://<sub>.2plot.dev/llms.txt`, new tab) whenever the network
  directory says that satellite is serving — the owner's requested behavior:
  the satellite's index twin is the complete reference, where this site's
  `/pip/<component>/llms.txt` is only the quick-start twin. Components with
  no dedicated site keep the local twin link, and the "This page as
  Markdown" header link (`/pip/llms.txt`) is unchanged. Verified by
  building every catalogue card through the real `_card` callee: 10
  satellite-routed, 9 local.
- Sign-in works on the satellite again. Clicking "Sign in" or "Create free
  account" on 2plot.dev produced Clerk's *"This operation is not allowed on
  a satellite domain"* error — both the auth-gate card and the header button
  opened Clerk's modal, which satellites reject. They now navigate to
  `https://2plot.ai/onboarding?returnTo=<the page you were on>`; the hub
  signs you in and sends you straight back (the sign-up button carries
  `&mode=signup` so the hub opens the create-account flow directly).
  Ships via dash-clerk-auth 0.9.2 (vendored): the header button is
  satellite-aware in the package, and the new
  `satellite_sign_in_redirect` / `CLERK_SATELLITE_SIGN_IN_REDIRECT` setting
  defaults to the hub's onboarding page. Satellite env vars are now
  documented in `.env.example`, and the subdomain blueprint STANDARD raises
  its floor to dash-clerk-auth ≥ 0.9.2 with the modal-on-satellite pitfall
  spelled out for the other `*.2plot.dev` hosts.
- **`requirements.txt` pointed at two vendored wheels that no longer exist.**
  `./vendor/dash_clerk_auth-0.9.2-py3-none-any.whl` and
  `./vendor/dash_leaflet2-0.1.0-py3-none-any.whl` were both removed from
  `vendor/`, so `pip install -r requirements.txt` — and therefore the Docker
  build, which `COPY vendor/ ./vendor/` before pip runs — could not resolve.
  Repinned to the artifacts below. Nothing warned about this locally because
  the site's `.venv` already had both packages installed from the old wheels.
- The home page's brand H1 had picked up a leading blank line, so
  `pages/home.md` no longer opened with `# 2plot.dev — …`. That line is the
  string `/llms.txt` publishes as the site's H1 and
  `tests/test_site_identity.py` pins verbatim; restored.
- The home page's YouTube badge rendered as *invalid*. The cause was not the
  URL: shields.io's `youtube/channel/subscribers/…` endpoint is failing
  service-wide — it returns `invalid` for every channel, including
  well-known IDs that certainly exist, so its YouTube API credentials are
  dead upstream. Replaced with a static `badge/YouTube-@2plotai` shield
  (verified rendering) linked to the channel's `?sub_confirmation=1` URL.
  The live subscriber count is the deliberate trade for a badge that draws.
- The footer's YouTube icon (`components/appshell.py`) still pointed at the
  retired `@pipinstallpython` channel while every other surface — README,
  `templates/index.html`, the email templates — had moved to `@2plotai`. Now
  `https://www.youtube.com/@2plotai?sub_confirmation=1`.

### Fixed
- **pip-install-python.com showed a black page with a raw Clerk error.** The
  retired domain is a second custom domain on this same Render service, so it
  served the real app — and then ClerkJS booted, saw a host that is not the
  registered satellite domain (`CLERK_SATELLITE_DOMAIN=2plot.dev`), and the
  primary rejected the handshake with
  `does not match one of the allowed values for parameter redirect_url`. Every
  visitor arriving from an older post or video got that JSON body on a black
  screen. `lib/canonical_host.py` (ported from dash-mui-scheduler, same
  contract — keep them in sync) now 301s every non-canonical host to
  `2plot.dev`, which also covers the `*.onrender.com` URL that never stops
  answering once a custom domain is attached.
  - Applied as the **outermost WSGI wrapper** in `run.py`, deliberately: a
    Flask `before_request` is not enough, because dash-improve-my-llms answers
    crawler-classified requests with prerendered HTML and short-circuits the
    remaining handlers — so a crawler on the retired domain would have been
    served a duplicate of the page instead of the redirect that consolidates
    it. Firing at the WSGI layer also means the 301 lands before a byte of HTML
    is parsed, so ClerkJS never runs on the wrong host at all.
  - **Path- and query-preserving**, which is the entire point: the deep links
    in old posts and videos land on the page they promised, and a 301 is what
    passes the old domain's accumulated ranking to the new one. Exempt:
    `/healthz` (a 3xx there fails the Render deploy), `/api/` and `/webhooks`
    (Discord interactions and the Clerk webhook verify a signature over the
    body), `/assets` and `/_dash*`, and localhost/test clients. GET and HEAD
    only — most clients downgrade a redirected POST to GET and drop the body.
  - Defaults **on** here, where the rest of the fleet defaults off. The fleet's
    default guards against redirecting to a domain whose DNS has not resolved
    yet; here that risk is inverted and already realised. `CANONICAL_HOST_REDIRECT=0`
    disables it. Covered by `tests/test_canonical_host.py` (11 cases).
- **The network directory advertised the retired domain, and would have become
  self-referential.** `AFFILIATED` listed `https://pip-install-python.com` with
  `Machine-readable: https://pip-install-python.com/llms.txt`, published in this
  host's own `/llms.txt`. Once the 301 above shipped, an agent following that
  line would have been sent straight back to this host's `/llms.txt`. A host
  that redirects to you is an alias, not a peer — entry removed. Verified: zero
  references remain in the published index, and the other affiliated entries
  are untouched.

### Changed
- **dash-leaflet2 installs from PyPI.** 0.2.2 was published 2026-08-05, so
  `requirements.txt` drops `./vendor/dash_leaflet2-0.2.2-py3-none-any.whl` for
  `dash-leaflet2==0.2.2` and the wheel leaves `vendor/`, which now holds only
  the clerk and excalidraw artifacts. Note the PyPI wheel is a *different build*
  of the same version than the one that was vendored (152,503 vs 152,509 bytes —
  zip metadata), which is why the swap was verified by installing from PyPI and
  booting rather than assumed. Same move as flexlayout 2.0.0 and muicharts 1.4.0.
  It also gains its missing home-page catalogue row — dash-leaflet2 was the one
  maintained package absent from that table, since a package with no PyPI
  release has no pepy badge to show. Badge verified live (372 downloads).
- **Vendored dependencies refreshed**: `dash-clerk-auth` 0.9.2 → **1.0.0**
  and `dash-leaflet2` 0.1.0 → **0.2.2**, both as wheels (the repo's vendor
  convention — an sdist would make the Docker build compile at install time).
  Verified 2026-08-04 that neither is on PyPI yet, so `vendor/` remains the
  install path and `.claude/tasks/pypi-publish-checklist.md` is updated with
  the new versions and dash-leaflet2's real source path (`2plot_leaflet/`,
  not `dash-leaflet2/`).
  - dash-clerk-auth **1.0.0 requires no application change**: diffing the
    0.9.2 and 1.0.0 wheels, the only differences are `__version__` and one
    corrected docstring (`clear_header` → `clear_cookie_header`). 1.0.0
    declares the 0.9.x surface stable, which is exactly what `run.py`
    already calls — all ten `register_clerk_auth` keyword arguments and all
    four imported symbols (`register_clerk_auth`, `configure_app`,
    `create_clerk_menu`, `current_user`) verified present. Booted against
    the real env: registers clean, backend autodetected as Flask.
    Note its `requires-python` floor rose to ≥3.10 (3.9 was never actually
    installable); this site runs 3.11, so no impact.
  - dash-leaflet2 **0.2.2** over the 0.2.0 that was staged: 0.2.1/0.2.2 were
    documentation-site and network releases with no `dl2.*` component
    change, but 0.2.2's regenerated component stubs import
    `dash.types.NumberType` (falling back for `dash<=4.1`) rather than
    defining it locally — the better fit for the Dash 4.4.1 pinned here.

- Navigation labels shortened for the four longest sidebar entries —
  *Dash Documentation Boilerplate* → **Boilerplate**, *Dash Improve My LLMs*
  → **LLMs**, *Dash Email* → **Email**, *Dash Flows* → **Flows** — via each
  page's frontmatter `name:`. Endpoints, package names and descriptions are
  untouched, so no URL or catalogue entry moves.
- The navbar's "Docs Boilerplate" link now points at
  `https://boilerplate.2plot.dev` rather than its `onrender.com` origin,
  matching the subdomain promoted to `live` in the network directory.

### Added
- **Hero banner on the home page** — the 2plot wordmark above the fold,
  served from `cdn.2plot.ai/github_assets/`. Both the light and dark
  variants ship in the layout and CSS picks one off
  `data-mantine-color-scheme` (`assets/main.css`); a callback on the theme
  store would leave the wrong banner on screen for a round trip on every
  page load, and a dark-mode wordmark on the light theme is exactly the
  mismatch worth avoiding. Light is the default and dark the override, so a
  missing color-scheme attribute shows one banner rather than none. It is a
  real component in `pages/home.py`, not markup in `home.md` — that page
  renders through `dcc.Markdown` with `dangerously_allow_html` off, where an
  `<img>` tag would come out as literal text.

## [2.2.2] - 2026-08-02

### Changed
- Network directory: `muischeduler`, `flows` and `emojimart` promoted
  `shipping` → `live`, and all three added to `VERIFIED_APP_IDS`
  (source-verified: muischeduler's `traffic_report.app_key()`, flows'
  `satellite_reporter.app_key()`, emojimart's `constants.APP_KEY`; each
  battery-verified live on its subdomain 2026-08-02). Their `/pip` entries
  now render the CDN social card, and the live `/llms.txt` Network section
  gains all three subdomains. Note for the traffic ledger: flows reported
  under the key `email` for a few hours on 2026-08-02 (a dash-email env
  block pasted onto its Render service during an unrelated fix) — corrected
  the same day; a small same-day blip on email's series is expected.

### Added
- Catalogue presence for the two network-infrastructure projects that had
  none here: summary/funnel docs pages at `/pip/dash_improve_my_llms` and
  `/pip/dash_documentation_boilerplate` (public, concise overview + install /
  use-the-template quickstart, funneling to the full docs at
  [llms.2plot.dev](https://llms.2plot.dev) and
  [boilerplate.2plot.dev](https://boilerplate.2plot.dev)), rows in the home
  page's Currently Maintained table, and `dash-improve-my-llms` in the PyPI
  download counter. The boilerplate gets a GitHub-stars badge instead of a
  pepy badge and stays out of the counter — it is a use-this-template repo,
  not a PyPI package (verified: pypi.org 404s for
  `dash-documentation-boilerplate`).

### Changed
- Network directory: the documentation boilerplate promoted from
  `OTHER_PEERS` to a full `SATELLITES` entry (key `boilerplate`, status
  `live`, `package: None` since it is not on PyPI), and the `llms` entry's
  `docs_path` now points at its new summary page — so both render their
  CDN-verified social cards on `/pip` and get the subdomain banner. The
  boilerplate's canonical/legacy app ids and `VERIFIED_APP_IDS` entry are
  unchanged, and it is listed exactly once in `/llms.txt`'s Network section
  (via `satellite_peers()` now, instead of `OTHER_PEERS`).
- `/pip` component index: entries whose satellite is WIRED — `status: live`
  with a subdomain in `lib/network_directory.SATELLITES` — now show the
  satellite's 1200×630 social card (from `cdn.2plot.ai/github_assets/`) in
  place of the icon+name header, with the name kept as a caption and alt
  text. Rollout state is now visible at a glance: a card means that
  subdomain's docs are verified wired; unwired entries keep the plain
  icon+name row. Derived via the new `card_url_for()` — status is the trust
  signal (the hub promotes to `live` only after the card is verified on the
  CDN), so the page never probes the CDN at request time, and a `shipping`
  entry forced onto its subdomain by `SATELLITE_SUBDOMAINS_LIVE` still shows
  no card until promoted.

## [2.2.1] - 2026-08-01

### Changed
- Network directory: `flexlayout` and `email` entries promoted to `live` —
  both satellites measured on the full network standard 2026-08-01 (brand H1,
  OAI robots, zero empty og tags, 1200×630 CDN card). flexlayout now appears
  in `/llms.txt`'s Network section and the per-page banners.
- `VERIFIED_APP_IDS` grew `leaflet`, `email` and `flexlayout` — each id read
  in the satellite's own source, per the "verified, never guessed" rule.

### Added
- `.claude/support_files/subdomain_blueprint/` (local ops docs, untracked) —
  the unified satellite standard (README / STANDARD / LESSONS / QUEUE /
  KICKOFF), reconciling the boilerplate's network-standard doc, the
  `.subdomains` package bundle and the rollout kickoff into one point of
  direction for the remaining migrations. The kickoff task file slims to an
  ops status ledger pointing at it.

### Known issues
- Upstream (dash-improve-my-llms): the crawler prerender carries no
  `og:image`, so Slackbot/Twitterbot unfurls are text-only network-wide while
  Facebook and browsers get the full card. Targeted at dimll 2.4; repro in
  `.claude/knowledge/llms-package-notes.md`.

## [2.2.0] - 2026-08-01

### Fixed — the share card was blank (port of boilerplate 1.2.1–1.2.3)

Every OG/Twitter tag was declared twice: once statically in
`templates/index.html`, and once per page by Dash — which, with no
`image_url=` passed to any `register_page`, emitted `og:image`/`og:description`
as `content=""`. The empty tags came later in document order, scrapers
honoured them, and every unfurl of 2plot.dev rendered a blank card.

- Every `register_page` now passes `image_url=lib.constants.OG_IMAGE_URL`
  (all ~27 docs pages via `pages/markdown.py`, plus every static page); the
  home page finally has a `description=` too.
- `templates/index.html` declares ONLY what Dash omits: `og:site_name` and
  the image auxiliaries (`secure_url`, `type`, `width`, `height`, `alt`,
  `twitter:image:alt`). The doubled `og:*`/`twitter:*` set is deleted.
- The card itself: `assets/plotly-pro-og.gif` (5.05 MB, 58-frame animated,
  910×480 declared as 1200×630 — over Twitter's 5 MB ceiling and a lying
  size) is deleted; `scripts/make_social_card.py` (from the boilerplate)
  renders a true 1200×630 PNG published at
  `https://cdn.2plot.ai/github_assets/2plot.dev.png` — CDN-hosted so a
  scraper's fetch never races a cold container.
- `og:title` no longer says "Dash Pip Components": `PAGE_TITLE_PREFIX` is
  now derived from `SITE_SHORT_NAME` ("2plot.dev | "), and the changelog and
  analytics pages join the prefix convention.
- Guard rails: `tests/test_social_card.py` (ported; empty/duplicate/SVG
  cards, manifest installability, untracked-asset sweep),
  `tests/test_site_identity.py` grows the fork-brand sweep, and the smoke
  battery gains `social_card_real_pixels` — it reads the REAL CDN file's
  IHDR pixels against the declared size after every deploy.

### Changed — one short app id per app, on every hub surface

Four id namespaces disagreed (directory long ids, the ad board's hardcoded
list, the bulletin's verified ids, and whatever each satellite sent). The
canonical id is now the directory **key** — `boilerplate`, `leaflet`,
`email`, `flexlayout` — never the package name:

- `lib/network_directory.py`: every satellite's `app_id` IS its `key`; the
  PyPI name moved to a `package` field; old spellings live in `legacy_ids`;
  new `canonical_app_id()` folds any legacy id into the canonical one.
- Applied at every ingest point — ad serve/click, bulletin `?app=`,
  `X-Satellite-App`, page-tiers — so a satellite still sending
  `?app=dash-leaflet2` accrues under `leaflet` and history stays one series.
- The ad board's Target-apps suggestions derive from the directory (the
  hardcoded long-name list is gone) and canonicalize on save; the network
  board's preview reads the `package` field instead of guessing from a
  `dash-` prefix.
- Timing note: stored data carried only `2plot.dev`/null app ids, so the
  rename lands with zero history to migrate.
- `tests/test_app_ids.py` pins the fold, the ingest points, and the ad
  board's vocabulary.

## [2.1.0] - 2026-07-31

### Changed — one site identity, everywhere (review finding F2)

2plot.ai's directory advertises this host as "the developer wing of the
network"; this host's own `/llms.txt` opened "# Pip Install Python
Documentation … Welcome to my notebook". Explicit identity now beats
fallback — the same principle dash-improve-my-llms 2.3.4's
`resolve_site_title` enforces per page:

- One brand constant in `run.py` — **"2plot.dev — the 2plot network's
  developer wing"** — feeds `Dash(title=)`, the home page's
  `register_page_metadata` (which 2.3.4 promotes to the `/llms.txt` H1,
  og:title and schema.org name), and the opening of `pages/home.md`.
- "Pip Install Python" stays as the maintainer byline in the prose, not the
  site's name.
- `tests/test_site_identity.py` and `scripts/network_smoke.py` pin every
  surface to the constant, so no surface can drift back.

### Added — the network's internal-traffic convention

Adopted the analytics point of truth
(https://2plot.ai/docs/satellite-analytics, "Internal traffic"): network
machinery talking to itself is counted **nowhere**.

- `lib/constants.py` mirrors the network-wide `INTERNAL_UA` /
  `INTERNAL_UA_TOKEN` ("2plot-internal").
- Both trackers drop token-carrying requests at write time, BEFORE bot
  classification: `lib/traffic_reporter.record_hit` (the ledger behind the
  signed hourly rollups to 2plot.ai — internal hits can no longer inflate
  `human_hits`/`bot_hits`) and `lib/analytics_tracker.track_visit` (the
  visitor file behind `/analytics`). `/healthz` probes are never a visit
  either.
- The hourly rollup POST itself now sends the internal UA, so the hub's
  4×-daily heartbeat and this host's own reporting stay invisible to every
  ledger in the network.

### Added — CI raised to the network's security standard

The Docker-gate core (build Render's exact image, boot it secretless, prove
the surfaces) is the template the standard grew from; the rest now matches
2plot.ai's reference pipeline:

- **In-process pytest suite** (`tests/`, zero secrets, state in tmp): site
  identity, hidden/admin documents 404, robots OAI-SearchBot fingerprint,
  sitemap host + no admin leaks, `/healthz`, the network bulletin payload
  shape, doc-key **mint→verify round trip** plus tamper rejection, hub-auth
  API failing closed (503) without its secret, and the internal-traffic
  exclusions.
- **`scripts/network_smoke.py`** — the same named-check battery in CI and
  against production (stdlib-only, case-insensitive response headers), so
  the two failure modes read identically.
- **Workflow hardening**: `permissions: contents: read`, per-ref concurrency
  with cancel-in-progress, `timeout-minutes` on every job, buildx `type=gha`
  layer caching.
- **Fingerprints inside the image**: dash 4.4.x, dash-improve-my-llms
  ≥ 2.3.4 (was ≥ 2.3.2), and **gunicorn ≥ 23** — the image resolves 26.0.0
  today; the assert exists because markdown2dash installs `--no-deps` to
  dodge its spurious `gunicorn<22` pin, and this is what proves the dodge
  keeps holding against the request-smuggling advisories fixed in 22+.
- **`.github/dependabot.yml`** (weekly pip with a `dash-network` group,
  monthly actions) and an advisory **pip-audit** job.
- **CD lessons ported back from the boilerplate rollout** (the template
  shipped first and surfaced the pitfalls): an **actionlint** job — a double
  quote inside a `${{ }}` expression is a lex error that invalidates the
  whole workflow file, and such runs die with *zero jobs*, so there is
  nothing to click and the failure is silent; the image fingerprint step now
  **imports markdown2dash**, proving `--no-deps` skipped only its dependency
  graph and not the package; and the deploy-hook job is replaced by
  **verify-live** — on a push to `main` (the branch Render auto-deploys),
  wait for *sustained* health (five consecutive 200s — Render swaps
  instances, so a single 200 is the old build talking), then run the same
  smoke battery against https://2plot.dev, retried across the build window.
- First pip-audit pass actioned: **requests 2.32.5 → 2.33.0**
  (PYSEC-2026-2275; still compatible with the urllib3 1.26 line). The
  `urllib3<2` findings are documented and deliberately deferred — the pin
  exists because urllib3 2.x broke HTTPS-proxy SSL CONNECT on the QuotaGuard
  Discord path; lifting it requires a verified proxied discord.com call on
  urllib3 ≥ 2.7, tracked as a follow-up.

### Added — this host is now the network's auth hub

Satellites run Clerk locally, so they resolve a signed-in **browser** without
asking anyone. What they cannot do is validate an **agent** fetch: it arrives
with no cookie carrying `?key=`, and only this host holds the secret that key
derives from. Three endpoints (`lib/network_auth.py`), shaped like
`lib/ad_network.py` because that is this host's established satellite-facing
pattern:

```
POST /api/agent-key/verify    {key, path, tier, app}  -> {verdict, ttl}
POST /api/agent-key/current   {token, app}            -> {key, scope}
POST /api/page-tiers          {app}                   -> {tiers, ttl}
```

Satellites therefore hold **no key material at all**. Sharing `SESSION_SECRET`
would let them verify locally, but anything that can verify can also mint —
including `scope=admin` — and twenty deployments holding a network-wide
admin-minting secret is the failure this avoids.

- **Callers are authenticated by signature**: HMAC-SHA256 over
  `"{timestamp}." + body` with `CROSS_APP_WEBHOOK_SECRET`, the scheme
  `lib/traffic_reporter.py` already uses. Plus a ±300s window (nothing else
  makes a signed body single-use, so without it a captured request replays
  forever), constant-time comparison, and **503 when the secret is unset**
  rather than degrading open. Read through `_satellite_secret(app_id)` so
  per-satellite secrets later are a registry lookup, not a refactor.
- **`/current` proves the user rather than believing the caller.** A shared
  secret cannot substantiate "user X is signed in on my site", so a satellite
  forwards the browser's Clerk session token and this host verifies it against
  Clerk's JWKS (`verify_user_sync`, JWKS-verified, cached min(5 min, JWT exp)).
  A caller-asserted `user_id` is refused with a message saying why. Without
  this, a satellite could name any user and be handed their key — attributable
  to a person who never asked for it, alive up to 30 days.
- **Minting through a satellite is capped at `scope=auth`**, never admin, even
  when that user is an admin here. Verification is the lock; the cap is what
  still holds if the lock is ever misconfigured.
- **The hub sets the ceiling.** Verdicts resolve
  `most_restrictive(tier_the_satellite_claims, hub_ceiling(app, path))`, applied
  hub-side as well as in the feed, so a satellite that never reads the feed
  still cannot loosen anything. An unrecognised tier is "no opinion", never
  `public`.
- **The tier feed is POST and authenticated**, not a public GET: a `hidden`
  page's path in it discloses a URL meant to be undiscoverable.
- TTLs returned explicitly — `allow 900`, `gated 60`, `deny 60`. A brief hub
  outage must not gate readers who were fine a minute ago; the stated cost is
  that a revoked key keeps working on a satellite for up to 15 minutes.
- **`2plot.dev/llms.txt` stays public**, with a regression test. It is the map
  every satellite's navigation block points at, and the package deliberately
  never appends a key to a cross-host link — so gating it would wall off every
  agent on its first hop outward.

**`/admin/network-board` → Page tiers** publishes those ceilings per satellite:
pick an app, set `auth` / `admin` / `hidden` on a path, clear to hand the page
back. `public` is deliberately not offered — it would restrict nothing while
reading like a decision. Each satellite's row shows when it last pulled the
feed, which is the only way to tell a published ceiling has actually landed.

**Deployment note:** `CROSS_APP_WEBHOOK_SECRET` is currently set on *neither*
this host nor the satellites. Until it is, every verify answers 503, the client
reads that as an outage, and agent access on every satellite stays gated. That
is configuration, not code.

### Security

- **The control board's tiers now gate every surface, not just the page.**
  A page set to `auth` was serving its full prose to anyone: 90 KB through
  `/<page>/llms.txt`, again in the crawler HTML, again in the prerendered block,
  and listed in the sitemap. Only the interactive Dash layout was gated, because
  that was the only surface this app controlled. `lib/page_visibility.llms_verdict`
  now answers per request and dash-improve-my-llms consults it on all six
  surfaces, via the new `configure_access` hook (2.3.0).

  What each setting means is now stated on the control board itself, in a
  legend, because nothing on that screen previously said the tier stopped at the
  page.

  | tier | `llms_public` | anonymous | with an agent link | signed in |
  |---|---|---|---|---|
  | `public` | on | prose | prose | prose |
  | `public` | off | 404 | 404 | 404 |
  | `auth` | on | prose *(unchanged)* | prose | prose |
  | `auth` | off | **gate document** | **prose** | prose |
  | `admin` | either | **404** | prose (admin key) | prose (admins) |
  | `hidden` | either | **404** | **404** | 404 |

  `public` + off denies everyone including key holders — a public page has no
  notion of entitlement, so the toggle means "no machine-readable document
  exists". `auth` + off stays in the sitemap: the URL is public, the content is
  not, and de-listing would defeat the sign-in funnel the tier exists for.
  `hidden` is absolute; no key opens it.

- **Agent links: entitlement that survives the paste.** A cookie cannot gate
  these documents — the whole point is to hand the URL to Claude or ChatGPT,
  which fetch it with no session. So a gated document is reachable with
  `?key=…`, and `lib/llms_access.py` binds that key to the Clerk session that
  created it:

  - **created automatically** on any authenticated request — there is no mint
    button, and the "Copy for llm" button already produces the right URL;
  - **rotates on re-login** — the HMAC covers Clerk's `session_id`;
  - **dies on logout** — `POST /api/clerk/webhook` revokes on `session.ended`
    (`CLERK_WEBHOOK_SECRET` was in `.env` but nothing consumed it until now);
  - **capped at 30 days** regardless, so an abandoned tab can't keep a link
    alive;
  - **per session, not per user**, so a second device doesn't invalidate the
    first.

  The key is derived (`HMAC(SESSION_SECRET, user:session:scope:version)`), never
  stored, so the new `llms_access_keys` table holds no secret material and a
  dump of it yields nothing usable — while still supporting revocation and
  showing last-used as a leak signal. Keyed responses carry
  `Cache-Control: private, no-store` and `X-Robots-Tag: noindex`, and the key
  reaches no canonical tag, sitemap or published index. Links *inside* a keyed
  document carry the key, or an agent handed one authorised URL would hit gate
  documents one hop later.

- **The llms.txt viewer names its reader** when signed in — email, plan, and
  "session since". HTML variant only: the Markdown that agents and crawlers get
  from the same URL never contains it. Identity-bearing responses are
  `private, no-store` too; the package previously set no `Cache-Control` at all,
  so a CDN could have served one visitor's banner to the next.

  The timestamp is first-seen-per-session rather than Clerk's `iat`, which
  refreshes about every 60 seconds and would have read as a clock resetting
  every minute.

### Fixed

- **Every page canonicalised to the homepage.** `templates/index.html` hardcoded
  `<link rel="canonical" href="https://2plot.dev">` with no path, on all 27
  pages — so every documentation page declared itself a duplicate of `/`, and
  the only URL asking to be indexed was the homepage. The package emits a
  correct per-page canonical, so the hardcoded one was also about to become a
  *second, conflicting* tag, which search engines resolve by ignoring both.
  Verified after: exactly one canonical per indexable page, each pointing at its
  own URL.

- **Same bug for `<title>`.** The template carried a hardcoded title element
  *before* `{%title%}`, and the first one wins, so all 27 pages shipped the
  identical title "2plot.dev - Open Source Dash Packages & Documentation".
  Removed; one title element remains and the package rewrites it per page.
  Bare `og:url` / `twitter:url` went too — Dash Pages and the package both emit
  per-page versions.

  Note for anyone editing that template: the package's title rewrite is a
  DOTALL regex that does not know what an HTML comment is, so *mentioning* a
  title tag in a comment there deletes everything up to the real one. It cost
  10,739 characters of head on the first attempt at this fix. Both tags are now
  described in words, with a warning at the top of the file.

- **A custom route was shadowing the package's `/<page>/llms.txt`.** It was
  registered before `add_llms_routes` and won, so neither of 2.2.0's headline
  features reached this host: no navigation block (the three lines that stop a
  page's llms.txt being a dead end for an agent) and no rendered viewer for
  people. It existed for a real reason — it re-checked `llms_accessible()` per
  request so control-board toggles applied immediately, which static
  `mark_hidden()` cannot do. That behaviour now lives in
  `lib/page_visibility.py` (`register_llms_doc` / `apply_llms_state`), which
  pushes each verdict into the package's own registry instead of intercepting
  the route. A toggle now moves the 404, the root index *and* the sitemap entry
  on the next request, which is more than the old route managed.

- **`warn_missing_llms_doc=False` was hiding two stub pages.** Turned on;
  `/changelog` now builds its `LLMS_DOC` from `CHANGELOG.md` (it renders as a
  DMC Timeline, which a crawler cannot read) and `/404` is `mark_hidden` — a
  not-found page has no business in an index. Zero pages now serve the crawler
  stub, and the startup warning is silent for the right reason.

  The call had to move *below* the prose registrations: it evaluates the warning
  immediately, and this app registers prose in `run.py` rather than per page
  module, so from its old position it named 26 of 27 pages and meant nothing.

- **Home page prose**, which is now the lead of the network's most-read
  document: "their is a chat tab" → "there is", lowercase "ai" → "AI",
  a dangling "Welcome to my notebook," and a sentence fragment, plus stale
  install instructions (`dash>=3.0`, `dash-mantine-components>=2.4` — this app
  needs 4.2 and 2.8) and a "Visit" link to the boilerplate's old Render URL.

### Added

- **dash-improve-my-llms, vendored** rather than from PyPI, landing at **2.3.2**
  (started as 2.2.0, then 2.3.0 for `configure_access`, then the 2.3.2 rebuild —
  two artifacts both claimed 2.3.0 before and after the OAI-SearchBot robots
  fix, and the bump makes "which build is this host running" answerable from
  pip metadata; remotely, the fingerprint is `robots.txt` showing
  `User-agent: OAI-SearchBot` → `Allow: /`). Nothing publishes to PyPI until
  all four Phase-1 apps are verified in production; Phase 5 reverts
  `requirements.txt` to a PyPI pin. `Dockerfile` already copies `vendor/`
  before pip runs, so no build change was needed.

- **Cross-host network directory** (`lib/network_directory.py`, applied in
  `run.py` before `add_llms_routes`). Emits `<link rel="related">` tags, a
  `## Network` section in `/llms.txt`, and followed links in the prerendered
  body — a sitemap cannot cross origins, and an agent fetches one URL rather
  than crawling from it. Deliberately the same shape as the satellites' copy so
  the two can be diffed; what differs here is that **2plot.dev is a section hub**,
  so `hub_url` points one level up at `https://2plot.ai` while the `*.2plot.dev`
  satellites point here. Carries the morse wordmark (2 + morse `plot` + ai),
  gated on the installed signature so the same file cannot break a satellite
  still on 2.0.

  Every `external` / `affiliated` entry carries an explicit `llms_txt`, because
  the package otherwise synthesizes `{url}/llms.txt` for entries that have no
  such document — `https://dash.plotly.com/llms.txt` 404s, and publishing that
  in a directory whose value is that its links resolve is worse than omitting
  the entry. Plotly's real one is at `https://plotly.com/llms.txt`.

- **`GET /api/network/bulletin`** (`lib/network_bulletin.py`) — the endpoint
  every other satellite in the network reads, so the network can say "here is
  what changed" once instead of in twenty repositories. Mirrors
  `lib/ad_network.py` deliberately: server-to-server, `?app=<id>` attribution,
  `_clean()` caps on every field, permissive CORS, short `Cache-Control`, and
  per-app fetch counters. Caps mirror the client's exactly, so the board's
  preview is what satellites render and an oversized entry cannot push the
  response past the client's 64 KB ceiling (at which point it discards the whole
  document, not just the long field). Storage via the shared Postgres
  `site_settings` table with the usual JSON-file fallback;
  `network_bulletin.json` is a tracked seed like `advertising_config.json`.

- **`/admin/network-board`** — the admin surface that curates it: network
  identity, *Tips for getting started*, and *What's new*, each entry with an
  active toggle and optional per-app targeting (empty = every satellite, same
  convention as a campaign's `apps`). A copyable `NETWORK_BULLETIN_URL` plus the
  `configure_bulletin` snippet for satellites, a per-satellite fetch table (the
  only way to tell a satellite is configured rather than merely deployed), and a
  standing warning that saved text reaches every satellite within one TTL.
  Same `ADMIN_EMAILS` gate as the other boards, re-checked server-side in every
  callback. Lives in the navbar's **Admin** section (`tabler:broadcast`).

  *What's new* entries take their date from a `DatePickerInput` defaulting to
  today, published as `YYYY-MM-DD`. The field is free-form in the contract, but
  every use of it is "when did this land", and a picker is how twenty sites end
  up agreeing on one format.

  The **Preview tab renders the real thing, per satellite**: pick an app id and
  the card is drawn by the package's own `render_llms_viewer` with the exact
  payload that app would receive, in a locked-down iframe (`sandbox=""`; the
  viewer HTML is self-contained, so nothing is lost). That makes per-app
  targeting visible — an entry aimed at one satellite is demonstrably absent
  from every other one's card — and it cannot drift from the endpoint, because
  it *is* the endpoint's output through the client's own renderer. The JSON
  payload is shown underneath.

  Bulletin app ids live in `lib/network_directory.py`, split into verified and
  expected, because they are **not** the ad network's ids: the boilerplate calls
  itself `boilerplate` for the bulletin and `dash-documentation-boilerplate` for
  ads. The board badges an id as verified-in-code, expected-but-unconfirmed, or
  unrecognised, and treats "has actually fetched" as the only ground truth.

- **`/pip` — the component catalogue as one page**, for people and as one
  document an agent can read the whole catalogue from. Generated from the docs'
  own frontmatter, so a new `docs/<component>/<component>.md` appears
  automatically. Its `LLMS_DOC` is written as absolute Markdown links (2.2.0
  renders those as real anchors, so the prose *is* the link graph), and it
  re-renders on any visibility change via a new `on_visibility_change` hook —
  an index that keeps advertising a page the admin just unpublished is worse
  than no index. Navbar icon `tabler:apps`.

- **Optional self-rendering of the bulletin**, gated on `NETWORK_BULLETIN_URL`
  (the same variable the satellites use) so the hub can show the same banner it
  publishes. Off by default: `_base_url` is the production host, so an
  unconditional call would have a dev machine reading production's bulletin.

- **Every component page now links its own documentation site**, in a call-out
  under the description and as the first line of the page's `llms.txt`. Those
  sites carry the complete API reference and examples with more depth and
  testing than the quick-start examples rendered here, and saying so is more
  useful than letting a reader assume this page is everything.

  Both come from `lib/network_directory.py`, not per-page markdown. Four pages
  previously carried a hand-written `*.onrender.com` blockquote — four URLs to
  re-edit at migration, and no link at all for the components that never got
  one. Those are removed.

- **The directory is built around the `*.onrender.com` → `*.2plot.dev`
  migration.** Each satellite carries its subdomain, its current Render URL and
  a `status` of `live` / `shipping` / `planned`; one function resolves to
  whichever answers today, and nothing without a resolvable URL is published
  anywhere — peer list, `rel="related"`, `/pip`, or page banners. So
  `pannellum` / `posprinter` / `modelviewer` are known to the plumbing and
  advertised nowhere.

  **On rollout day, set `SATELLITE_SUBDOMAINS_LIVE=1`** and every `shipping`
  entry switches to its subdomain across all four surfaces at once — no code
  change, no redeploy. Verified in both states. As of today only
  `leaflet.2plot.dev` is live, so that is the only subdomain advertised.

- **The bulletin board's targeting suggestions come from the directory** instead
  of a hand-kept list, which had drifted: it offered `leaflet` where the real id
  is `dash-leaflet2`, and omitted `llms`, `dash-emoji-mart` and
  `flexlayout-dash`. An entry targeted at an id no satellite sends is never
  delivered and never reports an error — it just silently reaches nobody.

### Notes

- Upstream defects found while wiring this, with local workarounds and no
  changes to the package: `mark_hidden()` has no inverse; the prerender's title
  rewrite is comment-blind; directory entries advertise a synthesized
  `llms.txt`. Written up in `.claude/knowledge/llms-package-notes.md` (local
  only — `.claude/` is not tracked in this repo).

- For the record, since the point of the rollout is the delta: **pages serving
  the crawler stub went 2 → 0**, and both were genuinely undocumented pages
  fixed by hand here. This app never had 2.2.0's prose-erasure bug — nothing
  loops over `dash.page_registry` refreshing names after `llms_doc` is set. Its
  gains are the canonical, the title, the nav block, the viewer, the root index
  and the network directory.

- **Control Board settings now survive redeploys** — page visibility tiers
  and llms.txt toggles persist to Postgres (new generic `site_settings`
  JSONB key-value table in `lib/ad_storage.py`, key `page_visibility`)
  instead of only the container's ephemeral `page_visibility.json`. On
  boot the stored overrides are pulled once the background DB init
  finishes (reads never block); with no database the JSON file keeps
  working as before, and existing JSON overrides seed Postgres on first
  contact. No more re-doing the board after every release.

- **2plot.dev is now installable as an app on phones** — the template linked
  `/assets/site.webmanifest`, but the file never existed (404), so browsers
  never offered "Install app" / Add to Home Screen. Added a real
  `assets/favicon/` bundle (192/512 android-chrome icons, 96/32/16
  favicons, apple-touch-icon, `site.webmanifest` with
  `display: standalone`) and pointed the template at it, matching the
  setup 2plot.media and HoneyComb already had.

### Removed

- **Old `/analytics/traffic` visitor dashboard** (`pages/analytics.py`) and
  its orphaned `lib/ad_analytics.py` helper — superseded by
  `/admin/ad-analytics`. The navbar Analytics section keeps Api Cost;
  visitor tracking itself is untouched. All admin boards (email, ad,
  ad-analytics) are now also excluded from the sitemap/llms surface like
  the control board already was.

### Added

- **`/admin/ad-analytics` — ad network performance dashboard** (admin-gated,
  in the admin nav section): KPI cards with sparklines (impressions, clicks,
  CTR, apps reporting), daily network traffic line chart, impressions
  stacked by app, impression/click share donuts, per-campaign
  impressions+clicks and CTR bars, a radial CTR-by-app chart, a
  campaign-activity timeline (dash-mui-scheduler `EventTimeline` — the
  served history day by day), and a top-pages table. 7/30/90-day ranges.
  Backed by a new `get_timeseries` aggregation (`lib/advertising.py`) with
  a Postgres daily GROUP BY (`stats_timeseries` in `lib/ad_storage.py`) so
  the history is persistent in production, and the JSON fallback in dev;
  the page footer shows which source is live. Built with dash-mui-charts +
  dash-mui-scheduler.

- **Ad board image uploads to the network CDN** — the `/admin/ad-board`
  campaign form now has a drop zone under the Image link field: drop or pick
  an image (PNG/JPG/GIF/WebP/AVIF, up to 10 MB) and it uploads to the shared
  2plot R2 bucket and comes back as a permanent `https://cdn.2plot.ai/ads/…`
  URL, filled into the form and previewed immediately. Ads finally survive
  redeploys (no more ephemeral `/assets/` images) and the same URL works
  across every app in the ad network. Pasting an external URL still works;
  deployments without the `R2_*` env vars keep the paste-a-link flow and the
  upload zone explains itself instead of failing. (`lib/r2_images.py`; adds
  `boto3`.)

### Added — Email system (dash-email + Resend)

- **dash-email 0.0.1 docs page** (`/pip/dash_email`) — quick start, rows/columns,
  and button/CTA live examples; added to the home catalogue table and download counter
- **`/admin/email-board`** — admin control board for transactional and promotional
  email: template picker (welcome / announcement / newsletter / plain) with live
  dash_email preview and generated-HTML view; single, scheduled ("in 1 hour" / ISO),
  and per-recipient batch sends (100/chunk); recipients as a searchable MultiSelect
  populated from the Clerk user base (`lib/clerk_users.py`) with select-all and
  custom-address entry; durable outbox history (`email_outbox.json`)
- **`lib/email_provider.py`** — Resend REST API via `requests` (no SDK) +
  dash_email component→HTML renderer; verified with a live send from
  `dashboard@2plot.dev`

### Added — Advertising platform

- **`/admin/ad-board`** — campaign CRUD from an image link + destination link, with
  rotation weights, live preview, active toggles, and per-campaign
  impressions / clicks / CTR
- **Live ad serving** — ads are now resolved per page view by a mount callback
  (`serve_ad`) instead of being baked into layouts at startup: board changes apply
  in production without a restart, and impressions count real views
- **Cross-app ad network** — satellites on their own domains fetch ads from
  `GET /api/ad-network/serve` (server-to-server, per page view) and beacon clicks to
  `POST /api/ad-network/click` (CORS, preflight-free text/plain) with app + page
  attribution; per-campaign **app targeting** and a board **Performance tab**
  (per-app and per-page CTR); satellite kit `lib/ad_client.py` (2s timeout, 60s
  failure circuit breaker) wired into dash-documentation-boilerplate, dash-email,
  dash-mui-scheduler, dash-flows, and dash-mui-charts
- **Postgres persistence** (`lib/ad_storage.py`) — campaigns + analytics in the
  shared 2plot production Render Postgres (`AD_DATABASE_URL`/`DATABASE_URL`),
  auto-DDL, one-time JSON migration, JSON-file fallback in dev

### Added — Navigation & branding

- **Admin nav section** — Control Board / Email Board / Ad Board moved out of
  Components into an admin-only section, rendered per request against the
  `ADMIN_EMAILS` allowlist (links never sent to non-admins)
- **README.md** — project README in the catalogue-standard format

### Changed

- **2plot.dev rebrand** of `templates/index.html` (titles, og:site_name, noscript);
  YouTube references now `@2plotai`
- **PyPI cleanup** — dash-email 0.0.1, dash-nle-timeline 0.0.1, and
  dash-mui-scheduler 0.1.0 now install from PyPI (vendor archives removed);
  dash-mui-charts / flexlayout-dash / dash-excalidraw stay vendored while PyPI
  lags the deployed versions
- **Docs cross-links** — dash-email, dash-mui-scheduler, dash-mui-charts,
  dash-flows, and dash-nle-timeline pages now open with a link to their dedicated
  deep-dive docs site plus GitHub/PyPI links
- **Header** — "Other Apps" menu hidden below the `md` breakpoint so the header
  fits on small screens
- **`.claude/` removed from the repository** (now gitignored)

### Fixed

- **Corrupt `advertising_analytics.json`** (truncated write) that had been failing
  every impression/click log — repaired (190 impressions / 4 clicks salvaged) and
  the loader now auto-recovers with a `.corrupt` backup
- **dash-email docs examples in dark mode** — all example text now carries explicit
  email-safe colors, plus a `[data-email-component]` CSS safety net so email
  previews always render as light artifacts

---

## [2.0.0] - 2026-07-07

### Changed — Core Framework Upgrade

- **Dash 3.3.0 → 4.4.0** — removed stale `_dash_renderer` imports (DMC 2.7+ no longer
  needs `_set_react_version`); `health_endpoint`, `app.setup_apis()`, and Dash hooks all
  carried forward unchanged
- **dash-mantine-components 2.4.0 → 2.8.0** (Mantine 8.3.x) — no breaking API changes
- **dash-improve-my-llms 0.3.0 → 2.0.0** (`[flask]` extra now required) — adopted the
  `LLMS_DOC` pattern: every docs page's processed markdown is registered via
  `register_page_metadata(llms_doc=…)`, powering bot-detection prerendering now and
  `dash.mcp` resources when we reach Dash 4.3+ MCP; the package's `/page.json`,
  `/architecture.txt`, and TOON endpoints were removed upstream (our own
  `/<page>/llms.txt` and `/<page>/page.json` routes are unaffected)
- **flexlayout-dash 1.0.0 → 1.1.0** — BREAKING import rename `dash_flex_layout` →
  `flexlayout_dash`; docs folder renamed to `docs/flexlayout_dash/` (endpoint URL
  `/pip/dash_flex_layout` unchanged)
- **dash-pannellum → 0.4.0** — docs now cover `lookAt` camera control, per-name
  `callbackHotspots` diffing, and the 0.4.0 gyroscope look-around
  (`orientation` / `orientationSupported` / `orientationActive`)
- **dash-excalidraw → 0.1.0** — docs rewritten for the command/lastExport imperative
  API and the JSON-safe prop surface (38-prop table regenerated from docstrings)
- **dash-mui-charts → 1.3.0**, **dash-flows → 1.3.0**, **dash-ag-grid → 35.x**

### Added — New Component Documentation

- **dash-mui-scheduler 0.1.0** (`/pip/dash_mui_scheduler`) — MUI X Scheduler event
  calendar/timeline; replaces dash-fullcalendar
- **dash-leaflet2 0.1.0** (`/pip/dash_leaflet2`) — Leaflet 2-native map components
  (admin-only visibility by default)
- **dash-nle-timeline 0.0.1** (`/pip/dash_nle_timeline`) — non-linear-editor timeline +
  scene compositor, including the "Python owns clips / apply lastEdit" pattern

### Added — Authentication & Access Control (Clerk)

- **dash-clerk-auth 0.9.0** integration: `register_clerk_auth()` before `Dash()`
  (headless mode), `configure_app()` after; account menu embedded in the header
- **Four-tier page visibility** (`lib/page_visibility.py`): `public` / `auth` /
  `admin` (ADMIN_EMAILS/ADMIN_USER_IDS allowlist) / `hidden`, defaulting from docs
  frontmatter (`visibility:`, `llms_public:`) and resolved per request — component docs
  default to `auth` so the sign-in card drives account creation
- **Admin Control Board** (`/admin/control-board`) — live toggles for every docs page's
  visibility tier and llms.txt exposure, persisted to `page_visibility.json`, applied
  without restart
- llms.txt / page.json routes respect visibility: hidden pages 404, admin-tier content
  is never served to anonymous or crawler traffic, `llms_public` is toggleable per page
- Graceful degradation: without Clerk keys the site runs fully open (dev mode) with a
  startup warning

### Removed

- **dash-fullcalendar** — docs removed, package moved to the Archived catalogue
  (superseded by dash-mui-scheduler)

### Infrastructure

- `vendor/` wheels for not-yet-published packages (flexlayout-dash 1.1.0,
  dash-mui-charts 1.3.0, dash-mui-scheduler 0.1.0, dash-excalidraw 0.1.0,
  dash-leaflet2 0.1.0, dash-nle-timeline 0.0.1, dash-clerk-auth 0.9.0) — see
  `.claude/tasks/pypi-publish-checklist.md`
- "Other Apps" header menu → piratesbargain.com, ai-agent.buzz, 2plot.ai, 2plot.media,
  2plot.xyz
- Home catalogue: 17 maintained / 8 archived packages; download counter tracks the new
  packages

## [1.2.0] - 2026-04-08

### Added

#### Discord Integration — `dash-widgetbot` v0.4.1
- Global Discord Crate widget embedded on every page via `add_discord_crate()` in `run.py`
- Custom floating action button replacing the default Crate button (`assets/widgetbot_fix.js`)
- Slash command bridge: `/ask`, `/ai`, `/gen`, `/status`, `/navigate` commands registered and dispatched through `callbacks/discord_handlers.py`
- Crate text bridge — intercepts free-text `/cmd` messages from WidgetBot and routes to bot handlers
- Bot bridge HTTP endpoint (`/api/bot-bridge-prompt`) serving AI-assisted responses with agent avatar
- Discord interactions endpoint (`/api/discord/interactions`) with guild command registration
- `lib/bot_bridge.py` — streaming Gemini AI integration for Discord bot responses
- New docs page at `/pip/dash_widgetbot` — comprehensive guide covering Crate setup, slash commands, bridge callbacks, and functions-as-props event handling

#### `dash-flows` v1.2.0 Feature Documentation
- **Sub-flows (Collapsible Groups)** — `docs/dash_flows/subflows.py` with working double-click collapse via `toggleCollapseNode` / `collapsedGroups` callback
- **Smart Handle Positioning** — `docs/dash_flows/smart_handles.py` — edges auto-route to the closest node side
- **Floating Edges** — `docs/dash_flows/floating_edges.py` — edges that connect to node perimeters
- **Helper Lines** — `docs/dash_flows/helper_lines.py` — alignment guides when dragging nodes
- **Undo / Redo** — `docs/dash_flows/undo_redo.py` — history tracking with `enableUndoRedo`, `undoRedoAction`, `undoRedoState`
- **Add Node on Edge Drop** — `docs/dash_flows/add_node_drop.py`
- **Animated Layout Transitions** — `docs/dash_flows/animated_layout.py`
- **Computing Flows** — `docs/dash_flows/computing_flows.py` — topological sort and data propagation
- **Resize Constraints** — `docs/dash_flows/resize_constraints.py` — aspect ratio lock, min/max dimensions
- **Accessibility (ARIA)** — `docs/dash_flows/accessibility.py` — screen reader labels and keyboard navigation
- **Viewport Portal** — `docs/dash_flows/viewport_portal.py` — floating annotations at flow coordinates
- **Custom Icons** — `docs/dash_flows/custom_icons.py` — DashIconify integration with stacked/horizontal layouts

#### `dash-mui-charts` v1.1.0 Upgrade
- **LiveTradingChart** — new component: real-time OHLCV candlestick streaming with volume histogram, forecast line with uncertainty bands, and swing-point alert labels
- **Tick Configuration & Date Formatting** — new `dateFormat` / `dateTickFormat` props on time-scale axes; no JavaScript required for formatted date labels
- **Functions-as-props pattern** — `alertFormatter` and `alertFilter` props accepting `{function, options}` dicts resolved from `window.dashMuiChartsFunctions`
- `assets/muiChartsFunctions.js` — JS registry providing `priceAlertFormatter` (▲/▼ price labels) and `swingAlertFilter` (configurable lookback-based swing detection)
- New docs section **Tick Configuration & Date Formatting** in `docs/dash_mui_charts/tick_hover.py` — time-scale axis, angled labels, reference lines, click-event capture
- New docs section **Live Trading Chart** in `docs/dash_mui_charts/live_trading.py` — full interactive demo with Start/Stop/Reset, Speed/Volatility/Drift/Window sliders, stats readout, and alert history log

#### New Layout Manager Documentation
- `docs/dash_dock_view/` — comprehensive docs for `dash-dock-view` drag-and-drop panel system
- `docs/dash_flex_layout/` — comprehensive docs for `flexlayout-dash` multi-panel tabbed layout
- `docs/dash_flows/` — full v1.2.0 documentation suite (12 example files)

### Changed

- **`dash-mui-charts` alert tuning** — reduced default `alertProbability` to `0.03` and raised `alertThresholdPct` to `3.0` in the docs example to keep labels sparse and meaningful
- **Navbar** — Changelog link moved out of the Components accordion; now appears as a top-level link between Index and Docs Boilerplate in both desktop and mobile layouts
- **`requirements.txt`** — upgraded `dash-mui-charts>=1.0.0` → `dash-mui-charts==1.1.0`

### Fixed

- **`dash-flows` sub-flows collapse** — `docs/dash_flows/subflows.py` was missing the callback connecting `doubleClickedNode` → `toggleCollapseNode`; double-clicking a group node now correctly collapses and expands it
- **`dash-flows` React Flow height error** — component renders correctly inside dock tabs; error was cosmetic (fired before container had visible dimensions) and resolves on scroll

---

## [1.1.0] - 2026-01-05

### Added
- **Liquid Glass Theme Showcases**
  - Created isolated liquid glass design example for `dash_dock_view/themes_example.py` with Apple WWDC 2025 inspired glassmorphism
  - Created liquid glass theme example for `dash_flex_layout/theming_example.py` with 3-panel layout and theme switching
  - Inline style definitions for backdrop blur, saturation, translucency effects
  - Dynamic theme switching between light and dark modes with helper functions
  - 5 themed panels in dock example, 3 themed panels in flex layout example
  - Professional gradient overlays and smooth transitions

### Changed
- **Component Migration to DMC**
  - Replaced all `dash.html` components with `dash_mantine_components` (DMC) in dash_flex_layout examples
  - Migrated `ide_layout.py`, `callbacks_example.py`, and `theming_example.py` to use DMC components
  - Updated HTML elements: `html.Div` → `dmc.Box`, `html.H4` → `dmc.Title`, `html.Pre` → `dmc.Code`, `html.P` → `dmc.Text`
  - Improved component theming consistency across light and dark modes

### Fixed
- **Theme-Aware Component Styling**
  - Fixed dmc.Paper components staying white in dark mode in dash_flows examples
  - Removed hardcoded `bg="gray.0"` props from `basic_nodes_edges.py` (2 instances) and `node_interactions.py` (3 instances)
  - Papers now properly respect Mantine's automatic light/dark mode theming
  - Fixed SegmentedControl labels to use emoji strings instead of DashIconify components (prevents JavaScript errors)
  - Removed invalid `height` prop usage on DashFlexLayout components

- **Layout and Sizing Issues**
  - Added `style={"height": "100%", "width": "100%"}` to all DashFlexLayout instances
  - Fixed components not filling their containers in 5 files: `introduction.py`, `two_panel_layout.py`, `ide_layout.py`, `callbacks_example.py`, `theming_example.py`
  - Replaced `html.Div` wrappers with `dmc.Box` for proper sizing behavior
  - Ensured all examples properly utilize 500px/450px/400px container heights

### Documentation
- **Code Quality Improvements**
  - Established rule: Never use `html.Style` (doesn't exist in Dash)
  - Standardized on inline Python style dictionaries for isolated component styling
  - Added comprehensive comments to liquid glass style definitions
  - Improved code examples with theme-aware patterns

---

## [1.0.0] - 2025-11-16

### Initial Production Release

Production-ready documentation platform for Pip Install Python's custom Dash components and Python packages, now live at **https://pip-install-python.com**

---

## 🎯 Core Features

### 📚 Documentation System
- **Markdown-Driven Content**
  - Write documentation in Markdown with Python integration
  - Frontmatter metadata for page configuration
  - Custom directive system for interactive examples
  - Automatic page generation and routing
  - Table of contents generation

- **Custom Markdown Directives**
  - `.. toc::` - Automatic table of contents from headings
  - `.. exec::module.path` - Render executable Python components
  - `.. source::file/path.py` - Display source code with syntax highlighting
  - `.. sourcetabs::module.path` - Tabbed source code display
  - `.. kwargs::ComponentName` - Component props documentation tables
  - `.. llms_copy::` - Copy llms.txt URL for AI assistant sharing

- **18 Custom Component Libraries Documented**
  - Currently maintained: dash-summernote, dash-insta-stories, dash-image-gallery, dash-fullcalendar, dash-gauge, dash-emoji-mart, dash-dock, dash-pannellum, dash-planet, dash-model-viewer, dash-excalidraw
  - Archived: dash-credit-cards, dash-charty, dash-nivo, dash-discord, dash-dynamic-grid-layout, dash-swiper, dash-fullcalendar
  - Each with comprehensive guides, live examples, and download statistics

### 🎨 Modern UI/UX
- **Responsive Design**
  - Mobile, tablet, and desktop optimized layouts
  - Adaptive navigation with hamburger menu on mobile
  - Responsive AppShell with collapsible sidebar
  - Custom breakpoints for optimal viewing

- **Theme System**
  - Light and dark mode with automatic persistence
  - Browser preference detection on first visit
  - Smooth theme transitions without page flash
  - Theme-aware Plotly charts with DMC figure templates
  - Professional typography with Inter font family
  - Systematic 4px-based spacing scale
  - 5-level shadow system for depth
  - Code block theming with proper syntax highlighting

- **Navigation & Search**
  - Custom page ordering and organization
  - Searchable component navigation
  - Icon-based visual hierarchy
  - "Other Apps" menu for quick access to related projects
  - Breadcrumb-style organization

### 📊 Analytics & Tracking

- **Visitor Analytics System**
  - Session-based tracking using MD5 hash of IP + user agent
  - Device type detection (desktop, mobile, tablet, bot)
  - Bot type classification (training, search, traditional)
  - Geolocation tracking with ip-api.com integration
  - Unique visitor counting (sessions, not page views)
  - First-visit-only location data to avoid duplicates
  - Privacy-conscious tracking with session IDs
  - Race condition handling for concurrent file access
  - JSON data persistence with error recovery

- **Analytics Dashboard** (`/analytics/traffic`)
  - Real-time visitor statistics (unique visitors by device type)
  - Interactive geolocation bubble map with Plotly
  - Device breakdown visualization (desktop/mobile/tablet/bot)
  - Traffic by page with bar charts
  - Hourly traffic patterns with line charts
  - Browser detection and analytics
  - Comprehensive visitor metrics and insights

- **PyPI Download Statistics**
  - Automatic aggregation across all 18 packages
  - Real-time download counters from pypistats.org API
  - Visual presentation on home page
  - Smart error handling and caching
  - Historical data tracking

### 🤖 AI Integration & Chat

- **Page-Level AI Chat**
  - Context-aware AI assistant on every documentation page
  - Streaming SSE responses for real-time interaction
  - Page context injection (markdown content, code examples, metadata)
  - Session-based conversation tracking
  - Multiple response formats (markdown, code)
  - Chat history and analytics tracking
  - Cost and token usage monitoring
  - Error handling and graceful degradation

- **AI Analytics Dashboard** (`/analytics`)
  - Cost breakdown by model (Claude Opus, Sonnet, Haiku)
  - Total API costs and token usage tracking
  - Questions per page visualization
  - Recent user questions with interactive modal details
  - Chat log viewing with formatted Q&A display
  - Time-based filtering and analysis
  - Cost efficiency metrics

- **AI Chat Modal System**
  - Row selection in dash-ag-grid tables
  - Modal display of complete chat interactions
  - Formatted question and response sections
  - Metadata badges (timestamp, page, model, cost, tokens)
  - Markdown rendering for responses
  - Clean, professional UI with DMC Paper components

### 🔍 SEO & LLM Integration

- **dash-improve-my-llms Integration (v0.3.0)**
  - Automatic llms.txt generation for AI understanding
  - page.json with technical architecture details
  - architecture.txt with ASCII art overview
  - robots.txt with intelligent bot management
  - sitemap.xml with smart priority inference
  - Schema.org structured data for search engines
  - Custom page-specific llms.txt routes
  - Source directive processing for complete context

- **Bot Management**
  - Blocks AI training bots (GPTBot, CCBot, anthropic-ai)
  - Allows AI search bots (ChatGPT-User, ClaudeBot, PerplexityBot)
  - Allows traditional search engines (Googlebot, Bingbot)
  - Configurable crawl delays and disallowed paths
  - Privacy controls with mark_hidden() for sensitive pages

- **SEO Optimization**
  - Google Analytics integration (G-6WYY9JHMP2)
  - Minimal, optimized HTML template
  - Fast page load times
  - Mobile-friendly design
  - Proper meta tag configuration
  - Structured data for rich search results

### 🐋 Production Infrastructure

- **Technology Stack**
  - **Dash 3.2.0** - Modern Plotly Dash framework
  - **Dash Mantine Components 2.4.0** - Beautiful React UI
  - **Mantine 8.3.6** - Latest Mantine design system
  - **React 18.2.0** - Modern React features
  - **Python 3.11+** - Latest Python capabilities
  - **Flask 3.1.2** - Production web server
  - **Plotly 6.4.0** - Interactive visualizations

- **Deployment Ready**
  - Docker and docker-compose support
  - Gunicorn production server configuration
  - Environment-based configuration
  - Error handling and logging
  - Graceful degradation for missing data
  - Production URL configuration (pip-install-python.com)

- **Performance Optimizations**
  - Fast file pattern matching with Glob
  - Efficient JSON data persistence
  - LRU caching for geolocation lookups
  - Optimized font loading (Inter, JetBrains Mono)
  - CSS optimization and minification
  - Chart template caching
  - Smart skip paths for analytics tracking

### 🎯 Developer Experience

- **Code Quality**
  - PEP 8 compliant Python code
  - Comprehensive inline comments
  - Type hints where appropriate
  - Modular, reusable components
  - Clean separation of concerns
  - Error handling throughout

- **Documentation Quality**
  - 5 comprehensive guides with 15+ examples
  - Live interactive code demonstrations
  - Best practices and patterns
  - Troubleshooting guides
  - Migration documentation
  - API references

- **Development Tools**
  - Hot reload during development
  - Debug mode support
  - Comprehensive error messages
  - Development server on port 8502
  - Environment variable support

---

## 📁 Project Structure

```
pip-docs/
├── assets/                          # Static assets
│   ├── main.css                    # Custom styles (theme-aware)
│   ├── m2d.css                     # Markdown styling
│   ├── chat.js                     # AI chat client
│   ├── llms_copy.js                # LLM copy button handler
│   ├── model_viewer_*.js           # 3D model viewer scripts
│   └── [images, icons, etc.]
│
├── callbacks/                       # Dash callbacks
│   └── chat_callbacks.py           # AI chat functionality
│
├── components/                      # UI components
│   ├── appshell.py                 # Main layout with MantineProvider
│   ├── header.py                   # Header with search and theme toggle
│   └── navbar.py                   # Navigation sidebar (custom ordering)
│
├── docs/                            # Documentation content
│   ├── dash_*/                     # Component-specific docs (18 packages)
│   └── [markdown files + Python examples]
│
├── lib/                             # Utility libraries
│   ├── analytics_tracker.py        # Visitor analytics system
│   ├── constants.py                # App-wide constants
│   ├── directives/                 # Custom markdown directives
│   └── page_chat/                  # AI chat context gathering
│
├── pages/                           # Dash pages
│   ├── home.py                     # Home page with download stats
│   ├── markdown.py                 # Dynamic markdown loader
│   ├── analytics.py                # Visitor analytics dashboard
│   ├── api_analytics.py            # AI chat analytics dashboard
│   └── not_found_404.py            # Custom 404 page
│
├── templates/
│   └── index.html                  # Production HTML template
│
├── CHANGELOG.md                    # This file
├── README.md                       # Project documentation
├── CLAUDE.md                       # AI development notes
├── requirements.txt                # Python dependencies
├── package.json                    # Node.js dependencies
├── run.py                          # Application entry point
└── visitor_analytics.json          # Analytics data store
```

---

## 🔧 Configuration

### Environment Variables
- `DASH_DEBUG` - Enable debug mode (default: False)
- `DASH_HOST` - Server host (default: 0.0.0.0)
- `DASH_PORT` - Server port (default: 8502)
- `ANTHROPIC_API_KEY` - API key for AI chat (required for chat features)

### Key Configuration Points
- `run.py:44` - Base URL for SEO (set to https://2plot.dev)
- `run.py:47-53` - Bot management policies
- `lib/constants.py` - App-wide constants and theming
- `templates/index.html` - Google Analytics and meta tags
- `components/appshell.py` - Theme configuration and MantineProvider settings

---

## 📊 Analytics Features Breakdown

### Session-Based Tracking
- Unique session ID generated from IP address + user agent (MD5 hash)
- Prevents duplicate counting of same visitor across pages
- First-visit-only geolocation to reduce API calls
- Consistent localhost location assignment for development testing

### Visitor Analytics
- **Device Detection**: Desktop, mobile, tablet, bot classification
- **Bot Classification**: Training bots, search bots, traditional crawlers
- **Geolocation**: City, region, country with coordinates
- **Privacy**: No PII stored, only hashed session IDs
- **Performance**: Skip tracking for assets (.css, .js, images, Dash internals)

### AI Chat Analytics
- **Cost Tracking**: Per-model cost breakdown (Opus $15/$75, Sonnet $3/$15, Haiku $0.25/$1.25)
- **Token Usage**: Input and output token counting
- **Question Analysis**: Track questions per page
- **Response Logging**: Complete chat history with timestamps
- **Modal Details**: Interactive row selection for full chat logs

---

## 🚀 Getting Started

### Installation
```bash
# Clone repository
git clone https://github.com/pip-install-python/pip-docs.git
cd pip-docs

# Install Python dependencies
pip install -r requirements.txt

# Install Node dependencies
npm install

# Run development server
python run.py
```

### Access Points
- **Main Application**: http://localhost:8502
- **Visitor Analytics**: http://localhost:8502/analytics/traffic
- **AI Chat Analytics**: http://localhost:8502/analytics
- **404 Page**: http://localhost:8502/404

### Production Deployment
```bash
# Build Docker image
docker build -t pip-docs .

# Run with Docker Compose
docker-compose up -d

# Access at port 8550
```

---

## 🎯 Future Enhancements

### Planned Features
- Extended analytics dashboard with more visualizations
- User authentication and personalized experiences
- Advanced search with full-text indexing
- Version switcher for documentation
- Code playground/sandbox for testing components
- Automated screenshot generation for examples
- More chart types (heatmaps, 3D plots, geographic maps)
- Export analytics to CSV/JSON
- Real-time visitor tracking with WebSocket
- A/B testing framework for documentation

### Under Consideration
- Multi-language support (i18n)
- Community contributions and voting system
- Interactive tutorials and walkthroughs
- Video tutorials integration
- RSS feed for updates
- Newsletter integration
- Component comparison tools
- Performance benchmarking suite

---

## 🙏 Acknowledgments

### Built With
- [Plotly Dash](https://dash.plotly.com/) - Web framework
- [Dash Mantine Components](https://dash-mantine-components.com/) - UI components
- [Mantine](https://mantine.dev/) - React component library
- [dash-improve-my-llms](https://pypi.org/project/dash-improve-my-llms/) - AI/SEO integration
- [dash-ag-grid](https://dash.plotly.com/dash-ag-grid) - Advanced data grids
- [Plotly](https://plotly.com/) - Interactive visualizations
- [Anthropic Claude](https://anthropic.com/) - AI chat capabilities

### Special Thanks
- Plotly Dash team for the amazing framework
- Snehil Vijay for Dash Mantine Components & Ann Marie W for maintaining DMC
- The many open-source community members for continuous support
- All contributors to the custom Dash components

---

## 📝 License

This project is licensed under the MIT License - see the LICENSE file for details.

---

## 📞 Support & Community

### Get Help
- **GitHub Issues**: [Report bugs or request features](https://github.com/pip-install-python/pip-docs/issues)
- **GitHub Discussions**: [Ask questions and share ideas](https://github.com/pip-install-python/pip-docs/discussions)
- **Dash Community**: [Plotly Community Forum](https://community.plotly.com/)
- **Discord**: [Join the Dash Discord](https://discord.gg/uwQ2f3KCad)

### Stay Connected
- **Website**: https://2plot.dev
- **GitHub**: [@pip-install-python](https://github.com/pip-install-python)
- **YouTube**: [Pip Install Python](https://youtube.com/@PipInstallPython)
- **2plot.ai**: https://plotly.pro
- **ai-agent.buzz**: https://ai-agent.buzz

---

**Made with ❤️ by Pip Install Python LLC**

**Star this repo if you find it useful!** ⭐

---

[1.1.0]: https://github.com/pip-install-python/pip-docs/releases/tag/v1.1.0
[1.0.0]: https://github.com/pip-install-python/pip-docs/releases/tag/v1.0.0

---

<!-- /pip — https://2plot.dev/pip/llms.txt -->

# Component Index

> Every Dash component library documented on this site, with a link to each component's documentation and to its machine-readable llms.txt.

Every component below is an independently released, open-source Dash component library published to PyPI by Pip Install Python. Each has its own documentation page on this site with live, interactive examples, and each page has a Markdown twin at the same URL with `/llms.txt` appended.

There are 20 documented components.

## Components

### [Boilerplate](https://2plot.dev/pip/dash_documentation_boilerplate)

The markdown-driven documentation template every *.2plot.dev satellite site is forked from — Dash 4.x, Dash Mantine Components, pluggable Flask/FastAPI/Quart backends, and llms.txt built in.

- Documentation: [https://2plot.dev/pip/dash_documentation_boilerplate](https://2plot.dev/pip/dash_documentation_boilerplate)
- Machine-readable: [https://2plot.dev/pip/dash_documentation_boilerplate/llms.txt](https://2plot.dev/pip/dash_documentation_boilerplate/llms.txt)
- Dedicated docs site: [https://boilerplate.2plot.dev](https://boilerplate.2plot.dev) — complete API reference and deeper examples
  - Machine-readable: [https://boilerplate.2plot.dev/llms.txt](https://boilerplate.2plot.dev/llms.txt)

### [Dock View](https://2plot.dev/pip/dash_dock_view)

A flexible docking layout system for Dash with DashDockLayout, DashGridLayout, DashSplitLayout, and DashPaneLayout components.

- Documentation: [https://2plot.dev/pip/dash_dock_view](https://2plot.dev/pip/dash_dock_view)
- Machine-readable: [https://2plot.dev/pip/dash_dock_view/llms.txt](https://2plot.dev/pip/dash_dock_view/llms.txt)
- PyPI: [dash-dock-view](https://pypi.org/project/dash-dock-view/) — `pip install dash-dock-view`

### [Email](https://2plot.dev/pip/dash_email)

Email components for Dash wrapping React Email patterns — 15 email-safe components, table-based layouts, live preview, and sending via Resend.

- Documentation: [https://2plot.dev/pip/dash_email](https://2plot.dev/pip/dash_email)
- Machine-readable: [https://2plot.dev/pip/dash_email/llms.txt](https://2plot.dev/pip/dash_email/llms.txt)
- Dedicated docs site: [https://email.2plot.dev](https://email.2plot.dev) — complete API reference and deeper examples
  - Machine-readable: [https://email.2plot.dev/llms.txt](https://email.2plot.dev/llms.txt)
- PyPI: [dash-email](https://pypi.org/project/dash-email/) — `pip install dash-email`

### [Emoji Mart](https://2plot.dev/pip/dash_emoji_mart)

Modern emoji picker component with custom emojis, themes, and extensive customization options.

- Documentation: [https://2plot.dev/pip/dash_emoji_mart](https://2plot.dev/pip/dash_emoji_mart)
- Machine-readable: [https://2plot.dev/pip/dash_emoji_mart/llms.txt](https://2plot.dev/pip/dash_emoji_mart/llms.txt)
- Dedicated docs site: [https://emojimart.2plot.dev](https://emojimart.2plot.dev) — complete API reference and deeper examples
  - Machine-readable: [https://emojimart.2plot.dev/llms.txt](https://emojimart.2plot.dev/llms.txt)
- PyPI: [dash-emoji-mart](https://pypi.org/project/dash-emoji-mart/) — `pip install dash-emoji-mart`

### [Excalidraw](https://2plot.dev/pip/dash_excalidraw)

Notebook, Freeform, Drawing type of component.

- Documentation: [https://2plot.dev/pip/dash_excalidraw](https://2plot.dev/pip/dash_excalidraw)
- Machine-readable: [https://2plot.dev/pip/dash_excalidraw/llms.txt](https://2plot.dev/pip/dash_excalidraw/llms.txt)
- Dedicated docs site: [https://excalidraw.2plot.dev](https://excalidraw.2plot.dev) — complete API reference and deeper examples
  - Machine-readable: [https://excalidraw.2plot.dev/llms.txt](https://excalidraw.2plot.dev/llms.txt)
- PyPI: [dash-excalidraw](https://pypi.org/project/dash-excalidraw/) — `pip install dash-excalidraw`

### [Flex Layout](https://2plot.dev/pip/dash_flex_layout)

Advanced docking layout system with resizable panels, floating windows, and theme integration.

- Documentation: [https://2plot.dev/pip/dash_flex_layout](https://2plot.dev/pip/dash_flex_layout)
- Machine-readable: [https://2plot.dev/pip/dash_flex_layout/llms.txt](https://2plot.dev/pip/dash_flex_layout/llms.txt)
- Dedicated docs site: [https://flexlayout.2plot.dev](https://flexlayout.2plot.dev) — complete API reference and deeper examples
  - Machine-readable: [https://flexlayout.2plot.dev/llms.txt](https://flexlayout.2plot.dev/llms.txt)
- PyPI: [flexlayout-dash](https://pypi.org/project/flexlayout-dash/) — `pip install flexlayout-dash`

### [Flows](https://2plot.dev/pip/dash_flows)

Interactive node-based flow diagrams with React Flow integration for Plotly Dash

- Documentation: [https://2plot.dev/pip/dash_flows](https://2plot.dev/pip/dash_flows)
- Machine-readable: [https://2plot.dev/pip/dash_flows/llms.txt](https://2plot.dev/pip/dash_flows/llms.txt)
- Dedicated docs site: [https://flows.2plot.dev](https://flows.2plot.dev) — complete API reference and deeper examples
  - Machine-readable: [https://flows.2plot.dev/llms.txt](https://flows.2plot.dev/llms.txt)
- PyPI: [dash-flows](https://pypi.org/project/dash-flows/) — `pip install dash-flows`

### [Gauge](https://2plot.dev/pip/dash_gauge)

A collection of gauge, knob, thermostat, joystick, and display components for Dash.

- Documentation: [https://2plot.dev/pip/dash_gauge](https://2plot.dev/pip/dash_gauge)
- Machine-readable: [https://2plot.dev/pip/dash_gauge/llms.txt](https://2plot.dev/pip/dash_gauge/llms.txt)
- PyPI: [dash-gauge](https://pypi.org/project/dash-gauge/) — `pip install dash-gauge`

### [Image Gallery](https://2plot.dev/pip/dash_image_gallery)

Responsive image gallery component with lightbox, thumbnails, fullscreen, slideshow, and touch support

- Documentation: [https://2plot.dev/pip/dash_image_gallery](https://2plot.dev/pip/dash_image_gallery)
- Machine-readable: [https://2plot.dev/pip/dash_image_gallery/llms.txt](https://2plot.dev/pip/dash_image_gallery/llms.txt)
- PyPI: [dash-image-gallery](https://pypi.org/project/dash-image-gallery/) — `pip install dash-image-gallery`

### [Insta Stories](https://2plot.dev/pip/dash_insta_stories)

Instagram and Snapchat style stories component with video support, custom renderers, and interactive controls.

- Documentation: [https://2plot.dev/pip/dash_insta_stories](https://2plot.dev/pip/dash_insta_stories)
- Machine-readable: [https://2plot.dev/pip/dash_insta_stories/llms.txt](https://2plot.dev/pip/dash_insta_stories/llms.txt)
- PyPI: [dash-insta-stories](https://pypi.org/project/dash-insta-stories/) — `pip install dash-insta-stories`

### [Leaflet2](https://2plot.dev/pip/dash_leaflet2)

Leaflet 2-native map components for Dash — Map, TileLayer, Marker, GeoJSON, LayersControl and more, with no react-leaflet dependency.

- Documentation: [https://2plot.dev/pip/dash_leaflet2](https://2plot.dev/pip/dash_leaflet2)
- Machine-readable: [https://2plot.dev/pip/dash_leaflet2/llms.txt](https://2plot.dev/pip/dash_leaflet2/llms.txt)
- Dedicated docs site: [https://leaflet.2plot.dev](https://leaflet.2plot.dev) — complete API reference and deeper examples
  - Machine-readable: [https://leaflet.2plot.dev/llms.txt](https://leaflet.2plot.dev/llms.txt)
- PyPI: [dash-leaflet2](https://pypi.org/project/dash-leaflet2/) — `pip install dash-leaflet2`

### [LLMs](https://2plot.dev/pip/dash_improve_my_llms)

Make a Dash app readable to search engines, crawlers and AI agents — llms.txt on every page, crawler-ready HTML, robots.txt, sitemap.xml, a cross-host network directory and an MCP bridge.

- Documentation: [https://2plot.dev/pip/dash_improve_my_llms](https://2plot.dev/pip/dash_improve_my_llms)
- Machine-readable: [https://2plot.dev/pip/dash_improve_my_llms/llms.txt](https://2plot.dev/pip/dash_improve_my_llms/llms.txt)
- Dedicated docs site: [https://llms.2plot.dev](https://llms.2plot.dev) — complete API reference and deeper examples
  - Machine-readable: [https://llms.2plot.dev/llms.txt](https://llms.2plot.dev/llms.txt)
- PyPI: [dash-improve-my-llms](https://pypi.org/project/dash-improve-my-llms/) — `pip install dash-improve-my-llms`

### [Model Viewer](https://2plot.dev/pip/dash_model_viewer)

Embed interactive 3D models with AR support into Dash applications using Google's model-viewer.

- Documentation: [https://2plot.dev/pip/dash_model_viewer](https://2plot.dev/pip/dash_model_viewer)
- Machine-readable: [https://2plot.dev/pip/dash_model_viewer/llms.txt](https://2plot.dev/pip/dash_model_viewer/llms.txt)
- Dedicated docs site: [https://modelviewer.2plot.dev](https://modelviewer.2plot.dev) — complete API reference and deeper examples
  - Machine-readable: [https://modelviewer.2plot.dev/llms.txt](https://modelviewer.2plot.dev/llms.txt)
- PyPI: [dash-model-viewer](https://pypi.org/project/dash-model-viewer/) — `pip install dash-model-viewer`

### [MUI Charts](https://2plot.dev/pip/dash_mui_charts)

13 MUI X components for Dash — line, bar, candlestick, pie, scatter, composite, heatmap, sparkline and live trading charts, plus TreeView, SimpleTreeView, TreeViewPro and TimeClock — with free and Pro tiers.

- Documentation: [https://2plot.dev/pip/dash_mui_charts](https://2plot.dev/pip/dash_mui_charts)
- Machine-readable: [https://2plot.dev/pip/dash_mui_charts/llms.txt](https://2plot.dev/pip/dash_mui_charts/llms.txt)
- Dedicated docs site: [https://muicharts.2plot.dev](https://muicharts.2plot.dev) — complete API reference and deeper examples
  - Machine-readable: [https://muicharts.2plot.dev/llms.txt](https://muicharts.2plot.dev/llms.txt)
- PyPI: [dash-mui-charts](https://pypi.org/project/dash-mui-charts/) — `pip install dash-mui-charts`

### [MUI Scheduler](https://2plot.dev/pip/dash_mui_scheduler)

Event calendars, resource timelines, and radial charts for Dash, wrapping the MUI X Scheduler.

- Documentation: [https://2plot.dev/pip/dash_mui_scheduler](https://2plot.dev/pip/dash_mui_scheduler)
- Machine-readable: [https://2plot.dev/pip/dash_mui_scheduler/llms.txt](https://2plot.dev/pip/dash_mui_scheduler/llms.txt)
- Dedicated docs site: [https://muischeduler.2plot.dev](https://muischeduler.2plot.dev) — complete API reference and deeper examples
  - Machine-readable: [https://muischeduler.2plot.dev/llms.txt](https://muischeduler.2plot.dev/llms.txt)
- PyPI: [dash-mui-scheduler](https://pypi.org/project/dash-mui-scheduler/) — `pip install dash-mui-scheduler`

### [NLE Timeline](https://2plot.dev/pip/dash_nle_timeline)

Non-linear-editor timeline and scene compositor for Dash — frame-accurate scrubbing, multi-track clips, waveforms, and a DOM+Canvas preview stage.

- Documentation: [https://2plot.dev/pip/dash_nle_timeline](https://2plot.dev/pip/dash_nle_timeline)
- Machine-readable: [https://2plot.dev/pip/dash_nle_timeline/llms.txt](https://2plot.dev/pip/dash_nle_timeline/llms.txt)
- PyPI: [dash-nle-timeline](https://pypi.org/project/dash-nle-timeline/) — `pip install dash-nle-timeline`

### [Pannellum](https://2plot.dev/pip/dash_pannellum)

Interactive 360° panorama viewer with tour mode, hotspots, and video support

- Documentation: [https://2plot.dev/pip/dash_pannellum](https://2plot.dev/pip/dash_pannellum)
- Machine-readable: [https://2plot.dev/pip/dash_pannellum/llms.txt](https://2plot.dev/pip/dash_pannellum/llms.txt)
- Dedicated docs site: [https://pannellum.2plot.dev](https://pannellum.2plot.dev) — complete API reference and deeper examples
  - Machine-readable: [https://pannellum.2plot.dev/llms.txt](https://pannellum.2plot.dev/llms.txt)
- PyPI: [dash-pannellum](https://pypi.org/project/dash-pannellum/) — `pip install dash-pannellum`

### [Planet](https://2plot.dev/pip/dash_planet)

Interactive orbital menu component with circular satellite layout, spring animations, and premium features

- Documentation: [https://2plot.dev/pip/dash_planet](https://2plot.dev/pip/dash_planet)
- Machine-readable: [https://2plot.dev/pip/dash_planet/llms.txt](https://2plot.dev/pip/dash_planet/llms.txt)
- PyPI: [dash-planet](https://pypi.org/project/dash-planet/) — `pip install dash-planet`

### [POS Printer](https://2plot.dev/pip/dash_pos_printer)

Cloud-based receipt printing for Dash applications using Star Micronics printers and CloudPRNT

- Documentation: [https://2plot.dev/pip/dash_pos_printer](https://2plot.dev/pip/dash_pos_printer)
- PyPI: [star-micronics-cloudprnt](https://pypi.org/project/star-micronics-cloudprnt/) — `pip install star-micronics-cloudprnt`

### [WidgetBot](https://2plot.dev/pip/dash_widgetbot)

Discord chat integration for Dash apps with floating Crate button, inline Widget embed, slash commands, and AI-powered responses.

- Documentation: [https://2plot.dev/pip/dash_widgetbot](https://2plot.dev/pip/dash_widgetbot)
- Machine-readable: [https://2plot.dev/pip/dash_widgetbot/llms.txt](https://2plot.dev/pip/dash_widgetbot/llms.txt)
- PyPI: [dash-widgetbot](https://pypi.org/project/dash-widgetbot/) — `pip install dash-widgetbot`

## Reading the whole site

- Site index: [https://2plot.dev/llms.txt](https://2plot.dev/llms.txt) — every page, with its own machine-readable URL.
- Sitemap: [https://2plot.dev/sitemap.xml](https://2plot.dev/sitemap.xml)
- Changelog: [https://2plot.dev/changelog](https://2plot.dev/changelog)

---

<!-- /pip/dash_dock_view — https://2plot.dev/pip/dash_dock_view/llms.txt -->

`dash-dock-view` is a professional-grade docking layout system for Dash applications. Build complex, resizable, and rearrangeable panel layouts with ease. It features 4 layout components (DashDockLayout, DashGridLayout, DashSplitLayout, DashPaneLayout), drag & drop tab management, panel positioning (left, right, top, bottom, center), floating panels, layout persistence, and Apple Liquid Glass themes.

### Installation

[Visit GitHub Repo](https://github.com/pip-install-python/dash-dock-view)

```bash
pip install dash-dock-view
```

**Requirements:**
- Python >= 3.7
- Dash >= 2.0

---

### Quick Start

Create a simple docking layout with three panels.



```python
# File: docs/dash_dock_view/docklayout_example.py

"""DashDockLayout example for Dock View documentation."""
from dash import html
import dash_mantine_components as dmc
from dash_iconify import DashIconify
from dash_dock_view import DashDockLayout

# Panel contents
panel_contents = [
    html.Div(
        id="editor-panel",
        children=[
            dmc.Stack([
                dmc.Group([
                    DashIconify(icon="mdi:file-code", width=20),
                    dmc.Text("app.py", fw=500),
                ]),
                dmc.CodeHighlight(
                    language="python",
                    code='from dash import Dash\n\napp = Dash(__name__)\n\nif __name__ == "__main__":\n    app.run(debug=True)',
                ),
            ], p="md")
        ],
        style={"height": "100%"}
    ),
    html.Div(
        id="console-panel",
        children=[
            html.Pre(
                "$ python app.py\n * Running on http://127.0.0.1:8050\n * Debug mode: on\n$ ",
                style={
                    "backgroundColor": "#1e1e1e",
                    "color": "#00ff00",
                    "padding": "10px",
                    "margin": "0",
                    "fontFamily": "monospace",
                    "height": "100%",
                }
            )
        ],
        style={"height": "100%"}
    ),
    html.Div(
        id="explorer-panel",
        children=[
            dmc.Stack([
                dmc.Text("EXPLORER", size="xs", fw=700, c="dimmed"),
                dmc.NavLink(label="src", leftSection=DashIconify(icon="mdi:folder", color="yellow")),
                dmc.NavLink(label="app.py", leftSection=DashIconify(icon="mdi:file-code", color="blue")),
                dmc.NavLink(label="README.md", leftSection=DashIconify(icon="mdi:file-document")),
            ], p="sm", gap="xs")
        ],
        style={"height": "100%"}
    ),
]

component = dmc.Paper(
    DashDockLayout(
        id="dock-demo",
        panels=[
            {"id": "editor-panel", "title": "Editor", "position": "center"},
            {"id": "console-panel", "title": "Console", "position": "bottom", "size": 150},
            {"id": "explorer-panel", "title": "Explorer", "position": "left", "size": 200},
        ],
        children=panel_contents,
        theme="dockview-theme-dark",
        height="400px",
        showAddButton=False,
    ),
    withBorder=True,
    radius="md",
    p=4,
)
```


---

### Layout Components

Dash Dock View provides four distinct layout components for different use cases:

#### 1. DashDockLayout

Full-featured docking system with tabs, floating panels, and drag-and-drop. Ideal for IDE-style interfaces.

```python
from dash_dock_view import DashDockLayout

DashDockLayout(
    id="my-dock",
    panels=[
        {"id": "panel-1", "title": "Editor", "position": "center"},
        {"id": "panel-2", "title": "Console", "position": "bottom", "size": 200},
        {"id": "panel-3", "title": "Explorer", "position": "left", "size": 250},
    ],
    children=[
        html.Div(id="panel-1", children="Main content"),
        html.Div(id="panel-2", children="Console output"),
        html.Div(id="panel-3", children="File explorer"),
    ],
    theme="dockview-theme-dark",
    height="600px",
)
```

#### 2. DashGridLayout

Grid-based resizable panel system for structured layouts.



```python
# File: docs/dash_dock_view/gridlayout_example.py

"""DashGridLayout example for Dock View documentation."""
from dash import html
import dash_mantine_components as dmc
from dash_dock_view import DashGridLayout

# Sample data for chart
chart_data = [
    {"month": "Jan", "value": 1200},
    {"month": "Feb", "value": 1900},
    {"month": "Mar", "value": 1600},
    {"month": "Apr", "value": 2100},
    {"month": "May", "value": 1800},
]

# Panel contents
panel_contents = [
    html.Div(
        id="grid-chart-panel",
        children=[
            dmc.BarChart(
                h="100%",
                data=chart_data,
                dataKey="month",
                series=[{"name": "value", "color": "blue.6"}],
                withLegend=True,
            )
        ],
        style={"height": "100%", "padding": "1rem"}
    ),
    html.Div(
        id="grid-info-panel",
        children=[
            dmc.Stack([
                dmc.Title("Grid Layout", order=4),
                dmc.Text("Resize panels by dragging the divider.", c="dimmed", size="sm"),
                dmc.Divider(my="md"),
                dmc.Text("Features:", fw=600, size="sm"),
                dmc.List([
                    dmc.ListItem("Horizontal or vertical orientation"),
                    dmc.ListItem("Proportional resizing"),
                    dmc.ListItem("Customizable panel sizes"),
                ], size="sm"),
            ], p="md")
        ],
        style={"height": "100%"}
    ),
]

component = dmc.Paper(
    DashGridLayout(
        id="grid-demo",
        panels=[
            {"id": "grid-chart-panel", "size": 300},
            {"id": "grid-info-panel", "size": 200},
        ],
        children=panel_contents,
        theme="dockview-theme-dark",
        height="300px",
        orientation="horizontal",
        proportionalLayout=True,
    ),
    withBorder=True,
    radius="md",
    p=4,
)
```


#### 3. DashSplitLayout

Simple split panel layout with horizontal or vertical orientation.

```python
from dash_dock_view import DashSplitLayout

DashSplitLayout(
    id="my-split",
    panels=[
        {"id": "left-panel", "size": 300},
        {"id": "right-panel"},
    ],
    children=[
        html.Div(id="left-panel", children="Left content"),
        html.Div(id="right-panel", children="Right content"),
    ],
    orientation="horizontal",  # or "vertical"
    height="400px",
)
```

#### 4. DashPaneLayout

Collapsible accordion-style panes for organized content.

```python
from dash_dock_view import DashPaneLayout

DashPaneLayout(
    id="my-panes",
    panels=[
        {"id": "pane-1", "title": "Section 1", "collapsed": False},
        {"id": "pane-2", "title": "Section 2", "collapsed": True},
    ],
    children=[
        html.Div(id="pane-1", children="Content 1"),
        html.Div(id="pane-2", children="Content 2"),
    ],
    height="400px",
)
```

---

### Panel Configuration

Each panel in the layout can be configured with the following options:

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| **`id`** | `string` | **Required** | Unique panel identifier (must match child component id) |
| `title` | `string` | id value | Display title in the tab |
| `position` | `string` | `'center'` | Position: `'left'`, `'right'`, `'top'`, `'bottom'`, `'center'` |
| `size` | `number` | auto | Initial size in pixels |
| `visible` | `boolean` | `True` | Whether panel is visible |
| `active` | `boolean` | `False` | Whether panel is active on load |
| `collapsed` | `boolean` | `False` | Start collapsed (click to expand) |

**Position Examples:**

```python
panels = [
    {"id": "editor", "title": "Editor", "position": "center"},      # Main area
    {"id": "explorer", "title": "Explorer", "position": "left", "size": 250},  # Left sidebar
    {"id": "console", "title": "Console", "position": "bottom", "size": 150},  # Bottom panel
    {"id": "props", "title": "Properties", "position": "right", "size": 200},  # Right sidebar
]
```

---

### Theming

Dash Dock View supports four built-in themes including Apple's Liquid Glass design.



```python
# File: docs/dash_dock_view/themes_example.py

"""
Liquid Glass Themes Example for Dash Dock View
================================================
Showcases Apple WWDC 2025 inspired liquid glass design with light/dark themes.
"""
from dash import callback, Input, Output
import dash_mantine_components as dmc
from dash_iconify import DashIconify
from dash_dock_view import DashDockLayout

# Inline style definitions for glass effects
GLASS_PANEL_STYLE_DARK = {
    "backdropFilter": "blur(20px) saturate(180%)",
    "WebkitBackdropFilter": "blur(20px) saturate(180%)",
    "background": "linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.05) 100%)",
    "border": "1px solid rgba(255, 255, 255, 0.15)",
    "boxShadow": "0 8px 32px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1)",
    "borderRadius": "12px",
    "transition": "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
}

GLASS_PANEL_STYLE_LIGHT = {
    "backdropFilter": "blur(20px) saturate(180%)",
    "WebkitBackdropFilter": "blur(20px) saturate(180%)",
    "background": "linear-gradient(135deg, rgba(255, 255, 255, 0.7) 0%, rgba(255, 255, 255, 0.5) 100%)",
    "border": "1px solid rgba(255, 255, 255, 0.5)",
    "boxShadow": "0 8px 32px rgba(31, 38, 135, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.8)",
    "borderRadius": "12px",
    "transition": "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
}

FEATURE_CARD_STYLE_DARK = {
    "backdropFilter": "blur(16px) saturate(160%)",
    "WebkitBackdropFilter": "blur(16px) saturate(160%)",
    "background": "rgba(45, 55, 72, 0.6)",
    "border": "1px solid rgba(255, 255, 255, 0.1)",
    "borderRadius": "10px",
    "padding": "1rem",
    "transition": "all 0.3s ease",
}

FEATURE_CARD_STYLE_LIGHT = {
    "backdropFilter": "blur(16px) saturate(160%)",
    "WebkitBackdropFilter": "blur(16px) saturate(160%)",
    "background": "rgba(255, 255, 255, 0.7)",
    "border": "1px solid rgba(0, 0, 0, 0.05)",
    "borderRadius": "10px",
    "padding": "1rem",
    "transition": "all 0.3s ease",
}

STAT_BADGE_STYLE_DARK = {
    "backdropFilter": "blur(8px)",
    "WebkitBackdropFilter": "blur(8px)",
    "background": "rgba(79, 195, 247, 0.2)",
    "border": "1px solid rgba(79, 195, 247, 0.3)",
    "borderRadius": "20px",
    "padding": "0.5rem 1rem",
}

STAT_BADGE_STYLE_LIGHT = {
    "backdropFilter": "blur(8px)",
    "WebkitBackdropFilter": "blur(8px)",
    "background": "rgba(79, 195, 247, 0.15)",
    "border": "1px solid rgba(79, 195, 247, 0.25)",
    "borderRadius": "20px",
    "padding": "0.5rem 1rem",
}


def create_overview_panel(theme="dark"):
    """Create overview panel with theme switcher."""
    glass_style = GLASS_PANEL_STYLE_DARK if theme == "dark" else GLASS_PANEL_STYLE_LIGHT
    stat_badge_style = STAT_BADGE_STYLE_DARK if theme == "dark" else STAT_BADGE_STYLE_LIGHT

    return dmc.Box(
        id="lg-overview-panel",
        p="lg",
        style={**glass_style, "height": "100%", "overflow": "auto"},
        children=[
            dmc.Stack(
                gap="md",
                children=[
                    dmc.Group(
                        justify="space-between",
                        children=[
                            dmc.Group(
                                gap="sm",
                                children=[
                                    DashIconify(icon="mdi:palette-advanced", width=32, color="#4FC3F7"),
                                    dmc.Stack(
                                        gap=0,
                                        children=[
                                            dmc.Title("Liquid Glass", order=3),
                                            dmc.Text("Apple WWDC 2025", size="xs", c="dimmed"),
                                        ]
                                    ),
                                ]
                            ),
                            dmc.SegmentedControl(
                                id="lg-theme-switcher",
                                data=[
                                    {"value": "dark", "label": "🌙 Dark"},
                                    {"value": "light", "label": "☀️ Light"},
                                ],
                                value=theme,
                                color="blue",
                                radius="lg",
                            ),
                        ]
                    ),
                    dmc.Divider(opacity=0.2),
                    dmc.Text(
                        "Experience the next generation of UI design with glassmorphism effects, backdrop blur, and translucent layers.",
                        size="sm",
                        c="dimmed",
                    ),
                    dmc.Group(
                        gap="xs",
                        children=[
                            dmc.Box(dmc.Badge("Backdrop Blur", variant="dot", color="cyan"), style=stat_badge_style),
                            dmc.Box(dmc.Badge("Translucency", variant="dot", color="violet"), style=stat_badge_style),
                            dmc.Box(dmc.Badge("Gradients", variant="dot", color="orange"), style=stat_badge_style),
                        ]
                    ),
                ]
            )
        ]
    )


def create_features_panel(theme="dark"):
    """Create features showcase panel."""
    feature_style = FEATURE_CARD_STYLE_DARK if theme == "dark" else FEATURE_CARD_STYLE_LIGHT

    return dmc.Box(
        id="lg-features-panel",
        p="md",
        style={"height": "100%", "overflow": "auto"},
        children=[
            dmc.Stack(
                gap="sm",
                children=[
                    dmc.Title("Key Features", order=5, mb="xs"),
                    dmc.Box(
                        style=feature_style,
                        children=[
                            dmc.Group(
                                gap="sm",
                                mb="xs",
                                children=[
                                    DashIconify(icon="mdi:blur", width=20, color="#4FC3F7"),
                                    dmc.Text("Backdrop Blur", fw=600, size="sm"),
                                ]
                            ),
                            dmc.Text(
                                "Advanced blur effects with 20px radius and 180% saturation for ultra-realistic glass.",
                                size="xs",
                                c="dimmed",
                            ),
                        ]
                    ),
                    dmc.Box(
                        style=feature_style,
                        children=[
                            dmc.Group(
                                gap="sm",
                                mb="xs",
                                children=[
                                    DashIconify(icon="mdi:opacity", width=20, color="#9C27B0"),
                                    dmc.Text("Transparency", fw=600, size="sm"),
                                ]
                            ),
                            dmc.Text(
                                "Layered semi-transparent gradients that adapt to light and dark modes.",
                                size="xs",
                                c="dimmed",
                            ),
                        ]
                    ),
                    dmc.Box(
                        style=feature_style,
                        children=[
                            dmc.Group(
                                gap="sm",
                                mb="xs",
                                children=[
                                    DashIconify(icon="mdi:gradient-vertical", width=20, color="#FFC107"),
                                    dmc.Text("Liquid Borders", fw=600, size="sm"),
                                ]
                            ),
                            dmc.Text(
                                "Animated gradient borders with smooth 8s infinite animation cycles.",
                                size="xs",
                                c="dimmed",
                            ),
                        ]
                    ),
                ]
            )
        ]
    )


def create_controls_panel(theme="dark"):
    """Create interactive controls panel."""
    glass_style = GLASS_PANEL_STYLE_DARK if theme == "dark" else GLASS_PANEL_STYLE_LIGHT

    return dmc.Box(
        id="lg-controls-panel",
        p="md",
        style={**glass_style, "height": "100%", "overflow": "auto"},
        children=[
            dmc.Stack(
                gap="md",
                children=[
                    dmc.Title("Controls", order=5),
                    dmc.Text("Interact with glass-style components:", size="xs", c="dimmed", mb="xs"),
                    dmc.Group(
                        gap="xs",
                        children=[
                            dmc.Button(
                                "Primary",
                                size="sm",
                                variant="light",
                                color="blue",
                                leftSection=DashIconify(icon="mdi:cursor-default-click", width=16),
                            ),
                            dmc.Button(
                                "Success",
                                size="sm",
                                variant="light",
                                color="green",
                                leftSection=DashIconify(icon="mdi:check-circle", width=16),
                            ),
                        ]
                    ),
                    dmc.Divider(opacity=0.2),
                    dmc.Slider(
                        id="lg-demo-slider",
                        value=65,
                        min=0,
                        max=100,
                        marks=[
                            {"value": 0, "label": "0%"},
                            {"value": 50, "label": "50%"},
                            {"value": 100, "label": "100%"},
                        ],
                        mb="xl",
                        color="cyan",
                    ),
                    dmc.Switch(
                        id="lg-demo-switch",
                        label="Enable animations",
                        checked=True,
                        size="md",
                        color="violet",
                    ),
                ]
            )
        ]
    )


def create_metrics_panel(theme="dark"):
    """Create metrics panel with glass effect."""
    feature_style = FEATURE_CARD_STYLE_DARK if theme == "dark" else FEATURE_CARD_STYLE_LIGHT

    return dmc.Box(
        id="lg-metrics-panel",
        p="md",
        style={"height": "100%", "overflow": "auto"},
        children=[
            dmc.Stack(
                gap="lg",
                children=[
                    dmc.Title("Metrics", order=5),
                    dmc.Stack(
                        gap="md",
                        children=[
                            dmc.Box(
                                style=feature_style,
                                children=[
                                    dmc.Group(
                                        justify="space-between",
                                        children=[
                                            dmc.Stack(
                                                gap=0,
                                                children=[
                                                    dmc.Text("Blur Radius", size="xs", c="dimmed"),
                                                    dmc.Title("20px", order=4, c="cyan"),
                                                ]
                                            ),
                                            DashIconify(icon="mdi:blur-radial", width=32, color="#4FC3F7", style={"opacity": 0.6}),
                                        ]
                                    ),
                                ]
                            ),
                            dmc.Box(
                                style=feature_style,
                                children=[
                                    dmc.Group(
                                        justify="space-between",
                                        children=[
                                            dmc.Stack(
                                                gap=0,
                                                children=[
                                                    dmc.Text("Saturation", size="xs", c="dimmed"),
                                                    dmc.Title("180%", order=4, c="violet"),
                                                ]
                                            ),
                                            DashIconify(icon="mdi:palette", width=32, color="#9C27B0", style={"opacity": 0.6}),
                                        ]
                                    ),
                                ]
                            ),
                            dmc.Box(
                                style=feature_style,
                                children=[
                                    dmc.Group(
                                        justify="space-between",
                                        children=[
                                            dmc.Stack(
                                                gap=0,
                                                children=[
                                                    dmc.Text("Opacity", size="xs", c="dimmed"),
                                                    dmc.Title("65%", order=4, c="orange"),
                                                ]
                                            ),
                                            DashIconify(icon="mdi:opacity", width=32, color="#FFC107", style={"opacity": 0.6}),
                                        ]
                                    ),
                                ]
                            ),
                        ]
                    ),
                ]
            )
        ]
    )


def create_code_panel(theme="dark"):
    """Create code preview panel."""
    glass_style = GLASS_PANEL_STYLE_DARK if theme == "dark" else GLASS_PANEL_STYLE_LIGHT

    return dmc.Box(
        id="lg-code-panel",
        p="md",
        style={**glass_style, "height": "100%", "overflow": "auto"},
        children=[
            dmc.Stack(
                gap="sm",
                children=[
                    dmc.Group(
                        justify="space-between",
                        children=[
                            dmc.Title("CSS Preview", order=5),
                            dmc.ActionIcon(
                                DashIconify(icon="mdi:content-copy", width=16),
                                variant="subtle",
                                color="gray",
                                size="sm",
                            ),
                        ]
                    ),
                    dmc.Code(
                        block=True,
                        children="""backdrop-filter: blur(20px) saturate(180%);
background: linear-gradient(
  135deg,
  rgba(255, 255, 255, 0.08) 0%,
  rgba(255, 255, 255, 0.05) 100%
);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow:
  0 8px 32px rgba(0, 0, 0, 0.5),
  inset 0 1px 0 rgba(255, 255, 255, 0.1);""",
                        style={"fontSize": "11px"},
                    ),
                ]
            )
        ]
    )


# Main component
component = dmc.Box(
    id="liquid-glass-demo-container",
    style={
        "background": "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)",
        "borderRadius": "16px",
        "padding": "4px",
        "position": "relative",
        "transition": "background 0.3s ease",
    },
    children=[
        DashDockLayout(
            id="lg-dock-layout",
            panels=[
                {"id": "lg-overview-panel", "title": "Overview", "closeable": False},
                {"id": "lg-features-panel", "title": "Features", "closeable": False},
                {"id": "lg-controls-panel", "title": "Controls", "closeable": False},
                {"id": "lg-metrics-panel", "title": "Metrics", "closeable": False},
                {"id": "lg-code-panel", "title": "Code", "closeable": False},
            ],
            children=[
                create_overview_panel("dark"),
                create_features_panel("dark"),
                create_controls_panel("dark"),
                create_metrics_panel("dark"),
                create_code_panel("dark"),
            ],
            theme="dockview-theme-liquid-glass-dark",
            height="450px",
            showAddButton=False,
        ),
    ]
)


# Callback to switch themes
@callback(
    Output("liquid-glass-demo-container", "style"),
    Output("lg-dock-layout", "theme"),
    Output("lg-dock-layout", "children"),
    Input("lg-theme-switcher", "value"),
)
def switch_liquid_glass_theme(theme_value):
    """Toggle between liquid glass light and dark themes."""
    if theme_value == "light":
        container_style = {
            "background": "linear-gradient(135deg, #e8f4f8 0%, #d4e5f7 50%, #c1d5eb 100%)",
            "borderRadius": "16px",
            "padding": "4px",
            "position": "relative",
            "transition": "background 0.3s ease",
        }
        return (
            container_style,
            "dockview-theme-liquid-glass-light",
            [
                create_overview_panel("light"),
                create_features_panel("light"),
                create_controls_panel("light"),
                create_metrics_panel("light"),
                create_code_panel("light"),
            ]
        )
    else:
        container_style = {
            "background": "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)",
            "borderRadius": "16px",
            "padding": "4px",
            "position": "relative",
            "transition": "background 0.3s ease",
        }
        return (
            container_style,
            "dockview-theme-liquid-glass-dark",
            [
                create_overview_panel("dark"),
                create_features_panel("dark"),
                create_controls_panel("dark"),
                create_metrics_panel("dark"),
                create_code_panel("dark"),
            ]
        )
```


**Available Themes:**

| Theme | Class Name | Description |
|:------|:-----------|:------------|
| Light | `dockview-theme-light` | Classic light theme |
| Dark | `dockview-theme-dark` | Classic dark theme |
| Liquid Glass Light | `dockview-theme-liquid-glass-light` | Apple glassmorphism (light) |
| Liquid Glass Dark | `dockview-theme-liquid-glass-dark` | Apple glassmorphism (dark) |

**Applying Themes:**

```python
DashDockLayout(
    id="my-dock",
    panels=[...],
    children=[...],
    theme="dockview-theme-liquid-glass-dark",  # Apply theme
    height="500px",
)
```

**Liquid Glass CSS Variables:**

```css
.dockview-theme-liquid-glass-dark {
    --dv-background-color: rgba(30, 30, 30, 0.65);
    --dv-foreground-color: rgba(255, 255, 255, 0.95);
    --dv-separator-border: rgba(255, 255, 255, 0.12);
}

.dockview-theme-liquid-glass-dark .dv-dockview {
    backdrop-filter: blur(16px) saturate(180%);
    border-radius: 12px;
}
```

---

### Layout Persistence

Save and restore layout configurations using the `layout` property.

```python
from dash import callback, Input, Output, State
import json

# Save layout
@callback(
    Output("layout-store", "data"),
    Input("dock-layout", "layout"),
)
def save_layout(layout):
    return json.dumps(layout) if layout else None

# Restore layout
@callback(
    Output("dock-layout", "layout"),
    Input("restore-btn", "n_clicks"),
    State("layout-store", "data"),
    prevent_initial_call=True,
)
def restore_layout(n_clicks, saved_layout):
    if saved_layout:
        return json.loads(saved_layout)
    return dash.no_update
```

---

### Callbacks and Interactivity

Dash Dock View components integrate seamlessly with Dash callbacks.

**Available Callback Properties:**

- **`activePanel`**: Currently active panel id
- **`panelCount`**: Number of visible panels
- **`layout`**: Current layout configuration (JSON)

```python
@callback(
    Output("active-display", "children"),
    Output("count-display", "children"),
    Input("dock-layout", "activePanel"),
    Input("dock-layout", "panelCount"),
)
def update_info(active_panel, panel_count):
    return f"Active: {active_panel}", f"Count: {panel_count}"
```

---

### Component Properties

#### DashDockLayout

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| **`id`** | `string` | **Required** | Unique component identifier |
| **`panels`** | `array` | **Required** | Panel configurations |
| **`children`** | `node` | **Required** | Dash components for panels |
| `theme` | `string` | `'dockview-theme-light'` | Theme class name |
| `height` | `string` | `'600px'` | Container height |
| `locked` | `boolean` | `False` | Lock layout modifications |
| `gap` | `number` | `0` | Gap between split panels |
| `showAddButton` | `boolean` | `True` | Show add panel button |
| `disableDnd` | `boolean` | `False` | Disable drag-and-drop |
| `disableFloatingGroups` | `boolean` | `False` | Disable floating windows |
| `hideBorders` | `boolean` | `False` | Hide panel borders |
| `singleTabMode` | `string` | `'default'` | Tab display mode: `'default'` or `'fullwidth'` |
| `activePanel` | `string` | Read-only | Currently active panel id |
| `panelCount` | `number` | Read-only | Number of visible panels |
| `layout` | `object` | Read-only | Current layout configuration |

#### DashGridLayout

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| **`id`** | `string` | **Required** | Unique component identifier |
| **`panels`** | `array` | **Required** | Panel configurations |
| **`children`** | `node` | **Required** | Dash components for panels |
| `theme` | `string` | `'dockview-theme-light'` | Theme class name |
| `height` | `string` | `'600px'` | Container height |
| `orientation` | `string` | `'horizontal'` | Layout orientation: `'horizontal'` or `'vertical'` |
| `proportionalLayout` | `boolean` | `True` | Maintain proportions on resize |

#### DashSplitLayout

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| **`id`** | `string` | **Required** | Unique component identifier |
| **`panels`** | `array` | **Required** | Panel configurations (2 panels) |
| **`children`** | `node` | **Required** | Dash components for panels |
| `theme` | `string` | `'dockview-theme-light'` | Theme class name |
| `height` | `string` | `'400px'` | Container height |
| `orientation` | `string` | `'horizontal'` | Split orientation |

#### DashPaneLayout

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| **`id`** | `string` | **Required** | Unique component identifier |
| **`panels`** | `array` | **Required** | Panel configurations |
| **`children`** | `node` | **Required** | Dash components for panels |
| `theme` | `string` | `'dockview-theme-light'` | Theme class name |
| `height` | `string` | `'400px'` | Container height |

---

### Dynamic Props vs Construction Props

Some props can be updated dynamically, while others require recreating the component:

**Dynamic Props (Live Update):**
- `locked`, `gap`, `disableDnd`, `disableFloatingGroups`, `showAddButton`, `height`

**Construction Props (Require Reset):**
- `hideBorders`, `singleTabMode`, `panels`

```python
# Dynamic prop update - works instantly
@callback(
    Output("dock-layout", "locked"),
    Input("lock-switch", "checked"),
)
def toggle_lock(checked):
    return checked

# Construction prop - requires component recreation
@callback(
    Output("dock-container", "children"),
    Input("apply-btn", "n_clicks"),
    State("hide-borders-switch", "checked"),
)
def apply_construction_props(n_clicks, hide_borders):
    return DashDockLayout(
        id="dock-layout",
        panels=[...],
        children=[...],
        hideBorders=hide_borders,  # Requires fresh component
    )
```

---

### Best Practices

1. **Match Panel IDs**: Ensure each panel's `id` matches the corresponding child component's `id`

```python
panels = [{"id": "my-panel", "title": "My Panel"}]
children = [html.Div(id="my-panel", children="Content")]  # IDs must match!
```

2. **Set Explicit Height**: Always set a height for the container

```python
DashDockLayout(
    ...,
    height="600px",  # or "80vh"
)
```

3. **Use Theme-Aware Backgrounds**: For Liquid Glass themes, use gradient backgrounds

```python
html.Div(
    DashDockLayout(..., theme="dockview-theme-liquid-glass-dark"),
    style={
        "background": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
        "borderRadius": "16px",
        "padding": "4px",
    }
)
```

4. **Prevent Initial Callbacks**: Use `prevent_initial_call=True` for callbacks that respond to user interactions

---

### Troubleshooting

**Issue: Panel content not appearing**

**Solution:** Ensure the panel `id` matches the child component's `id` exactly.

**Issue: Layout not filling container**

**Solution:** Set an explicit `height` prop (e.g., `"600px"` or `"80vh"`).

**Issue: Theme not applying correctly**

**Solution:** Make sure you're using the correct theme class name and the component is wrapped appropriately for glass themes.

**Issue: Drag and drop not working**

**Solution:** Check that `disableDnd` is not set to `True` and `locked` is `False`.

---

### Contributing

Contributions to dash-dock-view are welcome! Please refer to the project's issues on GitHub for any feature requests or bug reports.

### License

This project is created under [Pip Install Python LLC](https://2plot.dev) and licensed under the MIT License.

---

<!-- /pip/dash_documentation_boilerplate — https://2plot.dev/pip/dash_documentation_boilerplate/llms.txt -->

> **Full documentation:** [https://boilerplate.2plot.dev](https://boilerplate.2plot.dev) — the dedicated Documentation boilerplate documentation site, with the complete API reference and deeper examples. This page is the quick-start overview.



### Get the Template

[Visit GitHub Repo](https://github.com/pip-install-python/Dash-Documentation-Boilerplate) — use it as a GitHub template or clone it directly. It is deliberately **not** a PyPI package: you fork the repo and make it yours.

```bash
git clone https://github.com/pip-install-python/Dash-Documentation-Boilerplate.git my-docs
cd my-docs
pip install -r requirements.txt
python run.py
```

### Introduction

The Dash Documentation Boilerplate is the template every `*.2plot.dev`
component documentation site is forked from — including, in an earlier form,
the site you are reading right now. Write documentation as Markdown with
frontmatter, drop live Python examples next to it, and pages register
themselves: table of contents, searchable navigation, dark/light theme with
preference persistence, and interactive code examples with real callbacks.

It is also the network's reference implementation of the AI/SEO standard: every
page ships a Markdown twin at `/{page}/llms.txt`, crawler-ready prerendered
HTML, `sitemap.xml`, `robots.txt` with per-bot-class policy, and the cross-host
network directory — all powered by
[dash-improve-my-llms](/pip/dash_improve_my_llms).

### Full Documentation

**The complete documentation lives on its own site:
[boilerplate.2plot.dev](https://boilerplate.2plot.dev)** — setup, the directive
reference, backend deep dives, the network standard, and the machine-readable
twin at
[boilerplate.2plot.dev/llms.txt](https://boilerplate.2plot.dev/llms.txt). This
page is a summary; go there for the full guide.

### Writing a Page

A docs page is one Markdown file with frontmatter and directives:

```markdown
---
name: My Component
description: What it does, in one line
endpoint: /pip/my_component
icon: mdi:code-tags
---

.. toc::

## Quick Start

.. exec::docs.my_component.simple_usage
    :code: false

.. sourcetabs::docs/my_component/simple_usage.py
    :defaultExpanded: false
```

Restart the server and the page is registered, in the navigation, in the
search index, and serving its own `llms.txt`.

### Features

*   **Markdown-driven** — pages auto-register from `docs/**/*.md`; custom directives for live examples (`.. exec::`), collapsible source (`.. sourcetabs::`), TOC and auto-generated props tables.
*   **Modern UI** — [Dash Mantine Components](https://www.dash-mantine-components.com/), responsive layout, dark/light theme persistence.
*   **Pluggable backends** — the same app runs on Flask, FastAPI or Quart; switch with a single `DASH_BACKEND` env var (async callbacks and OpenAPI docs on the ASGI backends).
*   **AI/LLM & SEO built in** — `llms.txt` on every page, prerendered crawler HTML, sitemap, bot management and the cross-host network directory.
*   **Production ready** — Docker and docker-compose, Gunicorn/Uvicorn configs, CI test suite included.

---

<!-- /pip/dash_email — https://2plot.dev/pip/dash_email/llms.txt -->

> **Full documentation:** [https://email.2plot.dev](https://email.2plot.dev) — the dedicated dash-email documentation site, with the complete API reference and deeper examples. This page is the quick-start overview.



### Installation

[Visit GitHub Repo](https://github.com/pip-install-python/dash-email) · [PyPI](https://pypi.org/project/dash-email/)

```bash
pip install dash-email
```

### Introduction

`dash-email` bridges Python-first development with the component patterns pioneered by [React Email](https://react.email). Build email templates with familiar Dash syntax, preview them live inside your app, and export inline-styled, table-based HTML that renders correctly in Gmail, Outlook, Apple Mail, and every other major client.

*   **15 email-safe components** — structure, layout, typography, buttons, images, fonts.
*   **Email-client compatible by construction** — rows and columns render as real HTML tables; all styling is inlined.
*   **First-class Dash citizens** — every component takes an `id` and works with callbacks like any other Dash component.
*   **Send & schedule** — integrated [Resend](https://resend.com) support for single sends, batches up to 100 recipients, and scheduling.

```python
import dash_email as de
```

> Status: **early release (0.0.x)**. The component API is stable; the AI builder and sending utilities are actively evolving.

---

### Quick Start

A complete welcome email: `Email` → `EmailBody` → `EmailContainer` → `EmailSection`, with a heading, paragraph, and a bulletproof CTA button. What you see below is the live component tree rendered in the browser — the exact same tree converts to email-safe HTML at send time.



```python
# File: docs/dash_email/quick_start.py

import dash_email as de

component = de.Email(
    lang="en",
    children=[
        de.EmailBody(
            style={"backgroundColor": "#f6f9fc", "padding": "40px 0",
                   "borderRadius": "12px"},
            children=[
                de.EmailContainer([
                    de.EmailSection(
                        style={
                            "backgroundColor": "#ffffff",
                            "borderRadius": "8px",
                            "padding": "40px",
                        },
                        children=[
                            de.EmailHeading(
                                "Welcome to 2plot.dev!",
                                as_="h1",
                                style={"color": "#1a1a1a", "marginBottom": "16px"},
                            ),
                            de.EmailText(
                                "Thanks for creating an account. You now have access to "
                                "every live component example, full API references, and "
                                "the AI documentation assistant.",
                                style={"color": "#666666", "lineHeight": "1.6"},
                            ),
                            de.EmailButton(
                                "Explore the docs",
                                href="https://2plot.dev",
                                style={
                                    "backgroundColor": "#12B886",
                                    "color": "#ffffff",
                                    "padding": "12px 24px",
                                    "borderRadius": "4px",
                                    "fontWeight": "bold",
                                },
                            ),
                        ],
                    )
                ])
            ],
        )
    ],
)
```


---

### Rows & Columns

Email clients are not browsers — floats, flexbox, and grid are unreliable or stripped entirely. `EmailRow` / `EmailColumn` are the layout primitives that survive every client: rows render as HTML tables and columns as table cells. Set widths with percentage styles on each column.



```python
# File: docs/dash_email/rows_columns.py

import dash_email as de

# Emails are always light-themed artifacts: every text element carries an
# explicit color so the preview renders identically on the docs site's
# light AND dark modes (unstyled text would inherit the page theme color).
CELL = {"padding": "12px", "verticalAlign": "top"}
HEADING = {"color": "#1a1a1a"}
BODY_TEXT = {"color": "#666666", "lineHeight": "1.6"}

component = de.Email([
    de.EmailBody(
        style={
            "backgroundColor": "#f6f9fc",
            "padding": "32px 0",
            "borderRadius": "12px",
        },
        children=[
            de.EmailContainer([
                de.EmailSection(
                    style={
                        "backgroundColor": "#ffffff",
                        "borderRadius": "8px",
                        "padding": "24px",
                    },
                    children=[
                        de.EmailHeading("Two columns", as_="h3", style=HEADING),
                        de.EmailRow([
                            de.EmailColumn(
                                style={"width": "50%", **CELL},
                                children=[
                                    de.EmailText(
                                        "Left column — rows and columns render "
                                        "as real HTML tables, the only layout "
                                        "primitive every email client supports.",
                                        style=BODY_TEXT,
                                    )
                                ],
                            ),
                            de.EmailColumn(
                                style={"width": "50%", **CELL},
                                children=[
                                    de.EmailText(
                                        "Right column — set widths with "
                                        "percentage styles on each column.",
                                        style=BODY_TEXT,
                                    )
                                ],
                            ),
                        ]),
                        de.EmailDivider(style={"margin": "16px 0"}),
                        de.EmailHeading("70 / 30 split", as_="h3", style=HEADING),
                        de.EmailRow([
                            de.EmailColumn(
                                style={"width": "70%", **CELL},
                                children=[
                                    de.EmailText("dash-email Pro license",
                                                 style=BODY_TEXT)
                                ],
                            ),
                            de.EmailColumn(
                                style={"width": "30%", "textAlign": "right", **CELL},
                                children=[
                                    de.EmailText("$99.00", style=BODY_TEXT)
                                ],
                            ),
                        ]),
                    ],
                )
            ])
        ],
    )
])
```


---

### Buttons & Links

`EmailButton` is a "bulletproof" call-to-action — a styled anchor that keeps its padding and background across clients. `EmailLink` is its inline counterpart for links inside running text.



```python
# File: docs/dash_email/button_cta.py

import dash_email as de

component = de.Email([
    de.EmailBody(
        style={"backgroundColor": "#f6f9fc", "padding": "32px 0",
               "borderRadius": "12px"},
        children=[
            de.EmailContainer([
                de.EmailSection(
                    style={
                        "backgroundColor": "#ffffff",
                        "borderRadius": "8px",
                        "padding": "32px",
                        "textAlign": "center",
                    },
                    children=[
                        de.EmailHeading(
                            "New release: dash-leaflet2",
                            as_="h2",
                            style={"color": "#1a1a1a"},
                        ),
                        de.EmailText(
                            "Leaflet 2-native maps for Dash — 26 components, no "
                            "react-leaflet dependency. Read the announcement, or "
                            "jump straight into the interactive docs.",
                            style={"color": "#666666", "lineHeight": "1.6"},
                        ),
                        de.EmailButton(
                            "Read the docs",
                            href="https://2plot.dev/pip/dash_leaflet2",
                            style={
                                "backgroundColor": "#228be6",
                                "color": "#ffffff",
                                "padding": "12px 28px",
                                "borderRadius": "6px",
                                "fontWeight": "bold",
                                "marginRight": "8px",
                            },
                        ),
                        de.EmailText(
                            [
                                "Prefer video? Watch the walkthrough on ",
                                de.EmailLink(
                                    "YouTube @2plotai",
                                    href="https://www.youtube.com/@2plotai",
                                    style={"color": "#12B886"},
                                ),
                                ".",
                            ],
                            style={"color": "#999999", "fontSize": "13px"},
                        ),
                    ],
                )
            ])
        ],
    )
])
```


---

### The styling boundary

Email clients strip `<style>` tags and ignore most modern CSS, so every dash-email component takes a `style` dict of camelCase CSS that renders **inline** on the element.

Rules of thumb:

*   Keep content at **600px or less** — `EmailContainer` enforces this for you.
*   Multi-column layout goes through `EmailRow` / `EmailColumn` — never flexbox.
*   Prefer web-safe fonts, or load one via `EmailFont` with a `fallbackFontFamily`.
*   Always set explicit `width` / `height` + `alt` on `EmailImage`.

```python
de.EmailRow([
    de.EmailColumn(style={"width": "70%"}, children=[de.EmailText("Product")]),
    de.EmailColumn(style={"width": "30%", "textAlign": "right"}, children=[de.EmailText("$99.00")]),
])
```

---

### Sending with Resend

Templates built with these components convert to email-safe HTML and send through the [Resend](https://resend.com) API — single sends, batches up to 100 recipients, and scheduled delivery (up to 30 days ahead). Set `RESEND_API_KEY` in your environment:

```python
from utils.email_sender import send_email, component_to_html

html = component_to_html(my_email_component)
result = send_email(
    to_email="user@example.com",
    subject="Welcome!",
    html_content=html,
    from_email="hello@yourdomain.com",
    scheduled_at="in 1 hour",   # optional — ISO 8601 or natural language
)
```

This documentation site uses exactly this pipeline for its own transactional and announcement emails.

---

### Components

| Component        | Category  | What it is                                                        |
|:-----------------|:----------|:------------------------------------------------------------------|
| `Email`          | Structure | Root wrapper for the email template                               |
| `EmailHead`      | Structure | Metadata container (font imports, etc.)                           |
| `EmailPreview`   | Structure | Inbox preview text — visible next to the subject, hidden in body  |
| `EmailBody`      | Structure | Main content wrapper                                              |
| `EmailContainer` | Layout    | Centered container at the 600px email standard                    |
| `EmailSection`   | Layout    | Groups related content with its own background/padding            |
| `EmailRow`       | Layout    | Horizontal row — renders as an HTML `<table>`                     |
| `EmailColumn`    | Layout    | Column within a row — renders as a `<td>`                         |
| `EmailHeading`   | Content   | Headings `h1`–`h6` via the `as_` prop                             |
| `EmailText`      | Content   | Paragraph text                                                    |
| `EmailButton`    | Content   | Bulletproof call-to-action button (styled anchor)                 |
| `EmailLink`      | Content   | Inline hyperlink                                                  |
| `EmailImage`     | Media     | Image with explicit dimensions                                    |
| `EmailDivider`   | Media     | Horizontal rule separator                                         |
| `EmailFont`      | Media     | Web font loading with graceful fallback                           |

### Component Properties

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| id | string | — | Dash callback identity — available on every component |
| style | dict | — | camelCase CSS, rendered inline for email-client safety |
| children | node | — | Child components / text |
| as_ | string | "h1" | `EmailHeading` level: `"h1"`–`"h6"` (Python-safe alias for `as`) |
| href | string | — | `EmailButton` / `EmailLink` target URL |
| src | string | — | `EmailImage` source URL |
| alt | string | — | `EmailImage` alt text |
| width / height | string \| number | — | `EmailImage` explicit dimensions |
| lang | string | "en" | `Email` document language |
| fontFamily | string | — | `EmailFont` font family name |
| fallbackFontFamily | string | — | `EmailFont` web-safe fallback |
| webFont | object | — | `EmailFont` web font source configuration |

---

<!-- /pip/dash_emoji_mart — https://2plot.dev/pip/dash_emoji_mart/llms.txt -->

> **Full documentation:** [https://emojimart.2plot.dev](https://emojimart.2plot.dev) — the dedicated dash-emoji-mart documentation site, with the complete API reference and deeper examples. This page is the quick-start overview.



`dash-emoji-mart` is a Dash component library that provides a modern, feature-rich emoji picker for your Dash applications. Based on the popular Emoji Mart library, it features native emoji support, multiple emoji sets (Apple, Google, Twitter, Facebook), custom emojis with GIF/SVG support, theme customization, internationalization in 20+ languages, and extensive configuration options for appearance and behavior.

### Installation

[Visit GitHub Repo](https://github.com/pip-install-python/dash_emoji_mart)

⭐️ Star this component on GitHub! Stay up to date on new releases and browse the codebase.

```bash
pip install dash-emoji-mart
```

---

### Quick Start

Display a basic emoji picker with default settings. Click an emoji to select it.



```python
# File: docs/dash_emoji_mart/introduction.py

from dash import *
from dash_emoji_mart import DashEmojiMart
from urllib.parse import urlparse

custom = [
    {
        'id': 'custom',
        'name': 'Custom',
        'emojis': [
            {
                'id': 'party_parrot',
                'name': 'Party Parrot',
                'short_names': ['party_parrot'],
                'keywords': ['dance', 'dancing'],
                'skins': [{'src': 'https://missiveapp.com/open/emoji-mart/parrot.6a845cb2.gif'}],
                'native': '',
                'unified': 'custom',
            },
            {
                'id': 'plotly',
                'name': 'Plotly',
                'short_names': ['plotly'],
                'keywords': ['plotly', 'dash'],
                'skins': [{'src': 'https://store-images.s-microsoft.com/image/apps.36868.bfb0e2ee-be9e-4c73-807f-e0a7b805b1be.712aff5d-5800-47e0-97be-58d17ada3fb8.a46845e6-ce94-44cf-892b-54637c6fcf06'}],
                'native': '',
                'unified': 'custom',
            },
        ],
    },
]

component =  html.Div([
   DashEmojiMart(
       id='dash-emoji-intro-input',
       custom=custom,
       autoFocus=False,
       categories=['frequent', 'people', 'nature', 'foods', 'activity', 'places', 'objects', 'symbols', 'flags', 'custom'],
       categoryIcons={'activity': {'svg': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path d="M57.89 397.2c-6.262-8.616-16.02-13.19-25.92-13.19c-23.33 0-31.98 20.68-31.98 32.03c0 6.522 1.987 13.1 6.115 18.78l46.52 64C58.89 507.4 68.64 512 78.55 512c23.29 0 31.97-20.66 31.97-32.03c0-6.522-1.988-13.1-6.115-18.78L57.89 397.2zM496.1 352c-44.13 0-79.72 35.75-79.72 80s35.59 80 79.72 80s79.91-35.75 79.91-80S540.2 352 496.1 352zM640 99.38c0-13.61-4.133-27.34-12.72-39.2l-23.63-32.5c-13.44-18.5-33.77-27.68-54.12-27.68c-13.89 0-27.79 4.281-39.51 12.8L307.8 159.7C262.2 192.8 220.4 230.9 183.4 273.4c-24.22 27.88-59.18 63.99-103.5 99.63l56.34 77.52c53.79-35.39 99.15-55.3 127.1-67.27c51.88-22 101.3-49.87 146.9-82.1l202.3-146.7C630.5 140.4 640 120 640 99.38z"/></svg>', 'people': 'https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fstatic.vecteezy.com%2Fsystem%2Fresources%2Fpreviews%2F000%2F379%2F228%2Foriginal%2Fcool-emoji-vector-icon.jpg'}, 'custom': {'svg': '<svg xmlns="http://www.w3.org/2000/svg" width="0.92em" height="1em" viewBox="0 0 256 280"><path fill="#991b21" d="M70.882 129.428s-3.212-.666-4.155-.381c-.943.285-6.33-.66-8.693-2.37c-2.364-1.703-4.725-.47-6.897-1.8c-2.17-1.328-5.48-1.139-7.933-1.232c-2.456-.093-5.575.093-6.71 1.61c-1.133 1.516-5.571 6.164-7.177 8.057c-1.606 1.896-8.124 8.719-10.675 9.76c0 0 .66 1.046.283 1.802c-.379.762-.095 2.937-.095 3.698c0 .762.188 1.989-.469 2.75c-.665.76-1.041 1.99-1.041 2.463c0 .474-.758 2.18 0 4.457c.755 2.276 1.414 4.832-1.702 3.791c-3.118-1.04-2.456-3.884-5.005-3.983c-2.548-.093-5.855 0-6.801-3.503c-.949-3.51-2.74-9.48-3.119-10.429c-.376-.945-1.698-5.306.758-9.097c2.456-3.79 2.928-6.541 3.307-7.396c.376-.852 2.173-6.256 3.492-7.018c1.322-.759.848-3.128 1.606-4.169c.755-1.043.471-2.75.943-3.698c.471-.948.662-3.695 1.322-5.78c.662-2.084 2.835-5.311 4.157-7.015c1.322-1.704 2.08-4.262 2.08-5.117c0-.854-.471-2.651 1.698-4.169c2.17-1.517 6.044-6.445 6.423-7.204c0-.189 0-3.695-.19-4.928c-.189-1.233-.472-2.37.754-2.843c1.233-.477 2.93-1.52 2.93-1.52s-1.178-1.515-3.73-2.796C23.692 70.09 16.04 61.416 15.333 56.3c-.711-5.12-.995-13.224 5.95-16.067c6.945-2.847 14.594-4.41 15.731-4.55c1.134-.141 5.1-.996 6.661.14c0 0-1.7-.281-2.408 0c-.71.286 3.396.145 4.39 1.281c0 0-1.982-.567-3.116-.426c-1.131.141 1.985 1.422 2.552 2.418c.564.995 2.692 1.706 2.408 3.272c-.28 1.563-.424 3.98-.143 4.69c.287.712.994 1.992 1.137 3.98c.143 1.992.71 4.695 1.274 5.972c.568 1.281 1.985 5.261 1.418 8.104c0 0 .567 1.422 3.4-1.847c2.835-3.272 5.67-3.84 7.085-3.84c1.417 0 3.545-1.424 4.96-.854c1.42.57 2.98-.14 4.68.285c1.697.426 8.782 0 10.343-.57c1.555-.57 6.942-1.28 8.073-1.28c1.134 0-1.414-1.848.71-3.27c2.128-1.421-2.268-2.132.994-6.253c3.256-4.124.138-5.261-.426-6.113c-.565-.855-.708-3.557-.708-5.12c0-1.565-.424-5.545-.424-5.545l.567-.711s-.457-.756-.906-1.024c-.447-.268-.893-1.881-1.247-2.956c-.362-1.075-4.289-6.9-5.448-7.794c-1.162-.9-1.34-2.24-1.162-3.495c.18-1.258 1.07-2.33.892-3.675c-.18-1.343-.09-3.134-.18-3.76c-.086-.63.18-4.39.986-4.748c0 0 .623-.27.893.624c0 0-.45-3.134.533-3.134c0 0 1.162-.358 1.608 1.255c.444 1.614.893 2.508.983 2.956c.09.449.446 1.162.446 1.162s.98-.442 1.786-.087c.802.358 1.341.716 1.341 1.165c0 .448 0-.358.983-.358c.98 0 1.875-.271 2.144.716c.264.985.447 1.704.534 2.688c.092.985.27 1.792.626 2.508c.359.717.89 1.969.89 3.673c0 1.703.628 4.837.895 6.273c.267 1.433 3.304 6.987 3.304 6.987s4.643.897 6.785.449c2.144-.449 5.892.358 5.448 2.33c-.446 1.968-.713 7.793-.892 12.363c-.18 4.57-.896 13.438-1.609 16.575c-.716 3.134-5.09 11.288-6.874 12.631c-1.783 1.346-3.75 2.24-8.66 4.57a849.844 849.844 0 0 1-10.628 4.928s3.037 4.298 3.75 4.57c.713.267.626-.627 1.516-.272c.444.178 1.985.785 1.005.381c-.002.038 5.38 1.42 5.38 1.42c.515-.01.834-.104.77-.41c-.27-1.251.983-2.863 1.516-3.224c.537-.358-.803 1.612 0 2.506c.806.899 12.348-2.531 12.132-1.917c-.213.615.626.662 1.362.085c.73-.575 3.788-2.574 5.968-2.565c2.185.01 7.127.448 8.453-.037c1.325-.482 7.352-4.214 9.365-5.2c2.008-.993 8.948-.897 11.877-2.876c2.929-1.981 9.38-6.618 12.103-8.306c2.722-1.686 22.916-14.077 27.224-14.75c4.308-.674 14.634-4.761 16.403-6.027c1.77-1.263 7.335-4.338 8.45-5.302c1.118-.964 6.701 1.486 9.61 5.638c0 0 5.676-2.788 6.9-2.895c1.222-.107 3.485-1.689 5.221-3.022c1.738-1.336 6.973-4.873 9.262-5.992c0 0 1.157-.51 1.379-1.325c.221-.815 1.145-2.37 1.977-2.05c0 0 1.822-2.333 2.56-1.631c0 0 1.602-1.52 2.52-.694c0 0 .907.13 1.213.62c0 0 1.151-.933 1.685-.225c0 0 .831-.942 2.233.163c0 0 .932 1.153-.784 3.273c0 0-1.075.558-1.828 2.114c-.747 1.556-2.84 4.513-5.13 5.671c-2.298 1.159-4.95 3.194-4.85 7.354c.1 4.163-1.666 9.003-6.022 7.915c-4.355-1.088-8.175.38-11.988 2.463c0 0-.335 2.2-.702 2.723c-.371.522-2.076.353-4.42 1.827c-2.343 1.471-3.772 2.024-5.62 3.41c-1.85 1.387-2.247 2.323-3.803 3.047c-1.555.73-3.302 2.71-4.333 3.806c-1.036 1.093-4.847 3.785-9.351 5.984c-4.508 2.201-23.034 8.834-27.317 12.32c-4.288 3.487-12.17 9.733-24.134 15.607c-.497.243-.958.48-1.427.713c-.724.663-11.011 10.105-14.533 12.575c-3.65 2.559-5.11 2.925-6.206 8.78c-.719 3.859-3.654 10.088-5.681 15.049a243.782 243.782 0 0 0-.812 12.377c-.14 3.695-4.855 40.442-5.99 44.416c-1.137 3.98-1.418 6.965-.85 10.091c.566 3.12-.852 6.816-2.55 9.942c-1.705 3.126-4.539 10.514-4.115 14.775c.424 4.262.851 5.827-.424 6.393c-1.278.567-5.39.429-5.954 1.85c-.567 1.42-2.693 1.42-2.693 1.42s-.637 6.34.475 7.455c1.115 1.11 5.046 5.781.264 9.26c-4.788 3.484-9.835 6.573-11.011 9.005c-1.183 2.427-3.806 3.811-4.789 4.598c-.982.786-2.752 1.905-2.884 2.362c-.129.462-1.57 1.316-2.948.924c0 0-1.18.522-2.427-.86c0 0-2.027.198-2.423-1.113c0 0-1.705.127-1.902-1.184c0 0-.853-1.905 1.705-2.691c2.553-.793 6.62-6.509 6.751-7.428c.13-.919 2.033-6.638 3.474-9c1.44-2.367 3.342-8.543 3.606-14.192c0 0-.587-.392-.587-.98c0-.596.034-3.31.317-4.35c.287-1.046.851-4.073.475-8.527c-.38-4.447.567-11.742 0-16.762s.511-39.87 2.022-44.417c1.514-4.547 2.078-5.587 1.985-7.388c-.095-1.801 0-7.295.568-7.577c.564-.284.66-7.198.471-7.96c-.19-.755-4.254-3.596-4.819-5.586c0 0 .093 2.083 0 2.841c-.095.758-.188-.189-1.701-1.801c-1.517-1.612-4.35-5.02-4.539-7.484c-.338-.183-4.525-2.656-4.525-2.656c-.52.023-.78-.223-.905-.437"/><path d="M90.196 87.431c-.428 7.693-18.42 42.734-18.42 42.734c-.628 3.1-.498 5.995-.18 7.716c.762 4.126-3.05 10.316-3.05 10.316l4.151 2.185c5.607-3.25 3.203-12.999 3.203-12.999a12.21 12.21 0 0 1-.27-5l.045.046S96.078 91.987 95.09 88.802c-.002.006-2.434-.345-4.894-1.37"/></svg>'},
                      'custom': {'svg': '<svg xmlns="http://www.w3.org/2000/svg" width="0.9em" height="1em" viewBox="0 0 256 286"><path fill="#555" d="M246.716 95.492c-6.899-8.36-16.64-13.533-27.428-14.566c-10.79-1.035-21.335 2.197-29.695 9.095a40.467 40.467 0 0 0-10.383 12.96l-9.798-3.877c2.65-3.818 6.124-7.813 10.101-11.516c8.243-7.677 17.296-12.846 24.84-14.183c.501-.09.945-.38 1.226-.806c.503-.761 1.295-1.631 1.994-2.399c1.527-1.677 2.97-3.26 1.587-4.822a1.859 1.859 0 0 0-1.504-.624c-13.097.798-31.694 6.942-44.612 21.793c-2.365 2.72-4.382 5.231-6.087 7.629L118.79 79.072c-2.16-2.774-5.193-4.886-6.985-5.045c-.289-.028-.669.068-1.109.247c.938-2.355 1.86-5.157 2.468-8.525c1.135-6.28 1.284-11.546 3.085-11.96c3.328-.765 2.967.936 3.633-1.498c.994-3.628-3.753-7.925-2.368-11.047c1.022-2.304 2.025-3.969 2.93-5.214c3.368.642 6.327 1.178 9.217 3.068c3.039-2.815 5.578-7.006 5.011-10.16c-.583-3.25-6.866-3.094-5.57-8.94c.851-3.842 6.28-5.01 9.72-4.939c1.908.037 1.286-8.225-.992-9.536c-2.875-1.653-28.095-10.405-36.599-1.786c-4.755 4.82-7.037 17.652-1.607 22.301c.618.529 1.22 1.017 1.815 1.488c-.775.458-1.622.845-2.962.98c-2.953.297-4.594-.994-5.87.051c-1.293 1.06.928 2.372-.07 4.098c-1.826 3.156-3.506-1.163-9.501 4.77c-2.786 2.758-3.483 8.038-6.236 14.653c-4.423 10.629-8.302 8.584-13.394 24.955c-1.248 4.01 4.076 2.04 13.951 6.876c-4.814 9.116-7.933 17.75-6.46 23.152c1.813 6.649 14.69 17.015 21.371 22.921c.45.397.929.904 1.434 1.492C75.313 149.37 43.275 162.524.53 194.46c-2.014 3.252 2.225 8.644 4.114 9.021c25.15-21.219 55.4-28.04 86.691-25.796a491.496 491.496 0 0 1-10.724 6.506l1.576-4.027l-10.742 3.323l-49.651 15.615l-5.925 6.837l2.85 7.992l8.914 1.545l48.297-19.314l.459-1.173c15.614-4.419 18.506-.287 20.24 4.458l-7.657 8.169c-13.27-5.18-28.891-3.23-40.637 6.463c-17.257 14.24-19.712 39.866-5.472 57.122c8.013 9.71 19.63 14.733 31.333 14.733c9.095 0 18.243-3.035 25.79-9.263c8.36-6.897 13.533-16.638 14.567-27.428c.906-9.462-1.468-18.736-6.72-26.514l12.816-15.536c.582.175 1.123.232 1.616.153c1.824-.293 4.535-2.335 7.634-5.389c.597 1.13 1.804 1.849 3.12 1.724c1.76-.166 3.048-1.773 2.877-3.59a3.356 3.356 0 0 0-.643-1.672c5.807.902 10.524 1.093 10.913-.226c1.602-5.412-1.407-7.748-5.046-9.335a184.89 184.89 0 0 0 5.781-8.05c6.433-6.027 11.193-11.626 11.953-15.054c1.583-7.136-4.381-18.35-10.09-24.8c.415-4.287.917-9.205 1.397-14.104c.794 3.731 2.416 8.394 4.131 13.297c.999 2.855 2.031 5.807 2.884 8.554a1.858 1.858 0 0 0 3.247.583c.324-.422.723-.766 1.144-1.13c1.19-1.027 2.988-2.58 1.78-5.864c-5.2-14.149-1.259-26.205.635-31.999c.235-.718.437-1.337.6-1.885c.083-.278.182-.565.282-.852l11.166 3.981a41.157 41.157 0 0 0-1.004 5.91c-1.033 10.79 2.198 21.335 9.096 29.695s16.638 13.533 27.427 14.566a41.43 41.43 0 0 0 3.942.19c9.393 0 18.416-3.23 25.753-9.285c8.36-6.898 13.533-16.639 14.567-27.428c1.033-10.79-2.197-21.335-9.095-29.695zm-125.195 5.802c-4.583 1.607-8.715 3.808-11.544 6.606c-3.898-3.782-6.946-6.559-7.773-8.054a1.6 1.6 0 0 0 .85-.294c1.31-.957.145-3.288 1.818-4.575c2.736-2.107 7.584 2.796 10.737.856c1.432-.88 2.24-3.006 2.234-5.039l8.025 2.861zm-33.442 85.66a452.93 452.93 0 0 0 9.59-5.939a236.443 236.443 0 0 0 6.904 9.959l-3.36 3.585c-2.144-4.097-5.701-7.416-13.134-7.606zm18.096 21.31c.351-.053.713-.122 1.093-.216c2.18-.544 5.174-1.935 8.627-3.904c.166.146.33.29.494.427l-11.66 14.138a40.582 40.582 0 0 0-4.253-4.21zm-9.316 18.383c.222.27.433.546.644.82L85.682 241.8a12.017 12.017 0 0 0-2.215-4.103a12.045 12.045 0 0 0-2.273-2.11l11.752-12.853a29.421 29.421 0 0 1 3.913 3.913m6.595 21.534c-.75 7.825-4.5 14.889-10.563 19.891c-12.516 10.328-31.099 8.547-41.425-3.967c-10.327-12.515-8.546-31.098 3.968-41.426a29.284 29.284 0 0 1 18.703-6.716c2.139 0 4.274.233 6.365.692l-17.82 19.011l1.808 2.519c-3.239 4.351-3.24 10.498.368 14.87c4.24 5.138 11.844 5.866 16.983 1.626a12 12 0 0 0 3.41-4.569l15.053-18.25c2.587 4.98 3.698 10.6 3.15 16.32zm141.259-124.058c-.75 7.824-4.5 14.888-10.563 19.891c-6.063 5.003-13.707 7.344-21.535 6.596c-7.824-.75-14.888-4.501-19.89-10.564c-5.003-6.062-7.345-13.71-6.596-21.534c.104-1.083.271-2.15.488-3.2l16.742 5.97a12.015 12.015 0 0 0 2.758 7.714c4.24 5.139 11.844 5.867 16.983 1.627c5.138-4.24 5.867-11.844 1.626-16.983c-4.24-5.139-11.844-5.867-16.983-1.627c-.6.496-1.135 1.041-1.614 1.62l-16.485-6.523a29.393 29.393 0 0 1 7.05-8.488c5.32-4.392 11.863-6.733 18.675-6.733c.948 0 1.902.047 2.859.137c7.824.75 14.887 4.501 19.89 10.563c5.002 6.063 7.345 13.71 6.595 21.535z"/></svg>'}},
       dynamicWidth=False,
       emojiButtonColors=[],
       emojiButtonRadius="100%",
       emojiButtonSize=36,
       emojiSize=24,
       emojiVersion=14,
       exceptEmojis=[],
       icons="auto",
       locale="en",
       maxFrequentRows=4,
       navPosition="top",
       noCountryFlags=False,
       noResultsEmoji="cry",
       perLine=9,
       previewEmoji="point_up",
       previewPosition="bottom",
       searchPosition="sticky",
       set="native",
       skin=1,
       skinTonePosition="preview",
       theme="light",
    ),
    html.Div(id='dash-emoji-intro-output', style={'marginTop': '20px'}),
], style={'width': '100%', 'overflow':'auto', 'display': 'flex', 'flexDirection': 'column', 'alignItems': 'center'})


@callback(
    Output('dash-emoji-intro-output', 'children'),
    Input('dash-emoji-intro-input', 'value')
)
def update_output(value):
    # Check if value is a URL
    try:
        result = urlparse(value)
        if all([result.scheme, result.netloc]):
            # If value is a URL, return it as an image
            return html.Img(src=value, style={'width': '40px'})
    except ValueError:
        pass
    # If value is not a URL, return it as is
    return html.H1(value)
```


---

### Custom Emojis

Add custom emojis to the picker by providing an array of emoji configurations. Custom emojis support multiple skin tones and can use GIFs or SVGs as sources.

```python
from dash import Dash, html, callback, Input, Output
import dash_mantine_components as dmc
from dash_emoji_mart import DashEmojiMart

custom_emojis = [
    {
        'id': 'custom',
        'name': 'Custom',
        'emojis': [
            {
                'id': 'party_parrot',
                'name': 'Party Parrot',
                'short_names': ['party_parrot'],
                'keywords': ['dance', 'dancing'],
                'skins': [{'src': '/assets/party_parrot.gif'}],
                'native': '',
                'unified': 'custom',
            },
            {
                'id': 'plotly',
                'name': 'Plotly',
                'short_names': ['plotly'],
                'keywords': ['plotly', 'dash'],
                'skins': [{'src': 'https://example.com/plotly-logo.png'}],
                'native': '',
                'unified': 'custom',
            },
        ],
    },
]

app = Dash(__name__)

app.layout = dmc.Container([
    DashEmojiMart(
        id='emoji-picker',
        custom=custom_emojis,
    ),
    html.Div(id='selected-emoji')
])

@callback(
    Output('selected-emoji', 'children'),
    Input('emoji-picker', 'value')
)
def display_emoji(emoji_data):
    if emoji_data:
        return f"Selected: {emoji_data.get('native', emoji_data.get('src', ''))}"
    return "No emoji selected"
```

**Custom Emoji Structure:**

- **`id`**: Unique identifier for the emoji
- **`name`**: Display name
- **`short_names`**: Array of shortcode names
- **`keywords`**: Array of search keywords
- **`skins`**: Array of skin tone variations with `src` URL
- **`native`**: Native emoji character (empty string for custom)
- **`unified`**: Unicode representation (use 'custom' for custom emojis)

---

### Custom Category Icons

Customize category icons by providing an object with category names as keys and icon definitions as values. Icons can be SVG strings or image URLs.

```python
from dash_emoji_mart import DashEmojiMart

DashEmojiMart(
    id='emoji-picker',
    categoryIcons={
        'activity': {
            'svg': '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512">...</svg>'
        },
        'people': {
            'src': 'https://example.com/people-icon.png'
        },
        'custom': {
            'svg': '<svg xmlns="http://www.w3.org/2000/svg">...</svg>'
        }
    }
)
```

**Supported Icon Formats:**

- **SVG String**: Provide raw SVG markup via the `svg` key
- **Image URL**: Provide image URL via the `src` key

Icons are displayed in the category navigation bar and adapt to the current theme.

---

### Interactive Props Explorer

Explore all available properties with live controls. Adjust settings to see how they affect the emoji picker's appearance and behavior.



```python
# File: docs/dash_emoji_mart/props.py

from dash import *
import dash_mantine_components as dmc
from dash_emoji_mart import DashEmojiMart


# people, nature, foods, activity, places, objects, symbols, flags
data_grouped = [
        {
            "group": "people",
            "items": [
                    {"value": "grinning", "label": "😀 Grinning"},
                    {"value": "smiley", "label": "😃 Smiley"},
                    {"value": "smile", "label": "😄 Smile"},
                    {"value": "grin", "label": "😁 Grin"},
                    {"value": "laughing", "label": "😆 Laughing"},
                    {"value": "sweat_smile", "label": "😅 Sweat Smile"},
                    {"value": "rolling_on_the_floor_laughing", "label": "🤣 Rolling on the Floor Laughing"},
                    {"value": "joy", "label": "😂 Joy"},
                    {"value": "slightly_smiling_face", "label": "🙂 Slightly Smiling Face"},
                    {"value": "upside_down_face", "label": "🙃 Upside Down Face"},
                    {"value": "melting_face", "label": "🫠 Melting Face"},
                    {"value": "wink", "label": "😉 Wink"},
                    {"value": "blush", "label": "😊 Blush"},
                    {"value": "innocent", "label": "😇 Innocent"},
                    {"value": "smiling_face_with_3_hearts", "label": "🥰 Smiling Face with 3 Hearts"},
                    {"value": "heart_eyes", "label": "😍 Heart Eyes"},
                    {"value": "star-struck", "label": "🤩 Star-Struck"},
                    {"value": "kissing_heart", "label": "😘 Kissing Heart"},
                    {"value": "kissing", "label": "😗 Kissing"},
                    {"value": "relaxed", "label": "☺️ Relaxed"},
                    {"value": "kissing_closed_eyes", "label": "😚 Kissing Closed Eyes"},
                    {"value": "kissing_smiling_eyes", "label": "😙 Kissing Smiling Eyes"},
                    {"value": "smiling_face_with_tear", "label": "🥲 Smiling Face with Tear"},
                    {"value": "yum", "label": "😋 Yum"},
                    {"value": "stuck_out_tongue", "label": "😛 Stuck Out Tongue"},
                    {"value": "stuck_out_tongue_winking_eye", "label": "😜 Stuck Out Tongue Winking Eye"},
                    {"value": "zany_face", "label": "🤪 Zany Face"},
                    {"value": "stuck_out_tongue_closed_eyes", "label": "😝 Stuck Out Tongue Closed Eyes"},
                    {"value": "money_mouth_face", "label": "🤑 Money Mouth Face"},
                    {"value": "hugging_face", "label": "🤗 Hugging Face"},
                    {"value": "face_with_hand_over_mouth", "label": "🤭 Face with Hand Over Mouth"},
                    {"value": "face_with_open_eyes_and_hand_over_mouth",
                     "label": "🫢 Face with Open Eyes and Hand Over Mouth"},
                    {"value": "face_with_peeking_eye", "label": "🫣 Face with Peeking Eye"},
                    {"value": "shushing_face", "label": "🤫 Shushing Face"},
                    {"value": "thinking_face", "label": "🤔 Thinking Face"},
                    {"value": "saluting_face", "label": "🫡 Saluting Face"},
                    {"value": "zipper_mouth_face", "label": "🤐 Zipper Mouth Face"},
                    {"value": "face_with_raised_eyebrow", "label": "🤨 Face with Raised Eyebrow"},
                    {"value": "neutral_face", "label": "😐 Neutral Face"},
                    {"value": "expressionless", "label": "😑 Expressionless"},
                    {"value": "no_mouth", "label": "😶 No Mouth"},
                    {"value": "dotted_line_face", "label": "🫥 Dotted Line Face"},
                    {"value": "face_in_clouds", "label": "😶‍🌫️ Face in Clouds"},
                    {"value": "smirk", "label": "😏 Smirk"},
                    {"value": "unamused", "label": "😒 Unamused"},
                    {"value": "face_with_rolling_eyes", "label": "🙄 Face with Rolling Eyes"},
                    {"value": "grimacing", "label": "😬 Grimacing"},
                    {"value": "face_exhaling", "label": "😮‍💨 Face Exhaling"},
                    {"value": "lying_face", "label": "🤥 Lying Face"},
                    {"value": "relieved", "label": "😌 Relieved"},
                    {"value": "pensive", "label": "😔 Pensive"},
                    {"value": "sleepy", "label": "😪 Sleepy"},
                    {"value": "drooling_face", "label": "🤤 Drooling Face"},
                    {"value": "sleeping", "label": "😴 Sleeping"},
                    {"value": "mask", "label": "😷 Mask"},
                    {"value": "face_with_thermometer", "label": "🤒 Face with Thermometer"},
                    {"value": "face_with_head_bandage", "label": "🤕 Face with Head Bandage"},
                    {"value": "nauseated_face", "label": "🤢 Nauseated Face"},
                    {"value": "face_vomiting", "label": "🤮 Face Vomiting"},
                    {"value": "sneezing_face", "label": "🤧 Sneezing Face"},
                    {"value": "hot_face", "label": "🥵 Hot Face"},
                    {"value": "cold_face", "label": "🥶 Cold Face"},
                    {"value": "woozy_face", "label": "🥴 Woozy Face"},
                    {"value": "dizzy_face", "label": "😵 Dizzy Face"},
                    {"value": "face_with_spiral_eyes", "label": "😵‍💫 Face with Spiral Eyes"},
                    {"value": "exploding_head", "label": "🤯 Exploding Head"},
                    {"value": "face_with_cowboy_hat", "label": "🤠 Face with Cowboy Hat"},
                    {"value": "partying_face", "label": "🥳 Partying Face"},
                    {"value": "disguised_face", "label": "🥸 Disguised Face"},
                    {"value": "sunglasses", "label": "😎 Sunglasses"},
                    {"value": "nerd_face", "label": "🤓 Nerd Face"},
                    {"value": "face_with_monocle", "label": "🧐 Face with Monocle"},
                    {"value": "confused", "label": "😕 Confused"},
                    {"value": "face_with_diagonal_mouth", "label": "🫤 Face with Diagonal Mouth"},
                    {"value": "worried", "label": "😟 Worried"},
                    {"value": "slightly_frowning_face", "label": "🙁 Slightly Frowning Face"},
                    {"value": "white_frowning_face", "label": "☹️ White Frowning Face"},
                    {"value": "open_mouth", "label": "😮 Open Mouth"},
                    {"value": "hushed", "label": "😯 Hushed"},
                    {"value": "astonished", "label": "😲 Astonished"},
                    {"value": "flushed", "label": "😳 Flushed"},
                    {"value": "pleading_face", "label": "🥺 Pleading Face"},
                    {"value": "face_holding_back_tears", "label": "🥹 Face Holding Back Tears"},
                    {"value": "frowning", "label": "😦 Frowning"},
                    {"value": "anguished", "label": "😧 Anguished"},
                    {"value": "fearful", "label": "😨 Fearful"},
                    {"value": "cold_sweat", "label": "😰 Cold Sweat"},
                    {"value": "disappointed_relieved", "label": "😥 Disappointed Relieved"},
                    {"value": "cry", "label": "😢 Cry"},
                    {"value": "sob", "label": "😭 Sob"},
                    {"value": "scream", "label": "😱 Scream"},
                    {"value": "confounded", "label": "😖 Confounded"},
                    {"value": "persevere", "label": "😣 Persevere"},
                    {"value": "disappointed", "label": "😞 Disappointed"},
                    {"value": "sweat", "label": "😓 Sweat"},
                    {"value": "weary", "label": "😩 Weary"},
                    {"value": "tired_face", "label": "😫 Tired Face"},
                    {"value": "yawning_face", "label": "🥱 Yawning Face"},
                    {"value": "triumph", "label": "😤 Triumph"},
                    {"value": "rage", "label": "😡 Rage"},
                    {"value": "angry", "label": "😠 Angry"},
                    {"value": "face_with_symbols_on_mouth", "label": "🤬 Face with Symbols on Mouth"},
                    {"value": "smiling_imp", "label": "😈 Smiling Imp"},
                    {"value": "imp", "label": "👿 Imp"},
                    {"value": "skull", "label": "💀 Skull"},
                    {"value": "skull_and_crossbones", "label": "☠️ Skull and Crossbones"},
                    {"value": "hankey", "label": "💩 Hankey"},
                    {"value": "clown_face", "label": "🤡 Clown Face"},
                    {"value": "japanese_ogre", "label": "👹 Japanese Ogre"},
                    {"value": "japanese_goblin", "label": "👺 Japanese Goblin"},
                    {"value": "ghost", "label": "👻 Ghost"},
                    {"value": "alien", "label": "👽 Alien"},
                    {"value": "space_invader", "label": "👾 Space Invader"},
                    {"value": "robot_face", "label": "🤖 Robot Face"},
                    {"value": "wave", "label": "👋 Wave"},
                    {"value": "raised_back_of_hand", "label": "🤚 Raised Back of Hand"},
                    {"value": "raised_hand_with_fingers_splayed", "label": "🖐 Raised Hand with Fingers Splayed"},
                    {"value": "hand", "label": "✋ Hand"},
                    {"value": "spock-hand", "label": "🖖 Spock Hand"},
                    {"value": "rightwards_hand", "label": "🫱 Rightwards Hand"},
                    {"value": "leftwards_hand", "label": "🫲 Leftwards Hand"},
                    {"value": "palm_down_hand", "label": "🫳 Palm Down Hand"},
                    {"value": "palm_up_hand", "label": "🫴 Palm Up Hand"},
                    {"value": "ok_hand", "label": "👌 Ok Hand"},
                    {"value": "pinched_fingers", "label": "🤌 Pinched Fingers"},
                    {"value": "pinching_hand", "label": "🤏 Pinching Hand"},
                    {"value": "v", "label": "✌️ V"},
                    {"value": "crossed_fingers", "label": "🤞 Crossed Fingers"},
                    {"value": "hand_with_index_finger_and_thumb_crossed",
                     "label": "🫰 Hand with Index Finger and Thumb Crossed"},
                    {"value": "i_love_you_hand_sign", "label": "🤟 I Love You Hand Sign"},
                    {"value": "the_horns", "label": "🤘 The Horns"},
                    {"value": "call_me_hand", "label": "🤙 Call Me Hand"},
                    {"value": "point_left", "label": "👈 Point Left"},
                    {"value": "point_right", "label": "👉 Point Right"},
                    {"value": "point_up_2", "label": "👆 Point Up 2"},
                    {"value": "middle_finger", "label": "🖕 Middle Finger"},
                    {"value": "point_down", "label": "👇 Point Down"},
                    {"value": "point_up", "label": "☝️ Point Up"},
                    {"value": "index_pointing_at_the_viewer", "label": "🫵 Index Pointing at the Viewer"},
                    {"value": "+1", "label": "👍 +1"},
                    {"value": "-1", "label": "👎 -1"},
                    {"value": "fist", "label": "✊ Fist"},
                    {"value": "facepunch", "label": "👊 Facepunch"},
                    {"value": "left-facing_fist", "label": "🤛 Left-Facing Fist"},
                    {"value": "right-facing_fist", "label": "🤜 Right-Facing Fist"},
                    {"value": "clap", "label": "👏 Clap"},
                    {"value": "raised_hands", "label": "🙌 Raised Hands"},
                    {"value": "heart_hands", "label": "🫶 Heart Hands"},
                    {"value": "open_hands", "label": "👐 Open Hands"},
                    {"value": "palms_up_together", "label": "🤲 Palms Up Together"},
                    {"value": "handshake", "label": "🤝 Handshake"},
                    {"value": "pray", "label": "🙏 Pray"},
                    {"value": "writing_hand", "label": "✍️ Writing Hand"},
                    {"value": "nail_care", "label": "💅 Nail Care"},
                    {"value": "selfie", "label": "🤳 Selfie"},
                    {"value": "muscle", "label": "💪 Muscle"},
                    {"value": "mechanical_arm", "label": "🦾 Mechanical Arm"},
                    {"value": "mechanical_leg", "label": "🦿 Mechanical Leg"},
                    {"value": "leg", "label": "🦵 Leg"},
                    {"value": "foot", "label": "🦶 Foot"},
                    {"value": "ear", "label": "👂 Ear"},
                    {"value": "ear_with_hearing_aid", "label": "🦻 Ear with Hearing Aid"},
                    {"value": "nose", "label": "👃 Nose"},
                    {"value": "brain", "label": "🧠 Brain"},
                    {"value": "anatomical_heart", "label": "🫀 Anatomical Heart"},
                    {"value": "lungs", "label": "🫁 Lungs"},
                    {"value": "tooth", "label": "🦷 Tooth"},
                    {"value": "bone", "label": "🦴 Bone"},
                    {"value": "eyes", "label": "👀 Eyes"},
                    {"value": "eye", "label": "👁 Eye"},
                    {"value": "tongue", "label": "👅 Tongue"},
                    {"value": "lips", "label": "👄 Lips"},
                    {"value": "biting_lip", "label": "🫦 Biting Lip"},
                    {"value": "baby", "label": "👶 Baby"},
                    {"value": "child", "label": "🧒 Child"},
                    {"value": "boy", "label": "👦 Boy"},
                    {"value": "girl", "label": "👧 Girl"},
                    {"value": "adult", "label": "🧑 Adult"},
                    {"value": "person_with_blond_hair", "label": "👱 Person with Blond Hair"},
                    {"value": "man", "label": "👨 Man"},
                    {"value": "bearded_person", "label": "🧔 Bearded Person"},
                    {"value": "man_with_beard", "label": "🧔‍♂️ Man with Beard"},
                    {"value": "woman_with_beard", "label": "🧔‍♀️ Woman with Beard"},
                    {"value": "red_haired_man", "label": "👨‍🦰 Red-Haired Man"},
                    {"value": "curly_haired_man", "label": "👨‍🦱 Curly-Haired Man"},
                    {"value": "white_haired_man", "label": "👨‍🦳 White-Haired Man"},
                    {"value": "bald_man", "label": "👨‍🦲 Bald Man"},
                    {"value": "woman", "label": "👩 Woman"},
                    {"value": "red_haired_woman", "label": "👩‍🦰 Red-Haired Woman"},
                    {"value": "red_haired_person", "label": "🧑‍🦰 Red-Haired Person"},
                    {"value": "curly_haired_woman", "label": "👩‍🦱 Curly-Haired Woman"},
                    {"value": "curly_haired_person", "label": "🧑‍🦱 Curly-Haired Person"},
                    {"value": "white_haired_woman", "label": "👩‍🦳 White-Haired Woman"},
                    {"value": "white_haired_person", "label": "🧑‍🦳 White-Haired Person"},
                    {"value": "bald_woman", "label": "👩‍🦲 Bald Woman"},
                    {"value": "bald_person", "label": "🧑‍🦲 Bald Person"},
                    {"value": "blond-haired-woman", "label": "👱‍♀️ Blond-Haired Woman"},
                    {"value": "blond-haired-man", "label": "👱‍♂️ Blond-Haired Man"},
                    {"value": "older_adult", "label": "🧓 Older Adult"},
                    {"value": "older_man", "label": "👴 Older Man"},
                    {"value": "older_woman", "label": "👵 Older Woman"},
                    {"value": "person_frowning", "label": "🙍 Person Frowning"},
                    {"value": "man-frowning", "label": "🙍‍♂️ Man Frowning"},
                    {"value": "woman-frowning", "label": "🙍‍♀️ Woman Frowning"},
                    {"value": "person_with_pouting_face", "label": "🙎 Person with Pouting Face"},
                    {"value": "man-pouting", "label": "🙎‍♂️ Man Pouting"},
                    {"value": "woman-pouting", "label": "🙎‍♀️ Woman Pouting"},
                    {"value": "no_good", "label": "🙅 No Good"},
                    {"value": "man-gesturing-no", "label": "🙅‍♂️ Man Gesturing No"},
                    {"value": "woman-gesturing-no", "label": "🙅‍♀️ Woman Gesturing No"},
                    {"value": "ok_woman", "label": "🙆 Ok Woman"},
                    {"value": "man-gesturing-ok", "label": "🙆‍♂️ Man Gesturing Ok"},
                    {"value": "woman-gesturing-ok", "label": "🙆‍♀️ Woman Gesturing Ok"},
                    {"value": "information_desk_person", "label": "💁 Information Desk Person"},
                    {"value": "man-tipping-hand", "label": "💁‍♂️ Man Tipping Hand"},
                    {"value": "woman-tipping-hand", "label": "💁‍♀️ Woman Tipping Hand"},
                    {"value": "raising_hand", "label": "🙋 Raising Hand"},
                    {"value": "man-raising-hand", "label": "🙋‍♂️ Man Raising Hand"},
                    {"value": "woman-raising-hand", "label": "🙋‍♀️ Woman Raising Hand"},
                    {"value": "deaf_person", "label": "🧏 Deaf Person"},
                    {"value": "deaf_man", "label": "🧏‍♂️ Deaf Man"},
                    {"value": "deaf_woman", "label": "🧏‍♀️ Deaf Woman"},
                    {"value": "bow", "label": "🙇 Bow"},
                    {"value": "man-bowing", "label": "🙇‍♂️ Man Bowing"},
                    {"value": "woman-bowing", "label": "🙇‍♀️ Woman Bowing"},
                    {"value": "face_palm", "label": "🤦 Face Palm"},
                    {"value": "man-facepalming", "label": "🤦‍♂️ Man Facepalming"},
                    {"value": "woman-facepalming", "label": "🤦‍♀️ Woman Facepalming"},
                    {"value": "shrug", "label": "🤷 Shrug"},
                    {"value": "man-shrugging", "label": "🤷‍♂️ Man Shrugging"},
                    {"value": "woman-shrugging", "label": "🤷‍♀️ Woman Shrugging"},
                    {"value": "health_worker", "label": "🧑‍⚕️ Health Worker"},
                    {"value": "male-doctor", "label": "👨‍⚕️ Male Doctor"},
                    {"value": "female-doctor", "label": "👩‍⚕️ Female Doctor"},
                    {"value": "student", "label": "🧑‍🎓 Student"},
                    {"value": "male-student", "label": "👨‍🎓 Male Student"},
                    {"value": "female-student", "label": "👩‍🎓 Female Student"},
                    {"value": "teacher", "label": "🧑‍🏫 Teacher"},
                    {"value": "male-teacher", "label": "👨‍🏫 Male Teacher"},
                    {"value": "female-teacher", "label": "👩‍🏫 Female Teacher"},
                    {"value": "judge", "label": "🧑‍⚖️ Judge"},
                    {"value": "male-judge", "label": "👨‍⚖️ Male Judge"},
                    {"value": "female-judge", "label": "👩‍⚖️ Female Judge"},
                    {"value": "farmer", "label": "🧑‍🌾 Farmer"},
                    {"value": "male-farmer", "label": "👨‍🌾 Male Farmer"},
                    {"value": "female-farmer", "label": "👩‍🌾 Female Farmer"},
                    {"value": "cook", "label": "🧑‍🍳 Cook"},
                    {"value": "male-cook", "label": "👨‍🍳 Male Cook"},
                    {"value": "female-cook", "label": "👩‍🍳 Female Cook"},
                    {"value": "mechanic", "label": "🧑‍🔧 Mechanic"},
                    {"value": "male-mechanic", "label": "👨‍🔧 Male Mechanic"},
                    {"value": "female-mechanic", "label": "👩‍🔧 Female Mechanic"},
                    {"value": "factory_worker", "label": "🧑‍🏭 Factory Worker"},
                    {"value": "male-factory-worker", "label": "👨‍🏭 Male Factory Worker"},
                    {"value": "female-factory-worker", "label": "👩‍🏭 Female Factory Worker"},
                    {"value": "office_worker", "label": "🧑‍💼 Office Worker"},
                    {"value": "male-office-worker", "label": "👨‍💼 Male Office Worker"},
                    {"value": "female-office-worker", "label": "👩‍💼 Female Office Worker"},
                    {"value": "scientist", "label": "🧑‍🔬 Scientist"},
                    {"value": "male-scientist", "label": "👨‍🔬 Male Scientist"},
                    {"value": "female-scientist", "label": "👩‍🔬 Female Scientist"},
                    {"value": "technologist", "label": "🧑‍💻 Technologist"},
                    {"value": "male-technologist", "label": "👨‍💻 Male Technologist"},
                    {"value": "female-technologist", "label": "👩‍💻 Female Technologist"},
                    {"value": "singer", "label": "🧑‍🎤 Singer"},
                    {"value": "male-singer", "label": "👨‍🎤 Male Singer"},
                    {"value": "female-singer", "label": "👩‍🎤 Female Singer"},
                    {"value": "artist", "label": "🧑‍🎨 Artist"},
                    {"value": "male-artist", "label": "👨‍🎨 Male Artist"},
                    {"value": "female-artist", "label": "👩‍🎨 Female Artist"},
                    {"value": "pilot", "label": "🧑‍✈️ Pilot"},
                    {"value": "male-pilot", "label": "👨‍✈️ Male Pilot"},
                    {"value": "female-pilot", "label": "👩‍✈️ Female Pilot"},
                    {"value": "astronaut", "label": "🧑‍🚀 Astronaut"},
                    {"value": "male-astronaut", "label": "👨‍🚀 Male Astronaut"},
                    {"value": "female-astronaut", "label": "👩‍🚀 Female Astronaut"},
                    {"value": "firefighter", "label": "🧑‍🚒 Firefighter"},
                    {"value": "male-firefighter", "label": "👨‍🚒 Male Firefighter"},
                    {"value": "female-firefighter", "label": "👩‍🚒 Female Firefighter"},
                    {"value": "cop", "label": "👮 Cop"},
                    {"value": "male-police-officer", "label": "👮‍♂️ Male Police Officer"},
                    {"value": "female-police-officer", "label": "👮‍♀️ Female Police Officer"},
                    {"value": "sleuth_or_spy", "label": "🕵️ Sleuth or Spy"},
                    {"value": "male-detective", "label": "🕵️‍♂️ Male Detective"},
                    {"value": "female-detective", "label": "🕵️‍♀️ Female Detective"},
                    {"value": "guardsman", "label": "💂 Guardsman"},
                    {"value": "male-guard", "label": "💂‍♂️ Male Guard"},
                    {"value": "female-guard", "label": "💂‍♀️ Female Guard"},
                    {"value": "ninja", "label": "🥷 Ninja"},
                    {"value": "construction_worker", "label": "👷 Construction Worker"},
                    {"value": "male-construction-worker", "label": "👷‍♂️ Male Construction Worker"},
                    {"value": "female-construction-worker", "label": "👷‍♀️ Female Construction Worker"},
                    {"value": "person_with_crown", "label": "🫅 Person with Crown"},
                    {"value": "prince", "label": "🤴 Prince"},
                    {"value": "princess", "label": "👸 Princess"},
                    {"value": "man_with_turban", "label": "👳 Man with Turban"},
                    {"value": "man-wearing-turban", "label": "👳‍♂️ Man Wearing Turban"},
                    {"value": "woman-wearing-turban", "label": "👳‍♀️ Woman Wearing Turban"},
                    {"value": "man_with_gua_pi_mao", "label": "👲 Man with Gua Pi Mao"},
                    {"value": "person_with_headscarf", "label": "🧕 Person with Headscarf"},
                    {"value": "person_in_tuxedo", "label": "🤵 Person in Tuxedo"},
                    {"value": "man_in_tuxedo", "label": "🤵‍♂️ Man in Tuxedo"},
                    {"value": "woman_in_tuxedo", "label": "🤵‍♀️ Woman in Tuxedo"},
                    {"value": "bride_with_veil", "label": "👰 Bride with Veil"},
                    {"value": "man_with_veil", "label": "👰‍♂️ Man with Veil"},
                    {"value": "woman_with_veil", "label": "👰‍♀️ Woman with Veil"},
                    {"value": "pregnant_woman", "label": "🤰 Pregnant Woman"},
                    {"value": "pregnant_man", "label": "🤰‍♂️ Pregnant Man"},
                    {"value": "pregnant_person", "label": "🤰‍♀️ Pregnant Person"},
                    {"value": "breast-feeding", "label": "🤱 Breast-Feeding"},
                    {"value": "woman_feeding_baby", "label": "👩‍🍼 Woman Feeding Baby"},
                    {"value": "man_feeding_baby", "label": "👨‍🍼 Man Feeding Baby"},
                    {"value": "person_feeding_baby", "label": "🧑‍🍼 Person Feeding Baby"},
                    {"value": "angel", "label": "👼 Angel"},
                    {"value": "santa", "label": "🎅 Santa"},
                    {"value": "mrs_claus", "label": "🤶 Mrs. Claus"},
                    {"value": "mx_claus", "label": "🧑‍🎄 Mx Claus"},
                    {"value": "superhero", "label": "🦸 Superhero"},
                    {"value": "male_superhero", "label": "🦸‍♂️ Male Superhero"},
                    {"value": "female_superhero", "label": "🦸‍♀️ Female Superhero"},
                    {"value": "supervillain", "label": "🦹 Supervillain"},
                    {"value": "male_supervillain", "label": "🦹‍♂️ Male Supervillain"},
                    {"value": "female_supervillain", "label": "🦹‍♀️ Female Supervillain"},
                    {"value": "mage", "label": "🧙 Mage"},
                    {"value": "male_mage", "label": "🧙‍♂️ Male Mage"},
                    {"value": "female_mage", "label": "🧙‍♀️ Female Mage"},
                    {"value": "fairy", "label": "🧚 Fairy"},
                    {"value": "male_fairy", "label": "🧚‍♂️ Male Fairy"},
                    {"value": "female_fairy", "label": "🧚‍♀️ Female Fairy"},
                    {"value": "vampire", "label": "🧛 Vampire"},
                    {"value": "male_vampire", "label": "🧛‍♂️ Male Vampire"},
                    {"value": "female_vampire", "label": "🧛‍♀️ Female Vampire"},
                    {"value": "merperson", "label": "🧜 Merperson"},
                    {"value": "merman", "label": "🧜‍♂️ Merman"},
                    {"value": "mermaid", "label": "🧜‍♀️ Mermaid"},
                    {"value": "elf", "label": "🧝 Elf"},
                    {"value": "male_elf", "label": "🧝‍♂️ Male Elf"},
                    {"value": "female_elf", "label": "🧝‍♀️ Female Elf"},
                    {"value": "genie", "label": "🧞 Genie"},
                    {"value": "male_genie", "label": "🧞‍♂️ Male Genie"},
                    {"value": "female_genie", "label": "🧞‍♀️ Female Genie"},
                    {"value": "zombie", "label": "🧟 Zombie"},
                    {"value": "male_zombie", "label": "🧟‍♂️ Male Zombie"},
                    {"value": "female_zombie", "label": "🧟‍♀️ Female Zombie"},
                    {"value": "troll", "label": "🧌 Troll"},
                    {"value": "massage", "label": "💆 Massage"},
                    {"value": "man-getting-massage", "label": "💆‍♂️ Man Getting Massage"},
                    {"value": "woman-getting-massage", "label": "💆‍♀️ Woman Getting Massage"},
                    {"value": "haircut", "label": "💇 Haircut"},
                    {"value": "man-getting-haircut", "label": "💇‍♂️ Man Getting Haircut"},
                    {"value": "woman-getting-haircut", "label": "💇‍♀️ Woman Getting Haircut"},
                    {"value": "walking", "label": "🚶 Walking"},
                    {"value": "man-walking", "label": "🚶‍♂️ Man Walking"},
                    {"value": "woman-walking", "label": "🚶‍♀️ Woman Walking"},
                    {"value": "standing_person", "label": "🧍 Standing Person"},
                    {"value": "man_standing", "label": "🧍‍♂️ Man Standing"},
                    {"value": "woman_standing", "label": "🧍‍♀️ Woman Standing"},
                    {"value": "kneeling_person", "label": "🧎 Kneeling Person"},
                    {"value": "man_kneeling", "label": "🧎‍♂️ Man Kneeling"},
                    {"value": "woman_kneeling", "label": "🧎‍♀️ Woman Kneeling"},
                    {"value": "person_with_probing_cane", "label": "🧑‍🦯 Person with Probing Cane"},
                    {"value": "man_with_probing_cane", "label": "👨‍🦯 Man with Probing Cane"},
                    {"value": "woman_with_probing_cane", "label": "👩‍🦯 Woman with Probing Cane"},
                    {"value": "person_in_motorized_wheelchair", "label": "🧑‍🦼 Person in Motorized Wheelchair"},
                    {"value": "man_in_motorized_wheelchair", "label": "👨‍🦼 Man in Motorized Wheelchair"},
                    {"value": "woman_in_motorized_wheelchair", "label": "👩‍🦼 Woman in Motorized Wheelchair"},
                    {"value": "person_in_manual_wheelchair", "label": "🧑‍🦽 Person in Manual Wheelchair"},
                    {"value": "man_in_manual_wheelchair", "label": "👨‍🦽 Man in Manual Wheelchair"},
                    {"value": "woman_in_manual_wheelchair", "label": "👩‍🦽 Woman in Manual Wheelchair"},
                    {"value": "runner", "label": "🏃 Runner"},
                    {"value": "man-running", "label": "🏃‍♂️ Man Running"},
                    {"value": "woman-running", "label": "🏃‍♀️ Woman Running"},
                    {"value": "dancer", "label": "💃 Dancer"},
                    {"value": "man_dancing", "label": "🕺 Man Dancing"},
                    {"value": "man_in_business_suit_levitating", "label": "🕴 Man in Business Suit Levitating"},
                    {"value": "dancers", "label": "👯 Dancers"},
                    {"value": "men-with-bunny-ears-partying", "label": "👯‍♂️ Men with Bunny Ears Partying"},
                    {"value": "women-with-bunny-ears-partying", "label": "👯‍♀️ Women with Bunny Ears Partying"},
                    {"value": "person_in_steamy_room", "label": "🧖 Person in Steamy Room"},
                    {"value": "man_in_steamy_room", "label": "🧖‍♂️ Man in Steamy Room"},
                    {"value": "woman_in_steamy_room", "label": "🧖‍♀️ Woman in Steamy Room"},
                    {"value": "person_climbing", "label": "🧗 Person Climbing"},
                    {"value": "man_climbing", "label": "🧗‍♂️ Man Climbing"},
                    {"value": "woman_climbing", "label": "🧗‍♀️ Woman Climbing"},
                    {"value": "fencer", "label": "🤺 Fencer"},
                    {"value": "horse_racing", "label": "🏇 Horse Racing"},
                    {"value": "skier", "label": "⛷ Skier"},
                    {"value": "snowboarder", "label": "🏂 Snowboarder"},
                    {"value": "golfer", "label": "🏌️ Golfer"},
                    {"value": "man-golfing", "label": "🏌️‍♂️ Man Golfing"},
                    {"value": "woman-golfing", "label": "🏌️‍♀️ Woman Golfing"},
                    {"value": "surfer", "label": "🏄 Surfer"},
                    {"value": "man-surfing", "label": "🏄‍♂️ Man Surfing"},
                    {"value": "woman-surfing", "label": "🏄‍♀️ Woman Surfing"},
                    {"value": "rowboat", "label": "🚣 Rowboat"},
                    {"value": "man-rowing-boat", "label": "🚣‍♂️ Man Rowing Boat"},
                    {"value": "woman-rowing-boat", "label": "🚣‍♀️ Woman Rowing Boat"},
                    {"value": "swimmer", "label": "🏊 Swimmer"},
                    {"value": "man-swimming", "label": "🏊‍♂️ Man Swimming"},
                    {"value": "woman-swimming", "label": "🏊‍♀️ Woman Swimming"},
                    {"value": "person_with_ball", "label": "⛹️ Person with Ball"},
                    {"value": "man-bouncing-ball", "label": "⛹️‍♂️ Man Bouncing Ball"},
                    {"value": "woman-bouncing-ball", "label": "⛹️‍♀️ Woman Bouncing Ball"},
                    {"value": "weight_lifter", "label": "🏋️ Weight Lifter"},
                    {"value": "man-lifting-weights", "label": "🏋️‍♂️ Man Lifting Weights"},
                    {"value": "woman-lifting-weights", "label": "🏋️‍♀️ Woman Lifting Weights"},
                    {"value": "bicyclist", "label": "🚴 Bicyclist"},
                    {"value": "man-biking", "label": "🚴‍♂️ Man Biking"},
                    {"value": "woman-biking", "label": "🚴‍♀️ Woman Biking"},
                    {"value": "mountain_bicyclist", "label": "🚵 Mountain Bicyclist"},
                    {"value": "man-mountain-biking", "label": "🚵‍♂️ Man Mountain Biking"},
                    {"value": "woman-mountain-biking", "label": "🚵‍♀️ Woman Mountain Biking"},
                    {"value": "person_doing_cartwheel", "label": "🤸 Person Doing Cartwheel"},
                    {"value": "man-cartwheeling", "label": "🤸‍♂️ Man Cartwheeling"},
                    {"value": "woman-cartwheeling", "label": "🤸‍♀️ Woman Cartwheeling"},
                    {"value": "wrestlers", "label": "🤼 Wrestlers"},
                    {"value": "man-wrestling", "label": "🤼‍♂️ Man Wrestling"},
                    {"value": "woman-wrestling", "label": "🤼‍♀️ Woman Wrestling"},
                    {"value": "water_polo", "label": "🤽 Water Polo"},
                    {"value": "man-playing-water-polo", "label": "🤽‍♂️ Man Playing Water Polo"},
                    {"value": "woman-playing-water-polo", "label": "🤽‍♀️ Woman Playing Water Polo"},
                    {"value": "handball", "label": "🤾 Handball"},
                    {"value": "man-playing-handball", "label": "🤾‍♂️ Man Playing Handball"},
                    {"value": "woman-playing-handball", "label": "🤾‍♀️ Woman Playing Handball"},
                    {"value": "juggling", "label": "🤹 Juggling"},
                    {"value": "man-juggling", "label": "🤹‍♂️ Man Juggling"},
                    {"value": "woman-juggling", "label": "🤹‍♀️ Woman Juggling"},
                    {"value": "person_in_lotus_position", "label": "🧘 Person in Lotus Position"},
                    {"value": "man_in_lotus_position", "label": "🧘‍♂️ Man in Lotus Position"},
                    {"value": "woman_in_lotus_position", "label": "🧘‍♀️ Woman in Lotus Position"},
                    {"value": "bath", "label": "🛀 Bath"},
                    {"value": "sleeping_accommodation", "label": "🛌 Sleeping Accommodation"},
                    {"value": "people_holding_hands", "label": "🧑‍🤝‍🧑 People Holding Hands"},
                    {"value": "two_women_holding_hands", "label": "👭 Two Women Holding Hands"},
                    {"value": "man_and_woman_holding_hands", "label": "👫 Man and Woman Holding Hands"},
                    {"value": "two_men_holding_hands", "label": "👬 Two Men Holding Hands"},
                    {"value": "couplekiss", "label": "💏 Couple Kiss"},
                    {"value": "woman-kiss-man", "label": "👩‍❤️‍💋‍👨 Woman Kiss Man"},
                    {"value": "man-kiss-man", "label": "👨‍❤️‍💋‍👨 Man Kiss Man"},
                    {"value": "woman-kiss-woman", "label": "👩‍❤️‍💋‍👩 Woman Kiss Woman"},
                    {"value": "couple_with_heart", "label": "💑 Couple with Heart"},
                    {"value": "woman-heart-man", "label": "👩‍❤️‍👨 Woman Heart Man"},
                    {"value": "man-heart-man", "label": "👨‍❤️‍👨 Man Heart Man"},
                    {"value": "woman-heart-woman", "label": "👩‍❤️‍👩 Woman Heart Woman"},
                    {"value": "family", "label": "👪 Family"},
                    {"value": "man-woman-boy", "label": "👨‍👩‍👦 Man Woman Boy"},
                    {"value": "man-woman-girl", "label": "👨‍👩‍👧 Man Woman Girl"},
                    {"value": "man-woman-girl-boy", "label": "👨‍👩‍👧‍👦 Man Woman Girl Boy"},
                    {"value": "man-woman-boy-boy", "label": "👨‍👩‍👦‍👦 Man Woman Boy Boy"},
                    {"value": "man-woman-girl-girl", "label": "👨‍👩‍👧‍👧 Man Woman Girl Girl"},
                    {"value": "man-man-boy", "label": "👨‍👨‍👦 Man Man Boy"},
                    {"value": "man-man-girl", "label": "👨‍👨‍👧 Man Man Girl"},
                    {"value": "man-man-girl-boy", "label": "👨‍👨‍👧‍👦 Man Man Girl Boy"},
                    {"value": "man-man-boy-boy", "label": "👨‍👨‍👦‍👦 Man Man Boy Boy"},
                    {"value": "man-man-girl-girl", "label": "👨‍👨‍👧‍👧 Man Man Girl Girl"},
                    {"value": "woman-woman-boy", "label": "👩‍👩‍👦 Woman Woman Boy"},
                    {"value": "woman-woman-girl", "label": "👩‍👩‍👧 Woman Woman Girl"},
                    {"value": "woman-woman-girl-boy", "label": "👩‍👩‍👧‍👦 Woman Woman Girl Boy"},
                    {"value": "woman-woman-boy-boy", "label": "👩‍👩‍👦‍👦 Woman Woman Boy Boy"},
                    {"value": "woman-woman-girl-girl", "label": "👩‍👩‍👧‍👧 Woman Woman Girl Girl"},
                    {"value": "man-boy", "label": "👨‍👦 Man Boy"},
                    {"value": "man-boy-boy", "label": "👨‍👦‍👦 Man Boy Boy"},
                    {"value": "man-girl", "label": "👨‍👧 Man Girl"},
                    {"value": "man-girl-boy", "label": "👨‍👧‍👦 Man Girl Boy"},
                    {"value": "man-girl-girl", "label": "👨‍👧‍👧 Man Girl Girl"},
                    {"value": "woman-boy", "label": "👩‍👦 Woman Boy"},
                    {"value": "woman-boy-boy", "label": "👩‍👦‍👦 Woman Boy Boy"},
                    {"value": "woman-girl", "label": "👩‍👧 Woman Girl"},
                    {"value": "woman-girl-boy", "label": "👩‍👧‍👦 Woman Girl Boy"},
                    {"value": "woman-girl-girl", "label": "👩‍👧‍👧 Woman Girl Girl"},
                    {"value": "speaking_head_in_silhouette", "label": "🗣 Speaking Head in Silhouette"},
                    {"value": "bust_in_silhouette", "label": "👤 Bust in Silhouette"},
                    {"value": "busts_in_silhouette", "label": "👥 Busts in Silhouette"},
                    {"value": "people_hugging", "label": "🫂 People Hugging"},
                    {"value": "footprints", "label": "👣 Footprints"},
                    {"value": "smiley_cat", "label": "😺 Smiley Cat"},
                    {"value": "smile_cat", "label": "😸 Smile Cat"},
                    {"value": "joy_cat", "label": "😹 Joy Cat"},
                    {"value": "heart_eyes_cat", "label": "😻 Heart Eyes Cat"},
                    {"value": "smirk_cat", "label": "😼 Smirk Cat"},
                    {"value": "kissing_cat", "label": "😽 Kissing Cat"},
                    {"value": "scream_cat", "label": "🙀 Scream Cat"},
                    {"value": "crying_cat_face", "label": "😿 Crying Cat Face"},
                    {"value": "pouting_cat", "label": "😾 Pouting Cat"},
                    {"value": "see_no_evil", "label": "🙈 See No Evil"},
                    {"value": "hear_no_evil", "label": "🙉 Hear No Evil"},
                    {"value": "speak_no_evil", "label": "🙊 Speak No Evil"},
                    {"value": "love_letter", "label": "💌 Love Letter"},
                    {"value": "cupid", "label": "💘 Cupid"},
                    {"value": "gift_heart", "label": "💝 Gift Heart"},
                    {"value": "sparkling_heart", "label": "💖 Sparkling Heart"},
                    {"value": "heartpulse", "label": "💗 Heartpulse"},
                    {"value": "heartbeat", "label": "💓 Heartbeat"},
                    {"value": "revolving_hearts", "label": "💞 Revolving Hearts"},
                    {"value": "two_hearts", "label": "💕 Two Hearts"},
                    {"value": "heart_decoration", "label": "💟 Heart Decoration"},
                    {"value": "heavy_heart_exclamation_mark_ornament",
                     "label": "❣️ Heavy Heart Exclamation Mark Ornament"},
                    {"value": "broken_heart", "label": "💔 Broken Heart"},
                    {"value": "heart_on_fire", "label": "❤️‍🔥 Heart on Fire"},
                    {"value": "mending_heart", "label": "❤️‍🩹 Mending Heart"},
                    {"value": "heart", "label": "❤️ Heart"},
                    {"value": "orange_heart", "label": "🧡 Orange Heart"},
                    {"value": "yellow_heart", "label": "💛 Yellow Heart"},
                    {"value": "green_heart", "label": "💚 Green Heart"},
                    {"value": "blue_heart", "label": "💙 Blue Heart"},
                    {"value": "purple_heart", "label": "💜 Purple Heart"},
                    {"value": "brown_heart", "label": "🤎 Brown Heart"},
                    {"value": "black_heart", "label": "🖤 Black Heart"},
                    {"value": "white_heart", "label": "🤍 White Heart"},
                    {"value": "kiss", "label": "💋 Kiss"},
                    {"value": "100", "label": "💯 100"},
                    {"value": "anger", "label": "💢 Anger"},
                    {"value": "boom", "label": "💥 Boom"},
                    {"value": "dizzy", "label": "💫 Dizzy"},
                    {"value": "sweat_drops", "label": "💦 Sweat Drops"},
                    {"value": "dash", "label": "💨 Dash"},
                    {"value": "hole", "label": "🕳 Hole"},
                    {"value": "speech_balloon", "label": "💬 Speech Balloon"},
                    {"value": "eye-in-speech-bubble", "label": "👁️‍🗨️ Eye in Speech Bubble"},
                    {"value": "left_speech_bubble", "label": "🗨 Left Speech Bubble"},
                    {"value": "right_anger_bubble", "label": "🗯 Right Anger Bubble"},
                    {"value": "thought_balloon", "label": "💭 Thought Balloon"},
                    {"value": "zzz", "label": "💤 ZZZ"},
            ],
        },
        {
            "group": "nature",
            "items": [
                    {"value": "monkey_face", "label": "🐵 Monkey Face"},
                    {"value": "monkey", "label": "🐒 Monkey"},
                    {"value": "gorilla", "label": "🦍 Gorilla"},
                    {"value": "orangutan", "label": "🦧 Orangutan"},
                    {"value": "dog", "label": "🐶 Dog"},
                    {"value": "dog2", "label": "🐕 Dog 2"},
                    {"value": "guide_dog", "label": "🦮 Guide Dog"},
                    {"value": "service_dog", "label": "🐕‍🦺 Service Dog"},
                    {"value": "poodle", "label": "🐩 Poodle"},
                    {"value": "wolf", "label": "🐺 Wolf"},
                    {"value": "fox_face", "label": "🦊 Fox Face"},
                    {"value": "raccoon", "label": "🦝 Raccoon"},
                    {"value": "cat", "label": "🐱 Cat"},
                    {"value": "cat2", "label": "🐈 Cat 2"},
                    {"value": "black_cat", "label": "🐈‍⬛ Black Cat"},
                    {"value": "lion_face", "label": "🦁 Lion Face"},
                    {"value": "tiger", "label": "🐯 Tiger"},
                    {"value": "tiger2", "label": "🐅 Tiger 2"},
                    {"value": "leopard", "label": "🐆 Leopard"},
                    {"value": "horse", "label": "🐴 Horse"},
                    {"value": "racehorse", "label": "🐎 Racehorse"},
                    {"value": "unicorn_face", "label": "🦄 Unicorn Face"},
                    {"value": "zebra_face", "label": "🦓 Zebra Face"},
                    {"value": "deer", "label": "🦌 Deer"},
                    {"value": "bison", "label": "🦬 Bison"},
                    {"value": "cow", "label": "🐮 Cow"},
                    {"value": "ox", "label": "🐂 Ox"},
                    {"value": "water_buffalo", "label": "🐃 Water Buffalo"},
                    {"value": "cow2", "label": "🐄 Cow 2"},
                    {"value": "pig", "label": "🐷 Pig"},
                    {"value": "pig2", "label": "🐖 Pig 2"},
                    {"value": "boar", "label": "🐗 Boar"},
                    {"value": "pig_nose", "label": "🐽 Pig Nose"},
                    {"value": "ram", "label": "🐏 Ram"},
                    {"value": "sheep", "label": "🐑 Sheep"},
                    {"value": "goat", "label": "🐐 Goat"},
                    {"value": "dromedary_camel", "label": "🐪 Dromedary Camel"},
                    {"value": "camel", "label": "🐫 Camel"},
                    {"value": "llama", "label": "🦙 Llama"},
                    {"value": "giraffe_face", "label": "🦒 Giraffe Face"},
                    {"value": "elephant", "label": "🐘 Elephant"},
                    {"value": "mammoth", "label": "🦣 Mammoth"},
                    {"value": "rhinoceros", "label": "🦏 Rhinoceros"},
                    {"value": "hippopotamus", "label": "🦛 Hippopotamus"},
                    {"value": "mouse", "label": "🐭 Mouse"},
                    {"value": "mouse2", "label": "🐁 Mouse 2"},
                    {"value": "rat", "label": "🐀 Rat"},
                    {"value": "hamster", "label": "🐹 Hamster"},
                    {"value": "rabbit", "label": "🐰 Rabbit"},
                    {"value": "rabbit2", "label": "🐇 Rabbit 2"},
                    {"value": "chipmunk", "label": "🐿 Chipmunk"},
                    {"value": "beaver", "label": "🦫 Beaver"},
                    {"value": "hedgehog", "label": "🦔 Hedgehog"},
                    {"value": "bat", "label": "🦇 Bat"},
                    {"value": "bear", "label": "🐻 Bear"},
                    {"value": "polar_bear", "label": "🐻‍❄️ Polar Bear"},
                    {"value": "koala", "label": "🐨 Koala"},
                    {"value": "panda_face", "label": "🐼 Panda Face"},
                    {"value": "sloth", "label": "🦥 Sloth"},
                    {"value": "otter", "label": "🦦 Otter"},
                    {"value": "skunk", "label": "🦨 Skunk"},
                    {"value": "kangaroo", "label": "🦘 Kangaroo"},
                    {"value": "badger", "label": "🦡 Badger"},
                    {"value": "feet", "label": "🐾 Feet"},
                    {"value": "turkey", "label": "🦃 Turkey"},
                    {"value": "chicken", "label": "🐔 Chicken"},
                    {"value": "rooster", "label": "🐓 Rooster"},
                    {"value": "hatching_chick", "label": "🐣 Hatching Chick"},
                    {"value": "baby_chick", "label": "🐤 Baby Chick"},
                    {"value": "hatched_chick", "label": "🐥 Hatched Chick"},
                    {"value": "bird", "label": "🐦 Bird"},
                    {"value": "penguin", "label": "🐧 Penguin"},
                    {"value": "dove_of_peace", "label": "🕊 Dove of Peace"},
                    {"value": "eagle", "label": "🦅 Eagle"},
                    {"value": "duck", "label": "🦆 Duck"},
                    {"value": "swan", "label": "🦢 Swan"},
                    {"value": "owl", "label": "🦉 Owl"},
                    {"value": "dodo", "label": "🦤 Dodo"},
                    {"value": "feather", "label": "🪶 Feather"},
                    {"value": "flamingo", "label": "🦩 Flamingo"},
                    {"value": "peacock", "label": "🦚 Peacock"},
                    {"value": "parrot", "label": "🦜 Parrot"},
                    {"value": "frog", "label": "🐸 Frog"},
                    {"value": "crocodile", "label": "🐊 Crocodile"},
                    {"value": "turtle", "label": "🐢 Turtle"},
                    {"value": "lizard", "label": "🦎 Lizard"},
                    {"value": "snake", "label": "🐍 Snake"},
                    {"value": "dragon_face", "label": "🐲 Dragon Face"},
                    {"value": "dragon", "label": "🐉 Dragon"},
                    {"value": "sauropod", "label": "🦕 Sauropod"},
                    {"value": "t-rex", "label": "🦖 T-Rex"},
                    {"value": "whale", "label": "🐋 Whale"},
                    {"value": "whale2", "label": "🐋 Whale 2"},
                    {"value": "dolphin", "label": "🐬 Dolphin"},
                    {"value": "seal", "label": "🦭 Seal"},
                    {"value": "fish", "label": "🐟 Fish"},
                    {"value": "tropical_fish", "label": "🐠 Tropical Fish"},
                    {"value": "blowfish", "label": "🐡 Blowfish"},
                    {"value": "shark", "label": "🦈 Shark"},
                    {"value": "octopus", "label": "🐙 Octopus"},
                    {"value": "shell", "label": "🐚 Shell"},
                    {"value": "coral", "label": "🪸 Coral"},
                    {"value": "snail", "label": "🐌 Snail"},
                    {"value": "butterfly", "label": "🦋 Butterfly"},
                    {"value": "bug", "label": "🐛 Bug"},
                    {"value": "ant", "label": "🐜 Ant"},
                    {"value": "bee", "label": "🐝 Bee"},
                    {"value": "beetle", "label": "🐞 Beetle"},
                    {"value": "ladybug", "label": "🐞 Ladybug"},
                    {"value": "cricket", "label": "🦗 Cricket"},
                    {"value": "cockroach", "label": "🪳 Cockroach"},
                    {"value": "spider", "label": "🕷 Spider"},
                    {"value": "spider_web", "label": "🕸 Spider Web"},
                    {"value": "scorpion", "label": "🦂 Scorpion"},
                    {"value": "mosquito", "label": "🦟 Mosquito"},
                    {"value": "fly", "label": "🪰 Fly"},
                    {"value": "worm", "label": "🪱 Worm"},
                    {"value": "microbe", "label": "🦠 Microbe"},
                    {"value": "bouquet", "label": "💐 Bouquet"},
                    {"value": "cherry_blossom", "label": "🌸 Cherry Blossom"},
                    {"value": "white_flower", "label": "💮 White Flower"},
                    {"value": "lotus", "label": "🪷 Lotus"},
                    {"value": "rosette", "label": "🏵 Rosette"},
                    {"value": "rose", "label": "🌹 Rose"},
                    {"value": "wilted_flower", "label": "🥀 Wilted Flower"},
                    {"value": "hibiscus", "label": "🌺 Hibiscus"},
                    {"value": "sunflower", "label": "🌻 Sunflower"},
                    {"value": "blossom", "label": "🌼 Blossom"},
                    {"value": "tulip", "label": "🌷 Tulip"},
                    {"value": "seedling", "label": "🌱 Seedling"},
                    {"value": "potted_plant", "label": "🪴 Potted Plant"},
                    {"value": "evergreen_tree", "label": "🌲 Evergreen Tree"},
                    {"value": "deciduous_tree", "label": "🌳 Deciduous Tree"},
                    {"value": "palm_tree", "label": "🌴 Palm Tree"},
                    {"value": "cactus", "label": "🌵 Cactus"},
                    {"value": "ear_of_rice", "label": "🌾 Ear of Rice"},
                    {"value": "herb", "label": "🌿 Herb"},
                    {"value": "shamrock", "label": "☘️ Shamrock"},
                    {"value": "four_leaf_clover", "label": "🍀 Four Leaf Clover"},
                    {"value": "maple_leaf", "label": "🍁 Maple Leaf"},
                    {"value": "fallen_leaf", "label": "🍂 Fallen Leaf"},
                    {"value": "leaves", "label": "🍃 Leaves"},
                    {"value": "empty_nest", "label": "🪹 Empty Nest"},
                    {"value": "nest_with_eggs", "label": "🪺 Nest with Eggs"},
                    {"value": "mushroom", "label": "🍄 Mushroom"},
            ],
        },
        {
            "group": "food",
            "items": [
                    {"value": "grapes", "label": "🍇 Grapes"},
                    {"value": "melon", "label": "🍈 Melon"},
                    {"value": "watermelon", "label": "🍉 Watermelon"},
                    {"value": "tangerine", "label": "🍊 Tangerine"},
                    {"value": "lemon", "label": "🍋 Lemon"},
                    {"value": "banana", "label": "🍌 Banana"},
                    {"value": "pineapple", "label": "🍍 Pineapple"},
                    {"value": "mango", "label": "🥭 Mango"},
                    {"value": "apple", "label": "🍎 Apple"},
                    {"value": "green_apple", "label": "🍏 Green Apple"},
                    {"value": "pear", "label": "🍐 Pear"},
                    {"value": "peach", "label": "🍑 Peach"},
                    {"value": "cherries", "label": "🍒 Cherries"},
                    {"value": "strawberry", "label": "🍓 Strawberry"},
                    {"value": "blueberries", "label": "🫐 Blueberries"},
                    {"value": "kiwifruit", "label": "🥝 Kiwifruit"},
                    {"value": "tomato", "label": "🍅 Tomato"},
                    {"value": "olive", "label": "🫒 Olive"},
                    {"value": "coconut", "label": "🥥 Coconut"},
                    {"value": "avocado", "label": "🥑 Avocado"},
                    {"value": "eggplant", "label": "🍆 Eggplant"},
                    {"value": "potato", "label": "🥔 Potato"},
                    {"value": "carrot", "label": "🥕 Carrot"},
                    {"value": "corn", "label": "🌽 Corn"},
                    {"value": "hot_pepper", "label": "🌶 Hot Pepper"},
                    {"value": "bell_pepper", "label": "🫑 Bell Pepper"},
                    {"value": "cucumber", "label": "🥒 Cucumber"},
                    {"value": "leafy_green", "label": "🥬 Leafy Green"},
                    {"value": "broccoli", "label": "🥦 Broccoli"},
                    {"value": "garlic", "label": "🧄 Garlic"},
                    {"value": "onion", "label": "🧅 Onion"},
                    {"value": "peanuts", "label": "🥜 Peanuts"},
                    {"value": "beans", "label": "🫘 Beans"},
                    {"value": "chestnut", "label": "🌰 Chestnut"},
                    {"value": "bread", "label": "🍞 Bread"},
                    {"value": "croissant", "label": "🥐 Croissant"},
                    {"value": "baguette_bread", "label": "🥖 Baguette Bread"},
                    {"value": "flatbread", "label": "🫓 Flatbread"},
                    {"value": "pretzel", "label": "🥨 Pretzel"},
                    {"value": "bagel", "label": "🥯 Bagel"},
                    {"value": "pancakes", "label": "🥞 Pancakes"},
                    {"value": "waffle", "label": "🧇 Waffle"},
                    {"value": "cheese_wedge", "label": "🧀 Cheese Wedge"},
                    {"value": "meat_on_bone", "label": "🍖 Meat on Bone"},
                    {"value": "poultry_leg", "label": "🍗 Poultry Leg"},
                    {"value": "cut_of_meat", "label": "🥩 Cut of Meat"},
                    {"value": "bacon", "label": "🥓 Bacon"},
                    {"value": "hamburger", "label": "🍔 Hamburger"},
                    {"value": "fries", "label": "🍟 Fries"},
                    {"value": "pizza", "label": "🍕 Pizza"},
                    {"value": "hotdog", "label": "🌭 Hotdog"},
                    {"value": "sandwich", "label": "🥪 Sandwich"},
                    {"value": "taco", "label": "🌮 Taco"},
                    {"value": "burrito", "label": "🌯 Burrito"},
                    {"value": "tamale", "label": "🫔 Tamale"},
                    {"value": "stuffed_flatbread", "label": "🥙 Stuffed Flatbread"},
                    {"value": "falafel", "label": "🧆 Falafel"},
                    {"value": "egg", "label": "🥚 Egg"},
                    {"value": "fried_egg", "label": "🍳 Fried Egg"},
                    {"value": "shallow_pan_of_food", "label": "🥘 Shallow Pan of Food"},
                    {"value": "stew", "label": "🍲 Stew"},
                    {"value": "fondue", "label": "🫕 Fondue"},
                    {"value": "bowl_with_spoon", "label": "🥣 Bowl with Spoon"},
                    {"value": "green_salad", "label": "🥗 Green Salad"},
                    {"value": "popcorn", "label": "🍿 Popcorn"},
                    {"value": "butter", "label": "🧈 Butter"},
                    {"value": "salt", "label": "🧂 Salt"},
                    {"value": "canned_food", "label": "🥫 Canned Food"},
                    {"value": "bento", "label": "🍱 Bento"},
                    {"value": "rice_cracker", "label": "🍘 Rice Cracker"},
                    {"value": "rice_ball", "label": "🍙 Rice Ball"},
                    {"value": "rice", "label": "🍚 Rice"},
                    {"value": "curry", "label": "🍛 Curry"},
                    {"value": "ramen", "label": "🍜 Ramen"},
                    {"value": "spaghetti", "label": "🍝 Spaghetti"},
                    {"value": "sweet_potato", "label": "🍠 Sweet Potato"},
                    {"value": "oden", "label": "🍢 Oden"},
                    {"value": "sushi", "label": "🍣 Sushi"},
                    {"value": "fried_shrimp", "label": "🍤 Fried Shrimp"},
                    {"value": "fish_cake", "label": "🍥 Fish Cake"},
                    {"value": "moon_cake", "label": "🥮 Moon Cake"},
                    {"value": "dango", "label": "🍡 Dango"},
                    {"value": "dumpling", "label": "🥟 Dumpling"},
                    {"value": "fortune_cookie", "label": "🥠 Fortune Cookie"},
                    {"value": "takeout_box", "label": "🥡 Takeout Box"},
                    {"value": "crab", "label": "🦀 Crab"},
                    {"value": "lobster", "label": "🦞 Lobster"},
                    {"value": "shrimp", "label": "🦐 Shrimp"},
                    {"value": "squid", "label": "🦑 Squid"},
                    {"value": "oyster", "label": "🦪 Oyster"},
                    {"value": "icecream", "label": "🍦 Ice Cream"},
                    {"value": "shaved_ice", "label": "🍧 Shaved Ice"},
                    {"value": "ice_cream", "label": "🍨 Ice Cream"},
                    {"value": "doughnut", "label": "🍩 Doughnut"},
                    {"value": "cookie", "label": "🍪 Cookie"},
                    {"value": "birthday", "label": "🎂 Birthday"},
                    {"value": "cake", "label": "🍰 Cake"},
                    {"value": "cupcake", "label": "🧁 Cupcake"},
                    {"value": "pie", "label": "🥧 Pie"},
                    {"value": "chocolate_bar", "label": "🍫 Chocolate Bar"},
                    {"value": "candy", "label": "🍬 Candy"},
                    {"value": "lollipop", "label": "🍭 Lollipop"},
                    {"value": "custard", "label": "🍮 Custard"},
                    {"value": "honey_pot", "label": "🍯 Honey Pot"},
                    {"value": "baby_bottle", "label": "🍼 Baby Bottle"},
                    {"value": "glass_of_milk", "label": "🥛 Glass of Milk"},
                    {"value": "coffee", "label": "☕ Coffee"},
                    {"value": "teapot", "label": "🫖 Teapot"},
                    {"value": "tea", "label": "🍵 Tea"},
                    {"value": "sake", "label": "🍶 Sake"},
                    {"value": "champagne", "label": "🍾 Champagne"},
                    {"value": "wine_glass", "label": "🍷 Wine Glass"},
                    {"value": "cocktail", "label": "🍸 Cocktail"},
                    {"value": "tropical_drink", "label": "🍹 Tropical Drink"},
                    {"value": "beer", "label": "🍺 Beer"},
                    {"value": "beers", "label": "🍻 Beers"},
                    {"value": "clinking_glasses", "label": "🥂 Clinking Glasses"},
                    {"value": "tumbler_glass", "label": "🥃 Tumbler Glass"},
                    {"value": "pouring_liquid", "label": "🫗 Pouring Liquid"},
                    {"value": "cup_with_straw", "label": "🥤 Cup with Straw"},
                    {"value": "bubble_tea", "label": "🧋 Bubble Tea"},
                    {"value": "beverage_box", "label": "🧃 Beverage Box"},
                    {"value": "mate_drink", "label": "🧉 Mate Drink"},
                    {"value": "ice_cube", "label": "🧊 Ice Cube"},
                    {"value": "chopsticks", "label": "🥢 Chopsticks"},
                    {"value": "knife_fork_plate", "label": "🍽 Knife Fork Plate"},
                    {"value": "fork_and_knife", "label": "🍴 Fork and Knife"},
                    {"value": "spoon", "label": "🥄 Spoon"},
                    {"value": "hocho", "label": "🔪 Hocho"},
                    {"value": "jar", "label": "🫙 Jar"},
                    {"value": "amphora", "label": "🏺 Amphora"},
            ],
        },
        {
            "group": "activity",
            "items": [
                    {"value": "jack_o_lantern", "label": "🎃 Jack O Lantern"},
                    {"value": "christmas_tree", "label": "🎄 Christmas Tree"},
                    {"value": "fireworks", "label": "🎆 Fireworks"},
                    {"value": "sparkler", "label": "🎇 Sparkler"},
                    {"value": "firecracker", "label": "🧨 Firecracker"},
                    {"value": "sparkles", "label": "✨ Sparkles"},
                    {"value": "balloon", "label": "🎈 Balloon"},
                    {"value": "tada", "label": "🎉 Tada"},
                    {"value": "confetti_ball", "label": "🎊 Confetti Ball"},
                    {"value": "tanabata_tree", "label": "🎋 Tanabata Tree"},
                    {"value": "bamboo", "label": "🎍 Bamboo"},
                    {"value": "dolls", "label": "🎎 Dolls"},
                    {"value": "flags", "label": "🎏 Flags"},
                    {"value": "wind_chime", "label": "🎐 Wind Chime"},
                    {"value": "rice_scene", "label": "🎑 Rice Scene"},
                    {"value": "red_envelope", "label": "🧧 Red Envelope"},
                    {"value": "ribbon", "label": "🎀 Ribbon"},
                    {"value": "gift", "label": "🎁 Gift"},
                    {"value": "reminder_ribbon", "label": "🎗 Reminder Ribbon"},
                    {"value": "admission_tickets", "label": "🎟 Admission Tickets"},
                    {"value": "ticket", "label": "🎫 Ticket"},
                    {"value": "medal", "label": "🏅 Medal"},
                    {"value": "trophy", "label": "🏆 Trophy"},
                    {"value": "sports_medal", "label": "🎖 Sports Medal"},
                    {"value": "first_place_medal", "label": "🥇 First Place Medal"},
                    {"value": "second_place_medal", "label": "🥈 Second Place Medal"},
                    {"value": "third_place_medal", "label": "🥉 Third Place Medal"},
                    {"value": "soccer", "label": "⚽ Soccer"},
                    {"value": "baseball", "label": "⚾ Baseball"},
                    {"value": "softball", "label": "🥎 Softball"},
                    {"value": "basketball", "label": "🏀 Basketball"},
                    {"value": "volleyball", "label": "🏐 Volleyball"},
                    {"value": "football", "label": "🏈 Football"},
                    {"value": "rugby_football", "label": "🏉 Rugby Football"},
                    {"value": "tennis", "label": "🎾 Tennis"},
                    {"value": "flying_disc", "label": "🥏 Flying Disc"},
                    {"value": "bowling", "label": "🎳 Bowling"},
                    {"value": "cricket_bat_and_ball", "label": "🏏 Cricket Bat and Ball"},
                    {"value": "field_hockey_stick_and_ball", "label": "🏑 Field Hockey Stick and Ball"},
                    {"value": "ice_hockey_stick_and_puck", "label": "🏒 Ice Hockey Stick and Puck"},
                    {"value": "lacrosse", "label": "🥍 Lacrosse"},
                    {"value": "table_tennis_paddle_and_ball", "label": "🏓 Table Tennis Paddle and Ball"},
                    {"value": "badminton_racquet_and_shuttlecock", "label": "🏸 Badminton Racquet and Shuttlecock"},
                    {"value": "boxing_glove", "label": "🥊 Boxing Glove"},
                    {"value": "martial_arts_uniform", "label": "🥋 Martial Arts Uniform"},
                    {"value": "goal_net", "label": "🥅 Goal Net"},
                    {"value": "golf", "label": "⛳ Golf"},
                    {"value": "ice_skate", "label": "⛸ Ice Skate"},
                    {"value": "fishing_pole_and_fish", "label": "🎣 Fishing Pole and Fish"},
                    {"value": "diving_mask", "label": "🤿 Diving Mask"},
                    {"value": "running_shirt_with_sash", "label": "🎽 Running Shirt with Sash"},
                    {"value": "ski", "label": "🎿 Ski"},
                    {"value": "sled", "label": "🛷 Sled"},
                    {"value": "curling_stone", "label": "🥌 Curling Stone"},
                    {"value": "dart", "label": "🎯 Dart"},
                    {"value": "yo-yo", "label": "🪀 Yo-Yo"},
                    {"value": "kite", "label": "🪁 Kite"},
                    {"value": "gun", "label": "🔫 Gun"},
                    {"value": "8ball", "label": "🎱 8ball"},
                    {"value": "crystal_ball", "label": "🔮 Crystal Ball"},
                    {"value": "magic_wand", "label": "🪄 Magic Wand"},
                    {"value": "video_game", "label": "🎮 Video Game"},
                    {"value": "joystick", "label": "🕹 Joystick"},
                    {"value": "slot_machine", "label": "🎰 Slot Machine"},
                    {"value": "game_die", "label": "🎲 Game Die"},
                    {"value": "jigsaw", "label": "🧩 Jigsaw"},
                    {"value": "teddy_bear", "label": "🧸 Teddy Bear"},
                    {"value": "pinata", "label": "🪅 Pinata"},
                    {"value": "mirror_ball", "label": "🪩 Mirror Ball"},
                    {"value": "nesting_dolls", "label": "🪆 Nesting Dolls"},
                    {"value": "spades", "label": "♠️ Spades"},
                    {"value": "hearts", "label": "♥️ Hearts"},
                    {"value": "diamonds", "label": "♦️ Diamonds"},
                    {"value": "clubs", "label": "♣️ Clubs"},
                    {"value": "chess_pawn", "label": "♟ Chess Pawn"},
                    {"value": "black_joker", "label": "🃏 Black Joker"},
                    {"value": "mahjong", "label": "🀄 Mahjong"},
                    {"value": "flower_playing_cards", "label": "🎴 Flower Playing Cards"},
                    {"value": "performing_arts", "label": "🎭 Performing Arts"},
                    {"value": "frame_with_picture", "label": "🖼 Frame with Picture"},
                    {"value": "art", "label": "🎨 Art"},
                    {"value": "thread", "label": "🧵 Thread"},
                    {"value": "sewing_needle", "label": "🪡 Sewing Needle"},
                    {"value": "yarn", "label": "🧶 Yarn"},
                    {"value": "knot", "label": "🪢 Knot"},
            ],
        },
        {
            "group": "places",
            "items": [
                    {"value": "earth_africa", "label": "🌍 Earth Africa"},
                    {"value": "earth_americas", "label": "🌎 Earth Americas"},
                    {"value": "earth_asia", "label": "🌏 Earth Asia"},
                    {"value": "globe_with_meridians", "label": "🌐 Globe with Meridians"},
                    {"value": "world_map", "label": "🗺 World Map"},
                    {"value": "japan", "label": "🗾 Japan"},
                    {"value": "compass", "label": "🧭 Compass"},
                    {"value": "snow_capped_mountain", "label": "🏔 Snow-Capped Mountain"},
                    {"value": "mountain", "label": "⛰ Mountain"},
                    {"value": "volcano", "label": "🌋 Volcano"},
                    {"value": "mount_fuji", "label": "🗻 Mount Fuji"},
                    {"value": "camping", "label": "🏕 Camping"},
                    {"value": "beach_with_umbrella", "label": "🏖 Beach with Umbrella"},
                    {"value": "desert", "label": "🏜 Desert"},
                    {"value": "desert_island", "label": "🏝 Desert Island"},
                    {"value": "national_park", "label": "🏞 National Park"},
                    {"value": "stadium", "label": "🏟 Stadium"},
                    {"value": "classical_building", "label": "🏛 Classical Building"},
                    {"value": "building_construction", "label": "🏗 Building Construction"},
                    {"value": "bricks", "label": "🧱 Bricks"},
                    {"value": "rock", "label": "🪨 Rock"},
                    {"value": "wood", "label": "🪵 Wood"},
                    {"value": "hut", "label": "🛖 Hut"},
                    {"value": "house_buildings", "label": "🏘 House Buildings"},
                    {"value": "derelict_house_building", "label": "🏚 Derelict House Building"},
                    {"value": "house", "label": "🏠 House"},
                    {"value": "house_with_garden", "label": "🏡 House with Garden"},
                    {"value": "office", "label": "🏢 Office"},
                    {"value": "post_office", "label": "🏣 Post Office"},
                    {"value": "european_post_office", "label": "🏤 European Post Office"},
                    {"value": "hospital", "label": "🏥 Hospital"},
                    {"value": "bank", "label": "🏦 Bank"},
                    {"value": "hotel", "label": "🏨 Hotel"},
                    {"value": "love_hotel", "label": "🏩 Love Hotel"},
                    {"value": "convenience_store", "label": "🏪 Convenience Store"},
                    {"value": "school", "label": "🏫 School"},
                    {"value": "department_store", "label": "🏬 Department Store"},
                    {"value": "factory", "label": "🏭 Factory"},
                    {"value": "japanese_castle", "label": "🏯 Japanese Castle"},
                    {"value": "european_castle", "label": "🏰 European Castle"},
                    {"value": "wedding", "label": "💒 Wedding"},
                    {"value": "tokyo_tower", "label": "🗼 Tokyo Tower"},
                    {"value": "statue_of_liberty", "label": "🗽 Statue of Liberty"},
                    {"value": "church", "label": "⛪ Church"},
                    {"value": "mosque", "label": "🕌 Mosque"},
                    {"value": "hindu_temple", "label": "🛕 Hindu Temple"},
                    {"value": "synagogue", "label": "🕍 Synagogue"},
                    {"value": "shinto_shrine", "label": "⛩ Shinto Shrine"},
                    {"value": "kaaba", "label": "🕋 Kaaba"},
                    {"value": "fountain", "label": "⛲ Fountain"},
                    {"value": "tent", "label": "⛺ Tent"},
                    {"value": "foggy", "label": "🌁 Foggy"},
                    {"value": "night_with_stars", "label": "🌃 Night with Stars"},
                    {"value": "cityscape", "label": "🏙 Cityscape"},
                    {"value": "sunrise_over_mountains", "label": "🌄 Sunrise over Mountains"},
                    {"value": "sunrise", "label": "🌅 Sunrise"},
                    {"value": "city_sunset", "label": "🌆 City Sunset"},
                    {"value": "city_sunrise", "label": "🌇 City Sunrise"},
                    {"value": "bridge_at_night", "label": "🌉 Bridge at Night"},
                    {"value": "hotsprings", "label": "♨️ Hotsprings"},
                    {"value": "carousel_horse", "label": "🎠 Carousel Horse"},
                    {"value": "playground_slide", "label": "🛝 Playground Slide"},
                    {"value": "ferris_wheel", "label": "🎡 Ferris Wheel"},
                    {"value": "roller_coaster", "label": "🎢 Roller Coaster"},
                    {"value": "barber", "label": "💈 Barber"},
                    {"value": "circus_tent", "label": "🎪 Circus Tent"},
                    {"value": "steam_locomotive", "label": "🚂 Steam Locomotive"},
                    {"value": "railway_car", "label": "🚃 Railway Car"},
                    {"value": "bullettrain_side", "label": "🚄 Bullettrain Side"},
                    {"value": "bullettrain_front", "label": "🚅 Bullettrain Front"},
                    {"value": "train2", "label": "🚆 Train 2"},
                    {"value": "metro", "label": "🚇 Metro"},
                    {"value": "light_rail", "label": "🚈 Light Rail"},
                    {"value": "station", "label": "🚉 Station"},
                    {"value": "tram", "label": "🚊 Tram"},
                    {"value": "monorail", "label": "🚝 Monorail"},
                    {"value": "mountain_railway", "label": "🚞 Mountain Railway"},
                    {"value": "train", "label": "🚋 Train"},
                    {"value": "bus", "label": "🚌 Bus"},
                    {"value": "oncoming_bus", "label": "🚍 Oncoming Bus"},
                    {"value": "trolleybus", "label": "🚎 Trolleybus"},
                    {"value": "minibus", "label": "🚐 Minibus"},
                    {"value": "ambulance", "label": "🚑 Ambulance"},
                    {"value": "fire_engine", "label": "🚒 Fire Engine"},
                    {"value": "police_car", "label": "🚓 Police Car"},
                    {"value": "oncoming_police_car", "label": "🚔 Oncoming Police Car"},
                    {"value": "taxi", "label": "🚕 Taxi"},
                    {"value": "oncoming_taxi", "label": "🚖 Oncoming Taxi"},
                    {"value": "car", "label": "🚗 Car"},
                    {"value": "oncoming_automobile", "label": "🚘 Oncoming Automobile"},
                    {"value": "blue_car", "label": "🚙 Blue Car"},
                    {"value": "pickup_truck", "label": "🛻 Pickup Truck"},
                    {"value": "truck", "label": "🚚 Truck"},
                    {"value": "articulated_lorry", "label": "🚛 Articulated Lorry"},
                    {"value": "tractor", "label": "🚜 Tractor"},
                    {"value": "racing_car", "label": "🏎 Racing Car"},
                    {"value": "racing_motorcycle", "label": "🏍 Racing Motorcycle"},
                    {"value": "motor_scooter", "label": "🛵 Motor Scooter"},
                    {"value": "manual_wheelchair", "label": "🦽 Manual Wheelchair"},
                    {"value": "motorized_wheelchair", "label": "🦼 Motorized Wheelchair"},
                    {"value": "auto_rickshaw", "label": "🛺 Auto Rickshaw"},
                    {"value": "bike", "label": "🚲 Bike"},
                    {"value": "scooter", "label": "🛴 Scooter"},
                    {"value": "skateboard", "label": "🛹 Skateboard"},
                    {"value": "roller_skate", "label": "🛼 Roller Skate"},
                    {"value": "busstop", "label": "🚏 Bus Stop"},
                    {"value": "motorway", "label": "🛣 Motorway"},
                    {"value": "railway_track", "label": "🛤 Railway Track"},
                    {"value": "oil_drum", "label": "🛢 Oil Drum"},
                    {"value": "fuelpump", "label": "⛽ Fuel Pump"},
                    {"value": "wheel", "label": "🛞 Wheel"},
                    {"value": "rotating_light", "label": "🚨 Rotating Light"},
                    {"value": "traffic_light", "label": "🚥 Traffic Light"},
                    {"value": "vertical_traffic_light", "label": "🚦 Vertical Traffic Light"},
                    {"value": "octagonal_sign", "label": "🛑 Octagonal Sign"},
                    {"value": "construction", "label": "🚧 Construction"},
                    {"value": "anchor", "label": "⚓ Anchor"},
                    {"value": "ring_buoy", "label": "🛟 Ring Buoy"},
                    {"value": "boat", "label": "⛵ Boat"},
                    {"value": "canoe", "label": "🛶 Canoe"},
                    {"value": "speedboat", "label": "🚤 Speedboat"},
                    {"value": "passenger_ship", "label": "🛳 Passenger Ship"},
                    {"value": "ferry", "label": "⛴ Ferry"},
                    {"value": "motor_boat", "label": "🛥 Motor Boat"},
                    {"value": "ship", "label": "🚢 Ship"},
                    {"value": "airplane", "label": "✈️ Airplane"},
                    {"value": "small_airplane", "label": "🛩 Small Airplane"},
                    {"value": "airplane_departure", "label": "🛫 Airplane Departure"},
                    {"value": "airplane_arriving", "label": "🛬 Airplane Arriving"},
                    {"value": "parachute", "label": "🪂 Parachute"},
                    {"value": "seat", "label": "💺 Seat"},
                    {"value": "helicopter", "label": "🚁 Helicopter"},
                    {"value": "suspension_railway", "label": "🚟 Suspension Railway"},
                    {"value": "mountain_cableway", "label": "🚠 Mountain Cableway"},
                    {"value": "aerial_tramway", "label": "🚡 Aerial Tramway"},
                    {"value": "satellite", "label": "🛰 Satellite"},
                    {"value": "rocket", "label": "🚀 Rocket"},
                    {"value": "flying_saucer", "label": "🛸 Flying Saucer"},
                    {"value": "bellhop_bell", "label": "🛎 Bellhop Bell"},
                    {"value": "luggage", "label": "🧳 Luggage"},
                    {"value": "hourglass", "label": "⌛ Hourglass"},
                    {"value": "hourglass_flowing_sand", "label": "⏳ Hourglass Flowing Sand"},
                    {"value": "watch", "label": "⌚ Watch"},
                    {"value": "alarm_clock", "label": "⏰ Alarm Clock"},
                    {"value": "stopwatch", "label": "⏱ Stopwatch"},
                    {"value": "timer_clock", "label": "⏲ Timer Clock"},
                    {"value": "mantelpiece_clock", "label": "🕰 Mantelpiece Clock"},
                    {"value": "clock12", "label": "🕛 Clock 12"},
                    {"value": "clock1230", "label": "🕧 Clock 1230"},
                    {"value": "clock1", "label": "🕐 Clock 1"},
                    {"value": "clock130", "label": "🕜 Clock 130"},
                    {"value": "clock2", "label": "🕑 Clock 2"},
                    {"value": "clock230", "label": "🕝 Clock 230"},
                    {"value": "clock3", "label": "🕒 Clock 3"},
                    {"value": "clock330", "label": "🕞 Clock 330"},
                    {"value": "clock4", "label": "🕓 Clock 4"},
                    {"value": "clock430", "label": "🕟 Clock 430"},
                    {"value": "clock5", "label": "🕔 Clock 5"},
                    {"value": "clock530", "label": "🕠 Clock 530"},
                    {"value": "clock6", "label": "🕕 Clock 6"},
                    {"value": "clock630", "label": "🕡 Clock 630"},
                    {"value": "clock7", "label": "🕖 Clock 7"},
                    {"value": "clock730", "label": "🕢 Clock 730"},
                    {"value": "clock8", "label": "🕗 Clock 8"},
                    {"value": "clock830", "label": "🕣 Clock 830"},
                    {"value": "clock9", "label": "🕘 Clock 9"},
                    {"value": "clock930", "label": "🕤 Clock 930"},
                    {"value": "clock10", "label": "🕙 Clock 10"},
                    {"value": "clock1030", "label": "🕥 Clock 1030"},
                    {"value": "clock11", "label": "🕚 Clock 11"},
                    {"value": "clock1130", "label": "🕦 Clock 1130"},
                    {"value": "new_moon", "label": "🌑 New Moon"},
                    {"value": "waxing_crescent_moon", "label": "🌒 Waxing Crescent Moon"},
                    {"value": "first_quarter_moon", "label": "🌓 First Quarter Moon"},
                    {"value": "moon", "label": "🌔 Moon"},
                    {"value": "full_moon", "label": "🌕 Full Moon"},
                    {"value": "waning_gibbous_moon", "label": "🌖 Waning Gibbous Moon"},
                    {"value": "last_quarter_moon", "label": "🌗 Last Quarter Moon"},
                    {"value": "waning_crescent_moon", "label": "🌘 Waning Crescent Moon"},
                    {"value": "crescent_moon", "label": "🌙 Crescent Moon"},
                    {"value": "new_moon_with_face", "label": "🌚 New Moon with Face"},
                    {"value": "first_quarter_moon_with_face", "label": "🌛 First Quarter Moon with Face"},
                    {"value": "last_quarter_moon_with_face", "label": "🌜 Last Quarter Moon with Face"},
                    {"value": "thermometer", "label": "🌡 Thermometer"},
                    {"value": "sunny", "label": "☀️ Sunny"},
                    {"value": "full_moon_with_face", "label": "🌝 Full Moon with Face"},
                    {"value": "sun_with_face", "label": "🌞 Sun with Face"},
                    {"value": "ringed_planet", "label": "🪐 Ringed Planet"},
                    {"value": "star", "label": "⭐ Star"},
                    {"value": "star2", "label": "🌟 Star 2"},
                    {"value": "stars", "label": "🌠 Stars"},
                    {"value": "milky_way", "label": "🌌 Milky Way"},
                    {"value": "cloud", "label": "☁️ Cloud"},
                    {"value": "partly_sunny", "label": "⛅ Partly Sunny"},
                    {"value": "thunder_cloud_and_rain", "label": "⛈ Thunder Cloud and Rain"},
                    {"value": "mostly_sunny", "label": "🌤 Mostly Sunny"},
                    {"value": "barely_sunny", "label": "🌥 Barely Sunny"},
                    {"value": "partly_sunny_rain", "label": "🌦 Partly Sunny Rain"},
                    {"value": "rain_cloud", "label": "🌧 Rain Cloud"},
                    {"value": "snow_cloud", "label": "🌨 Snow Cloud"},
                    {"value": "lightning", "label": "🌩 Lightning"},
                    {"value": "tornado", "label": "🌪 Tornado"},
                    {"value": "fog", "label": "🌫 Fog"},
                    {"value": "wind_blowing_face", "label": "🌬 Wind Blowing Face"},
                    {"value": "cyclone", "label": "🌀 Cyclone"},
                    {"value": "rainbow", "label": "🌈 Rainbow"},
                    {"value": "closed_umbrella", "label": "🌂 Closed Umbrella"},
                    {"value": "umbrella", "label": "☂️ Umbrella"},
                    {"value": "umbrella_with_rain_drops", "label": "☔ Umbrella with Rain Drops"},
                    {"value": "umbrella_on_ground", "label": "⛱ Umbrella on Ground"},
                    {"value": "zap", "label": "⚡ Zap"},
                    {"value": "snowflake", "label": "❄️ Snowflake"},
                    {"value": "snowman", "label": "☃️ Snowman"},
                    {"value": "snowman_without_snow", "label": "⛄ Snowman without Snow"},
                    {"value": "comet", "label": "☄️ Comet"},
                    {"value": "fire", "label": "🔥 Fire"},
                    {"value": "droplet", "label": "💧 Droplet"},
                    {"value": "ocean", "label": "🌊 Ocean"},
            ],
        },
        {
            "group": "objects",
            "items": [
                    {"value": "eyeglasses", "label": "👓 Eyeglasses"},
                    {"value": "dark_sunglasses", "label": "🕶 Dark Sunglasses"},
                    {"value": "goggles", "label": "🥽 Goggles"},
                    {"value": "lab_coat", "label": "🥼 Lab Coat"},
                    {"value": "safety_vest", "label": "🦺 Safety Vest"},
                    {"value": "necktie", "label": "👔 Necktie"},
                    {"value": "shirt", "label": "👕 Shirt"},
                    {"value": "jeans", "label": "👖 Jeans"},
                    {"value": "scarf", "label": "🧣 Scarf"},
                    {"value": "gloves", "label": "🧤 Gloves"},
                    {"value": "coat", "label": "🧥 Coat"},
                    {"value": "socks", "label": "🧦 Socks"},
                    {"value": "dress", "label": "👗 Dress"},
                    {"value": "kimono", "label": "👘 Kimono"},
                    {"value": "sari", "label": "🥻 Sari"},
                    {"value": "one-piece_swimsuit", "label": "🩱 One-Piece Swimsuit"},
                    {"value": "briefs", "label": "🩲 Briefs"},
                    {"value": "shorts", "label": "🩳 Shorts"},
                    {"value": "bikini", "label": "👙 Bikini"},
                    {"value": "womans_clothes", "label": "👚 Woman's Clothes"},
                    {"value": "purse", "label": "👛 Purse"},
                    {"value": "handbag", "label": "👜 Handbag"},
                    {"value": "pouch", "label": "👝 Pouch"},
                    {"value": "shopping_bags", "label": "🛍 Shopping Bags"},
                    {"value": "school_satchel", "label": "🎒 School Satchel"},
                    {"value": "thong_sandal", "label": "🩴 Thong Sandal"},
                    {"value": "mans_shoe", "label": "👞 Man's Shoe"},
                    {"value": "athletic_shoe", "label": "👟 Athletic Shoe"},
                    {"value": "hiking_boot", "label": "🥾 Hiking Boot"},
                    {"value": "womans_flat_shoe", "label": "🥿 Woman's Flat Shoe"},
                    {"value": "high_heel", "label": "👠 High Heel"},
                    {"value": "sandal", "label": "👡 Sandal"},
                    {"value": "ballet_shoes", "label": "🩰 Ballet Shoes"},
                    {"value": "boot", "label": "👢 Boot"},
                    {"value": "crown", "label": "👑 Crown"},
                    {"value": "womans_hat", "label": "👒 Woman's Hat"},
                    {"value": "tophat", "label": "🎩 Tophat"},
                    {"value": "mortar_board", "label": "🎓 Mortar Board"},
                    {"value": "billed_cap", "label": "🧢 Billed Cap"},
                    {"value": "military_helmet", "label": "🪖 Military Helmet"},
                    {"value": "helmet_with_white_cross", "label": "⛑ Helmet with White Cross"},
                    {"value": "prayer_beads", "label": "📿 Prayer Beads"},
                    {"value": "lipstick", "label": "💄 Lipstick"},
                    {"value": "ring", "label": "💍 Ring"},
                    {"value": "gem", "label": "💎 Gem"},
                    {"value": "mute", "label": "🔇 Mute"},
                    {"value": "speaker", "label": "🔈 Speaker"},
                    {"value": "sound", "label": "🔉 Sound"},
                    {"value": "loud_sound", "label": "🔊 Loud Sound"},
                    {"value": "loudspeaker", "label": "📢 Loudspeaker"},
                    {"value": "mega", "label": "📣 Mega"},
                    {"value": "postal_horn", "label": "📯 Postal Horn"},
                    {"value": "bell", "label": "🔔 Bell"},
                    {"value": "no_bell", "label": "🔕 No Bell"},
                    {"value": "musical_score", "label": "🎼 Musical Score"},
                    {"value": "musical_note", "label": "🎵 Musical Note"},
                    {"value": "notes", "label": "🎶 Notes"},
                    {"value": "studio_microphone", "label": "🎙 Studio Microphone"},
                    {"value": "level_slider", "label": "🎚 Level Slider"},
                    {"value": "control_knobs", "label": "🎛 Control Knobs"},
                    {"value": "microphone", "label": "🎤 Microphone"},
                    {"value": "headphones", "label": "🎧 Headphones"},
                    {"value": "radio", "label": "📻 Radio"},
                    {"value": "saxophone", "label": "🎷 Saxophone"},
                    {"value": "accordion", "label": "🪗 Accordion"},
                    {"value": "guitar", "label": "🎸 Guitar"},
                    {"value": "musical_keyboard", "label": "🎹 Musical Keyboard"},
                    {"value": "trumpet", "label": "🎺 Trumpet"},
                    {"value": "violin", "label": "🎻 Violin"},
                    {"value": "banjo", "label": "🪕 Banjo"},
                    {"value": "drum_with_drumsticks", "label": "🥁 Drum with Drumsticks"},
                    {"value": "long_drum", "label": "🪘 Long Drum"},
                    {"value": "iphone", "label": "📱 iPhone"},
                    {"value": "calling", "label": "📲 Calling"},
                    {"value": "phone", "label": "☎️ Phone"},
                    {"value": "telephone_receiver", "label": "📞 Telephone Receiver"},
                    {"value": "pager", "label": "📟 Pager"},
                    {"value": "fax", "label": "📠 Fax"},
                    {"value": "battery", "label": "🔋 Battery"},
                    {"value": "low_battery", "label": "🪫 Low Battery"},
                    {"value": "electric_plug", "label": "🔌 Electric Plug"},
                    {"value": "computer", "label": "💻 Computer"},
                    {"value": "desktop_computer", "label": "🖥 Desktop Computer"},
                    {"value": "printer", "label": "🖨 Printer"},
                    {"value": "keyboard", "label": "⌨️ Keyboard"},
                    {"value": "three_button_mouse", "label": "🖱 Three-Button Mouse"},
                    {"value": "trackball", "label": "🖲 Trackball"},
                    {"value": "minidisc", "label": "💽 Minidisc"},
                    {"value": "floppy_disk", "label": "💾 Floppy Disk"},
                    {"value": "cd", "label": "💿 CD"},
                    {"value": "dvd", "label": "📀 DVD"},
                    {"value": "abacus", "label": "🧮 Abacus"},
                    {"value": "movie_camera", "label": "🎥 Movie Camera"},
                    {"value": "film_frames", "label": "🎞 Film Frames"},
                    {"value": "film_projector", "label": "📽 Film Projector"},
                    {"value": "clapper", "label": "🎬 Clapper"},
                    {"value": "tv", "label": "📺 TV"},
                    {"value": "camera", "label": "📷 Camera"},
                    {"value": "camera_with_flash", "label": "📸 Camera with Flash"},
                    {"value": "video_camera", "label": "📹 Video Camera"},
                    {"value": "vhs", "label": "📼 VHS"},
                    {"value": "mag", "label": "🔍 Mag"},
                    {"value": "mag_right", "label": "🔎 Mag Right"},
                    {"value": "candle", "label": "🕯 Candle"},
                    {"value": "bulb", "label": "💡 Bulb"},
                    {"value": "flashlight", "label": "🔦 Flashlight"},
                    {"value": "izakaya_lantern", "label": "🏮 Izakaya Lantern"},
                    {"value": "diya_lamp", "label": "🪔 Diya Lamp"},
                    {"value": "notebook_with_decorative_cover", "label": "📔 Notebook with Decorative Cover"},
                    {"value": "closed_book", "label": "📕 Closed Book"},
                    {"value": "book", "label": "📖 Book"},
                    {"value": "green_book", "label": "📗 Green Book"},
                    {"value": "blue_book", "label": "📘 Blue Book"},
                    {"value": "orange_book", "label": "📙 Orange Book"},
                    {"value": "books", "label": "📚 Books"},
                    {"value": "notebook", "label": "📓 Notebook"},
                    {"value": "ledger", "label": "📒 Ledger"},
                    {"value": "page_with_curl", "label": "📃 Page with Curl"},
                    {"value": "scroll", "label": "📜 Scroll"},
                    {"value": "page_facing_up", "label": "📄 Page Facing Up"},
                    {"value": "newspaper", "label": "📰 Newspaper"},
                    {"value": "rolled_up_newspaper", "label": "🗞 Rolled Up Newspaper"},
                    {"value": "bookmark_tabs", "label": "📑 Bookmark Tabs"},
                    {"value": "bookmark", "label": "🔖 Bookmark"},
                    {"value": "label", "label": "🏷 Label"},
                    {"value": "moneybag", "label": "💰 Moneybag"},
                    {"value": "coin", "label": "🪙 Coin"},
                    {"value": "yen", "label": "💴 Yen"},
                    {"value": "dollar", "label": "💵 Dollar"},
                    {"value": "euro", "label": "💶 Euro"},
                    {"value": "pound", "label": "💷 Pound"},
                    {"value": "money_with_wings", "label": "💸 Money with Wings"},
                    {"value": "credit_card", "label": "💳 Credit Card"},
                    {"value": "receipt", "label": "🧾 Receipt"},
                    {"value": "chart", "label": "💹 Chart"},
                    {"value": "email", "label": "📧 Email"},
                    {"value": "e-mail", "label": "📧 E-mail"},
                    {"value": "incoming_envelope", "label": "📨 Incoming Envelope"},
                    {"value": "envelope_with_arrow", "label": "📩 Envelope with Arrow"},
                    {"value": "outbox_tray", "label": "📤 Outbox Tray"},
                    {"value": "inbox_tray", "label": "📥 Inbox Tray"},
                    {"value": "package", "label": "📦 Package"},
                    {"value": "mailbox", "label": "📫 Mailbox"},
                    {"value": "mailbox_closed", "label": "📪 Mailbox Closed"},
                    {"value": "mailbox_with_mail", "label": "📬 Mailbox with Mail"},
                    {"value": "mailbox_with_no_mail", "label": "📭 Mailbox with No Mail"},
                    {"value": "postbox", "label": "📮 Postbox"},
                    {"value": "ballot_box_with_ballot", "label": "🗳 Ballot Box with Ballot"},
                    {"value": "pencil2", "label": "✏️ Pencil 2"},
                    {"value": "black_nib", "label": "✒️ Black Nib"},
                    {"value": "lower_left_fountain_pen", "label": "🖋 Lower Left Fountain Pen"},
                    {"value": "lower_left_ballpoint_pen", "label": "🖊 Lower Left Ballpoint Pen"},
                    {"value": "lower_left_paintbrush", "label": "🖌 Lower Left Paintbrush"},
                    {"value": "lower_left_crayon", "label": "🖍 Lower Left Crayon"},
                    {"value": "memo", "label": "📝 Memo"},
                    {"value": "briefcase", "label": "💼 Briefcase"},
                    {"value": "file_folder", "label": "📁 File Folder"},
                    {"value": "open_file_folder", "label": "📂 Open File Folder"},
                    {"value": "card_index_dividers", "label": "🗂 Card Index Dividers"},
                    {"value": "date", "label": "📅 Date"},
                    {"value": "calendar", "label": "📆 Calendar"},
                    {"value": "spiral_note_pad", "label": "🗒 Spiral Note Pad"},
                    {"value": "spiral_calendar_pad", "label": "🗓 Spiral Calendar Pad"},
                    {"value": "card_index", "label": "📇 Card Index"},
                    {"value": "chart_with_upwards_trend", "label": "📈 Chart with Upwards Trend"},
                    {"value": "chart_with_downwards_trend", "label": "📉 Chart with Downwards Trend"},
                    {"value": "bar_chart", "label": "📊 Bar Chart"},
                    {"value": "clipboard", "label": "📋 Clipboard"},
                    {"value": "pushpin", "label": "📌 Pushpin"},
                    {"value": "round_pushpin", "label": "📍 Round Pushpin"},
                    {"value": "paperclip", "label": "📎 Paperclip"},
                    {"value": "linked_paperclips", "label": "🖇 Linked Paperclips"},
                    {"value": "straight_ruler", "label": "📏 Straight Ruler"},
                    {"value": "triangular_ruler", "label": "📐 Triangular Ruler"},
                    {"value": "scissors", "label": "✂️ Scissors"},
                    {"value": "card_file_box", "label": "🗃 Card File Box"},
                    {"value": "file_cabinet", "label": "🗄 File Cabinet"},
                    {"value": "wastebasket", "label": "🗑 Wastebasket"},
                    {"value": "lock", "label": "🔒 Lock"},
                    {"value": "unlock", "label": "🔓 Unlock"},
                    {"value": "lock_with_ink_pen", "label": "🔏 Lock with Ink Pen"},
                    {"value": "closed_lock_with_key", "label": "🔐 Closed Lock with Key"},
                    {"value": "key", "label": "🔑 Key"},
                    {"value": "old_key", "label": "🗝 Old Key"},
                    {"value": "hammer", "label": "🔨 Hammer"},
                    {"value": "axe", "label": "🪓 Axe"},
                    {"value": "pick", "label": "⛏ Pick"},
                    {"value": "hammer_and_pick", "label": "⚒ Hammer and Pick"},
                    {"value": "hammer_and_wrench", "label": "🛠 Hammer and Wrench"},
                    {"value": "dagger_knife", "label": "🗡 Dagger Knife"},
                    {"value": "crossed_swords", "label": "⚔️ Crossed Swords"},
                    {"value": "bomb", "label": "💣 Bomb"},
                    {"value": "boomerang", "label": "🪃 Boomerang"},
                    {"value": "bow_and_arrow", "label": "🏹 Bow and Arrow"},
                    {"value": "shield", "label": "🛡 Shield"},
                    {"value": "carpentry_saw", "label": "🪚 Carpentry Saw"},
                    {"value": "wrench", "label": "🔧 Wrench"},
                    {"value": "screwdriver", "label": "🪛 Screwdriver"},
                    {"value": "nut_and_bolt", "label": "🔩 Nut and Bolt"},
                    {"value": "gear", "label": "⚙️ Gear"},
                    {"value": "compression", "label": "🗜 Compression"},
                    {"value": "scales", "label": "⚖️ Scales"},
                    {"value": "probing_cane", "label": "🦯 Probing Cane"},
                    {"value": "link", "label": "🔗 Link"},
                    {"value": "chains", "label": "⛓ Chains"},
                    {"value": "hook", "label": "🪝 Hook"},
                    {"value": "toolbox", "label": "🧰 Toolbox"},
                    {"value": "magnet", "label": "🧲 Magnet"},
                    {"value": "ladder", "label": "🪜 Ladder"},
                    {"value": "alembic", "label": "⚗️ Alembic"},
                    {"value": "test_tube", "label": "🧪 Test Tube"},
                    {"value": "petri_dish", "label": "🧫 Petri Dish"},
                    {"value": "dna", "label": "🧬 DNA"},
                    {"value": "microscope", "label": "🔬 Microscope"},
                    {"value": "telescope", "label": "🔭 Telescope"},
                    {"value": "satellite_antenna", "label": "📡 Satellite Antenna"},
                    {"value": "syringe", "label": "💉 Syringe"},
                    {"value": "drop_of_blood", "label": "🩸 Drop of Blood"},
                    {"value": "pill", "label": "💊 Pill"},
                    {"value": "adhesive_bandage", "label": "🩹 Adhesive Bandage"},
                    {"value": "crutch", "label": "🩼 Crutch"},
                    {"value": "stethoscope", "label": "🩺 Stethoscope"},
                    {"value": "x-ray", "label": "🩻 X-Ray"},
                    {"value": "door", "label": "🚪 Door"},
                    {"value": "elevator", "label": "🛗 Elevator"},
                    {"value": "mirror", "label": "🪞 Mirror"},
                    {"value": "window", "label": "🪟 Window"},
                    {"value": "bed", "label": "🛏 Bed"},
                    {"value": "couch_and_lamp", "label": "🛋 Couch and Lamp"},
                    {"value": "chair", "label": "🪑 Chair"},
                    {"value": "toilet", "label": "🚽 Toilet"},
                    {"value": "plunger", "label": "🪠 Plunger"},
                    {"value": "shower", "label": "🚿 Shower"},
                    {"value": "bathtub", "label": "🛁 Bathtub"},
                    {"value": "mouse_trap", "label": "🪤 Mouse Trap"},
                    {"value": "razor", "label": "🪒 Razor"},
                    {"value": "lotion_bottle", "label": "🧴 Lotion Bottle"},
                    {"value": "safety_pin", "label": "🧷 Safety Pin"},
                    {"value": "broom", "label": "🧹 Broom"},
                    {"value": "basket", "label": "🧺 Basket"},
                    {"value": "roll_of_paper", "label": "🧻 Roll of Paper"},
                    {"value": "bucket", "label": "🪣 Bucket"},
                    {"value": "soap", "label": "🧼 Soap"},
                    {"value": "bubbles", "label": "🫧 Bubbles"},
                    {"value": "toothbrush", "label": "🪥 Toothbrush"},
                    {"value": "sponge", "label": "🧽 Sponge"},
                    {"value": "fire_extinguisher", "label": "🧯 Fire Extinguisher"},
                    {"value": "shopping_trolley", "label": "🛒 Shopping Trolley"},
                    {"value": "smoking", "label": "🚬 Smoking"},
                    {"value": "coffin", "label": "⚰️ Coffin"},
                    {"value": "headstone", "label": "🪦 Headstone"},
                    {"value": "funeral_urn", "label": "⚱️ Funeral Urn"},
                    {"value": "nazar_amulet", "label": "🧿 Nazar Amulet"},
                    {"value": "hamsa", "label": "🪬 Hamsa"},
                    {"value": "moyai", "label": "🗿 Moyai"},
                    {"value": "placard", "label": "🪧 Placard"},
                    {"value": "identification_card", "label": "🪪 Identification Card"},
            ],
        },
        {
            "group": "symbols",
            "items": [
                    {"value": "atm", "label": "🏧 ATM"},
                    {"value": "put_litter_in_its_place", "label": "🚮 Put Litter in Its Place"},
                    {"value": "potable_water", "label": "🚰 Potable Water"},
                    {"value": "wheelchair", "label": "♿ Wheelchair"},
                    {"value": "mens", "label": "🚹 Mens"},
                    {"value": "womens", "label": "🚺 Womens"},
                    {"value": "restroom", "label": "🚻 Restroom"},
                    {"value": "baby_symbol", "label": "🚼 Baby Symbol"},
                    {"value": "wc", "label": "🚾 WC"},
                    {"value": "passport_control", "label": "🛂 Passport Control"},
                    {"value": "customs", "label": "🛃 Customs"},
                    {"value": "baggage_claim", "label": "🛄 Baggage Claim"},
                    {"value": "left_luggage", "label": "🛅 Left Luggage"},
                    {"value": "warning", "label": "⚠️ Warning"},
                    {"value": "children_crossing", "label": "🚸 Children Crossing"},
                    {"value": "no_entry", "label": "⛔ No Entry"},
                    {"value": "no_entry_sign", "label": "🚫 No Entry Sign"},
                    {"value": "no_bicycles", "label": "🚳 No Bicycles"},
                    {"value": "no_smoking", "label": "🚭 No Smoking"},
                    {"value": "do_not_litter", "label": "🚯 Do Not Litter"},
                    {"value": "non-potable_water", "label": "🚱 Non-Potable Water"},
                    {"value": "no_pedestrians", "label": "🚷 No Pedestrians"},
                    {"value": "no_mobile_phones", "label": "📵 No Mobile Phones"},
                    {"value": "underage", "label": "🔞 Underage"},
                    {"value": "radioactive_sign", "label": "☢️ Radioactive Sign"},
                    {"value": "biohazard_sign", "label": "☣️ Biohazard Sign"},
                    {"value": "arrow_up", "label": "⬆️ Arrow Up"},
                    {"value": "arrow_upper_right", "label": "↗️ Arrow Upper Right"},
                    {"value": "arrow_right", "label": "➡️ Arrow Right"},
                    {"value": "arrow_lower_right", "label": "↘️ Arrow Lower Right"},
                    {"value": "arrow_down", "label": "⬇️ Arrow Down"},
                    {"value": "arrow_lower_left", "label": "↙️ Arrow Lower Left"},
                    {"value": "arrow_left", "label": "⬅️ Arrow Left"},
                    {"value": "arrow_upper_left", "label": "↖️ Arrow Upper Left"},
                    {"value": "arrow_up_down", "label": "↕️ Arrow Up Down"},
                    {"value": "left_right_arrow", "label": "↔️ Left Right Arrow"},
                    {"value": "leftwards_arrow_with_hook", "label": "↩️ Leftwards Arrow with Hook"},
                    {"value": "arrow_right_hook", "label": "↪️ Arrow Right Hook"},
                    {"value": "arrow_heading_up", "label": "⤴️ Arrow Heading Up"},
                    {"value": "arrow_heading_down", "label": "⤵️ Arrow Heading Down"},
                    {"value": "arrows_clockwise", "label": "🔃 Arrows Clockwise"},
                    {"value": "arrows_counterclockwise", "label": "🔄 Arrows Counterclockwise"},
                    {"value": "back", "label": "🔙 Back"},
                    {"value": "end", "label": "🔚 End"},
                    {"value": "on", "label": "🔛 On"},
                    {"value": "soon", "label": "🔜 Soon"},
                    {"value": "top", "label": "🔝 Top"},
                    {"value": "place_of_worship", "label": "🛐 Place of Worship"},
                    {"value": "atom_symbol", "label": "⚛️ Atom Symbol"},
                    {"value": "om_symbol", "label": "🕉️ Om Symbol"},
                    {"value": "star_of_david", "label": "✡️ Star of David"},
                    {"value": "wheel_of_dharma", "label": "☸️ Wheel of Dharma"},
                    {"value": "yin_yang", "label": "☯️ Yin Yang"},
                    {"value": "latin_cross", "label": "✝️ Latin Cross"},
                    {"value": "orthodox_cross", "label": "☦️ Orthodox Cross"},
                    {"value": "star_and_crescent", "label": "☪️ Star and Crescent"},
                    {"value": "peace_symbol", "label": "☮️ Peace Symbol"},
                    {"value": "menorah_with_nine_branches", "label": "🕎 Menorah with Nine Branches"},
                    {"value": "six_pointed_star", "label": "🔯 Six Pointed Star"},
                    {"value": "aries", "label": "♈ Aries"},
                    {"value": "taurus", "label": "♉ Taurus"},
                    {"value": "gemini", "label": "♊ Gemini"},
                    {"value": "cancer", "label": "♋ Cancer"},
                    {"value": "leo", "label": "♌ Leo"},
                    {"value": "virgo", "label": "♍ Virgo"},
                    {"value": "libra", "label": "♎ Libra"},
                    {"value": "scorpius", "label": "♏ Scorpius"},
                    {"value": "sagittarius", "label": "♐ Sagittarius"},
                    {"value": "capricorn", "label": "♑ Capricorn"},
                    {"value": "aquarius", "label": "♒ Aquarius"},
                    {"value": "pisces", "label": "♓ Pisces"},
                    {"value": "ophiuchus", "label": "⛎ Ophiuchus"},
                    {"value": "twisted_rightwards_arrows", "label": "🔀 Twisted Rightwards Arrows"},
                    {"value": "repeat", "label": "🔁 Repeat"},
                    {"value": "repeat_one", "label": "🔂 Repeat One"},
                    {"value": "arrow_forward", "label": "▶️ Arrow Forward"},
                    {"value": "fast_forward", "label": "⏩ Fast Forward"},
                    {"value": "black_right_pointing_double_triangle_with_vertical_bar",
                     "label": "⏯ Black Right Pointing Double Triangle with Vertical Bar"},
                    {"value": "black_right_pointing_triangle_with_double_vertical_bar",
                     "label": "⏭ Black Right Pointing Triangle with Double Vertical Bar"},
                    {"value": "arrow_backward", "label": "◀️ Arrow Backward"},
                    {"value": "rewind", "label": "⏪ Rewind"},
                    {"value": "black_left_pointing_double_triangle_with_vertical_bar",
                     "label": "⏮ Black Left Pointing Double Triangle with Vertical Bar"},
                    {"value": "arrow_up_small", "label": "🔼 Arrow Up Small"},
                    {"value": "arrow_double_up", "label": "⏫ Arrow Double Up"},
                    {"value": "arrow_down_small", "label": "🔽 Arrow Down Small"},
                    {"value": "arrow_double_down", "label": "⏬ Arrow Double Down"},
                    {"value": "double_vertical_bar", "label": "⏸ Double Vertical Bar"},
                    {"value": "black_square_for_stop", "label": "⏹ Black Square for Stop"},
                    {"value": "black_circle_for_record", "label": "⏺ Black Circle for Record"},
                    {"value": "eject", "label": "⏏️ Eject"},
                    {"value": "cinema", "label": "🎦 Cinema"},
                    {"value": "low_brightness", "label": "🔅 Low Brightness"},
                    {"value": "high_brightness", "label": "🔆 High Brightness"},
                    {"value": "signal_strength", "label": "📶 Signal Strength"},
                    {"value": "vibration_mode", "label": "📳 Vibration Mode"},
                    {"value": "mobile_phone_off", "label": "📴 Mobile Phone Off"},
                    {"value": "female_sign", "label": "♀️ Female Sign"},
                    {"value": "male_sign", "label": "♂️ Male Sign"},
                    {"value": "transgender_symbol", "label": "⚧ Transgender Symbol"},
                    {"value": "heavy_multiplication_x", "label": "✖️ Heavy Multiplication X"},
                    {"value": "heavy_plus_sign", "label": "➕ Heavy Plus Sign"},
                    {"value": "heavy_minus_sign", "label": "➖ Heavy Minus Sign"},
                    {"value": "heavy_division_sign", "label": "➗ Heavy Division Sign"},
                    {"value": "heavy_equals_sign", "label": "🟰 Heavy Equals Sign"},
                    {"value": "infinity", "label": "♾ Infinity"},
                    {"value": "bangbang", "label": "‼️ Bangbang"},
                    {"value": "interrobang", "label": "⁉️ Interrobang"},
                    {"value": "question", "label": "❓ Question"},
                    {"value": "grey_question", "label": "❔ Grey Question"},
                    {"value": "grey_exclamation", "label": "❕ Grey Exclamation"},
                    {"value": "exclamation", "label": "❗ Exclamation"},
                    {"value": "wavy_dash", "label": "〰️ Wavy Dash"},
                    {"value": "currency_exchange", "label": "💱 Currency Exchange"},
                    {"value": "heavy_dollar_sign", "label": "💲 Heavy Dollar Sign"},
                    {"value": "medical_symbol", "label": "⚕️ Medical Symbol"},
                    {"value": "recycle", "label": "♻️ Recycle"},
                    {"value": "fleur_de_lis", "label": "⚜️ Fleur de Lis"},
                    {"value": "trident", "label": "🔱 Trident"},
                    {"value": "name_badge", "label": "📛 Name Badge"},
                    {"value": "beginner", "label": "🔰 Beginner"},
                    {"value": "o", "label": "⭕ O"},
                    {"value": "white_check_mark", "label": "✅ White Check Mark"},
                    {"value": "ballot_box_with_check", "label": "☑️ Ballot Box with Check"},
                    {"value": "heavy_check_mark", "label": "✔️ Heavy Check Mark"},
                    {"value": "x", "label": "❌ X"},
                    {"value": "negative_squared_cross_mark", "label": "❎ Negative Squared Cross Mark"},
                    {"value": "curly_loop", "label": "➰ Curly Loop"},
                    {"value": "loop", "label": "➿ Loop"},
                    {"value": "part_alternation_mark", "label": "〽️ Part Alternation Mark"},
                    {"value": "eight_spoked_asterisk", "label": "✳️ Eight Spoked Asterisk"},
                    {"value": "eight_pointed_black_star", "label": "✴️ Eight Pointed Black Star"},
                    {"value": "sparkle", "label": "❇️ Sparkle"},
                    {"value": "copyright", "label": "©️ Copyright"},
                    {"value": "registered", "label": "®️ Registered"},
                    {"value": "tm", "label": "™️ TM"},
                    {"value": "hash", "label": "#️⃣ Hash"},
                    {"value": "keycap_star", "label": "*️⃣ Keycap Star"},
                    {"value": "zero", "label": "0️⃣ Zero"},
                    {"value": "one", "label": "1️⃣ One"},
                    {"value": "two", "label": "2️⃣ Two"},
                    {"value": "three", "label": "3️⃣ Three"},
                    {"value": "four", "label": "4️⃣ Four"},
                    {"value": "five", "label": "5️⃣ Five"},
                    {"value": "six", "label": "6️⃣ Six"},
                    {"value": "seven", "label": "7️⃣ Seven"},
                    {"value": "eight", "label": "8️⃣ Eight"},
                    {"value": "nine", "label": "9️⃣ Nine"},
                    {"value": "keycap_ten", "label": "🔟 Keycap Ten"},
                    {"value": "capital_abcd", "label": "🔠 Capital ABCD"},
                    {"value": "abcd", "label": "🔡 ABCD"},
                    {"value": "1234", "label": "🔢 1234"},
                    {"value": "symbols", "label": "🔣 Symbols"},
                    {"value": "abc", "label": "🔤 ABC"},
                    {"value": "a", "label": "🅰️ A"},
                    {"value": "ab", "label": "🆎 AB"},
                    {"value": "b", "label": "🅱️ B"},
                    {"value": "cl", "label": "🆑 CL"},
                    {"value": "cool", "label": "🆒 Cool"},
                    {"value": "free", "label": "🆓 Free"},
                    {"value": "information_source", "label": "ℹ️ Information Source"},
                    {"value": "id", "label": "🆔 ID"},
                    {"value": "m", "label": "Ⓜ️ M"},
                    {"value": "new", "label": "🆕 New"},
                    {"value": "ng", "label": "🆖 NG"},
                    {"value": "o2", "label": "🅾️ O2"},
                    {"value": "ok", "label": "🆗 OK"},
                    {"value": "parking", "label": "🅿️ Parking"},
                    {"value": "sos", "label": "🆘 SOS"},
                    {"value": "up", "label": "🆙 UP"},
                    {"value": "vs", "label": "🆚 VS"},
                    {"value": "koko", "label": "🈁 KOKO"},
                    {"value": "sa", "label": "🈂️ SA"},
                    {"value": "u6708", "label": "🈷️ U6708"},
                    {"value": "u6709", "label": "🈶 U6709"},
                    {"value": "u6307", "label": "🈯 U6307"},
                    {"value": "ideograph_advantage", "label": "🉐 Ideograph Advantage"},
                    {"value": "u5272", "label": "🈹 U5272"},
                    {"value": "u7121", "label": "🈚 U7121"},
                    {"value": "u7981", "label": "🈲 U7981"},
                    {"value": "accept", "label": "🉑 Accept"},
                    {"value": "u7533", "label": "🈸 U7533"},
                    {"value": "u5408", "label": "🈴 U5408"},
                    {"value": "u7a7a", "label": "🈳 U7A7A"},
                    {"value": "congratulations", "label": "㊗️ Congratulations"},
                    {"value": "secret", "label": "㊙️ Secret"},
                    {"value": "u55b6", "label": "🈺 U55B6"},
                    {"value": "u6e80", "label": "🈵 U6E80"},
                    {"value": "red_circle", "label": "🔴 Red Circle"},
                    {"value": "large_orange_circle", "label": "🟠 Large Orange Circle"},
                    {"value": "large_yellow_circle", "label": "🟡 Large Yellow Circle"},
                    {"value": "large_green_circle", "label": "🟢 Large Green Circle"},
                    {"value": "large_blue_circle", "label": "🔵 Large Blue Circle"},
                    {"value": "large_purple_circle", "label": "🟣 Large Purple Circle"},
                    {"value": "large_brown_circle", "label": "🟤 Large Brown Circle"},
                    {"value": "black_circle", "label": "⚫ Black Circle"},
                    {"value": "white_circle", "label": "⚪ White Circle"},
                    {"value": "large_red_square", "label": "🟥 Large Red Square"},
                    {"value": "large_orange_square", "label": "🟧 Large Orange Square"},
                    {"value": "large_yellow_square", "label": "🟨 Large Yellow Square"},
                    {"value": "large_green_square", "label": "🟩 Large Green Square"},
                    {"value": "large_blue_square", "label": "🟦 Large Blue Square"},
                    {"value": "large_purple_square", "label": "🟪 Large Purple Square"},
                    {"value": "large_brown_square", "label": "🟫 Large Brown Square"},
                    {"value": "black_large_square", "label": "⬛ Black Large Square"},
                    {"value": "white_large_square", "label": "⬜ White Large Square"},
                    {"value": "black_medium_square", "label": "◼️ Black Medium Square"},
                    {"value": "white_medium_square", "label": "◻️ White Medium Square"},
                    {"value": "black_medium_small_square", "label": "◾ Black Medium Small Square"},
                    {"value": "white_medium_small_square", "label": "◽ White Medium Small Square"},
                    {"value": "black_small_square", "label": "▪️ Black Small Square"},
                    {"value": "white_small_square", "label": "▫️ White Small Square"},
                    {"value": "large_orange_diamond", "label": "🔶 Large Orange Diamond"},
                    {"value": "large_blue_diamond", "label": "🔷 Large Blue Diamond"},
                    {"value": "small_orange_diamond", "label": "🔸 Small Orange Diamond"},
                    {"value": "small_blue_diamond", "label": "🔹 Small Blue Diamond"},
                    {"value": "small_red_triangle", "label": "🔺 Small Red Triangle"},
                    {"value": "small_red_triangle_down", "label": "🔻 Small Red Triangle Down"},
                    {"value": "diamond_shape_with_a_dot_inside", "label": "💠 Diamond Shape with a Dot Inside"},
                    {"value": "radio_button", "label": "🔘 Radio Button"},
                    {"value": "white_square_button", "label": "🔳 White Square Button"},
                    {"value": "black_square_button", "label": "🔲 Black Square Button"},
            ],
        },
        {
            "group": "flags",
            "items": [
                    {"value": "checkered_flag", "label": "🏁 Checkered Flag"},
                    {"value": "cn", "label": "🇨🇳 CN"},
                    {"value": "crossed_flags", "label": "🎌 Crossed Flags"},
                    {"value": "de", "label": "🇩🇪 DE"},
                    {"value": "es", "label": "🇪🇸 ES"},
                    {"value": "flag-ac", "label": "🇦🇨 Flag AC"},
                    {"value": "flag-ad", "label": "🇦🇩 Flag AD"},
                    {"value": "flag-ae", "label": "🇦🇪 Flag AE"},
                    {"value": "flag-af", "label": "🇦🇫 Flag AF"},
                    {"value": "flag-ag", "label": "🇦🇬 Flag AG"},
                    {"value": "flag-ai", "label": "🇦🇮 Flag AI"},
                    {"value": "flag-al", "label": "🇦🇱 Flag AL"},
                    {"value": "flag-am", "label": "🇦🇲 Flag AM"},
                    {"value": "flag-ao", "label": "🇦🇴 Flag AO"},
                    {"value": "flag-aq", "label": "🇦🇶 Flag AQ"},
                    {"value": "flag-ar", "label": "🇦🇷 Flag AR"},
                    {"value": "flag-as", "label": "🇦🇸 Flag AS"},
                    {"value": "flag-at", "label": "🇦🇹 Flag AT"},
                    {"value": "flag-au", "label": "🇦🇺 Flag AU"},
                    {"value": "flag-aw", "label": "🇦🇼 Flag AW"},
                    {"value": "flag-ax", "label": "🇦🇽 Flag AX"},
                    {"value": "flag-az", "label": "🇦🇿 Flag AZ"},
                    {"value": "flag-ba", "label": "🇧🇦 Flag BA"},
                    {"value": "flag-bb", "label": "🇧🇧 Flag BB"},
                    {"value": "flag-bd", "label": "🇧🇩 Flag BD"},
                    {"value": "flag-be", "label": "🇧🇪 Flag BE"},
                    {"value": "flag-bf", "label": "🇧🇫 Flag BF"},
                    {"value": "flag-bg", "label": "🇧🇬 Flag BG"},
                    {"value": "flag-bh", "label": "🇧🇭 Flag BH"},
                    {"value": "flag-bi", "label": "🇧🇮 Flag BI"},
                    {"value": "flag-bj", "label": "🇧🇯 Flag BJ"},
                    {"value": "flag-bl", "label": "🇧🇱 Flag BL"},
                    {"value": "flag-bm", "label": "🇧🇲 Flag BM"},
                    {"value": "flag-bn", "label": "🇧🇳 Flag BN"},
                    {"value": "flag-bo", "label": "🇧🇴 Flag BO"},
                    {"value": "flag-bq", "label": "🇧🇶 Flag BQ"},
                    {"value": "flag-br", "label": "🇧🇷 Flag BR"},
                    {"value": "flag-bs", "label": "🇧🇸 Flag BS"},
                    {"value": "flag-bt", "label": "🇧🇹 Flag BT"},
                    {"value": "flag-bv", "label": "🇧🇻 Flag BV"},
                    {"value": "flag-bw", "label": "🇧🇼 Flag BW"},
                    {"value": "flag-by", "label": "🇧🇾 Flag BY"},
                    {"value": "flag-bz", "label": "🇧🇿 Flag BZ"},
                    {"value": "flag-ca", "label": "🇨🇦 Flag CA"},
                    {"value": "flag-cc", "label": "🇨🇨 Flag CC"},
                    {"value": "flag-cd", "label": "🇨🇩 Flag CD"},
                    {"value": "flag-cf", "label": "🇨🇫 Flag CF"},
                    {"value": "flag-cg", "label": "🇨🇬 Flag CG"},
                    {"value": "flag-ch", "label": "🇨🇭 Flag CH"},
                    {"value": "flag-ci", "label": "🇨🇮 Flag CI"},
                    {"value": "flag-ck", "label": "🇨🇰 Flag CK"},
                    {"value": "flag-cl", "label": "🇨🇱 Flag CL"},
                    {"value": "flag-cm", "label": "🇨🇲 Flag CM"},
                    {"value": "flag-co", "label": "🇨🇴 Flag CO"},
                    {"value": "flag-cp", "label": "🇨🇵 Flag CP"},
                    {"value": "flag-cr", "label": "🇨🇷 Flag CR"},
                    {"value": "flag-cu", "label": "🇨🇺 Flag CU"},
                    {"value": "flag-cv", "label": "🇨🇻 Flag CV"},
                    {"value": "flag-cw", "label": "🇨🇼 Flag CW"},
                    {"value": "flag-cx", "label": "🇨🇽 Flag CX"},
                    {"value": "flag-cy", "label": "🇨🇾 Flag CY"},
                    {"value": "flag-cz", "label": "🇨🇿 Flag CZ"},
                    {"value": "flag-dg", "label": "🇩🇬 Flag DG"},
                    {"value": "flag-dj", "label": "🇩🇯 Flag DJ"},
                    {"value": "flag-dk", "label": "🇩🇰 Flag DK"},
                    {"value": "flag-dm", "label": "🇩🇲 Flag DM"},
                    {"value": "flag-do", "label": "🇩🇴 Flag DO"},
                    {"value": "flag-dz", "label": "🇩🇿 Flag DZ"},
                    {"value": "flag-ea", "label": "🇪🇦 Flag EA"},
                    {"value": "flag-ec", "label": "🇪🇨 Flag EC"},
                    {"value": "flag-ee", "label": "🇪🇪 Flag EE"},
                    {"value": "flag-eg", "label": "🇪🇬 Flag EG"},
                    {"value": "flag-eh", "label": "🇪🇭 Flag EH"},
                    {"value": "flag-england", "label": "🏴 Flag England"},
                    {"value": "flag-er", "label": "🇪🇷 Flag ER"},
                    {"value": "flag-et", "label": "🇪🇹 Flag ET"},
                    {"value": "flag-eu", "label": "🇪🇺 Flag EU"},
                    {"value": "flag-fi", "label": "🇫🇮 Flag FI"},
                    {"value": "flag-fj", "label": "🇫🇯 Flag FJ"},
                    {"value": "flag-fk", "label": "🇫🇰 Flag FK"},
                    {"value": "flag-fm", "label": "🇫🇲 Flag FM"},
                    {"value": "flag-fo", "label": "🇫🇴 Flag FO"},
                    {"value": "flag-ga", "label": "🇬🇦 Flag GA"},
                    {"value": "flag-gd", "label": "🇬🇩 Flag GD"},
                    {"value": "flag-ge", "label": "🇬🇪 Flag GE"},
                    {"value": "flag-gf", "label": "🇬🇫 Flag GF"},
                    {"value": "flag-gg", "label": "🇬🇬 Flag GG"},
                    {"value": "flag-gh", "label": "🇬🇭 Flag GH"},
                    {"value": "flag-gi", "label": "🇬🇮 Flag GI"},
                    {"value": "flag-gl", "label": "🇬🇱 Flag GL"},
                    {"value": "flag-gm", "label": "🇬🇲 Flag GM"},
                    {"value": "flag-gn", "label": "🇬🇳 Flag GN"},
                    {"value": "flag-gp", "label": "🇬🇵 Flag GP"},
                    {"value": "flag-gq", "label": "🇬🇶 Flag GQ"},
                    {"value": "flag-gr", "label": "🇬🇷 Flag GR"},
                    {"value": "flag-gs", "label": "🇬🇸 Flag GS"},
                    {"value": "flag-gt", "label": "🇬🇹 Flag GT"},
                    {"value": "flag-gu", "label": "🇬🇺 Flag GU"},
                    {"value": "flag-gw", "label": "🇬🇼 Flag GW"},
                    {"value": "flag-gy", "label": "🇬🇾 Flag GY"},
                    {"value": "flag-hk", "label": "🇭🇰 Flag HK"},
                    {"value": "flag-hm", "label": "🇭🇲 Flag HM"},
                    {"value": "flag-hn", "label": "🇭🇳 Flag HN"},
                    {"value": "flag-hr", "label": "🇭🇷 Flag HR"},
                    {"value": "flag-ht", "label": "🇭🇹 Flag HT"},
                    {"value": "flag-hu", "label": "🇭🇺 Flag HU"},
                    {"value": "flag-ic", "label": "🇮🇨 Flag IC"},
                    {"value": "flag-id", "label": "🇮🇩 Flag ID"},
                    {"value": "flag-ie", "label": "🇮🇪 Flag IE"},
                    {"value": "flag-il", "label": "🇮🇱 Flag IL"},
                    {"value": "flag-im", "label": "🇮🇲 Flag IM"},
                    {"value": "flag-in", "label": "🇮🇳 Flag IN"},
                    {"value": "flag-io", "label": "🇮🇴 Flag IO"},
                    {"value": "flag-iq", "label": "🇮🇶 Flag IQ"},
                    {"value": "flag-ir", "label": "🇮🇷 Flag IR"},
                    {"value": "flag-is", "label": "🇮🇸 Flag IS"},
                    {"value": "flag-je", "label": "🇯🇪 Flag JE"},
                    {"value": "flag-jm", "label": "🇯🇲 Flag JM"},
                    {"value": "flag-jo", "label": "🇯🇴 Flag JO"},
                    {"value": "flag-ke", "label": "🇰🇪 Flag KE"},
                    {"value": "flag-kg", "label": "🇰🇬 Flag KG"},
                    {"value": "flag-kh", "label": "🇰🇭 Flag KH"},
                    {"value": "flag-ki", "label": "🇰🇮 Flag KI"},
                    {"value": "flag-km", "label": "🇰🇲 Flag KM"},
                    {"value": "flag-kn", "label": "🇰🇳 Flag KN"},
                    {"value": "flag-kp", "label": "🇰🇵 Flag KP"},
                    {"value": "flag-kw", "label": "🇰🇼 Flag KW"},
                    {"value": "flag-ky", "label": "🇰🇾 Flag KY"},
                    {"value": "flag-kz", "label": "🇰🇿 Flag KZ"},
                    {"value": "flag-la", "label": "🇱🇦 Flag LA"},
                    {"value": "flag-lb", "label": "🇱🇧 Flag LB"},
                    {"value": "flag-lc", "label": "🇱🇨 Flag LC"},
                    {"value": "flag-li", "label": "🇱🇮 Flag LI"},
                    {"value": "flag-lk", "label": "🇱🇰 Flag LK"},
                    {"value": "flag-lr", "label": "🇱🇷 Flag LR"},
                    {"value": "flag-ls", "label": "🇱🇸 Flag LS"},
                    {"value": "flag-lt", "label": "🇱🇹 Flag LT"},
                    {"value": "flag-lu", "label": "🇱🇺 Flag LU"},
                    {"value": "flag-lv", "label": "🇱🇻 Flag LV"},
                    {"value": "flag-ly", "label": "🇱🇾 Flag LY"},
                    {"value": "flag-ma", "label": "🇲🇦 Flag MA"},
                    {"value": "flag-mc", "label": "🇲🇨 Flag MC"},
                    {"value": "flag-md", "label": "🇲🇩 Flag MD"},
                    {"value": "flag-me", "label": "🇲🇪 Flag ME"},
                    {"value": "flag-mf", "label": "🇲🇫 Flag MF"},
                    {"value": "flag-mg", "label": "🇲🇬 Flag MG"},
                    {"value": "flag-mh", "label": "🇲🇭 Flag MH"},
                    {"value": "flag-mk", "label": "🇲🇰 Flag MK"},
                    {"value": "flag-ml", "label": "🇲🇱 Flag ML"},
                    {"value": "flag-mm", "label": "🇲🇲 Flag MM"},
                    {"value": "flag-mn", "label": "🇲🇳 Flag MN"},
                    {"value": "flag-mo", "label": "🇲🇴 Flag MO"},
                    {"value": "flag-mp", "label": "🇲🇵 Flag MP"},
                    {"value": "flag-mq", "label": "🇲🇶 Flag MQ"},
                    {"value": "flag-mr", "label": "🇲🇷 Flag MR"},
                    {"value": "flag-ms", "label": "🇲🇸 Flag MS"},
                    {"value": "flag-mt", "label": "🇲🇹 Flag MT"},
                    {"value": "flag-mu", "label": "🇲🇺 Flag MU"},
                    {"value": "flag-mv", "label": "🇲🇻 Flag MV"},
                    {"value": "flag-mw", "label": "🇲🇼 Flag MW"},
                    {"value": "flag-mx", "label": "🇲🇽 Flag MX"},
                    {"value": "flag-my", "label": "🇲🇾 Flag MY"},
                    {"value": "flag-mz", "label": "🇲🇿 Flag MZ"},
                    {"value": "flag-na", "label": "🇳🇦 Flag NA"},
                    {"value": "flag-nc", "label": "🇳🇨 Flag NC"},
                    {"value": "flag-ne", "label": "🇳🇪 Flag NE"},
                    {"value": "flag-nf", "label": "🇳🇫 Flag NF"},
                    {"value": "flag-ng", "label": "🇳🇬 Flag NG"},
                    {"value": "flag-ni", "label": "🇳🇮 Flag NI"},
                    {"value": "flag-nl", "label": "🇳🇱 Flag NL"},
                    {"value": "flag-no", "label": "🇳🇴 Flag NO"},
                    {"value": "flag-np", "label": "🇳🇵 Flag NP"},
                    {"value": "flag-nr", "label": "🇳🇷 Flag NR"},
                    {"value": "flag-nu", "label": "🇳🇺 Flag NU"},
                    {"value": "flag-nz", "label": "🇳🇿 Flag NZ"},
                    {"value": "flag-om", "label": "🇴🇲 Flag OM"},
                    {"value": "flag-pa", "label": "🇵🇦 Flag PA"},
                    {"value": "flag-pe", "label": "🇵🇪 Flag PE"},
                    {"value": "flag-pf", "label": "🇵🇫 Flag PF"},
                    {"value": "flag-pg", "label": "🇵🇬 Flag PG"},
                    {"value": "flag-ph", "label": "🇵🇭 Flag PH"},
                    {"value": "flag-pk", "label": "🇵🇰 Flag PK"},
                    {"value": "flag-pl", "label": "🇵🇱 Flag PL"},
                    {"value": "flag-pm", "label": "🇵🇲 Flag PM"},
                    {"value": "flag-pn", "label": "🇵🇳 Flag PN"},
                    {"value": "flag-pr", "label": "🇵🇷 Flag PR"},
                    {"value": "flag-ps", "label": "🇵🇸 Flag PS"},
                    {"value": "flag-pt", "label": "🇵🇹 Flag PT"},
                    {"value": "flag-pw", "label": "🇵🇼 Flag PW"},
                    {"value": "flag-py", "label": "🇵🇾 Flag PY"},
                    {"value": "flag-qa", "label": "🇶🇦 Flag QA"},
                    {"value": "flag-re", "label": "🇷🇪 Flag RE"},
                    {"value": "flag-ro", "label": "🇷🇴 Flag RO"},
                    {"value": "flag-rs", "label": "🇷🇸 Flag RS"},
                    {"value": "flag-rw", "label": "🇷🇼 Flag RW"},
                    {"value": "flag-sa", "label": "🇸🇦 Flag SA"},
                    {"value": "flag-sb", "label": "🇸🇧 Flag SB"},
                    {"value": "flag-sc", "label": "🇸🇨 Flag SC"},
                    {"value": "flag-scotland", "label": "🏴 Flag Scotland"},
                    {"value": "flag-sd", "label": "🇸🇩 Flag SD"},
                    {"value": "flag-se", "label": "🇸🇪 Flag SE"},
                    {"value": "flag-sg", "label": "🇸🇬 Flag SG"},
                    {"value": "flag-sh", "label": "🇸🇭 Flag SH"},
                    {"value": "flag-si", "label": "🇸🇮 Flag SI"},
                    {"value": "flag-sj", "label": "🇸🇯 Flag SJ"},
                    {"value": "flag-sk", "label": "🇸🇰 Flag SK"},
                    {"value": "flag-sl", "label": "🇸🇱 Flag SL"},
                    {"value": "flag-sm", "label": "🇸🇲 Flag SM"},
                    {"value": "flag-sn", "label": "🇸🇳 Flag SN"},
                    {"value": "flag-so", "label": "🇸🇴 Flag SO"},
                    {"value": "flag-sr", "label": "🇸🇷 Flag SR"},
                    {"value": "flag-ss", "label": "🇸🇸 Flag SS"},
                    {"value": "flag-st", "label": "🇸🇹 Flag ST"},
                    {"value": "flag-sv", "label": "🇸🇻 Flag SV"},
                    {"value": "flag-sx", "label": "🇸🇽 Flag SX"},
                    {"value": "flag-sy", "label": "🇸🇾 Flag SY"},
                    {"value": "flag-sz", "label": "🇸🇿 Flag SZ"},
                    {"value": "flag-ta", "label": "🇹🇦 Flag TA"},
                    {"value": "flag-tc", "label": "🇹🇨 Flag TC"},
                    {"value": "flag-td", "label": "🇹🇩 Flag TD"},
                    {"value": "flag-tf", "label": "🇹🇫 Flag TF"},
                    {"value": "flag-tg", "label": "🇹🇬 Flag TG"},
                    {"value": "flag-th", "label": "🇹🇭 Flag TH"},
                    {"value": "flag-tj", "label": "🇹🇯 Flag TJ"},
                    {"value": "flag-tk", "label": "🇹🇰 Flag TK"},
                    {"value": "flag-tl", "label": "🇹🇱 Flag TL"},
                    {"value": "flag-tm", "label": "🇹🇲 Flag TM"},
                    {"value": "flag-tn", "label": "🇹🇳 Flag TN"},
                    {"value": "flag-to", "label": "🇹🇴 Flag TO"},
                    {"value": "flag-tr", "label": "🇹🇷 Flag TR"},
                    {"value": "flag-tt", "label": "🇹🇹 Flag TT"},
                    {"value": "flag-tv", "label": "🇹🇻 Flag TV"},
                    {"value": "flag-tw", "label": "🇹🇼 Flag TW"},
                    {"value": "flag-tz", "label": "🇹🇿 Flag TZ"},
                    {"value": "flag-ua", "label": "🇺🇦 Flag UA"},
                    {"value": "flag-ug", "label": "🇺🇬 Flag UG"},
                    {"value": "flag-um", "label": "🇺🇲 Flag UM"},
                    {"value": "flag-un", "label": "🇺🇳 Flag UN"},
                    {"value": "flag-uy", "label": "🇺🇾 Flag UY"},
                    {"value": "flag-uz", "label": "🇺🇿 Flag UZ"},
                    {"value": "flag-va", "label": "🇻🇦 Flag VA"},
                    {"value": "flag-vc", "label": "🇻🇨 Flag VC"},
                    {"value": "flag-ve", "label": "🇻🇪 Flag VE"},
                    {"value": "flag-vg", "label": "🇻🇬 Flag VG"},
                    {"value": "flag-vi", "label": "🇻🇮 Flag VI"},
                    {"value": "flag-vn", "label": "🇻🇳 Flag VN"},
                    {"value": "flag-vu", "label": "🇻🇺 Flag VU"},
                    {"value": "flag-wales", "label": "🏴 Flag Wales"},
                    {"value": "flag-wf", "label": "🇼🇫 Flag WF"},
                    {"value": "flag-ws", "label": "🇼🇸 Flag WS"},
                    {"value": "flag-xk", "label": "🇽🇰 Flag XK"},
                    {"value": "flag-ye", "label": "🇾🇪 Flag YE"},
                    {"value": "flag-yt", "label": "🇾🇹 Flag YT"},
                    {"value": "flag-za", "label": "🇿🇦 Flag ZA"},
                    {"value": "flag-zm", "label": "🇿🇲 Flag ZM"},
                    {"value": "flag-zw", "label": "🇿🇼 Flag ZW"},
                    {"value": "fr", "label": "🇫🇷 FR"},
                    {"value": "gb", "label": "🇬🇧 GB"},
                    {"value": "it", "label": "🇮🇹 IT"},
                    {"value": "jp", "label": "🇯🇵 JP"},
                    {"value": "kr", "label": "🇰🇷 KR"},
                    {"value": "pirate_flag", "label": "🏴‍☠️ Pirate Flag"},
                    {"value": "rainbow-flag", "label": "🏳️‍🌈 Rainbow Flag"},
                    {"value": "ru", "label": "🇷🇺 RU"},
                    {"value": "transgender_flag", "label": "🏳️‍⚧️ Transgender Flag"},
                    {"value": "triangular_flag_on_post", "label": "🚩 Triangular Flag on Post"},
                    {"value": "us", "label": "🇺🇸 US"},
                    {"value": "waving_black_flag", "label": "🏴 Waving Black Flag"},
                    {"value": "waving_white_flag", "label": "🏳️ Waving White Flag"}
            ],
        },
    ]

component = dmc.Grid(
    children=[
        dmc.GridCol(dmc.Paper(
            html.Div(id="view-dem", style={'marginTop': '40px'}),
            id="intro-wrapper-dem",

        ), span=6),
        dmc.GridCol(dmc.Stack(
            [
                dmc.MultiSelect(
                            label="categories",
                            placeholder="Select all emoji categories you'd want displayed.",
                            id="dem-categories-multi-select",
                            value=['frequent', 'people', 'nature', 'foods', 'activity', 'places', 'objects', 'symbols', 'flags',
                    'custom'],
                            data=[
                                    {"value": "frequent", "label": "Frequent"},
                                    {"value": "people", "label": "People"},
                                    {"value": "nature", "label": "Nature"},
                                    {"value": "foods", "label": "Foods"},
                                    {"value": "activity", "label": "Activity"},
                                    {"value": "places", "label": "Places"},
                                    {"value": "objects", "label": "Objects"},
                                    {"value": "symbols", "label": "Symbols"},
                                    {"value": "flags", "label": "Flags"},
                                    {"value": "custom", "label": "Custom"},
                            ],
                            mb=10,
                            searchable=True,
                        ),
                dmc.Select(
                    label="Select Set (doesn't fully work yet)",
                    placeholder="Select one",
                    id="dem-text-set",
                    value="native",
                    data=[
                        {"value": "native", "label": "native"},
                        {"value": "apple", "label": "apple"},
                        {"value": "google", "label": "google"},
                        {"value": "twitter", "label": "twitter"},
                        {"value": "facebook", "label": "facebook"},
                    ],
                ),
                dmc.Select(
                    label="Select theme",
                    placeholder="Select one",
                    id="dem-text-theme",
                    value="auto",
                    data=[
                        {"value": "auto", "label": "auto"},
                        {"value": "light", "label": "light"},
                        {"value": "dark", "label": "dark"},

                    ],
                ),
                dmc.NumberInput(id="dem-text-emojiButtonRadius", label="emojiButtonRadius", mb=10, value=100, min=0,
                                max=100, step=1),
                dmc.NumberInput(id="dem-text-emojiButtonSize", label="emojiButtonSize", mb=10, value=36, min=0,
                                                max=100, step=1),
                dmc.NumberInput(id="dem-text-emojiSize", label="emojiSize", mb=10, value=24, min=0,
                                                                max=100, step=1),
                dmc.NumberInput(id="dem-text-emojiVersion", label="emojiVersion", mb=10, value=24, min=0,
                                                                                max=15, step=1),
                dmc.MultiSelect(
                            label="exceptEmojis",
                            placeholder="Select all emoji's you'd want excluded.",
                            id="dem-exceptEmojis-multi-select",
                            value=["grinning", "bear"],
                            data=data_grouped,
                            mb=10,
                            searchable=True,
                        ),
                dmc.Select(
                    label="Select icons",
                    placeholder="Select one",
                    id="dem-text-icons",
                    value="auto",
                    data=[
                        {"value": "auto", "label": "auto"},
                        {"value": "outline", "label": "outline"},
                        {"value": "solid", "label": "solid"},
                    ],
                ),
                # en, ar, be, cs, de, es, fa, fi, fr, hi, it, ja, ko, nl, pl, pt, ru, sa, tr, uk, vi, zh
                dmc.Select(
                    label="Select locale",
                    placeholder="Select one",
                    id="dem-text-locale",
                    value="en",
                    data=[
                        {"value": "en", "label": "en"},
                        {"value": "ar", "label": "ar"},
                        {"value": "be", "label": "be"},
                        {"value": "cs", "label": "cs"},
                        {"value": "de", "label": "de"},
                        {"value": "es", "label": "es"},
                        {"value": "fa", "label": "fa"},
                        {"value": "fi", "label": "fi"},
                        {"value": "fr", "label": "fr"},
                        {"value": "hi", "label": "hi"},
                        {"value": "it", "label": "it"},
                        {"value": "ja", "label": "ja"},
                        {"value": "ko", "label": "ko"},
                        {"value": "nl", "label": "nl"},
                        {"value": "pl", "label": "pl"},
                        {"value": "pt", "label": "pt"},
                        {"value": "ru", "label": "ru"},
                        {"value": "sa", "label": "sa"},
                        {"value": "tr", "label": "tr"},
                        {"value": "uk", "label": "uk"},
                        {"value": "vi", "label": "vi"},
                        {"value": "zh", "label": "zh"},
                    ],
                ),
                dmc.NumberInput(id="dem-maxFrequentRows", label="maxFrequentRows", mb=10, value=24, min=0, max=10, step=1),
                dmc.Select(
                    label="Select navPosition",
                    placeholder="Select one",
                    id="dem-text-navPosition",
                    value="top",
                    data=[
                        {"value": "top", "label": "top"},
                        {"value": "bottom", "label": "bottom"},
                        {"value": "none", "label": "none"},

                    ],
                ),
                    # dynamicWidth
                dmc.Checkbox(
                    id="checkbox-dynamicWidth", label="dynamicWidth", checked=False, mb=10
                ),
                dmc.Checkbox(
                    id="checkbox-noCountryFlags", label="noCountryFlags", checked=False, mb=10
                ),
                dmc.NumberInput(id="dem-perLine", label="perLine", mb=10, value=9, min=0, max=20,
                            step=1),

                dmc.Select(
                    label="Select previewEmoji",
                    placeholder="Select one",
                    id="dem-text-previewEmoji",
                    value="point_up",
                    data=data_grouped,
                    searchable=True,
                ),

                dmc.Select(
                    label="Select previewPosition",
                    placeholder="Select one",
                    id="dem-text-previewPosition",
                    value="bottom",
                    data=[
                        {"value": "top", "label": "top"},
                        {"value": "bottom", "label": "bottom"},
                        {"value": "none", "label": "none"},
                    ],
                ),
                    # searchPosition sticky, static, none
                dmc.Select(
                    label="Select searchPosition",
                    placeholder="Select one",
                    id="dem-text-searchPosition",
                    value="sticky",
                    data=[
                        {"value": "sticky", "label": "sticky"},
                        {"value": "static", "label": "static"},
                        {"value": "none", "label": "none"},
                    ],
                ),
                dmc.NumberInput(id="dem-skin", label="skin", mb=10, value=1, min=1, max=6,
                            step=1),
                # preview, search, none
                dmc.Select(
                    label="Select skinTonePosition",
                    placeholder="Select one",
                    id="dem-text-skinTonePosition",
                    value="preview",
                    data=[
                        {"value": "preview", "label": "preview"},
                        {"value": "search", "label": "search"},
                        {"value": "none", "label": "none"},
                    ],
                ),
            ],
            style={'overflow-y': 'auto', 'max-height': '500px', 'width': '25vw'},
        ), span=6),
    ],
    grow=True
)

@callback(
    Output("view-dem", "children"),
    Input("dem-categories-multi-select", "value"),
    Input("dem-text-set", "value"),
    Input("dem-text-theme", "value"),
    Input("dem-text-emojiButtonRadius", "value"),
    Input("dem-text-emojiButtonSize", "value"),
    Input("dem-text-emojiSize", "value"),
    Input("dem-text-emojiVersion", "value"),
    Input("dem-exceptEmojis-multi-select", "value"),
    Input("dem-text-icons", "value"),
    Input("dem-text-locale", "value"),
    Input("dem-maxFrequentRows", "value"),
    Input("dem-text-navPosition", "value"),
    Input("checkbox-dynamicWidth", "checked"),
    Input("checkbox-noCountryFlags", "checked"),
    Input("dem-perLine", "value"),
    Input("dem-text-previewEmoji", "value"),
    Input("dem-text-previewPosition", "value"),
    Input("dem-text-searchPosition", "value"),
    Input("dem-skin", "value"),
)
def update_output(categories, set, theme, emojiButtonRadius, emojiButtonSize, emojiSize, emojiVersion, exceptEmojis, icons, locale, maxFrequentRows, navPosition, dynamicWidth, noCountryFlags, perLine, previewEmoji, previewPosition, searchPosition, skin):
    return dmc.Center(DashEmojiMart(
        id=f"emoji-mart-example-{set}-{theme}-{emojiButtonRadius}-{emojiButtonSize}-{emojiSize}-{emojiVersion}-{icons}-{locale}-{maxFrequentRows}-{navPosition}-{dynamicWidth}-{noCountryFlags}-{perLine}-{previewPosition}-{searchPosition}-{skin}",
        categories=categories,
        set=set,
        theme=theme,
        emojiButtonRadius=f"{emojiButtonRadius}%",
        emojiButtonSize=emojiButtonSize,
        emojiSize=emojiSize,
        emojiVersion=emojiVersion,
        exceptEmojis=exceptEmojis,
        icons=icons,
        locale=locale,
        maxFrequentRows=maxFrequentRows,
        navPosition=navPosition,
        dynamicWidth=dynamicWidth,
        noCountryFlags=noCountryFlags,
        perLine=perLine,
        previewEmoji=previewEmoji,
        previewPosition=previewPosition,
        searchPosition=searchPosition,
        skin=skin,

    ))
```


---

### Themes and Appearance

The emoji picker supports three theme modes that integrate seamlessly with your application's design.

**Theme Options:**

- **`auto`**: Automatically matches system preference (light/dark mode)
- **`light`**: Forces light theme regardless of system settings
- **`dark`**: Forces dark theme regardless of system settings

**Customizing Appearance:**

```python
DashEmojiMart(
    id='custom-styled-picker',
    theme='dark',
    emojiButtonRadius='8px',        # Border radius for emoji buttons
    emojiButtonSize=40,              # Size of emoji buttons in pixels
    emojiSize=28,                    # Size of emojis within buttons
    emojiButtonColors=[              # Custom hover colors
        '#FF6B6B',
        '#4ECDC4',
        '#45B7D1'
    ]
)
```

---

### Internationalization

The picker supports 20+ languages for the user interface. Change the locale to display category names, search placeholder, and other UI text in different languages.

**Supported Locales:**

`en`, `ar`, `be`, `cs`, `de`, `es`, `fa`, `fi`, `fr`, `hi`, `it`, `ja`, `ko`, `nl`, `pl`, `pt`, `ru`, `sa`, `tr`, `uk`, `vi`, `zh`

**Example:**

```python
DashEmojiMart(
    id='emoji-picker-spanish',
    locale='es',
)
```

---

### Emoji Sets

Choose from multiple emoji sets to match your application's style or target platform.

**Available Sets:**

- **`native`**: Uses system emojis (most performant, recommended)
- **`apple`**: Apple emoji style
- **`google`**: Google emoji style
- **`twitter`**: Twitter emoji style (Twemoji)
- **`facebook`**: Facebook emoji style

```python
DashEmojiMart(
    id='emoji-picker-twitter',
    set='twitter',
)
```

**Note:** Non-native sets rely on sprite sheets and may have slower initial load times.

---

### Categories and Filtering

Control which emoji categories are displayed and exclude specific emojis.

**Customizing Categories:**

```python
DashEmojiMart(
    id='emoji-picker-filtered',
    categories=['people', 'nature', 'foods'],  # Only show these categories
    exceptEmojis=['eggplant', 'peach'],        # Exclude specific emojis
    maxFrequentRows=2,                          # Limit frequently used emojis
)
```

**Available Categories:**

`frequent`, `people`, `nature`, `foods`, `activity`, `places`, `objects`, `symbols`, `flags`

The order of categories in the array determines their display order.

---

### Layout Configuration

Customize the picker's layout including navigation position, preview position, and search bar placement.

```python
DashEmojiMart(
    id='emoji-picker-layout',
    navPosition='bottom',         # 'top', 'bottom', 'none'
    previewPosition='top',        # 'top', 'bottom', 'none'
    searchPosition='static',      # 'sticky', 'static', 'none'
    perLine=12,                   # Emojis per row
    dynamicWidth=True,            # Calculate perLine based on container width
)
```

---

### Component Properties

| Property              | Type                                | Default                                    | Description                                                                                                     |
| :-------------------- | :---------------------------------- | :----------------------------------------- |:----------------------------------------------------------------------------------------------------------------|
| **`id`**              | `string`                            | **Required**                               | Unique identifier for the component used in Dash callbacks.                                                     |
| `data`                | `dict`                              | `{}`                                       | Custom emoji data object to use for the picker.                                                                 |
| `i18n`                | `dict`                              | `{}`                                       | Localization data object for custom translations.                                                               |
| `categories`          | `list`                              | `[]`                                       | Categories to show in the picker. Order is respected. Options: frequent, people, nature, foods, activity, places, objects, symbols, flags. |
| `custom`              | `list`                              | `[]`                                       | Array of custom emoji category objects.                                                                         |
| `onEmojiSelect`       | `func`                              | `None`                                     | Callback function when an emoji is selected.                                                                    |
| `onClickOutside`      | `func`                              | `None`                                     | Callback function when a click outside the picker occurs.                                                       |
| `onAddCustomEmoji`    | `func`                              | `None`                                     | Callback when Add custom emoji button is clicked. Button only displays if this callback is provided.           |
| `autoFocus`           | `bool`                              | `False`                                    | If true, automatically focuses the search input on mount.                                                       |
| `categoryIcons`       | `dict`                              | `{}`                                       | Custom category icons object with category names as keys and icon definitions (svg or src) as values.          |
| `dynamicWidth`        | `bool`                              | `False`                                    | If true, calculates perLine dynamically based on container width. When enabled, perLine is ignored.             |
| `emojiButtonColors`   | `list`                              | `[]`                                       | Array of CSS colors for emoji button hover backgrounds (e.g., #f00, pink, rgba(155,223,88,.7)).                 |
| `emojiButtonRadius`   | `string`                            | `'100%'`                                   | Border radius of emoji buttons. Accepts CSS values (e.g., 6px, 1em, 100%).                                      |
| `emojiButtonSize`     | `number`                            | `36`                                       | Size of emoji buttons in pixels.                                                                                |
| `emojiSize`           | `number`                            | `24`                                       | Size of emojis (inside buttons) in pixels.                                                                      |
| `emojiVersion`        | `number`                            | `14`                                       | Emoji data version to use. Options: 1, 2, 3, 4, 5, 11, 12, 12.1, 13, 13.1, 14.                                  |
| `exceptEmojis`        | `list`                              | `[]`                                       | Array of emoji IDs to exclude from the picker.                                                                  |
| `icons`               | `'auto'`, `'outline'`, `'solid'`    | `'auto'`                                   | Icon style for the picker. Auto uses outline with light theme and solid with dark theme.                        |
| `locale`              | `string`                            | `'en'`                                     | Locale for UI text. Options: en, ar, be, cs, de, es, fa, fi, fr, hi, it, ja, ko, nl, pl, pt, ru, sa, tr, uk, vi, zh. |
| `maxFrequentRows`     | `number`                            | `4`                                        | Maximum number of frequently used emoji rows to show. Set to 0 to disable frequent category.                    |
| `navPosition`         | `'top'`, `'bottom'`, `'none'`       | `'top'`                                    | Position of the category navigation bar.                                                                        |
| `noCountryFlags`      | `bool`                              | `False`                                    | If true, hides country flag emojis. Automatically handled on Windows (which doesn't support flags).             |
| `noResultsEmoji`      | `string`                            | `'cry'`                                    | Emoji ID to display when search returns no results.                                                             |
| `perLine`             | `number`                            | `9`                                        | Number of emojis to display per line.                                                                           |
| `previewEmoji`        | `string`                            | `'point_up'`                               | Emoji ID to show in preview when not hovering. Defaults to point_up (bottom) or point_down (top).               |
| `previewPosition`     | `'top'`, `'bottom'`, `'none'`       | `'bottom'`                                 | Position of the emoji preview section.                                                                          |
| `searchPosition`      | `'sticky'`, `'static'`, `'none'`    | `'sticky'`                                 | Position of the search input. Sticky keeps it visible while scrolling.                                          |
| `set`                 | `'native'`, `'apple'`, `'facebook'`, `'google'`, `'twitter'` | `'native'` | Emoji set to use. Native is most performant, others use sprite sheets.                                          |
| `skin`                | `number`                            | `1`                                        | Default skin tone for emojis. Range: 1-6.                                                                       |
| `skinTonePosition`    | `'preview'`, `'search'`, `'none'`   | `'preview'`                                | Position of the skin tone selector.                                                                             |
| `theme`               | `'auto'`, `'light'`, `'dark'`       | `'auto'`                                   | Color theme of the picker. Auto matches system preference.                                                      |
| `getSpritesheetURL`   | `func`                              | `None`                                     | Function that returns the sprite sheet URL for non-native emoji sets.                                           |
| `value`               | `dict`                              | (read-only)                                | Selected emoji data object returned via callbacks.                                                              |
| `setProps`            | `func`                              | (Dash Internal)                            | Callback function to update component properties.                                                               |
| `loading_state`       | `object`                            | (Dash Internal)                            | Object describing the loading state of the component or its props.                                              |

---

### Contributing

Contributions to dash-emoji-mart are welcome! Please refer to the project's issues on GitHub for any feature requests or bug reports.

### License

This project is licensed under the MIT License.

---

<!-- /pip/dash_excalidraw — https://2plot.dev/pip/dash_excalidraw/llms.txt -->

> **Full documentation:** [https://excalidraw.2plot.dev](https://excalidraw.2plot.dev) — the dedicated dash-excalidraw documentation site, with the complete API reference and deeper examples. This page is the quick-start overview.




Features
The Excalidraw editor (pip dash package) supports:

- 💯 Free & open-source.
- 🎨 Infinite, canvas-based whiteboard.
- ✍️ Hand-drawn like style.
- 🌓 Dark mode.
- 🏗️ Customizable.
- 📷 Image support.
- 😀 Shape libraries support.
- 👅 Localization (i18n) support.
- 🖼️ Export to PNG, SVG & clipboard.
- 💾 Open format - export drawings as an .excalidraw json file.
- ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
- ➡️ Arrow-binding & labeled arrows.
- 🔙 Undo / Redo.
- 🔍 Zoom and panning support.
- 🚀 +Many more...

### Installation
[Visit GitHub Repo](https://github.com/pip-install-python/dash_excalidraw)
```bash
pip install dash-excalidraw
```

### What's New in 0.1.0

This release is a full rebuild on Excalidraw `0.18.x` with a **JSON-safe prop surface** — every prop round-trips cleanly through Dash callbacks. There are no function props, no React children, no RegExp values: just JSON.

Props now fall into three groups:

| Kind | Direction | Props |
|:-----|:----------|:------|
| **Declarative** | Python → canvas | `theme`, `viewModeEnabled`, `zenModeEnabled`, `gridModeEnabled`, `isCollaborating`, `UIOptions`, `validateEmbeddable`, `interceptLinkOpens`, `hideExcalidrawLinks`, `langCode`, `name` |
| **Event snapshots** (read-only) | canvas → Python | `elements`, `appState`, `files`, `serializedData`, `externalizedSerializedData`, `sceneVersion`, `lastPointerDown`, `lastPointerUp`, `lastPointerMove`, `lastScrollChange`, `lastPaste`, `lastLibraryChange`, `lastLinkOpen`, `lastExport`, `lastFileAdded`, `lastExternalDrop` |
| **Command dispatch** | Python → canvas (imperative) | `command` — write `{id, type, payload}` from a callback |

#### Migrating from 0.0.x

**Nothing was renamed and nothing was removed.** Every prop that existed in
0.0.4 still exists and still means the same thing — the old prop set is a
strict subset of today's. What changed is three **defaults**, so an app that
relied on the old ones has to set them explicitly now:

| Prop | Old default | New default |
|:-----|:------------|:------------|
| `isCollaborating` | `True` | `False` — opt in explicitly |
| `height` | `"400px"` | `"600px"` |
| `langCode` | unset | `"en"` |

**Not a migration step, for the record:** `validateEmbeddable`'s declared union
dropped its RegExp and function members, which JSON could never carry from
Python; `True`/`False` and a list of glob strings are unchanged and were
already valid in 0.0.x.

### Introduction



```python
# File: docs/dash_excalidraw/introduction.py

import dash_excalidraw
from dash import Dash, html, dcc, callback, Input, Output, State
import json
import dash_mantine_components as dmc
from dash_ace import DashAceEditor

# _dash_renderer._set_react_version("18.2.0")

class CustomJSONDecoder(json.JSONDecoder):
    def decode(self, s):
        result = super().decode(s)
        return self._decode(result)

    def _decode(self, o):
        if isinstance(o, bool):
            return o
        if isinstance(o, dict):
            return {k: self._decode(v) for k, v in o.items()}
        if isinstance(o, list):
            return [self._decode(v) for v in o]
        if o == "true":
            return True
        if o == "false":
            return False
        if o == "null":
            return None
        return o


def custom_pprint(obj, indent=2):
    def format_value(v):
        if isinstance(v, (dict, list)):
            return custom_pprint(v, indent)
        elif v is True:
            return 'True'
        elif v is False:
            return 'False'
        elif v is None:
            return 'None'
        else:
            return repr(v)

    if isinstance(obj, dict):
        items = [f"{' ' * indent}{repr(k)}: {format_value(v)}" for k, v in obj.items()]
        return "{\n" + ",\n".join(items) + "\n}"
    elif isinstance(obj, list):
        items = [f"{' ' * indent}{format_value(v)}" for v in obj]
        return "[\n" + ",\n".join(items) + "\n]"
    else:
        return repr(obj)


initialCanvasData = {}

component = html.Div([
dmc.Tabs(
    [
        dmc.TabsList(
            [
                dmc.TabsTab(
                    "Dash Excalidraw",
                    # leftSection=DashIconify(icon="tabler:message"),
                    value="dashecalidraw-component",
                    style={'font-size': '1.5rem', 'color': 'light-dark(rgb(28, 126, 214), rgb(116, 192, 252))'}
                ),
                dmc.TabsTab(
                    "DashExcalidraw .json Output",
                    # leftSection=DashIconify(icon="tabler:settings"),
                    value="canvas-output",
                    style={'font-size': '1.5rem', 'color': 'light-dark(rgb(28, 126, 214), rgb(116, 192, 252))'}
                ),
            ]
        ),
        dmc.TabsPanel(dash_excalidraw.DashExcalidraw(
        id='excalidraw',
        width='100%',
        height='65vh',
        initialData=initialCanvasData,
        # validateEmbeddable=False,
        # isCollaborating=False,
    ), value="dashecalidraw-component"),
        dmc.TabsPanel(html.Div([
            html.Div(id='number-of-elements'),
            html.Div(id='output')
        ]), value="canvas-output"),
    ],
    value="dashecalidraw-component",
),
    # dcc.Interval(id='interval', interval=1000)
]
)


@callback(
    Output('output', 'children'),
    Output('number-of-elements', 'children'),
    Input('excalidraw', 'serializedData'),
)
def display_output(serializedData):
    if not serializedData:
        return 'No elements drawn yet', 'Number of elements: 0'

    # Parse the serialized data with custom decoder
    data = json.loads(serializedData, cls=CustomJSONDecoder)

    # Count the number of elements
    num_elements = len(data.get('elements', []))

    # Use custom pretty-print function
    output = custom_pprint(data, indent=2)

    # Add a key to force re-rendering
    return DashAceEditor(
        id='dash-ace-editor',
        value=f'{output}',
        theme='monokai',
        mode='python',
        tabSize=2,
        enableBasicAutocompletion=True,
        enableLiveAutocompletion=True,
        autocompleter='/autocompleter?prefix=',
        placeholder='Python code ...',
        style={'height': '500px', 'width': '80vw'}
    ),  html.Label(f"Number of elements: {num_elements}")




```


### Simple Example



```python
# File: docs/dash_excalidraw/simple_example.py

from dash_excalidraw import DashExcalidraw
from dash import Dash, html, dcc, callback, Input, Output, State

initialCanvasData = {}

component = html.Div([
        DashExcalidraw(
        id='excalidraw-simple',
        width='100%',
        height='80vh',
        initialData=initialCanvasData,
    )
    ])
```


### Commands and the Export Round-Trip

Imperative actions — exporting, updating the scene, scrolling to content — go through the **command** prop rather than a JavaScript API object, so they stay drivable from Python. Write a dict to the `command` prop from a Python callback:

```python
@callback(Output("board", "command"), Input("update-btn", "n_clicks"),
          prevent_initial_call=True)
def send_command(_):
    return {
        "id": f"cmd-{uuid.uuid4()}",   # MUST be unique — drives de-duplication
        "type": "updateScene",          # see supported types below
        "payload": {...},               # shape depends on type
    }
```

Supported `type` values:

| Scene mutation | Async export (round-trips via `lastExport`) | Other |
|:---------------|:--------------------------------------------|:------|
| `updateScene` | `exportToSvg` | `setActiveTool` |
| `resetScene` | `exportToBlob` | `setToast` |
| `addFiles` | `exportToCanvas` | `toggleSidebar` |
| `replaceFiles` | | `updateLibrary` |
| `scrollToContent` | | |

Each dispatch is de-duplicated by `id`, and the component clears the prop once the action completes so React re-renders do not re-fire it.

Exports are **async**: dispatch the export command in one callback, then observe the result on the `lastExport` prop (`{timestamp, id, type, result, error?}`) in a *separate* callback. Always match on `id` or `type` — `lastExport` holds the result of *some* export, not necessarily your latest command.

Draw something below, then click **Export to SVG**:



```python
# File: docs/dash_excalidraw/export_roundtrip.py

"""Export round-trip example for dash-excalidraw 0.1.0.

Demonstrates the imperative `command` prop and the `lastExport` event:
a Python callback dispatches an `exportToSvg` (or `setActiveTool`)
command, and a second callback observes the async result on `lastExport`.
"""
import uuid

import dash_mantine_components as dmc
from dash import Input, Output, callback, ctx, html, no_update
from dash_excalidraw import DashExcalidraw

component = html.Div(
    [
        DashExcalidraw(
            id="excalidraw-export-canvas",
            width="100%",
            height="50vh",
            initialData={
                "elements": [],
                "appState": {},
                "scrollToContent": True,
            },
        ),
        dmc.Group(
            [
                dmc.Button(
                    "Export to SVG",
                    id="excalidraw-export-svg-btn",
                    color="blue",
                ),
                dmc.Button(
                    "Select Rectangle Tool",
                    id="excalidraw-export-tool-btn",
                    variant="light",
                    color="teal",
                ),
            ],
            mt="sm",
            mb="sm",
        ),
        html.Div(id="excalidraw-export-preview"),
    ]
)


@callback(
    Output("excalidraw-export-canvas", "command"),
    Input("excalidraw-export-svg-btn", "n_clicks"),
    Input("excalidraw-export-tool-btn", "n_clicks"),
    prevent_initial_call=True,
)
def dispatch_command(_svg_clicks, _tool_clicks):
    """Write a {id, type, payload} dict to `command` to act on the canvas.

    The `id` must be unique per dispatch — the component de-duplicates
    on it, then clears the prop once the action completes.
    """
    if ctx.triggered_id == "excalidraw-export-tool-btn":
        return {
            "id": f"tool-{uuid.uuid4()}",
            "type": "setActiveTool",
            "payload": {"type": "rectangle"},
        }
    return {
        "id": f"export-svg-{uuid.uuid4()}",
        "type": "exportToSvg",
        "payload": {"exportPadding": 20},
    }


@callback(
    Output("excalidraw-export-preview", "children"),
    Input("excalidraw-export-canvas", "lastExport"),
    prevent_initial_call=True,
)
def render_export(result):
    """Exports are async — observe them in a separate callback.

    Always match on `id`/`type`: `lastExport` is the result of *some*
    export, not necessarily the latest command you dispatched.
    """
    if not result or result.get("type") != "exportToSvg":
        return no_update
    if result.get("error"):
        return dmc.Alert(str(result["error"]), color="red", title="Export failed")
    return dmc.Paper(
        [
            dmc.Text("SVG export result:", size="sm", c="dimmed", mb="xs"),
            html.Iframe(
                srcDoc=result.get("result", ""),
                style={
                    "width": "100%",
                    "height": "300px",
                    "border": "none",
                    "background": "white",
                },
            ),
        ],
        withBorder=True,
        p="sm",
    )
```


### Initial Data

`initialData` seeds the scene on mount with `{elements, appState, files, libraryItems, scrollToContent}`. It is **mount-only** — updating the prop after render does nothing. To change the scene after mount, dispatch a `command` with `type="updateScene"`:

```python
@callback(
    Output("canvas", "command"),
    Input("restore-btn", "n_clicks"),
    State("store", "data"),
    prevent_initial_call=True,
)
def restore(_, snapshot):
    parsed = json.loads(snapshot)
    return {
        "id": f"restore-{uuid.uuid4()}",
        "type": "updateScene",
        "payload": {
            "elements": parsed.get("elements", []),
            "appState": parsed.get("appState", {}),
        },
    }
```

For persistence, stream `serializedData` into a `dcc.Store` — but guard against Excalidraw's mount-time empty scene (skip envelopes with no elements) or you'll clobber a valid snapshot on every page refresh. When the scene contains images, prefer persisting `externalizedSerializedData`, which strips inline base64 `data:` URIs.

### Theme and View Modes

`theme` (`"light"` / `"dark"`), `viewModeEnabled`, `zenModeEnabled`, and `gridModeEnabled` are plain declarative props — set them from any callback and the canvas reflects the change:

```python
# Follow your app's color scheme
clientside_callback(
    "function(scheme) { return scheme || 'light'; }",
    Output("canvas", "theme"),
    Input("color-scheme-store", "data"),
)
```

- `viewModeEnabled=True` — read-only canvas: drawing tools disabled, pan/zoom still available.
- `zenModeEnabled=True` — hides most of the chrome for a distraction-free canvas.
- `gridModeEnabled=True` — snap-to-grid plus grid background (now defaults to `False`).

### Component Properties

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| id | string | - | Unique ID to identify this component in Dash callbacks |
| width | string | `'100%'` | CSS width of the canvas container |
| height | string | `'600px'` | CSS height of the canvas container (Excalidraw fills its parent) |
| initialData | dict | - | Initial scene on mount: `{elements, appState, files, libraryItems, scrollToContent}`. Mount-only — use `command: updateScene` afterwards |
| command | dict | - | Imperative dispatch: `{id, type, payload}`. De-duplicated by unique `id`; cleared after the action completes |
| theme | `'light'` / `'dark'` | `'light'` | Canvas color theme |
| viewModeEnabled | boolean | `False` | View-only mode: disables drawing tools; pan/zoom still available |
| zenModeEnabled | boolean | `False` | Zen mode hides most of the chrome |
| gridModeEnabled | boolean | `False` | Snap to grid and draw the grid background |
| isCollaborating | boolean | `False` | Renders the collaborator UI; feed `appState.collaborators` yourself (no transport bundled) |
| UIOptions | dict | - | JSON-serializable subset of Excalidraw `UIOptions`: `canvasActions`, `tools.image`, `welcomeScreen`, `dockedSidebarBreakpoint` |
| validateEmbeddable | boolean or list of strings | - | `True` allow all, `False` deny all, or domain globs (e.g. `["*.youtube.com"]`) compiled to RegExps internally |
| interceptLinkOpens | boolean | `False` | Prevent default on link opens so Python can handle `lastLinkOpen` itself |
| hideExcalidrawLinks | boolean | `True` | Hides Excalidraw's built-in GitHub/Discord/Twitter menu group |
| langCode | string | `'en'` | UI language code (e.g. `en`, `fr-FR`, `zh-CN`) |
| name | string | - | Drawing name — appears in the top bar and export filenames |
| autoFocus | boolean | `True` | Focus the canvas on mount |
| detectScroll | boolean | `True` | Whether Excalidraw listens to wheel-scroll events on the canvas |
| handleKeyboardGlobally | boolean | `True` | Keyboard shortcuts work even when the canvas is not focused |
| libraryReturnUrl | string | - | Optional URL appended to the "Browse Library" button |
| pointerMoveThrottleMs | number | `50` | Debounce interval for `lastPointerMove` writes (ms) |
| scrollThrottleMs | number | `100` | Debounce interval for `lastScrollChange` writes (ms) |
| elements | list of dicts | - | Current element array (read-only from Python) |
| appState | dict | - | Full serializable app state (read-only from Python) |
| files | dict | - | Binary file entries: image id → `{dataURL, mimeType, ...}` (read-only) |
| serializedData | string | - | JSON string of the canonical Excalidraw envelope `{type, version, source, elements, appState, files}` |
| externalizedSerializedData | string | - | Same envelope with inline `data:` URIs stripped to `null` — persist this to avoid base64 bloat |
| sceneVersion | number | - | Monotonic scene version — cheap change detection without diffing elements |
| lastExport | dict | - | Result of the most recent export command: `{timestamp, id, type, result, error?}` |
| lastPointerDown | dict | - | `{timestamp, activeTool, pointer: {x, y}}` |
| lastPointerUp | dict | - | `{timestamp, activeTool, pointer: {x, y}}` |
| lastPointerMove | dict | - | Throttled `{timestamp, pointer, button, pointersMap}` |
| lastScrollChange | dict | - | Throttled `{timestamp, scrollX, scrollY}` |
| lastPaste | dict | - | `{timestamp, data}` snapshot of the last clipboard paste |
| lastLibraryChange | dict | - | `{timestamp, items}` snapshot of the last library change |
| lastLinkOpen | dict | - | `{timestamp, elementId, url}` — fired on Cmd/Ctrl-click of a linked element |
| lastFileAdded | dict | - | Fires when new files appear with inline `data:` dataURLs; batch under `files`, first file at top level |
| lastExternalDrop | dict | - | Fires on drops Excalidraw doesn't accept (non-image or multi-file): `{timestamp, files, dropPoint, placeholderIds}` |

### Helpers

The package also exports file-handling helpers for the image-externalization workflow:

```python
from dash_excalidraw import decode_data_url, strip_inline_files, restore_inline_files
```

- `decode_data_url(data_url)` — split a `data:` URI into `(mime, raw_bytes)` for uploading.
- `strip_inline_files(serialized)` — remove inline base64 from a serialized envelope.
- `restore_inline_files(serialized, files)` — rehydrate inline bytes into a stripped envelope.

---

<!-- /pip/dash_flex_layout — https://2plot.dev/pip/dash_flex_layout/llms.txt -->

> **Full documentation:** [https://flexlayout.2plot.dev](https://flexlayout.2plot.dev) — the dedicated flexlayout-dash documentation site, with the complete API reference and deeper examples. This page is the quick-start overview.



`flexlayout-dash` is a Dash component library that provides a flexible, dock-able layout system for building professional dashboard interfaces. It features resizable and draggable panels, floating windows with pop-out support, multiple layout types (horizontal, vertical, nested), collapsible sidebar borders, theme integration with Mantine, state persistence, and extensive customization options for creating IDE-style layouts, dashboards, and complex multi-panel applications.

### Installation

[Visit GitHub Repo](https://github.com/pip-install-python/flexlayout-dash)

⭐️ Star this component on GitHub! Stay up to date on new releases and browse the codebase.

```bash
pip install flexlayout-dash
```

**Requirements:**
- Python >= 3.7
- Dash >= 2.0
- dash-mantine-components (recommended for theming)

---

### Quick Start

Create a simple two-panel layout with resizable divider.



```python
# File: docs/flexlayout_dash/introduction.py

"""
Dash Flex Layout - Quick Start Example
======================================
A simple two-panel layout with resizable divider.
"""

import dash_mantine_components as dmc
import flexlayout_dash as dfl

# Define the layout model with 50/50 horizontal split
model = {
    "global": {
        "tabEnableClose": False,           # Prevent closing tabs
        "tabEnableRenderOnDemand": False,  # Keep all tabs mounted for callbacks
    },
    "layout": {
        "type": "row",  # Horizontal split
        "children": [
            {
                "type": "tabset",
                "weight": 50,  # 50% of width
                "children": [
                    {
                        "type": "tab",
                        "name": "Left Panel",
                        "id": "left-panel",
                    }
                ]
            },
            {
                "type": "tabset",
                "weight": 50,  # 50% of width
                "children": [
                    {
                        "type": "tab",
                        "name": "Right Panel",
                        "id": "right-panel",
                    }
                ]
            }
        ]
    }
}

# Create tab components with content
tabs = [
    dfl.Tab(
        id="left-panel",
        children=[
            dmc.Stack([
                dmc.Title("Welcome to Flex Layout", order=3),
                dmc.Text(
                    "This is a simple two-panel layout with a resizable divider. "
                    "Drag the divider in the middle to adjust panel sizes.",
                    size="sm"
                ),
                dmc.Badge("Left Panel", color="blue", size="lg", mt="md"),
            ], p="md")
        ]
    ),
    dfl.Tab(
        id="right-panel",
        children=[
            dmc.Stack([
                dmc.Title("Right Panel", order=3),
                dmc.Text(
                    "Panels automatically adjust their size based on the weight property. "
                    "Both panels have equal weight (50), so they split evenly.",
                    size="sm"
                ),
                dmc.Badge("Right Panel", color="green", size="lg", mt="md"),
            ], p="md")
        ]
    ),
]

# Main component for rendering
component = dmc.Stack([
    dmc.Alert(
        children="Try resizing the panels by dragging the divider!",
        title="Quick Start",
        color="blue",
    ),
    dmc.Box(
        style={"height": "400px", "width": "100%", "position": "relative"},
        children=dfl.DashFlexLayout(
            id='flex-layout-intro',
            model=model,
            children=tabs,
            style={"height": "100%", "width": "100%"},
            useStateForModel=True,
            supportsPopout=False,
        )
    )
], gap="md")
```


---

### Two-Panel Layouts

Split your interface horizontally or vertically using the weight system. The weight determines how much space each panel occupies relative to others.

```python
import flexlayout_dash as dfl

# 50/50 split
model = {
    "global": {"tabEnableClose": False, "tabEnableRenderOnDemand": False},
    "layout": {
        "type": "row",  # Horizontal split
        "children": [
            {"type": "tabset", "weight": 50, "children": [{"type": "tab", "name": "Left", "id": "left"}]},
            {"type": "tabset", "weight": 50, "children": [{"type": "tab", "name": "Right", "id": "right"}]}
        ]
    }
}

# Create tabs
tabs = [
    dfl.Tab(id="left", children=[...]),
    dfl.Tab(id="right", children=[...])
]

# Render layout
dfl.DashFlexLayout(
    id='layout',
    model=model,
    children=tabs,
    useStateForModel=True,
    supportsPopout=False,
)
```

**Weight System:**
- Weights are relative values (e.g., 70/30 creates a 70%-30% split)
- Total weights don't need to sum to 100
- Adjust weights to create custom proportions (e.g., 2:1 ratio = weights 66:33)

---

### Multiple Tabs

Organize multiple views within a single panel using tabs. Users can click tabs to switch between different content.



```python
# File: docs/flexlayout_dash/two_panel_layout.py

"""
Dash Flex Layout - Multiple Tabs Example
=========================================
Single tabset with multiple tabs for navigation.
"""

import dash_mantine_components as dmc
import flexlayout_dash as dfl
from dash_iconify import DashIconify

# Define the layout model with single tabset
model = {
    "global": {
        "tabEnableClose": False,
        "tabEnableFloat": True,            # Allow floating tabs
        "tabEnableRenderOnDemand": False,  # Keep all tabs mounted
    },
    "layout": {
        "type": "row",
        "children": [
            {
                "type": "tabset",
                "weight": 100,  # Full width
                "selected": 0,   # Initially selected tab (Overview)
                "children": [
                    {"type": "tab", "name": "Overview", "id": "tab-overview"},
                    {"type": "tab", "name": "Details", "id": "tab-details"},
                    {"type": "tab", "name": "Settings", "id": "tab-settings"}
                ]
            }
        ]
    }
}

# Create tab components
tabs = [
    dfl.Tab(
        id="tab-overview",
        children=[
            dmc.Card([
                dmc.Group([
                    DashIconify(icon="mdi:view-dashboard", width=32, color="blue"),
                    dmc.Title("Overview", order=3)
                ], gap="sm"),
                dmc.Text(
                    "This tab shows an overview of your data. Multiple tabs allow you to organize "
                    "different views within the same panel.",
                    size="sm",
                    mt="md"
                ),
                dmc.SimpleGrid([
                    dmc.Paper([
                        dmc.Text("Total Users", size="xs", c="dimmed"),
                        dmc.Title("1,234", order=2),
                    ], p="md", withBorder=True),
                    dmc.Paper([
                        dmc.Text("Active Now", size="xs", c="dimmed"),
                        dmc.Title("567", order=2),
                    ], p="md", withBorder=True),
                    dmc.Paper([
                        dmc.Text("Revenue", size="xs", c="dimmed"),
                        dmc.Title("$12.3k", order=2),
                    ], p="md", withBorder=True),
                ], cols=3, mt="xl")
            ], p="xl")
        ]
    ),
    dfl.Tab(
        id="tab-details",
        children=[
            dmc.Card([
                dmc.Group([
                    DashIconify(icon="mdi:information-outline", width=32, color="green"),
                    dmc.Title("Details", order=3)
                ], gap="sm"),
                dmc.Text(
                    "Detailed information and analytics are shown in this tab. "
                    "Switch between tabs to see different content.",
                    size="sm",
                    mt="md"
                ),
                dmc.Stack([
                    dmc.Text("• Flexible tab navigation", size="sm"),
                    dmc.Text("• Organize content into logical groups", size="sm"),
                    dmc.Text("• Easy to understand structure", size="sm"),
                    dmc.Text("• Customizable tab names and icons", size="sm"),
                ], gap="xs", mt="xl")
            ], p="xl")
        ]
    ),
    dfl.Tab(
        id="tab-settings",
        children=[
            dmc.Card([
                dmc.Group([
                    DashIconify(icon="mdi:cog-outline", width=32, color="orange"),
                    dmc.Title("Settings", order=3)
                ], gap="sm"),
                dmc.Text(
                    "Configuration and preferences go here. Each tab can contain "
                    "any Dash components you need.",
                    size="sm",
                    mt="md"
                ),
                dmc.Stack([
                    dmc.Switch(label="Enable notifications", checked=True, mt="lg"),
                    dmc.Switch(label="Auto-save changes", checked=False),
                    dmc.Switch(label="Dark mode", checked=False),
                ], gap="md", mt="xl")
            ], p="xl")
        ]
    ),
]

# Main component for rendering
component = dmc.Stack([
    dmc.Alert(
        children="Click on different tabs to navigate between views!",
        title="Multiple Tabs",
        color="teal",
    ),
    dmc.Box(
        style={"height": "450px", "width": "100%", "position": "relative"},
        children=dfl.DashFlexLayout(
            id='flex-layout-tabs',
            model=model,
            children=tabs,
            style={"height": "100%", "width": "100%"},
            useStateForModel=True,
            supportsPopout=False,
        )
    )
], gap="md")
```


**Tab Configuration:**
- Set `selected: 0` in tabset to choose initially selected tab (zero-indexed)
- Enable `tabEnableFloat: True` to allow tabs to be popped out into floating windows
- Each tab needs a unique `id` that matches a Tab component

---

### Vertical Layouts

Use `"type": "column"` to create vertical splits with top/bottom panels.

```python
model = {
    "global": {"tabEnableClose": False, "tabEnableRenderOnDemand": False},
    "layout": {
        "type": "column",  # Vertical split
        "children": [
            {"type": "tabset", "weight": 40, "children": [{"type": "tab", "name": "Top", "id": "top"}]},
            {"type": "tabset", "weight": 60, "children": [{"type": "tab", "name": "Bottom", "id": "bottom"}]}
        ]
    }
}
```

**Use Cases:**
- Dashboard with chart on top and data table below
- Editor with preview panel underneath
- Main content with footer panel

---

### Nested Layouts

Combine rows and columns to create complex multi-panel interfaces. Perfect for IDE-style layouts and advanced dashboards.



```python
# File: docs/flexlayout_dash/ide_layout.py

"""
Dash Flex Layout - Nested Layout Example
==========================================
Simple nested layout demonstrating rows within columns.
"""

import dash_mantine_components as dmc
import flexlayout_dash as dfl

# Define nested layout model (no borders for simplicity)
model = {
    "global": {
        "tabEnableClose": False,
        "tabEnableRenderOnDemand": False,
    },
    "layout": {
        "type": "row",
        "children": [
            {
                "type": "column",
                "weight": 70,
                "children": [
                    {
                        "type": "tabset",
                        "weight": 60,
                        "children": [
                            {"type": "tab", "name": "Editor", "id": "editor"}
                        ]
                    },
                    {
                        "type": "tabset",
                        "weight": 40,
                        "children": [
                            {"type": "tab", "name": "Terminal", "id": "terminal"}
                        ]
                    }
                ]
            },
            {
                "type": "tabset",
                "weight": 30,
                "children": [
                    {"type": "tab", "name": "Preview", "id": "preview"}
                ]
            }
        ]
    }
}

# Create tab components
tabs = [
    # Editor Tab (top left)
    dfl.Tab(
        id="editor",
        children=[
            dmc.Box(
                p="md",
                children=[
                    dmc.Title("Code Editor", order=4, mb="md"),
                    dmc.Code(
                        block=True,
                        children="""from dash import Dash
import dash_mantine_components as dmc

app = Dash(__name__)

app.layout = dmc.Container([
    dmc.Title("Hello Dash"),
    dmc.Text("Nested layouts!")
])

if __name__ == '__main__':
    app.run(debug=True)""",
                        style={
                            "fontSize": "13px",
                        }
                    )
                ]
            )
        ]
    ),
    # Terminal Tab (bottom left)
    dfl.Tab(
        id="terminal",
        children=[
            dmc.Box(
                p="md",
                children=[
                    dmc.Title("Terminal", order=4, mb="md"),
                    dmc.Code(
                        block=True,
                        children="""$ python app.py
Dash is running on http://127.0.0.1:8050/

 * Debug mode: on""",
                        style={
                            "fontSize": "13px",
                            "backgroundColor": "var(--mantine-color-dark-6)",
                            "color": "var(--mantine-color-green-4)",
                        }
                    )
                ]
            )
        ]
    ),
    # Preview Tab (right side)
    dfl.Tab(
        id="preview",
        children=[
            dmc.Box(
                p="md",
                children=[
                    dmc.Title("Live Preview", order=4, mb="md"),
                    dmc.Paper(
                        p="xl",
                        withBorder=True,
                        children=[
                            dmc.Title("Hello Dash", order=2, mb="xs"),
                            dmc.Text("Nested layouts!", c="dimmed")
                        ]
                    )
                ]
            )
        ]
    ),
]

# Main component for rendering
component = dmc.Stack(
    gap="md",
    children=[
        dmc.Alert(
            color="violet",
            variant="light",
            children=[
                dmc.Text("Nested Layouts", fw=700, mb="xs"),
                dmc.Text("Nested layout with a vertical split on the left (Editor/Terminal) and Preview panel on the right.")
            ]
        ),
        dmc.Box(
            style={"height": "500px", "width": "100%", "position": "relative"},
            children=dfl.DashFlexLayout(
                id='flex-layout-nested',
                model=model,
                children=tabs,
                style={"height": "100%", "width": "100%"},
                useStateForModel=True,
                supportsPopout=False,
            )
        )
    ]
)
```


**Nested Example:**
```python
model = {
    "layout": {
        "type": "row",
        "children": [
            {
                "type": "column",  # Nested vertical split
                "weight": 70,
                "children": [
                    {"type": "tabset", "weight": 60, "children": [...]},
                    {"type": "tabset", "weight": 40, "children": [...]}
                ]
            },
            {"type": "tabset", "weight": 30, "children": [...]}  # Right sidebar
        ]
    }
}
```

---

### Borders (Sidebars)

Create collapsible sidebars on the left, right, or bottom edges using borders. Perfect for navigation, tools, or secondary content.

```python
model = {
    "global": {...},
    "borders": [
        {
            "type": "border",
            "location": "left",  # "left", "right", or "bottom"
            "size": 250,         # Width in pixels
            "selected": 0,       # Initially selected tab
            "children": [
                {"type": "tab", "name": "Files", "id": "files"},
                {"type": "tab", "name": "Search", "id": "search"}
            ]
        }
    ],
    "layout": {
        "type": "row",
        "children": [...]  # Main content area
    }
}
```

**Border Features:**
- Click border button to expand/collapse
- Support left, right, and bottom positions (**Note**: top borders not supported)
- Can contain multiple tabs like regular tabsets
- Size in pixels (width for left/right, height for bottom)

---

### Theme Integration

Flex Layout automatically detects your app's color scheme from MantineProvider and adjusts colors accordingly.



```python
# File: docs/flexlayout_dash/theming_example.py

"""
Liquid Glass Themes Example for Dash Flex Layout
=================================================
Showcases Apple WWDC 2025 inspired liquid glass design with light/dark themes.
This example is completely isolated and won't affect other DashFlexLayout instances.
"""
from dash import callback, Input, Output
import dash_mantine_components as dmc
from dash_iconify import DashIconify
import flexlayout_dash as dfl

# Inline style definitions for glass effects
GLASS_CONTAINER_STYLE_DARK = {
    "background": "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)",
    "borderRadius": "16px",
    "padding": "4px",
    "position": "relative",
    "transition": "background 0.3s ease",
}

GLASS_CONTAINER_STYLE_LIGHT = {
    "background": "linear-gradient(135deg, #e8f4f8 0%, #d4e5f7 50%, #c1d5eb 100%)",
    "borderRadius": "16px",
    "padding": "4px",
    "position": "relative",
    "transition": "background 0.3s ease",
}

GLASS_PANEL_STYLE_DARK = {
    "backdropFilter": "blur(20px) saturate(180%)",
    "WebkitBackdropFilter": "blur(20px) saturate(180%)",
    "background": "linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.05) 100%)",
    "border": "1px solid rgba(255, 255, 255, 0.15)",
    "boxShadow": "0 8px 32px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1)",
    "borderRadius": "12px",
    "padding": "1rem",
    "height": "100%",
    "overflow": "auto",
}

GLASS_PANEL_STYLE_LIGHT = {
    "backdropFilter": "blur(20px) saturate(180%)",
    "WebkitBackdropFilter": "blur(20px) saturate(180%)",
    "background": "linear-gradient(135deg, rgba(255, 255, 255, 0.7) 0%, rgba(255, 255, 255, 0.5) 100%)",
    "border": "1px solid rgba(255, 255, 255, 0.5)",
    "boxShadow": "0 8px 32px rgba(31, 38, 135, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.8)",
    "borderRadius": "12px",
    "padding": "1rem",
    "height": "100%",
    "overflow": "auto",
}

FEATURE_CARD_STYLE_DARK = {
    "backdropFilter": "blur(16px) saturate(160%)",
    "WebkitBackdropFilter": "blur(16px) saturate(160%)",
    "background": "rgba(45, 55, 72, 0.6)",
    "border": "1px solid rgba(255, 255, 255, 0.1)",
    "borderRadius": "10px",
    "padding": "1rem",
    "marginBottom": "0.5rem",
}

FEATURE_CARD_STYLE_LIGHT = {
    "backdropFilter": "blur(16px) saturate(160%)",
    "WebkitBackdropFilter": "blur(16px) saturate(160%)",
    "background": "rgba(255, 255, 255, 0.7)",
    "border": "1px solid rgba(0, 0, 0, 0.05)",
    "borderRadius": "10px",
    "padding": "1rem",
    "marginBottom": "0.5rem",
}

# Define layout model
model = {
    "global": {
        "tabEnableClose": False,
        "tabEnableRenderOnDemand": False,
    },
    "layout": {
        "type": "row",
        "children": [
            {
                "type": "tabset",
                "weight": 30,
                "children": [
                    {"type": "tab", "name": "Overview", "id": "theme-overview"}
                ]
            },
            {
                "type": "tabset",
                "weight": 40,
                "children": [
                    {"type": "tab", "name": "Features", "id": "theme-features"}
                ]
            },
            {
                "type": "tabset",
                "weight": 30,
                "children": [
                    {"type": "tab", "name": "Metrics", "id": "theme-metrics"}
                ]
            }
        ]
    }
}


def create_overview_panel(theme="dark"):
    """Create overview panel with theme switcher."""
    glass_style = GLASS_PANEL_STYLE_DARK if theme == "dark" else GLASS_PANEL_STYLE_LIGHT

    return dmc.Box(
        id="theme-overview",
        style=glass_style,
        children=[
            dmc.Stack(
                gap="md",
                children=[
                    dmc.Group(
                        justify="space-between",
                        children=[
                            dmc.Group(
                                gap="sm",
                                children=[
                                    DashIconify(icon="mdi:palette-advanced", width=32, color="#4FC3F7"),
                                    dmc.Stack(
                                        gap=0,
                                        children=[
                                            dmc.Title("Liquid Glass", order=4),
                                            dmc.Text("Apple WWDC 2025", size="xs", c="dimmed"),
                                        ]
                                    ),
                                ]
                            ),
                            dmc.SegmentedControl(
                                id="flex-theme-switcher",
                                data=[
                                    {"value": "dark", "label": "🌙 Dark"},
                                    {"value": "light", "label": "☀️ Light"},
                                ],
                                value=theme,
                                color="blue",
                                radius="lg",
                                size="sm",
                            ),
                        ]
                    ),
                    dmc.Divider(opacity=0.2),
                    dmc.Text(
                        "Experience Apple's next-generation glassmorphism design with backdrop blur, translucency, and gradient overlays.",
                        size="sm",
                        c="dimmed",
                    ),
                    dmc.Group(
                        gap="xs",
                        mt="sm",
                        children=[
                            dmc.Badge("20px Blur", variant="dot", color="cyan", size="sm"),
                            dmc.Badge("180% Saturation", variant="dot", color="violet", size="sm"),
                            dmc.Badge("Glass Morphism", variant="dot", color="orange", size="sm"),
                        ]
                    ),
                ]
            )
        ]
    )


def create_features_panel(theme="dark"):
    """Create features showcase panel."""
    card_style = FEATURE_CARD_STYLE_DARK if theme == "dark" else FEATURE_CARD_STYLE_LIGHT

    return dmc.Box(
        id="theme-features",
        style=GLASS_PANEL_STYLE_DARK if theme == "dark" else GLASS_PANEL_STYLE_LIGHT,
        children=[
            dmc.Stack(
                gap="sm",
                children=[
                    dmc.Title("Key Features", order=5, mb="xs"),
                    dmc.Box(
                        style=card_style,
                        children=[
                            dmc.Group(
                                gap="sm",
                                mb="xs",
                                children=[
                                    DashIconify(icon="mdi:blur", width=18, color="#4FC3F7"),
                                    dmc.Text("Backdrop Blur", fw=600, size="sm"),
                                ]
                            ),
                            dmc.Text(
                                "Advanced blur effects with 20px radius and 180% saturation.",
                                size="xs",
                                c="dimmed",
                            ),
                        ]
                    ),
                    dmc.Box(
                        style=card_style,
                        children=[
                            dmc.Group(
                                gap="sm",
                                mb="xs",
                                children=[
                                    DashIconify(icon="mdi:opacity", width=18, color="#9C27B0"),
                                    dmc.Text("Translucency", fw=600, size="sm"),
                                ]
                            ),
                            dmc.Text(
                                "Semi-transparent gradients that adapt to light and dark modes.",
                                size="xs",
                                c="dimmed",
                            ),
                        ]
                    ),
                    dmc.Box(
                        style=card_style,
                        children=[
                            dmc.Group(
                                gap="sm",
                                mb="xs",
                                children=[
                                    DashIconify(icon="mdi:gradient-vertical", width=18, color="#FFC107"),
                                    dmc.Text("Liquid Borders", fw=600, size="sm"),
                                ]
                            ),
                            dmc.Text(
                                "Smooth gradient borders with animated effects.",
                                size="xs",
                                c="dimmed",
                            ),
                        ]
                    ),
                    dmc.Box(
                        style=card_style,
                        children=[
                            dmc.Group(
                                gap="sm",
                                mb="xs",
                                children=[
                                    DashIconify(icon="mdi:theme-light-dark", width=18, color="#00BCD4"),
                                    dmc.Text("Theme Aware", fw=600, size="sm"),
                                ]
                            ),
                            dmc.Text(
                                "Automatically adapts to light and dark color schemes.",
                                size="xs",
                                c="dimmed",
                            ),
                        ]
                    ),
                ]
            )
        ]
    )


def create_metrics_panel(theme="dark"):
    """Create metrics panel."""
    card_style = FEATURE_CARD_STYLE_DARK if theme == "dark" else FEATURE_CARD_STYLE_LIGHT

    return dmc.Box(
        id="theme-metrics",
        style=GLASS_PANEL_STYLE_DARK if theme == "dark" else GLASS_PANEL_STYLE_LIGHT,
        children=[
            dmc.Stack(
                gap="md",
                children=[
                    dmc.Title("Design Metrics", order=5),
                    dmc.Box(
                        style=card_style,
                        children=[
                            dmc.Group(
                                justify="space-between",
                                align="center",
                                children=[
                                    dmc.Stack(
                                        gap=0,
                                        children=[
                                            dmc.Text("Blur Radius", size="xs", c="dimmed"),
                                            dmc.Title("20px", order=4, c="cyan"),
                                        ]
                                    ),
                                    DashIconify(icon="mdi:blur-radial", width=28, color="#4FC3F7", style={"opacity": 0.6}),
                                ]
                            ),
                        ]
                    ),
                    dmc.Box(
                        style=card_style,
                        children=[
                            dmc.Group(
                                justify="space-between",
                                align="center",
                                children=[
                                    dmc.Stack(
                                        gap=0,
                                        children=[
                                            dmc.Text("Saturation", size="xs", c="dimmed"),
                                            dmc.Title("180%", order=4, c="violet"),
                                        ]
                                    ),
                                    DashIconify(icon="mdi:palette", width=28, color="#9C27B0", style={"opacity": 0.6}),
                                ]
                            ),
                        ]
                    ),
                    dmc.Box(
                        style=card_style,
                        children=[
                            dmc.Group(
                                justify="space-between",
                                align="center",
                                children=[
                                    dmc.Stack(
                                        gap=0,
                                        children=[
                                            dmc.Text("Opacity", size="xs", c="dimmed"),
                                            dmc.Title("65%", order=4, c="orange"),
                                        ]
                                    ),
                                    DashIconify(icon="mdi:opacity", width=28, color="#FFC107", style={"opacity": 0.6}),
                                ]
                            ),
                        ]
                    ),
                ]
            )
        ]
    )


# Create tabs with default dark theme
tabs_dark = [
    dfl.Tab(id="theme-overview", children=[create_overview_panel("dark")]),
    dfl.Tab(id="theme-features", children=[create_features_panel("dark")]),
    dfl.Tab(id="theme-metrics", children=[create_metrics_panel("dark")]),
]

# Main component
component = dmc.Stack(
    gap="md",
    children=[
        dmc.Alert(
            color="indigo",
            variant="light",
            children=[
                dmc.Text("Liquid Glass Theme Demo", fw=700, mb="xs"),
                dmc.Text("Toggle between light and dark themes to see the glassmorphism effect adapt dynamically!")
            ]
        ),
        dmc.Box(
            id="flex-glass-container",
            style=GLASS_CONTAINER_STYLE_DARK,
            children=[
                dmc.Box(
                    style={"height": "450px", "position": "relative"},
                    children=dfl.DashFlexLayout(
                        id='flex-layout-glass-theme',
                        model=model,
                        children=tabs_dark,
                        style={"height": "100%", "width": "100%"},
                        useStateForModel=True,
                        supportsPopout=False,
                    )
                )
            ]
        )
    ]
)


# Callback to switch themes
@callback(
    Output("flex-glass-container", "style"),
    Output("flex-layout-glass-theme", "children"),
    Input("flex-theme-switcher", "value"),
)
def switch_flex_glass_theme(theme_value):
    """Toggle between liquid glass light and dark themes."""
    if theme_value == "light":
        tabs = [
            dfl.Tab(id="theme-overview", children=[create_overview_panel("light")]),
            dfl.Tab(id="theme-features", children=[create_features_panel("light")]),
            dfl.Tab(id="theme-metrics", children=[create_metrics_panel("light")]),
        ]
        return GLASS_CONTAINER_STYLE_LIGHT, tabs
    else:
        tabs = [
            dfl.Tab(id="theme-overview", children=[create_overview_panel("dark")]),
            dfl.Tab(id="theme-features", children=[create_features_panel("dark")]),
            dfl.Tab(id="theme-metrics", children=[create_metrics_panel("dark")]),
        ]
        return GLASS_CONTAINER_STYLE_DARK, tabs
```


**Theme Options:**
- **Automatic Detection**: Component reads theme from MantineProvider context
- **Manual Override**: Use `colorScheme="light"` or `colorScheme="dark"` prop
- **Liquid Glass Themes**: Available via custom CSS (see assets/flexlayout-dock.css)

**Custom CSS:**
```css
/* Apply custom styles to flexlayout */
:root[data-mantine-color-scheme="light"] .flexlayout__layout {
    background: rgba(255, 255, 255, 0.95);
    backdrop-filter: blur(10px);
}

:root[data-mantine-color-scheme="dark"] .flexlayout__layout {
    background: rgba(30, 30, 35, 0.95);
    backdrop-filter: blur(10px);
}
```

---

### Interactive Callbacks

Use callbacks to create dynamic, interactive layouts with real-time updates.



```python
# File: docs/flexlayout_dash/callbacks_example.py

"""
Dash Flex Layout - Interactive Callbacks Example
=================================================
Demonstrates callbacks and dynamic content updates.
"""

from dash import callback, Input, Output, dcc
import dash_mantine_components as dmc
import flexlayout_dash as dfl
import json

# Define 3-panel layout model
model = {
    "global": {
        "tabEnableClose": False,
        "tabEnableRenderOnDemand": False,  # CRITICAL for callbacks!
    },
    "layout": {
        "type": "row",
        "children": [
            {
                "type": "tabset",
                "weight": 30,
                "children": [
                    {"type": "tab", "name": "Controls", "id": "controls-panel"}
                ]
            },
            {
                "type": "tabset",
                "weight": 40,
                "children": [
                    {"type": "tab", "name": "Output", "id": "output-panel"}
                ]
            },
            {
                "type": "tabset",
                "weight": 30,
                "children": [
                    {"type": "tab", "name": "Data", "id": "data-panel"}
                ]
            }
        ]
    }
}

# Create tab components
tabs = [
    dfl.Tab(
        id="controls-panel",
        children=[
            dmc.Stack(
                p="md",
                gap="md",
                children=[
                    dmc.Title("Controls", order=4),
                    dmc.Select(
                        id="value-select",
                        label="Select a value:",
                        data=[
                            {"label": "Option 1", "value": "opt1"},
                            {"label": "Option 2", "value": "opt2"},
                            {"label": "Option 3", "value": "opt3"},
                        ],
                        value="opt1"
                    ),
                    dmc.NumberInput(
                        id="number-input",
                        label="Enter a number:",
                        value=5,
                        min=1,
                        max=10,
                    ),
                    dmc.Button(
                        "Update Output",
                        id="update-button",
                        fullWidth=True,
                        color="blue"
                    ),
                ]
            )
        ]
    ),
    dfl.Tab(
        id="output-panel",
        children=[
            dmc.Stack(
                p="md",
                gap="md",
                children=[
                    dmc.Title("Output", order=4),
                    dmc.Paper(
                        id="output-container",
                        p="md",
                        withBorder=True,
                        style={"minHeight": "200px"},
                        children=[
                            dmc.Center(
                                p="xl",
                                children=dmc.Text("Click 'Update Output' to see results here", c="dimmed")
                            )
                        ]
                    ),
                ]
            )
        ]
    ),
    dfl.Tab(
        id="data-panel",
        children=[
            dmc.Stack(
                p="md",
                gap="md",
                children=[
                    dmc.Title("Data Preview", order=4),
                    dmc.Code(
                        id="data-display",
                        block=True,
                        children="Click 'Update Output' to see data here",
                        style={
                            "fontSize": "12px",
                            "minHeight": "200px"
                        }
                    ),
                ]
            )
        ]
    ),
]


# Callback to generate data and update output
@callback(
    Output("output-container", "children"),
    Output("data-display", "children"),
    Input("update-button", "n_clicks"),
    Input("number-input", "value"),
    Input("value-select", "value"),
)
def update_output(n_clicks, number, selected_value):
    # Generate output data
    data = {
        "selected": selected_value,
        "number": number,
        "result": number * 10
    }

    # Create output display
    output = dmc.Stack(
        p="md",
        gap="sm",
        children=[
            dmc.Title(f"Result: {data['result']}", order=3, c="blue"),
            dmc.Text(f"You selected: {selected_value}"),
            dmc.Text(f"Number entered: {number}"),
            dmc.Text(f"Calculation: {number} × 10 = {data['result']}"),
        ]
    )

    # Format data display
    data_json = json.dumps(data, indent=2)

    return output, data_json


# Main component for rendering
component = dmc.Stack(
    gap="md",
    children=[
        dmc.Alert(
            color="grape",
            variant="light",
            children=[
                dmc.Text("Interactive Callbacks", fw=700, mb="xs"),
                dmc.Text("Use the controls to update the output and data preview. Callbacks work seamlessly with FlexLayout!")
            ]
        ),
        dmc.Box(
            style={"height": "500px", "position": "relative"},
            children=dfl.DashFlexLayout(
                id='flex-layout-callbacks',
                model=model,
                children=tabs,
                style={"height": "100%", "width": "100%"},
                useStateForModel=True,
                supportsPopout=False,
            )
        )
    ]
)
```


**CRITICAL: Set `tabEnableRenderOnDemand: False`**

```python
model = {
    "global": {
        "tabEnableRenderOnDemand": False,  # Required for callbacks!
    },
    # ...the rest of the model (layout, borders)...
}
```

Without this setting, tabs are unmounted when not visible, causing callbacks to fail.

**Callback Best Practices:**
- Use `useStateForModel=True` to avoid excessive callback triggers
- Define all chart/component types upfront, toggle visibility with styles
- Don't dynamically replace `children` in callbacks—update props instead
- Read layout state via `Input('layout-id', 'model')` if needed

---

### Model Configuration

The `model` prop defines the entire layout structure using a JSON-like dictionary.

**Global Settings:**
```python
model = {
    "global": {
        "tabEnableClose": False,           # Allow closing tabs
        "tabEnableFloat": True,            # Allow floating tabs as windows
        "tabEnableRenderOnDemand": False,  # CRITICAL: Keep all tabs mounted for callbacks
        "tabEnableMaximize": True,         # Allow maximizing tabsets
        "tabEnableDrag": True,             # Allow dragging tabs
    },
}
```

**Layout Structure:**
```python
model = {
    "layout": {
        "type": "row",                 # "row" (horizontal) or "column" (vertical)
        "weight": 100,                 # Relative size percentage
        "children": [...]              # Array of tabsets or nested layouts
    },
}
```

**Tabset:**
```python
{
    "type": "tabset",
    "weight": 50,                      # 50% of parent width/height
    "selected": 0,                     # Initially selected tab index
    "children": [...]                  # Array of tab objects
}
```

**Tab:**
```python
{
    "type": "tab",
    "name": "Tab Name",                # Display name
    "id": "unique-id",                 # Must match Tab component id
    "enableClose": True,               # Override global setting
}
```

---

### Component Properties

#### DashFlexLayout Properties

| Property | Type | Default | Description |
| :---------- | :---------- | :---------- | :---------- |
| **`id`** | `string` | **Required** | Unique identifier for the component used in Dash callbacks. |
| **`model`** | `dict` | **Required** | Layout configuration object defining structure, global settings, and borders. |
| **`children`** | `list[Tab]` | **Required** | List of Tab components. Each Tab id must match a tab id in the model. |
| `headers` | `dict` | `None` | Custom header components for tabs. Keys are tab IDs, values are Dash components. |
| `useStateForModel` | `bool` | `False` | Use internal state management for model. Recommended: `True` to avoid excessive callbacks. |
| `colorScheme` | `string` | Auto-detect | Force color scheme: `"light"` or `"dark"`. Auto-detects from MantineProvider if not set. |
| `supportsPopout` | `bool` | `True` | Enable pop-out windows for floating tabs. Set to `False` to disable. |
| `popoutURL` | `string` | `"/assets/popout.html"` | URL for pop-out window HTML template. |
| `realtimeResize` | `bool` | `False` | Resize panels in real-time during drag (performance impact). |
| `debugMode` | `bool` | `False` | Enable debug logging to console. |
| `style` | `dict` | `None` | CSS styles for the container element. |
| `font` | `string` | `None` | Custom font family for layout UI. |
| `loading_state` | `object` | (Dash Internal) | Object describing the loading state of the component. |

#### Tab Properties

| Property | Type | Default | Description |
| :---------- | :---------- | :---------- | :---------- |
| **`id`** | `string` | **Required** | Unique identifier that must match a tab id in the model configuration. |
| **`children`** | `list` | **Required** | Dash components to render inside the tab panel. |

---

### Best Practices

**For Callbacks:**
- Always set `tabEnableRenderOnDemand: False` in global config
- Use `useStateForModel=True` to avoid excessive callback triggers
- Define all chart types upfront, toggle visibility with styles
- Don't dynamically replace children in callbacks

**For Layout Design:**
- Use weight system for responsive sizing (e.g., 70/30 splits)
- Keep nested layouts shallow (max 2-3 levels deep)
- Test mobile responsiveness with smaller weights
- Use borders for collapsible sidebars

**For Theming:**
- Let component auto-detect Mantine theme when possible
- Use `colorScheme` prop only to force specific theme
- Apply custom CSS to `.flexlayout__layout` class
- Test both light and dark modes

---

### Troubleshooting

**Callbacks Not Firing:**
- Ensure `tabEnableRenderOnDemand: False` in global config
- Verify tab IDs match between model and Tab components

**Layout Not Filling Container:**
- Set explicit height on parent container
- Example: `style={"height": "100vh"}` or `h="500px"`

**Tabs Not Appearing:**
- Check that Tab component `id` matches tab `id` in model
- Verify children list includes all Tab components

**Theme Not Applying:**
- Ensure MantineProvider wraps the layout
- Check that colorScheme prop matches theme

**Pop-out Windows Not Working:**
- Verify `supportsPopout=True` (default)
- Check that popoutURL points to valid HTML file
- Ensure HTML file has proper window communication setup

---

### Contributing

Contributions to flexlayout-dash are welcome! Please refer to the project's issues on GitHub for any feature requests or bug reports.

### License

This project is licensed under the MIT License.

---

<!-- /pip/dash_flows — https://2plot.dev/pip/dash_flows/llms.txt -->

> **Full documentation:** [https://flows.2plot.dev](https://flows.2plot.dev) — the dedicated dash-flows documentation site, with the complete API reference and deeper examples. This page is the quick-start overview.



### Overview

**Dash Flows** is a powerful React Flow 12.3.5+ integration for Plotly Dash that enables developers to create interactive, node-based flow diagrams and visual workflows. Build data pipelines, process flows, org charts, and complex graph visualizations with ease.

#### Key Features

| Feature | Description |
|---------|-------------|
| **6 Node Types** | Input, Output, Default, Group, Toolbar, Resizable, Circle |
| **7 Edge Types** | Bezier, Straight, Step, SmoothStep, Button, Data, AnimatedSVG |
| **Custom Icons** | DashIconify integration with flexible layouts |
| **Auto Layouts** | ELK.js algorithms (layered, force, radial, stress) |
| **Rich Callbacks** | Click, double-click, hover, context menu, selection events |
| **Theming** | Glass, solid, minimal presets with 6 color schemes |
| **Dark Mode** | Full support via Mantine integration |

#### Installation

[Visit GitHub Repo](https://github.com/pip-install-python/dash-flows) · [PyPI](https://pypi.org/project/dash-flows/)

```bash
pip install dash-flows
```

**Optional dependencies for enhanced features:**
```bash
pip install dash-iconify          # For custom icons
pip install dash-mantine-components  # For theming & UI
```

---

### Quick Start

Get started with a minimal working example. The key requirements are:
- Define **nodes** with `id`, `type`, `data`, and `position`
- Define **edges** with `id`, `source`, and `target`
- Set an explicit **height** on the component



```python
# File: docs/dash_flows/introduction.py

"""
Dash Flows - Quick Start Introduction
=====================================
A minimal working example to get started with dash-flows.
"""

import dash_mantine_components as dmc
from dash import html
import dash_flows

# Define a simple 3-node flow
nodes = [
    {
        "id": "start",
        "type": "input",
        "data": {"label": "Start", "sublabel": "Entry point"},
        "position": {"x": 50, "y": 50},
    },
    {
        "id": "process",
        "type": "default",
        "data": {"label": "Process", "sublabel": "Transform data"},
        "position": {"x": 50, "y": 150},
    },
    {
        "id": "end",
        "type": "output",
        "data": {"label": "End", "sublabel": "Output result"},
        "position": {"x": 50, "y": 250},
    },
]

# Connect nodes with edges
edges = [
    {"id": "e1", "source": "start", "target": "process", "animated": True},
    {"id": "e2", "source": "process", "target": "end", "animated": True},
]

component = dmc.Paper(
    [
        dmc.Text(
            "A minimal flow diagram with input, process, and output nodes.",
            c="dimmed",
            size="sm",
            mb="md",
        ),
        dash_flows.DashFlows(
            id="dash-flows-intro",
            nodes=nodes,
            edges=edges,
            style={"height": "350px"},
            fitView=True,
            showControls=True,
            showMiniMap=True,
        ),
        dmc.Alert(
            [
                dmc.Text("Key requirements:", size="sm", fw=500),
                dmc.List(
                    [
                        dmc.ListItem("Each node needs: id, type, data, position"),
                        dmc.ListItem("Each edge needs: id, source, target"),
                        dmc.ListItem("Container must have explicit height"),
                    ],
                    size="sm",
                ),
            ],
            color="blue",
            mt="md",
        ),
    ],
    p="md",
    withBorder=True,
    radius="md",
)
```


---

### Basic Concepts

Understanding the fundamental building blocks of Dash Flows:

#### Node Structure

Every node requires these properties:

```python
{
    "id": "unique-id",           # Required: Unique identifier
    "type": "default",           # Required: Node type
    "data": {"label": "Text"},   # Required: Node content
    "position": {"x": 100, "y": 50}  # Required: Initial position
}
```

#### Edge Structure

Edges connect nodes together:

```python
{
    "id": "edge-1",              # Required: Unique identifier
    "source": "node-1",          # Required: Source node ID
    "target": "node-2",          # Required: Target node ID
    "animated": True,            # Optional: Animated dashed line
    "label": "Step 1",           # Optional: Edge label
    "type": "smoothstep"         # Optional: Edge style
}
```



```python
# File: docs/dash_flows/basic_nodes_edges.py

"""
Dash Flows - Basic Nodes and Edges
==================================
Understanding the fundamental building blocks of flow diagrams.
"""

import dash_mantine_components as dmc
from dash import html
import dash_flows

# Nodes with detailed structure
nodes = [
    {
        "id": "node-1",
        "type": "default",
        "data": {"label": "Start Node"},
        "position": {"x": 100, "y": 50},
    },
    {
        "id": "node-2",
        "type": "default",
        "data": {"label": "Process A"},
        "position": {"x": 50, "y": 150},
    },
    {
        "id": "node-3",
        "type": "default",
        "data": {"label": "Process B"},
        "position": {"x": 200, "y": 150},
    },
    {
        "id": "node-4",
        "type": "default",
        "data": {"label": "End Node"},
        "position": {"x": 125, "y": 250},
    },
]

# Various edge configurations
edges = [
    {
        "id": "e1-2",
        "source": "node-1",
        "target": "node-2",
        "animated": True,  # Animated dashed line
    },
    {
        "id": "e1-3",
        "source": "node-1",
        "target": "node-3",
    },
    {
        "id": "e2-4",
        "source": "node-2",
        "target": "node-4",
        "label": "Step 1",  # Edge with label
    },
    {
        "id": "e3-4",
        "source": "node-3",
        "target": "node-4",
        "label": "Step 2",
    },
]

component = dmc.Paper(
    [
        dmc.Text(
            "Node and edge structure demonstration with labels and animations.",
            c="dimmed",
            size="sm",
            mb="md",
        ),
        dmc.SimpleGrid(
            cols={"base": 1, "md": 2},
            spacing="md",
            mb="md",
            children=[
                dmc.Paper(
                    [
                        dmc.Text("Node Structure", fw=600, size="sm", mb="xs"),
                        dmc.Code(
                            block=True,
                            children="""{
    "id": "unique-id",
    "type": "default",
    "data": {"label": "Text"},
    "position": {"x": 100, "y": 50}
}""",
                        ),
                    ],
                    p="sm",
                    withBorder=True,
                ),
                dmc.Paper(
                    [
                        dmc.Text("Edge Structure", fw=600, size="sm", mb="xs"),
                        dmc.Code(
                            block=True,
                            children="""{
    "id": "edge-1",
    "source": "node-1",
    "target": "node-2",
    "animated": True,
    "label": "Step 1"
}""",
                        ),
                    ],
                    p="sm",
                    withBorder=True,
                ),
            ],
        ),
        dash_flows.DashFlows(
            id="dash-flows-basic",
            nodes=nodes,
            edges=edges,
            style={"height": "350px"},
            fitView=True,
            showControls=True,
            showMiniMap=True,
        ),
    ],
    p="md",
    withBorder=True,
    radius="md",
)
```


---

### Node Types

Dash Flows provides 6 built-in node types, each designed for specific use cases:

| Type | Description | Handles |
|------|-------------|---------|
| `input` | Entry point nodes (green accent) | Source only |
| `output` | Exit point nodes (purple accent) | Target only |
| `default` | Standard processing nodes | Both |
| `group` | Container for child nodes | None (container) |
| `toolbar` | Nodes with floating action toolbar | Both |
| `resizable` | User-resizable nodes | Configurable |
| `circle` | Small animated circular indicators | Both |

---

### Edge Types

Connect nodes with various edge styles:

| Type | Description |
|------|-------------|
| `default` / `bezier` | Smooth curved connections |
| `straight` | Direct line connections |
| `step` | Right-angle with sharp corners |
| `smoothstep` | Right-angle with rounded corners |
| `button` | Edge with interactive delete button |
| `data` | Edge displaying data labels inline |
| `animatedsvg` | Flowing animated shapes |



```python
# File: docs/dash_flows/edge_types.py

"""
Dash Flows - Edge Types
=======================
Demonstrates all available edge connection styles.
"""

import dash_mantine_components as dmc
from dash import html
import dash_flows

# Create nodes for demonstrating different edge types
nodes = [
    # Row 1 - Connection style demos
    {"id": "n1", "type": "default", "data": {"label": "Bezier"}, "position": {"x": 50, "y": 50}},
    {"id": "n2", "type": "default", "data": {"label": "Target"}, "position": {"x": 200, "y": 50}},

    {"id": "n3", "type": "default", "data": {"label": "Straight"}, "position": {"x": 50, "y": 120}},
    {"id": "n4", "type": "default", "data": {"label": "Target"}, "position": {"x": 200, "y": 120}},

    {"id": "n5", "type": "default", "data": {"label": "Step"}, "position": {"x": 50, "y": 190}},
    {"id": "n6", "type": "default", "data": {"label": "Target"}, "position": {"x": 200, "y": 190}},

    {"id": "n7", "type": "default", "data": {"label": "SmoothStep"}, "position": {"x": 50, "y": 260}},
    {"id": "n8", "type": "default", "data": {"label": "Target"}, "position": {"x": 200, "y": 260}},

    # Row 2 - Special edge types
    {"id": "n9", "type": "default", "data": {"label": "Button Edge"}, "position": {"x": 350, "y": 50}},
    {"id": "n10", "type": "default", "data": {"label": "Target"}, "position": {"x": 500, "y": 50}},

    {"id": "n11", "type": "default", "data": {"label": "Data Edge"}, "position": {"x": 350, "y": 140}},
    {"id": "n12", "type": "default", "data": {"label": "Target"}, "position": {"x": 500, "y": 140}},

    {"id": "n13", "type": "default", "data": {"label": "Animated"}, "position": {"x": 350, "y": 230}},
    {"id": "n14", "type": "default", "data": {"label": "Target"}, "position": {"x": 500, "y": 230}},
]

edges = [
    # Standard connection styles
    {
        "id": "e-bezier",
        "source": "n1",
        "target": "n2",
        "type": "default",  # or "bezier" - smooth curve
        "label": "default",
    },
    {
        "id": "e-straight",
        "source": "n3",
        "target": "n4",
        "type": "straight",  # Direct line
        "label": "straight",
    },
    {
        "id": "e-step",
        "source": "n5",
        "target": "n6",
        "type": "step",  # Right angles, sharp corners
        "label": "step",
    },
    {
        "id": "e-smoothstep",
        "source": "n7",
        "target": "n8",
        "type": "smoothstep",  # Right angles, rounded corners
        "label": "smoothstep",
    },
    # Special edge types
    {
        "id": "e-button",
        "source": "n9",
        "target": "n10",
        "type": "button",  # Has delete button
    },
    {
        "id": "e-data",
        "source": "n11",
        "target": "n12",
        "type": "data",  # Shows data inline
        "data": {"label": "42 items"},
    },
    {
        "id": "e-animated",
        "source": "n13",
        "target": "n14",
        "animated": True,  # Animated dashed line
        "style": {"stroke": "#10b981"},
    },
]

component = dmc.Paper(
    [
        dmc.Text(
            "Different edge styles for connecting nodes.",
            c="dimmed",
            size="sm",
            mb="md",
        ),
        dash_flows.DashFlows(
            id="dash-flows-edge-types",
            nodes=nodes,
            edges=edges,
            style={"height": "380px"},
            fitView=True,
            showControls=True,
            showMiniMap=False,
        ),
        dmc.Table(
            data={
                "head": ["Edge Type", "Description", "Use Case"],
                "body": [
                    ["default/bezier", "Smooth curved line", "General connections"],
                    ["straight", "Direct line", "Simple relationships"],
                    ["step", "Right angles, sharp", "Flowcharts"],
                    ["smoothstep", "Right angles, rounded", "Modern flowcharts"],
                    ["button", "Has delete button", "Editable flows"],
                    ["data", "Shows inline data", "Data pipelines"],
                    ["animated", "Animated dashes", "Active/processing"],
                ],
            },
            striped=True,
            mt="md",
        ),
    ],
    p="md",
    withBorder=True,
    radius="md",
)
```


---

### Custom Icons

Enhance your nodes with custom icons using **DashIconify**. Supports flexible layouts and content-aware sizing.

#### Icon Props

| Prop | Type | Description |
|------|------|-------------|
| `icon` | DashIconify | Icon component to display |
| `iconColor` | str | Background color for icon container |
| `showIcon` | bool | Toggle icon visibility |
| `layout` | str | `"stacked"` (vertical) or `"horizontal"` (two-column) |

#### Content Modes

- **Full Content**: Icon + text = standard layout
- **Icon Only**: Compact square sizing
- **Text Only**: Centered without reserved icon space



```python
# File: docs/dash_flows/custom_icons.py

"""
Dash Flows - Custom Icons
=========================
Using DashIconify for custom node icons with flexible layouts.
"""

import dash_mantine_components as dmc
from dash import html
from dash_iconify import DashIconify
import dash_flows

# Nodes demonstrating icon features
nodes = [
    # Full content with stacked layout (icon above text)
    {
        "id": "db-node",
        "type": "input",
        "data": {
            "icon": DashIconify(icon="mdi:database", width=20, color="white"),
            "label": "Database",
            "body": "PostgreSQL",
            "layout": "stacked",
        },
        "position": {"x": 50, "y": 50},
    },
    # Horizontal layout (icon left, text right)
    {
        "id": "process-node",
        "type": "default",
        "data": {
            "icon": DashIconify(icon="mdi:cog", width=20, color="white"),
            "label": "Transform",
            "body": "Clean data",
            "layout": "horizontal",
        },
        "position": {"x": 50, "y": 150},
    },
    # Icon-only node (compact)
    {
        "id": "icon-only",
        "type": "default",
        "data": {
            "icon": DashIconify(icon="mdi:lightning-bolt", width=24, color="white"),
            "iconColor": "#f59e0b",
            "showIcon": True,
        },
        "position": {"x": 220, "y": 50},
    },
    # Text-only node (centered)
    {
        "id": "text-only",
        "type": "default",
        "data": {
            "label": "Validate",
            "sublabel": "Quality check",
            "showIcon": False,
        },
        "position": {"x": 220, "y": 150},
    },
    # Output with horizontal layout
    {
        "id": "output-node",
        "type": "output",
        "data": {
            "icon": DashIconify(icon="mdi:chart-bar", width=20, color="white"),
            "label": "Dashboard",
            "body": "Visualization",
            "layout": "horizontal",
        },
        "position": {"x": 130, "y": 260},
    },
]

edges = [
    {"id": "e1", "source": "db-node", "target": "process-node", "animated": True},
    {"id": "e2", "source": "icon-only", "target": "text-only", "animated": True},
]

component = dmc.Paper(
    [
        dmc.Text(
            "Custom icons with DashIconify and flexible layout options.",
            c="dimmed",
            size="sm",
            mb="md",
        ),
        dmc.Group(
            [
                dmc.Badge("Stacked Layout", color="blue", variant="light", size="sm"),
                dmc.Badge("Horizontal Layout", color="green", variant="light", size="sm"),
                dmc.Badge("Icon Only", color="orange", variant="light", size="sm"),
                dmc.Badge("Text Only", color="cyan", variant="light", size="sm"),
            ],
            gap="xs",
            mb="md",
        ),
        dash_flows.DashFlows(
            id="dash-flows-icons",
            nodes=nodes,
            edges=edges,
            style={"height": "380px"},
            fitView=True,
            showControls=True,
            showMiniMap=True,
        ),
        dmc.Accordion(
            [
                dmc.AccordionItem(
                    [
                        dmc.AccordionControl("Icon Props Reference"),
                        dmc.AccordionPanel(
                            dmc.Table(
                                data={
                                    "head": ["Prop", "Type", "Description"],
                                    "body": [
                                        ["icon", "DashIconify", "Icon component to display"],
                                        ["iconColor", "str", "Background color for icon"],
                                        ["showIcon", "bool", "Toggle icon visibility"],
                                        ["layout", "str", "'stacked' or 'horizontal'"],
                                    ],
                                },
                                striped=True,
                            )
                        ),
                    ],
                    value="props",
                ),
                dmc.AccordionItem(
                    [
                        dmc.AccordionControl("Content Modes"),
                        dmc.AccordionPanel(
                            dmc.List(
                                [
                                    dmc.ListItem("Full Content: Icon + text = standard sizing"),
                                    dmc.ListItem("Icon Only: Compact square (no label)"),
                                    dmc.ListItem("Text Only: Centered without icon space"),
                                ],
                                size="sm",
                            )
                        ),
                    ],
                    value="modes",
                ),
            ],
            mt="md",
        ),
        dmc.Alert(
            [
                dmc.Text("Browse icons at ", size="sm", span=True),
                dmc.Anchor(
                    "icon-sets.iconify.design",
                    href="https://icon-sets.iconify.design/",
                    target="_blank",
                    size="sm",
                ),
            ],
            color="gray",
            mt="md",
        ),
    ],
    p="md",
    withBorder=True,
    radius="md",
)
```


---

### Node Data Props

Customize node content with these data properties:

| Prop | Type | Description |
|------|------|-------------|
| `label` | str | Primary text (or `title` alias) |
| `sublabel` | str | Secondary text below label |
| `body` | str | Description text |
| `status` | str | Visual state: `"initial"`, `"loading"`, `"success"`, `"error"` |
| `multiline` | bool | Enable text wrapping |

#### Status Indicators

```python
# Loading state - blue pulsing glow
{"data": {"label": "Processing...", "status": "loading"}}

# Success state - green border with checkmark
{"data": {"label": "Complete", "status": "success"}}

# Error state - red border with X badge
{"data": {"label": "Failed", "status": "error"}}
```

---

### Callbacks & Interactivity

Dash Flows provides rich callback support for handling user interactions:

#### Available Output Props

| Prop | Trigger | Data |
|------|---------|------|
| `clickedNode` | Single click | Node object |
| `doubleClickedNode` | Double click | Node object |
| `contextMenuNode` | Right-click | Node + position |
| `hoveredNode` | Mouse enter/leave | Node ID or None |
| `selectedNodes` | Selection change | List of node IDs |
| `selectedEdges` | Selection change | List of edge IDs |
| `lastConnection` | New connection | Source/target info |
| `deletedNodes` | Node deletion | List of deleted IDs |
| `deletedEdges` | Edge deletion | List of deleted IDs |
| `droppedNode` | External drop | Drop position + data |



```python
# File: docs/dash_flows/node_interactions.py

"""
Dash Flows - Node Interactions
==============================
Demonstrates callback support for node events.
"""

import dash_mantine_components as dmc
from dash import html, callback, Input, Output, State
import dash_flows
import json

# Initial nodes for interaction demo
initial_nodes = [
    {"id": "1", "type": "default", "data": {"label": "Click Me"}, "position": {"x": 50, "y": 50}},
    {"id": "2", "type": "default", "data": {"label": "Drag Me"}, "position": {"x": 200, "y": 50}},
    {"id": "3", "type": "default", "data": {"label": "Right-Click"}, "position": {"x": 350, "y": 50}},
    {"id": "4", "type": "default", "data": {"label": "Node 4"}, "position": {"x": 50, "y": 150}},
    {"id": "5", "type": "default", "data": {"label": "Node 5"}, "position": {"x": 200, "y": 150}},
    {"id": "6", "type": "default", "data": {"label": "Node 6"}, "position": {"x": 350, "y": 150}},
]

initial_edges = [
    {"id": "e1-4", "source": "1", "target": "4"},
    {"id": "e2-5", "source": "2", "target": "5"},
    {"id": "e3-6", "source": "3", "target": "6"},
]

component = dmc.Paper(
    [
        dmc.Text(
            "Interact with nodes and observe the callback events below.",
            c="dimmed",
            size="sm",
            mb="md",
        ),
        dmc.SimpleGrid(
            cols={"base": 1, "md": 2},
            spacing="md",
            children=[
                # Flow canvas
                dmc.Paper(
                    dash_flows.DashFlows(
                        id="dash-flows-interactions",
                        nodes=initial_nodes,
                        edges=initial_edges,
                        style={"height": "300px"},
                        fitView=True,
                        showControls=True,
                        showMiniMap=False,
                        nodesDraggable=True,
                        nodesConnectable=True,
                        elementsSelectable=True,
                        multiSelectionKeyCode="Shift",
                    ),
                    withBorder=True,
                    radius="md",
                    style={"overflow": "hidden"},
                ),
                # Event panels
                dmc.Stack(
                    [
                        dmc.Paper(
                            [
                                dmc.Text("Selected Nodes:", fw=600, size="sm"),
                                html.Pre(
                                    id="dash-flows-selection-info",
                                    children="Select nodes (Shift+click for multi)...",
                                    style={"fontSize": "11px", "margin": 0, "maxHeight": "60px", "overflow": "auto"},
                                ),
                            ],
                            p="sm",
                            withBorder=True,
                        ),
                        dmc.Paper(
                            [
                                dmc.Text("Context Menu (Right-Click):", fw=600, size="sm"),
                                html.Pre(
                                    id="dash-flows-context-info",
                                    children="Right-click a node...",
                                    style={"fontSize": "11px", "margin": 0, "maxHeight": "60px", "overflow": "auto"},
                                ),
                            ],
                            p="sm",
                            withBorder=True,
                        ),
                        dmc.Paper(
                            [
                                dmc.Text("Node Positions:", fw=600, size="sm"),
                                html.Pre(
                                    id="dash-flows-positions-info",
                                    children="Drag a node to see updates...",
                                    style={"fontSize": "11px", "margin": 0, "maxHeight": "80px", "overflow": "auto"},
                                ),
                            ],
                            p="sm",
                            withBorder=True,
                        ),
                    ],
                    gap="xs",
                ),
            ],
        ),
        dmc.Accordion(
            [
                dmc.AccordionItem(
                    [
                        dmc.AccordionControl("Available Callback Props"),
                        dmc.AccordionPanel(
                            dmc.Table(
                                data={
                                    "head": ["Output Prop", "Trigger", "Data"],
                                    "body": [
                                        ["clickedNode", "Single click", "Node object"],
                                        ["doubleClickedNode", "Double click", "Node object"],
                                        ["contextMenuNode", "Right-click", "Node + position"],
                                        ["hoveredNode", "Mouse enter/leave", "Node ID"],
                                        ["selectedNodes", "Selection change", "List of IDs"],
                                        ["selectedEdges", "Selection change", "List of IDs"],
                                        ["lastConnection", "New connection", "Source/target"],
                                        ["deletedNodes", "Deletion", "Deleted IDs"],
                                    ],
                                },
                                striped=True,
                            )
                        ),
                    ],
                    value="props",
                ),
            ],
            mt="md",
        ),
        dmc.Alert(
            [
                dmc.Text("Tips:", fw=500, size="sm"),
                dmc.List(
                    [
                        dmc.ListItem("Hold Shift and click to multi-select nodes"),
                        dmc.ListItem("Drag on canvas to create selection box"),
                        dmc.ListItem("Use prevent_initial_call=True in callbacks"),
                    ],
                    size="sm",
                ),
            ],
            color="blue",
            mt="md",
        ),
    ],
    p="md",
    withBorder=True,
    radius="md",
)


# Callbacks for the interaction demo
@callback(
    Output("dash-flows-selection-info", "children"),
    Input("dash-flows-interactions", "selectedNodes"),
    Input("dash-flows-interactions", "selectedEdges"),
)
def update_selection(nodes, edges):
    """Show currently selected nodes and edges."""
    node_ids = []
    if nodes:
        for n in nodes:
            if isinstance(n, dict) and "id" in n:
                node_ids.append(n["id"])
            elif isinstance(n, str):
                node_ids.append(n)

    edge_ids = []
    if edges:
        for e in edges:
            if isinstance(e, dict) and "id" in e:
                edge_ids.append(e["id"])
            elif isinstance(e, str):
                edge_ids.append(e)

    return json.dumps({"nodes": node_ids, "edges": edge_ids}, indent=2)


@callback(
    Output("dash-flows-context-info", "children"),
    Input("dash-flows-interactions", "contextMenuNode"),
    prevent_initial_call=True,
)
def update_context_menu(context_data):
    """Show context menu event data."""
    if not context_data:
        return "Right-click a node..."
    return json.dumps(context_data, indent=2)


@callback(
    Output("dash-flows-positions-info", "children"),
    Input("dash-flows-interactions", "nodes"),
    prevent_initial_call=True,
)
def update_positions(nodes):
    """Show node positions after dragging."""
    if not nodes:
        return "No nodes"
    positions = {n["id"]: n["position"] for n in nodes[:3]}  # Show first 3
    return json.dumps(positions, indent=2)
```


---

### Automatic Layouts (ELK)

Dash Flows integrates **ELK.js** for automatic graph layout. Pass layout options as a JSON string:

#### Layout Algorithms

| Algorithm | Best For |
|-----------|----------|
| `layered` | Hierarchical/directed graphs |
| `org.eclipse.elk.force` | Organic, force-directed |
| `org.eclipse.elk.radial` | Concentric circles |
| `org.eclipse.elk.stress` | Balanced edge lengths |

#### Direction Options (Layered)

- `DOWN` - Top to bottom
- `UP` - Bottom to top
- `RIGHT` - Left to right
- `LEFT` - Right to left

```python
import json

layout_options = json.dumps({
    "elk.algorithm": "layered",
    "elk.direction": "DOWN",
    "elk.spacing.nodeNode": 50,
    "elk.layered.spacing.nodeNodeBetweenLayers": 80,
})

dash_flows.DashFlows(
    layoutOptions=layout_options,
    # ... other props
)
```



```python
# File: docs/dash_flows/elk_layouts.py

"""
Dash Flows - ELK Automatic Layouts
==================================
Demonstrates automatic graph layout algorithms using ELK.js.
"""

import dash_mantine_components as dmc
from dash import html, callback, Input, Output, State
import dash_flows
import json

# Create a graph to demonstrate layouts
initial_nodes = [
    {"id": "1", "type": "input", "data": {"label": "Start"}, "position": {"x": 0, "y": 0}},
    {"id": "2", "type": "default", "data": {"label": "Step A"}, "position": {"x": 0, "y": 0}},
    {"id": "3", "type": "default", "data": {"label": "Step B"}, "position": {"x": 0, "y": 0}},
    {"id": "4", "type": "default", "data": {"label": "Step C"}, "position": {"x": 0, "y": 0}},
    {"id": "5", "type": "default", "data": {"label": "Step D"}, "position": {"x": 0, "y": 0}},
    {"id": "6", "type": "default", "data": {"label": "Merge"}, "position": {"x": 0, "y": 0}},
    {"id": "7", "type": "output", "data": {"label": "End"}, "position": {"x": 0, "y": 0}},
]

initial_edges = [
    {"id": "e1-2", "source": "1", "target": "2"},
    {"id": "e1-3", "source": "1", "target": "3"},
    {"id": "e2-4", "source": "2", "target": "4"},
    {"id": "e3-5", "source": "3", "target": "5"},
    {"id": "e4-6", "source": "4", "target": "6"},
    {"id": "e5-6", "source": "5", "target": "6"},
    {"id": "e6-7", "source": "6", "target": "7"},
]

# Layout presets
layout_presets = {
    "layered-down": {
        "elk.algorithm": "layered",
        "elk.direction": "DOWN",
        "elk.spacing.nodeNode": 50,
        "elk.layered.spacing.nodeNodeBetweenLayers": 80,
    },
    "layered-right": {
        "elk.algorithm": "layered",
        "elk.direction": "RIGHT",
        "elk.spacing.nodeNode": 50,
        "elk.layered.spacing.nodeNodeBetweenLayers": 120,
    },
    "force": {
        "elk.algorithm": "org.eclipse.elk.force",
        "elk.force.iterations": 300,
        "elk.spacing.nodeNode": 80,
    },
    "radial": {
        "elk.algorithm": "org.eclipse.elk.radial",
        "elk.radial.radius": 150,
    },
}

component = dmc.Paper(
    [
        dmc.Text(
            "Apply automatic layout algorithms to arrange nodes.",
            c="dimmed",
            size="sm",
            mb="md",
        ),
        dmc.Group(
            [
                dmc.Select(
                    id="dash-flows-elk-layout-select",
                    label="Layout Algorithm",
                    data=[
                        {"value": "layered-down", "label": "Layered (Top to Bottom)"},
                        {"value": "layered-right", "label": "Layered (Left to Right)"},
                        {"value": "force", "label": "Force-Directed"},
                        {"value": "radial", "label": "Radial"},
                    ],
                    value="layered-down",
                    style={"width": "220px"},
                ),
                dmc.Button(
                    "Apply Layout",
                    id="dash-flows-elk-apply-btn",
                    variant="filled",
                    mt="auto",
                ),
            ],
            gap="md",
            mb="md",
        ),
        dash_flows.DashFlows(
            id="dash-flows-elk",
            nodes=initial_nodes,
            edges=initial_edges,
            style={"height": "350px"},
            fitView=True,
            showControls=True,
            showMiniMap=True,
            layoutOptions=json.dumps(layout_presets["layered-down"]),
        ),
        dmc.Accordion(
            [
                dmc.AccordionItem(
                    [
                        dmc.AccordionControl("Current Layout Options"),
                        dmc.AccordionPanel(
                            html.Pre(
                                id="dash-flows-elk-options-display",
                                children=json.dumps(layout_presets["layered-down"], indent=2),
                                style={"fontSize": "11px", "margin": 0},
                            )
                        ),
                    ],
                    value="options",
                ),
                dmc.AccordionItem(
                    [
                        dmc.AccordionControl("Layout Algorithms"),
                        dmc.AccordionPanel(
                            dmc.Table(
                                data={
                                    "head": ["Algorithm", "Best For"],
                                    "body": [
                                        ["layered", "Hierarchical/directed graphs"],
                                        ["org.eclipse.elk.force", "Organic, force-directed"],
                                        ["org.eclipse.elk.radial", "Concentric circles"],
                                        ["org.eclipse.elk.stress", "Balanced edge lengths"],
                                    ],
                                },
                                striped=True,
                            )
                        ),
                    ],
                    value="algorithms",
                ),
            ],
            value="options",
            mt="md",
        ),
        dmc.Alert(
            [
                dmc.Text("Important:", fw=500, size="sm"),
                dmc.Text(
                    "layoutOptions must be a JSON string. Use json.dumps() to convert Python dicts.",
                    size="sm",
                ),
            ],
            color="yellow",
            mt="md",
        ),
    ],
    p="md",
    withBorder=True,
    radius="md",
)


@callback(
    Output("dash-flows-elk", "layoutOptions"),
    Output("dash-flows-elk-options-display", "children"),
    Input("dash-flows-elk-apply-btn", "n_clicks"),
    State("dash-flows-elk-layout-select", "value"),
    prevent_initial_call=True,
)
def apply_layout(n_clicks, layout_type):
    """Apply the selected layout algorithm."""
    options = layout_presets.get(layout_type, layout_presets["layered-down"])
    return json.dumps(options), json.dumps(options, indent=2)
```


---

### Theming & Dark Mode

Customize the visual appearance with theme presets and color schemes:

#### Theme Presets

| Preset | Description |
|--------|-------------|
| `glass` | Glass morphism with blur effects (default) |
| `solid` | Solid backgrounds |
| `minimal` | Clean, minimal styling |

#### Color Schemes

`default`, `ocean`, `forest`, `sunset`, `midnight`, `rose`

#### Dark Mode

Integrates with Dash Mantine Components for dark mode support:

```python
dmc.MantineProvider([
    dash_flows.DashFlows(
        colorMode="dark",  # or "light" or "system"
        # ... other props
    )
])
```



```python
# File: docs/dash_flows/theming_dark_mode.py

"""
Dash Flows - Theming & Dark Mode
================================
Demonstrates theme presets, color schemes, and dark mode integration.
"""

import dash_mantine_components as dmc
from dash import html, callback, Input, Output
import dash_flows

# Sample nodes for theming demo
nodes = [
    {
        "id": "input-1",
        "type": "input",
        "data": {"label": "Data Source", "sublabel": "Input"},
        "position": {"x": 50, "y": 50},
    },
    {
        "id": "process-1",
        "type": "default",
        "data": {"label": "Transform", "sublabel": "Process"},
        "position": {"x": 50, "y": 150},
    },
    {
        "id": "process-2",
        "type": "default",
        "data": {"label": "Validate", "sublabel": "Check"},
        "position": {"x": 200, "y": 150},
    },
    {
        "id": "output-1",
        "type": "output",
        "data": {"label": "Dashboard", "sublabel": "Output"},
        "position": {"x": 125, "y": 250},
    },
]

edges = [
    {"id": "e1", "source": "input-1", "target": "process-1", "animated": True},
    {"id": "e2", "source": "input-1", "target": "process-2", "animated": True},
    {"id": "e3", "source": "process-1", "target": "output-1"},
    {"id": "e4", "source": "process-2", "target": "output-1"},
]

component = dmc.Paper(
    [
        dmc.Text(
            "Customize appearance with theme presets and color schemes.",
            c="dimmed",
            size="sm",
            mb="md",
        ),
        dmc.SimpleGrid(
            cols={"base": 1, "sm": 3},
            spacing="md",
            mb="md",
            children=[
                dmc.Select(
                    id="dash-flows-theme-preset",
                    label="Theme Preset",
                    data=[
                        {"value": "glass", "label": "Glass (Default)"},
                        {"value": "solid", "label": "Solid"},
                        {"value": "minimal", "label": "Minimal"},
                    ],
                    value="glass",
                ),
                dmc.Select(
                    id="dash-flows-color-scheme",
                    label="Color Scheme",
                    data=[
                        {"value": "default", "label": "Default"},
                        {"value": "ocean", "label": "Ocean"},
                        {"value": "forest", "label": "Forest"},
                        {"value": "sunset", "label": "Sunset"},
                        {"value": "midnight", "label": "Midnight"},
                        {"value": "rose", "label": "Rose"},
                    ],
                    value="default",
                ),
                dmc.Select(
                    id="dash-flows-color-mode",
                    label="Color Mode",
                    data=[
                        {"value": "light", "label": "Light"},
                        {"value": "dark", "label": "Dark"},
                    ],
                    value="light",
                ),
            ],
        ),
        dash_flows.DashFlows(
            id="dash-flows-theming",
            nodes=nodes,
            edges=edges,
            style={"height": "350px"},
            fitView=True,
            showControls=True,
            showMiniMap=True,
            themePreset="glass",
            colorScheme="default",
            colorMode="light",
        ),
        dmc.Accordion(
            [
                dmc.AccordionItem(
                    [
                        dmc.AccordionControl("Theme Presets"),
                        dmc.AccordionPanel(
                            dmc.Table(
                                data={
                                    "head": ["Preset", "Description"],
                                    "body": [
                                        ["glass", "Glass morphism with blur effects"],
                                        ["solid", "Solid, opaque backgrounds"],
                                        ["minimal", "Clean, minimal styling"],
                                    ],
                                },
                                striped=True,
                            )
                        ),
                    ],
                    value="presets",
                ),
                dmc.AccordionItem(
                    [
                        dmc.AccordionControl("Color Schemes"),
                        dmc.AccordionPanel(
                            dmc.Group(
                                [
                                    dmc.Badge("default", color="blue", variant="filled"),
                                    dmc.Badge("ocean", color="cyan", variant="filled"),
                                    dmc.Badge("forest", color="green", variant="filled"),
                                    dmc.Badge("sunset", color="orange", variant="filled"),
                                    dmc.Badge("midnight", color="indigo", variant="filled"),
                                    dmc.Badge("rose", color="pink", variant="filled"),
                                ],
                                gap="xs",
                            )
                        ),
                    ],
                    value="schemes",
                ),
            ],
            mt="md",
        ),
        dmc.Alert(
            [
                dmc.Text("Dark Mode Integration:", fw=500, size="sm"),
                dmc.Text(
                    "For full dark mode support, wrap your app in dmc.MantineProvider "
                    "and use colorMode='dark' on DashFlows.",
                    size="sm",
                ),
            ],
            color="gray",
            mt="md",
        ),
    ],
    p="md",
    withBorder=True,
    radius="md",
)


@callback(
    Output("dash-flows-theming", "themePreset"),
    Output("dash-flows-theming", "colorScheme"),
    Output("dash-flows-theming", "colorMode"),
    Input("dash-flows-theme-preset", "value"),
    Input("dash-flows-color-scheme", "value"),
    Input("dash-flows-color-mode", "value"),
)
def update_theme(preset, scheme, mode):
    """Update the flow theme based on selections."""
    return preset or "glass", scheme or "default", mode or "light"
```


---

### Controls & Display

#### Built-in UI Components

| Prop | Default | Description |
|------|---------|-------------|
| `showControls` | True | Zoom +/- and fit view buttons |
| `showMiniMap` | True | Overview minimap |
| `showBackground` | True | Canvas background pattern |
| `showDevTools` | False | Debug information panel |

#### Background Patterns

```python
dash_flows.DashFlows(
    showBackground=True,
    backgroundVariant="dots",  # "dots", "lines", or "cross"
    backgroundGap=16,          # Pattern spacing
)
```

#### Control Positions

```python
controlsPosition="bottom-left"   # Default
miniMapPosition="bottom-right"   # Default
```

---

### Viewport Control

Programmatically control the viewport:

```python
# Fit all nodes in view
viewportAction={"type": "fitView", "options": {"padding": 0.2}}

# Zoom in/out
viewportAction={"type": "zoomIn"}
viewportAction={"type": "zoomOut"}

# Center on specific coordinates
viewportAction={"type": "setCenter", "x": 200, "y": 150, "zoom": 1.5}
```

#### Viewport Props

| Prop | Default | Description |
|------|---------|-------------|
| `minZoom` | 0.5 | Minimum zoom level |
| `maxZoom` | 2 | Maximum zoom level |
| `fitView` | False | Auto-fit on initial render |
| `snapToGrid` | False | Snap nodes to grid |
| `snapGrid` | [15, 15] | Grid size for snapping |

---

### Interaction Props

Control how users interact with the flow:

| Prop | Default | Description |
|------|---------|-------------|
| `nodesDraggable` | True | Allow dragging nodes |
| `nodesConnectable` | True | Allow creating connections |
| `elementsSelectable` | True | Allow selecting elements |
| `panOnDrag` | True | Pan canvas by dragging |
| `zoomOnScroll` | True | Zoom with scroll wheel |
| `zoomOnPinch` | True | Zoom with pinch gesture |
| `zoomOnDoubleClick` | True | Zoom on double-click |
| `multiSelectionKeyCode` | "Shift" | Key for multi-select |
| `deleteKeyCode` | "Backspace" | Key to delete selected |

---

### Props Reference

#### Core Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `id` | str | Required | Component ID for callbacks |
| `nodes` | list | `[]` | Array of node objects |
| `edges` | list | `[]` | Array of edge objects |
| `style` | dict | `{}` | Container style (height required!) |

#### Display Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `fitView` | bool | False | Auto-fit on mount |
| `showControls` | bool | True | Show viewport controls |
| `showMiniMap` | bool | True | Show minimap |
| `showBackground` | bool | True | Show background pattern |
| `backgroundVariant` | str | "dots" | "dots", "lines", "cross" |
| `controlsPosition` | str | "bottom-left" | Control panel position |
| `miniMapPosition` | str | "bottom-right" | Minimap position |

#### Theme Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `themePreset` | str | "glass" | "glass", "solid", "minimal" |
| `colorScheme` | str | "default" | Color scheme name |
| `colorMode` | str | "light" | "light", "dark", "system" |

#### Layout Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `layoutOptions` | str | None | ELK layout JSON string |
| `defaultViewport` | dict | None | Initial viewport {x, y, zoom} |
| `minZoom` | float | 0.5 | Minimum zoom level |
| `maxZoom` | float | 2 | Maximum zoom level |

#### Callback Output Props

| Prop | Type | Description |
|------|------|-------------|
| `selectedNodes` | list | Currently selected node IDs |
| `selectedEdges` | list | Currently selected edge IDs |
| `clickedNode` | dict | Last clicked node |
| `doubleClickedNode` | dict | Last double-clicked node |
| `contextMenuNode` | dict | Right-clicked node with position |
| `hoveredNode` | dict | Currently hovered node |
| `lastConnection` | dict | Last created connection |
| `deletedNodes` | list | Recently deleted node IDs |
| `deletedEdges` | list | Recently deleted edge IDs |
| `viewport` | dict | Current viewport state |

---

### New in 1.2.0

#### Smart Handle Positioning



```python
# File: docs/dash_flows/smart_handles.py

"""Smart Handle Positioning — auto-routes edges to closest node side."""
import dash_mantine_components as dmc
import dash_flows
from dash_iconify import DashIconify

nodes = [
    {"id": "a", "type": "input", "data": {"label": "Data Source"}, "position": {"x": 50, "y": 50}},
    {"id": "b", "type": "default", "data": {"label": "Transform"}, "position": {"x": 300, "y": 50}},
    {"id": "c", "type": "default", "data": {"label": "Validate"}, "position": {"x": 150, "y": 200}},
    {"id": "d", "type": "output", "data": {"label": "Output"}, "position": {"x": 400, "y": 200}},
    {"id": "e", "type": "default", "data": {"label": "Cache"}, "position": {"x": 50, "y": 300}},
]
edges = [
    {"id": "e1", "source": "a", "target": "b", "animated": True},
    {"id": "e2", "source": "a", "target": "c", "animated": True},
    {"id": "e3", "source": "b", "target": "d", "animated": True},
    {"id": "e4", "source": "c", "target": "d", "animated": True},
    {"id": "e5", "source": "c", "target": "e", "animated": True},
]

component = dmc.Paper([
    dmc.Group([
        dmc.Text("Smart Handle Positioning", fw=600),
        dmc.Badge("1.2.0", color="teal", variant="light", size="sm"),
    ], mb="xs"),
    dmc.Text("Edges automatically route to the closest side of each node. Drag nodes around to see handles reposition.", size="sm", c="dimmed", mb="md"),
    dash_flows.DashFlows(
        id="df-smart-handles",
        nodes=nodes,
        edges=edges,
        smartHandles=True,
        style={"height": "400px"},
        fitView=True,
        showControls=True,
        showMiniMap=True,
    ),
], p="md", withBorder=True, radius="md")
```


---

#### Floating Edges



```python
# File: docs/dash_flows/floating_edges.py

"""Floating Edges — connect to nearest point on node border."""
import dash_mantine_components as dmc
import dash_flows

nodes = [
    {"id": "1", "type": "input", "data": {"label": "API Gateway"}, "position": {"x": 0, "y": 100}},
    {"id": "2", "type": "default", "data": {"label": "Auth Service"}, "position": {"x": 250, "y": 0}},
    {"id": "3", "type": "default", "data": {"label": "User Service"}, "position": {"x": 250, "y": 200}},
    {"id": "4", "type": "default", "data": {"label": "Database"}, "position": {"x": 500, "y": 100}},
    {"id": "5", "type": "output", "data": {"label": "Response"}, "position": {"x": 700, "y": 100}},
]
edges = [
    {"id": "e1", "source": "1", "target": "2", "type": "floating", "animated": True},
    {"id": "e2", "source": "1", "target": "3", "type": "floating", "animated": True},
    {"id": "e3", "source": "2", "target": "4", "type": "floating"},
    {"id": "e4", "source": "3", "target": "4", "type": "floating"},
    {"id": "e5", "source": "4", "target": "5", "type": "floating", "animated": True},
]

component = dmc.Paper([
    dmc.Group([
        dmc.Text("Floating Edges", fw=600),
        dmc.Badge("1.2.0", color="teal", variant="light", size="sm"),
    ], mb="xs"),
    dmc.Text(
        "Edges connect to the nearest point on each node's border instead of fixed handle positions. "
        "Drag nodes to see edges dynamically recalculate their connection points.",
        size="sm", c="dimmed", mb="md",
    ),
    dash_flows.DashFlows(
        id="df-floating-edges",
        nodes=nodes,
        edges=edges,
        style={"height": "380px"},
        fitView=True,
        showControls=True,
    ),
    dmc.Code('{"type": "floating"}  # Set on any edge to enable border intersection', block=True),
], p="md", withBorder=True, radius="md")
```


---

#### Helper Lines (Alignment Guides)



```python
# File: docs/dash_flows/helper_lines.py

"""Helper Lines — alignment guides when dragging nodes."""
import dash_mantine_components as dmc
import dash_flows

nodes = [
    {"id": "1", "type": "input", "data": {"label": "Node A"}, "position": {"x": 50, "y": 50}},
    {"id": "2", "type": "default", "data": {"label": "Node B"}, "position": {"x": 250, "y": 50}},
    {"id": "3", "type": "default", "data": {"label": "Node C"}, "position": {"x": 150, "y": 180}},
    {"id": "4", "type": "output", "data": {"label": "Node D"}, "position": {"x": 350, "y": 180}},
]
edges = [
    {"id": "e1", "source": "1", "target": "3"},
    {"id": "e2", "source": "2", "target": "3"},
    {"id": "e3", "source": "3", "target": "4"},
]

component = dmc.Paper([
    dmc.Group([
        dmc.Text("Helper Lines (Alignment Guides)", fw=600),
        dmc.Badge("1.2.0", color="teal", variant="light", size="sm"),
    ], mb="xs"),
    dmc.Text(
        "Blue alignment guides appear when dragging nodes near other nodes' edges. "
        "Nodes snap to alignment within the threshold distance.",
        size="sm", c="dimmed", mb="md",
    ),
    dash_flows.DashFlows(
        id="df-helper-lines",
        nodes=nodes,
        edges=edges,
        helperLines=True,
        helperLineThreshold=5,
        style={"height": "350px"},
        fitView=True,
        showControls=True,
    ),
    dmc.Alert(
        "Drag any node slowly near another to see the blue alignment guides appear and snap.",
        color="blue", variant="light", mt="md",
    ),
], p="md", withBorder=True, radius="md")
```


---

#### Sub-flows (Collapsible Groups)



```python
# File: docs/dash_flows/subflows.py

"""Sub-flows — collapsible group nodes."""
import dash_mantine_components as dmc
import dash_flows
from dash import callback, Input, Output, no_update

nodes = [
    {"id": "source", "type": "input", "data": {"label": "Data Source"}, "position": {"x": 50, "y": 130}},
    # Group container
    {
        "id": "pipeline",
        "type": "group",
        "data": {"label": "ETL Pipeline", "collapsedWidth": 200, "collapsedHeight": 52},
        "position": {"x": 250, "y": 50},
        "style": {"width": 350, "height": 250},
    },
    # Children inside group
    {"id": "extract", "type": "default", "data": {"label": "Extract"}, "position": {"x": 30, "y": 40}, "parentId": "pipeline", "extent": "parent"},
    {"id": "transform", "type": "default", "data": {"label": "Transform"}, "position": {"x": 30, "y": 130}, "parentId": "pipeline", "extent": "parent"},
    {"id": "load", "type": "default", "data": {"label": "Load"}, "position": {"x": 180, "y": 85}, "parentId": "pipeline", "extent": "parent"},
    # Output
    {"id": "dashboard", "type": "output", "data": {"label": "Dashboard"}, "position": {"x": 700, "y": 130}},
]
edges = [
    {"id": "e1", "source": "source", "target": "extract", "animated": True},
    {"id": "e2", "source": "extract", "target": "transform"},
    {"id": "e3", "source": "transform", "target": "load"},
    {"id": "e4", "source": "load", "target": "dashboard", "animated": True},
]

@callback(
    Output("df-subflows", "toggleCollapseNode"),
    Input("df-subflows", "doubleClickedNode"),
    prevent_initial_call=True,
)
def toggle_group_collapse(double_clicked):
    if double_clicked and double_clicked.get("type") == "group":
        return double_clicked["id"]
    return no_update


component = dmc.Paper([
    dmc.Group([
        dmc.Text("Sub-flows (Collapsible Groups)", fw=600),
        dmc.Badge("1.2.0", color="teal", variant="light", size="sm"),
    ], mb="xs"),
    dmc.Text(
        "Double-click the 'ETL Pipeline' group to collapse/expand it. "
        "Child nodes hide when collapsed; external edges remain connected at the group level.",
        size="sm", c="dimmed", mb="md",
    ),
    dash_flows.DashFlows(
        id="df-subflows",
        nodes=nodes,
        edges=edges,
        style={"height": "380px"},
        fitView=True,
        showControls=True,
        showMiniMap=True,
    ),
    dmc.Alert(
        "Group nodes support collapsedWidth/collapsedHeight for compact dimensions when collapsed.",
        color="blue", variant="light", mt="md",
    ),
], p="md", withBorder=True, radius="md")
```


---

#### Undo / Redo



```python
# File: docs/dash_flows/undo_redo.py

"""Undo/Redo — history tracking for node/edge changes."""
import dash_mantine_components as dmc
from dash import html, callback, Input, Output, State
from dash_iconify import DashIconify
import dash_flows

nodes = [
    {"id": "a", "type": "input", "data": {"label": "Input A"}, "position": {"x": 50, "y": 50}},
    {"id": "b", "type": "default", "data": {"label": "Process"}, "position": {"x": 250, "y": 50}},
    {"id": "c", "type": "output", "data": {"label": "Output"}, "position": {"x": 450, "y": 50}},
]
edges = [
    {"id": "e1", "source": "a", "target": "b", "animated": True},
    {"id": "e2", "source": "b", "target": "c", "animated": True},
]

component = dmc.Paper([
    dmc.Group([
        dmc.Text("Undo / Redo", fw=600),
        dmc.Badge("1.2.0", color="teal", variant="light", size="sm"),
    ], mb="xs"),
    dmc.Text(
        "Drag nodes, create connections, or delete elements — then undo/redo with the buttons below.",
        size="sm", c="dimmed", mb="md",
    ),
    dmc.Group([
        dmc.Button("Undo", id="df-undo-btn", leftSection=DashIconify(icon="tabler:arrow-back-up", width=16), variant="light", size="sm"),
        dmc.Button("Redo", id="df-redo-btn", leftSection=DashIconify(icon="tabler:arrow-forward-up", width=16), variant="light", size="sm"),
        html.Div(id="df-undo-status"),
    ], mb="md"),
    dash_flows.DashFlows(
        id="df-undo-redo",
        nodes=nodes,
        edges=edges,
        enableUndoRedo=True,
        undoRedoMaxHistory=30,
        style={"height": "300px"},
        fitView=True,
        showControls=True,
    ),
    dmc.Alert(
        "Try: drag a node, then click Undo to reverse. Tracks position changes, additions, and deletions.",
        color="blue", variant="light", mt="md",
    ),
], p="md", withBorder=True, radius="md")


@callback(
    Output("df-undo-redo", "undoRedoAction"),
    Input("df-undo-btn", "n_clicks"),
    Input("df-redo-btn", "n_clicks"),
    prevent_initial_call=True,
)
def handle_undo_redo(undo_clicks, redo_clicks):
    from dash import ctx
    if ctx.triggered_id == "df-undo-btn":
        return {"action": "undo"}
    return {"action": "redo"}


@callback(
    Output("df-undo-status", "children"),
    Input("df-undo-redo", "undoRedoState"),
    prevent_initial_call=True,
)
def show_undo_state(state):
    if not state:
        return ""
    return dmc.Group([
        dmc.Badge(f"Undo: {state.get('undoCount', 0)}", color="gray", variant="light", size="sm"),
        dmc.Badge(f"Redo: {state.get('redoCount', 0)}", color="gray", variant="light", size="sm"),
    ], gap="xs")
```


---

#### Additional 1.2.0 Features

The following features are also available — view source code for implementation details:

**Add Node on Edge Drop** — Drag a connection to empty canvas to create a node:



```python
# File: docs/dash_flows/add_node_drop.py

"""Add Node on Edge Drop — drag a connection to empty canvas to create a node."""
import dash_mantine_components as dmc
import dash_flows

nodes = [
    {"id": "start", "type": "input", "data": {"label": "Start"}, "position": {"x": 50, "y": 100}},
    {"id": "step-1", "type": "default", "data": {"label": "Step 1"}, "position": {"x": 300, "y": 100}},
]
edges = [
    {"id": "e1", "source": "start", "target": "step-1", "animated": True},
]

component = dmc.Paper([
    dmc.Group([
        dmc.Text("Add Node on Edge Drop", fw=600),
        dmc.Badge("1.2.0", color="teal", variant="light", size="sm"),
    ], mb="xs"),
    dmc.Text(
        "Drag a connection from any handle and drop it on empty canvas to create a new node. "
        "The new node is automatically connected to the source.",
        size="sm", c="dimmed", mb="md",
    ),
    dash_flows.DashFlows(
        id="df-add-node-drop",
        nodes=nodes,
        edges=edges,
        addNodeOnEdgeDrop=True,
        style={"height": "350px"},
        fitView=True,
        showControls=True,
    ),
    dmc.Alert(
        "Drag from the bottom handle of any node into empty space, then release to create a new node.",
        color="indigo", variant="light", mt="md",
    ),
], p="md", withBorder=True, radius="md")
```


**Animated Layout Transitions** — Smooth ELK layout changes with callbacks:



```python
# File: docs/dash_flows/animated_layout.py

"""Animated Layout Transitions — smooth ELK layout changes."""
import json
import dash_mantine_components as dmc
from dash import callback, Input, Output
import dash_flows

nodes = [
    {"id": "1", "type": "input", "data": {"label": "Source"}, "position": {"x": 0, "y": 0}},
    {"id": "2", "type": "default", "data": {"label": "Parse"}, "position": {"x": 200, "y": 0}},
    {"id": "3", "type": "default", "data": {"label": "Transform"}, "position": {"x": 100, "y": 120}},
    {"id": "4", "type": "default", "data": {"label": "Validate"}, "position": {"x": 300, "y": 120}},
    {"id": "5", "type": "default", "data": {"label": "Enrich"}, "position": {"x": 200, "y": 240}},
    {"id": "6", "type": "output", "data": {"label": "Output"}, "position": {"x": 200, "y": 360}},
]
edges = [
    {"id": "e1", "source": "1", "target": "2"},
    {"id": "e2", "source": "1", "target": "3"},
    {"id": "e3", "source": "2", "target": "4"},
    {"id": "e4", "source": "3", "target": "5"},
    {"id": "e5", "source": "4", "target": "5"},
    {"id": "e6", "source": "5", "target": "6", "animated": True},
]

LAYOUTS = {
    "layered-down": json.dumps({"elk.algorithm": "layered", "elk.direction": "DOWN", "elk.spacing.nodeNode": "60"}),
    "layered-right": json.dumps({"elk.algorithm": "layered", "elk.direction": "RIGHT", "elk.spacing.nodeNode": "60"}),
    "force": json.dumps({"elk.algorithm": "org.eclipse.elk.force", "elk.spacing.nodeNode": "80"}),
    "radial": json.dumps({"elk.algorithm": "org.eclipse.elk.radial", "elk.spacing.nodeNode": "80"}),
}

component = dmc.Paper([
    dmc.Group([
        dmc.Text("Animated Layout Transitions", fw=600),
        dmc.Badge("1.2.0", color="teal", variant="light", size="sm"),
    ], mb="xs"),
    dmc.Text("Nodes animate smoothly between positions when switching ELK layouts.", size="sm", c="dimmed", mb="md"),
    dmc.Group([
        dmc.Select(
            id="df-anim-layout-select",
            data=[
                {"value": "layered-down", "label": "Layered (Down)"},
                {"value": "layered-right", "label": "Layered (Right)"},
                {"value": "force", "label": "Force-Directed"},
                {"value": "radial", "label": "Radial"},
            ],
            value="layered-down",
            w=200,
        ),
    ], mb="md"),
    dash_flows.DashFlows(
        id="df-animated-layout",
        nodes=nodes,
        edges=edges,
        animateLayout=True,
        animateLayoutDuration=500,
        style={"height": "400px"},
        fitView=True,
        showControls=True,
        showMiniMap=True,
        layoutOptions=LAYOUTS["layered-down"],
    ),
], p="md", withBorder=True, radius="md")


@callback(
    Output("df-animated-layout", "layoutOptions"),
    Input("df-anim-layout-select", "value"),
    prevent_initial_call=True,
)
def switch_df_layout(layout_key):
    from dash import no_update
    if not layout_key:
        return no_update
    return LAYOUTS.get(layout_key, LAYOUTS["layered-down"])
```


**Computing Flows** — Topological sort and data propagation:



```python
# File: docs/dash_flows/computing_flows.py

"""Computing Flows — topological sort and data propagation."""
import dash_mantine_components as dmc
from dash import callback, Input, Output
from dash_iconify import DashIconify
import dash_flows
import json

nodes = [
    {"id": "input-a", "type": "input", "data": {"label": "Input A", "computedValue": 10}, "position": {"x": 0, "y": 0}},
    {"id": "input-b", "type": "input", "data": {"label": "Input B", "computedValue": 5}, "position": {"x": 0, "y": 150}},
    {"id": "add", "type": "default", "data": {"label": "Add"}, "position": {"x": 250, "y": 75}},
    {"id": "multiply", "type": "default", "data": {"label": "Multiply ×2"}, "position": {"x": 500, "y": 75}},
    {"id": "result", "type": "output", "data": {"label": "Result"}, "position": {"x": 750, "y": 75}},
]
edges = [
    {"id": "e1", "source": "input-a", "target": "add", "animated": True},
    {"id": "e2", "source": "input-b", "target": "add", "animated": True},
    {"id": "e3", "source": "add", "target": "multiply", "animated": True},
    {"id": "e4", "source": "multiply", "target": "result", "animated": True},
]

component = dmc.Paper([
    dmc.Group([
        dmc.Text("Computing Flows", fw=600),
        dmc.Badge("1.2.0", color="teal", variant="light", size="sm"),
    ], mb="xs"),
    dmc.Text(
        "Topological sort determines execution order. JS handles graph traversal; Python handles computation.",
        size="sm", c="dimmed", mb="md",
    ),
    dmc.Group([
        dmc.Button("Compute Flow", id="df-compute-btn", leftSection=DashIconify(icon="tabler:player-play", width=16), color="green", size="sm"),
    ], mb="md"),
    dash_flows.DashFlows(
        id="df-computing-flow",
        nodes=nodes,
        edges=edges,
        style={"height": "280px"},
        fitView=True,
        showControls=True,
    ),
    dmc.Code(id="df-compute-result", children="Click 'Compute Flow' to run topological sort", block=True),
], p="md", withBorder=True, radius="md")


@callback(
    Output("df-computing-flow", "computeAction"),
    Input("df-compute-btn", "n_clicks"),
    prevent_initial_call=True,
)
def trigger_compute(_n):
    return {"action": "compute"}


@callback(
    Output("df-compute-result", "children"),
    Input("df-computing-flow", "computeResult"),
    prevent_initial_call=True,
)
def show_result(result):
    if not result:
        return "No result yet"
    return json.dumps(result, indent=2)
```


**Resize Constraints** — Aspect ratio lock and min/max dimensions:



```python
# File: docs/dash_flows/resize_constraints.py

"""Resize Constraints — aspect ratio, min/max dimensions."""
import dash_mantine_components as dmc
import dash_flows

nodes = [
    {
        "id": "free", "type": "resizable",
        "data": {"label": "Free Resize", "sublabel": "No constraints"},
        "position": {"x": 50, "y": 50},
        "style": {"width": 180, "height": 100},
    },
    {
        "id": "aspect", "type": "resizable",
        "data": {"label": "Locked Aspect", "sublabel": "keepAspectRatio: true", "keepAspectRatio": True},
        "position": {"x": 300, "y": 50},
        "style": {"width": 180, "height": 120},
    },
    {
        "id": "constrained", "type": "resizable",
        "data": {
            "label": "Min/Max Limits",
            "sublabel": "100-400px wide, 80-200px tall",
            "minWidth": 100, "minHeight": 80,
            "maxWidth": 400, "maxHeight": 200,
        },
        "position": {"x": 550, "y": 50},
        "style": {"width": 200, "height": 120},
    },
]
edges = [
    {"id": "e1", "source": "free", "target": "aspect"},
    {"id": "e2", "source": "aspect", "target": "constrained"},
]

component = dmc.Paper([
    dmc.Group([
        dmc.Text("Resize Constraints", fw=600),
        dmc.Badge("1.2.0", color="teal", variant="light", size="sm"),
    ], mb="xs"),
    dmc.Text("Drag the resize handles on each node to see different constraint behaviors.", size="sm", c="dimmed", mb="md"),
    dash_flows.DashFlows(
        id="df-resize-constraints",
        nodes=nodes,
        edges=edges,
        style={"height": "300px"},
        fitView=True,
        showControls=True,
    ),
    dmc.SimpleGrid(cols=3, mt="md", children=[
        dmc.Badge("Free: no limits", color="gray", variant="light", fullWidth=True),
        dmc.Badge("Aspect: ratio locked", color="blue", variant="light", fullWidth=True),
        dmc.Badge("Min/Max: bounded size", color="orange", variant="light", fullWidth=True),
    ]),
], p="md", withBorder=True, radius="md")
```


**Accessibility (ARIA)** — Screen reader labels and keyboard navigation:



```python
# File: docs/dash_flows/accessibility.py

"""Accessibility — ARIA labels and keyboard navigation."""
import dash_mantine_components as dmc
import dash_flows

nodes = [
    {"id": "start", "type": "input", "data": {"label": "Start"}, "position": {"x": 50, "y": 50}, "ariaLabel": "Start node — entry point of the workflow"},
    {"id": "validate", "type": "default", "data": {"label": "Validate"}, "position": {"x": 250, "y": 50}, "ariaLabel": "Validation step — checks input data integrity"},
    {"id": "process", "type": "default", "data": {"label": "Process"}, "position": {"x": 450, "y": 50}, "ariaLabel": "Processing step — transforms validated data"},
    {"id": "end", "type": "output", "data": {"label": "Complete"}, "position": {"x": 650, "y": 50}, "ariaLabel": "Completion node — workflow output"},
]
edges = [
    {"id": "e1", "source": "start", "target": "validate", "ariaLabel": "Flow from start to validation"},
    {"id": "e2", "source": "validate", "target": "process", "ariaLabel": "Flow from validation to processing"},
    {"id": "e3", "source": "process", "target": "end", "ariaLabel": "Flow from processing to completion"},
]

component = dmc.Paper([
    dmc.Group([
        dmc.Text("Accessibility (ARIA Support)", fw=600),
        dmc.Badge("1.2.0", color="teal", variant="light", size="sm"),
    ], mb="xs"),
    dmc.Text("Full ARIA labels for screen readers. Tab through nodes, use arrow keys to pan.", size="sm", c="dimmed", mb="md"),
    dash_flows.DashFlows(
        id="df-accessibility",
        nodes=nodes,
        edges=edges,
        nodesFocusable=True,
        edgesFocusable=True,
        ariaLabelConfig={
            "rfDiagram": "Interactive data processing workflow",
            "miniMap": "Minimap navigation panel",
            "controls": "Zoom and pan controls",
        },
        style={"height": "280px"},
        fitView=True,
        showControls=True,
        showMiniMap=True,
    ),
    dmc.Alert(
        dmc.Stack([
            dmc.Text("Keyboard Navigation:", size="sm", fw=500),
            dmc.List([
                dmc.ListItem("Tab — cycle through nodes and edges"),
                dmc.ListItem("Enter — select focused element"),
                dmc.ListItem("Delete/Backspace — remove selected"),
                dmc.ListItem("Arrow keys — pan the viewport"),
            ], size="sm"),
        ], gap="xs"),
        color="indigo", variant="light", mt="md",
    ),
], p="md", withBorder=True, radius="md")
```


**Viewport Portal** — Floating annotations at flow coordinates:



```python
# File: docs/dash_flows/viewport_portal.py

"""Viewport Portal — floating annotations at flow coordinates."""
import dash_mantine_components as dmc
import dash_flows

nodes = [
    {"id": "api", "type": "input", "data": {"label": "API Layer"}, "position": {"x": 100, "y": 100}},
    {"id": "cache", "type": "default", "data": {"label": "Cache"}, "position": {"x": 350, "y": 50}},
    {"id": "db", "type": "default", "data": {"label": "Database"}, "position": {"x": 350, "y": 180}},
    {"id": "response", "type": "output", "data": {"label": "Response"}, "position": {"x": 600, "y": 100}},
]
edges = [
    {"id": "e1", "source": "api", "target": "cache"},
    {"id": "e2", "source": "api", "target": "db"},
    {"id": "e3", "source": "cache", "target": "response"},
    {"id": "e4", "source": "db", "target": "response"},
]

overlays = [
    {"x": 80, "y": 30, "content": "Incoming Traffic", "style": {"background": "rgba(59,130,246,0.1)", "color": "#3b82f6", "padding": "4px 10px", "borderRadius": "6px", "fontSize": "12px", "fontWeight": "600", "border": "1px solid rgba(59,130,246,0.3)"}},
    {"x": 320, "y": -10, "content": "Hot Path (< 5ms)", "style": {"background": "rgba(34,197,94,0.1)", "color": "#22c55e", "padding": "4px 10px", "borderRadius": "6px", "fontSize": "11px", "border": "1px solid rgba(34,197,94,0.3)"}},
    {"x": 320, "y": 250, "content": "Cold Path (50-200ms)", "style": {"background": "rgba(249,115,22,0.1)", "color": "#f97316", "padding": "4px 10px", "borderRadius": "6px", "fontSize": "11px", "border": "1px solid rgba(249,115,22,0.3)"}},
    {"x": 580, "y": 30, "content": "Output", "style": {"background": "rgba(168,85,247,0.1)", "color": "#a855f7", "padding": "4px 10px", "borderRadius": "6px", "fontSize": "12px", "fontWeight": "600", "border": "1px solid rgba(168,85,247,0.3)"}},
]

component = dmc.Paper([
    dmc.Group([
        dmc.Text("Viewport Portal (Floating Annotations)", fw=600),
        dmc.Badge("1.2.0", color="teal", variant="light", size="sm"),
    ], mb="xs"),
    dmc.Text(
        "Floating overlays anchored to flow coordinates that move with pan and zoom. "
        "Use for annotations, labels, region markers, and performance indicators.",
        size="sm", c="dimmed", mb="md",
    ),
    dash_flows.DashFlows(
        id="df-viewport-portal",
        nodes=nodes,
        edges=edges,
        viewportOverlays=overlays,
        style={"height": "350px"},
        fitView=True,
        showControls=True,
    ),
    dmc.Code(
        '''viewportOverlays=[
    {"x": 100, "y": 50, "content": "Label", "style": {"background": "...", "color": "..."}},
]''',
        block=True,
    ),
], p="md", withBorder=True, radius="md")
```


---

### 1.2.0 Props Reference

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| `smartHandles` | bool | `False` | Auto-route edges to closest node side |
| `helperLines` | bool | `False` | Show alignment guides when dragging |
| `helperLineThreshold` | number | `5` | Snap distance in pixels |
| `addNodeOnEdgeDrop` | bool | `False` | Create node by dropping edge on canvas |
| `animateLayout` | bool | `False` | Smooth transitions between layouts |
| `animateLayoutDuration` | number | `300` | Animation duration (ms) |
| `enableUndoRedo` | bool | `False` | Enable history tracking |
| `undoRedoMaxHistory` | number | `50` | Max history snapshots |
| `undoRedoAction` | dict | `None` | Trigger undo/redo: `{action: 'undo'}` |
| `undoRedoState` | dict | Output | `{canUndo, canRedo, undoCount, redoCount}` |
| `computeAction` | dict | `None` | Trigger computation: `{action: 'compute'}` |
| `computeResult` | dict | Output | `{traversalOrder, nodeInputs, timestamp}` |
| `toggleCollapseNode` | string | `None` | Group node ID to toggle collapse |
| `collapsedGroups` | list | Output | Currently collapsed group IDs |
| `viewportOverlays` | list | `None` | Floating annotations `[{x, y, content, style}]` |
| `connectionDragThreshold` | number | `1` | Min drag distance before connection |
| `zIndexMode` | string | `"default"` | `"default"` or `"elevate"` |
| `autoPanOnNodeFocus` | bool | `False` | Pan when Tab-focusing nodes |
| `nodesFocusable` | bool | `False` | Enable Tab navigation |
| `edgesFocusable` | bool | `False` | Enable Tab for edges |
| `ariaLabelConfig` | dict | `None` | ARIA labels for regions |
| `deleteElementsAction` | dict | `None` | Programmatic deletion |
| `edgeDroppedNode` | dict | Output | Node created from dropped edge |
| `nodeConnections` | dict | Output | Real-time connection map |

---

### Troubleshooting

#### Nodes Not Appearing

- Ensure each node has `id`, `type`, `data`, and `position`
- Check that container has explicit height: `style={"height": "500px"}`
- Verify node IDs are unique strings

#### Edges Not Connecting

- Verify `source` and `target` match existing node IDs
- Check that source node has a source handle (not output type)
- Check that target node has a target handle (not input type)

#### Callbacks Not Firing

- Add `prevent_initial_call=True` to avoid initial triggers
- Ensure component ID matches callback Input/Output
- Check browser console for JavaScript errors

#### Layout Not Applying

- Ensure `layoutOptions` is a valid JSON string via `json.dumps()`
- Verify ELK algorithm name is correct
- Try adding explicit spacing options

#### Multi-Select Not Working

- Set `multiSelectionKeyCode="Shift"` explicitly
- Ensure `elementsSelectable=True`

#### Dark Mode Issues

- Use modern DMC v2.4.0+ pattern
- Wrap in `dmc.MantineProvider`
- Set `colorMode="dark"` on DashFlows

---

### Resources

- [React Flow Documentation](https://reactflow.dev/) - Underlying library
- [Iconify Icon Search](https://icon-sets.iconify.design/) - Browse icons
- [ELK Layout Options](https://eclipse.dev/elk/reference/options.html) - Layout configuration
- [Dash Mantine Components](https://www.dash-mantine-components.com/) - UI components

---

<!-- /pip/dash_gauge — https://2plot.dev/pip/dash_gauge/llms.txt -->

### Installation
[Visit GitHub Repo](https://github.com/pip-install-python/dash-gauge)
```bash
pip install dash-gauge
```


### Introduction

The `dash-gauge` package provides a suite of interactive and visually appealing components to enhance your Plotly Dash dashboards. Built as wrappers around popular React libraries, this collection includes:

*   **`DashGauge`**: Customizable gauge charts (Grafana, Semicircle, Radial styles).
*   **`DashRotaryKnob`**: Interactive rotary knob controls with various skins.
*   **`DashThermostat`**: A thermostat-like input component.
*   **`DashRCJoystick`**: A virtual joystick component for directional input.
*   **`Dash7SegmentDisplay`**: A classic 7-segment display for numbers and hex values.

This page documents each component included in the suite.



```python
# File: docs/dash_gauge/introduction_example.py

import dash
from dash import html, callback, Input, Output
import dash_gauge as dg
import dash_mantine_components as dmc

# app = dash.Dash(__name__) # Define app in your main docs entry point

component = dmc.Container([
    dmc.Title("Dash Gauge Component Suite Showcase", order=2, ta="center", mb="lg"),

    dmc.SimpleGrid(
        cols={"base": 1, "sm": 2, "lg": 3},
        spacing="lg",
        verticalSpacing="lg",
        children=[
            # 1. DashGauge Example
            dmc.Paper(
                p="md", shadow="sm", withBorder=True, children=[
                    dmc.Stack([
                        dmc.Text("DashGauge (Semicircle)", fw=500, ta="center"),
                        dmc.Center(
                            dg.DashGauge(
                                id="intro-gauge",
                                type="semicircle",
                                value=65,
                                minValue=0,
                                maxValue=100,
                                style={'width': '90%'} # Adjust width as needed
                            )
                        )
                    ])
                ]
            ),

            # 2. DashRotaryKnob Example
            dmc.Paper(
                p="md", shadow="sm", withBorder=True, children=[
                    dmc.Stack([
                        dmc.Text("DashRotaryKnob", fw=500, ta="center"),
                        dmc.Center(
                            dg.DashRotaryKnob(
                                id="intro-knob",
                                skinName="s12", # Choose a visually interesting skin
                                value=30,
                                min=0,
                                max=100,
                                style={"width": "100px", "height": "100px"} # Explicit size
                            )
                        ),
                        dmc.Text("Value: --", id="intro-knob-output", ta="center", size="sm", c="dimmed")
                    ])
                ]
            ),

            # 3. DashThermostat Example
            dmc.Paper(
                p="md", shadow="sm", withBorder=True, children=[
                    dmc.Stack([
                        dmc.Text("DashThermostat", fw=500, ta="center"),
                        dmc.Space(h=20),
                        dmc.Center(
                            dg.DashThermostat(
                                id="intro-thermostat",
                                value=21,
                                min=5,
                                max=35,
                                valueSuffix="°C",
                                style={'width': '200px', 'height': '300px'} # Control size
                            )
                        )
                    ])
                ]
            ),

            # 4. DashRCJoystick Example
            dmc.Paper(
                p="md", shadow="sm", withBorder=True, children=[
                    dmc.Stack([
                        dmc.Text("DashRCJoystick", fw=500, ta="center"),
                        dmc.Center(
                            dg.DashRCJoystick(
                                id='intro-joystick',
                                directionCountMode='Nine',
                                baseRadius=60, # Slightly smaller for grid
                                controllerRadius=30,
                            )
                        ),
                        dmc.Text("Direction: Center", id="intro-joystick-direction", ta="center", size="sm", c="dimmed"),
                        dmc.Text("Angle: N/A", id="intro-joystick-angle", ta="center", size="sm", c="dimmed"),
                        dmc.Text("Distance: 0.00", id="intro-joystick-distance", ta="center", size="sm", c="dimmed"),
                    ])
                ]
            ),
        ]
    )
], fluid=True, pt="xl", pb="xl")


# --- Callbacks ---

@callback(
    Output("intro-knob-output", "children"),
    Input("intro-knob", "value")
)
def update_intro_knob_output(value):
    if value is None:
        return "Value: --"
    return f"Value: {value:.1f}"

@callback(
    Output('intro-joystick-direction', 'children'),
    Output('intro-joystick-angle', 'children'),
    Output('intro-joystick-distance', 'children'),
    Input('intro-joystick', 'direction'),
    Input('intro-joystick', 'angle'),
    Input('intro-joystick', 'distance')
)
def update_intro_joystick_output(direction, angle, distance):
    angle_str = f"Angle: {angle:.1f}°" if angle is not None else "Angle: N/A"
    distance_str = f"Distance: {distance:.2f}" if distance is not None else "Distance: N/A"
    direction_str = f"Direction: {direction}" if direction is not None else "Direction: N/A"
    return direction_str, angle_str, distance_str

# Add this section if running this file standalone for testing
# if __name__ == "__main__":
#     app = dash.Dash(__name__)
#     app.layout = component
#     app.run_server(debug=True)
```


---

### DashGauge

Displays a value on a customizable gauge. Supports different styles, color ranges, sub-arcs, custom labels, and pointers. Ideal for visualizing KPIs, sensor readings, or progress metrics.



```python
# File: docs/dash_gauge/gauge_example.py

import dash
from dash import html, dcc, callback, Input, Output
import dash_gauge as dg
import dash_mantine_components as dmc

# Based on usage_gauge.py

component = dmc.Container([
    dmc.Title("DashGauge Examples", order=3, ta="center", mb="lg"),
    dmc.SimpleGrid(
        cols={"base": 1, "sm": 1, "lg": 3},
        spacing="lg",
        children=[
            dmc.Paper(p="md", shadow="sm", withBorder=True, children=dmc.Stack([
                dmc.Text("Basic Gauge", fw=500, ta="center"),
                dmc.Center(
                    dg.DashGauge(
                        id="basic-gauge-docs",
                        value=50,
                        style={'width': '90%'}
                    )
                )
            ])),
            dmc.Paper(p="md", shadow="sm", withBorder=True, children=dmc.Stack([
                dmc.Text("Temperature Gauge (Semicircle)", fw=500, ta="center"),
                dmc.Center(
                    dg.DashGauge(
                        id="temperature-gauge-docs",
                        type="semicircle",
                        value=22.5,
                        minValue=10,
                        maxValue=35,
                        arc={
                            "width": 0.2, "padding": 0.005, "cornerRadius": 1,
                            "subArcs": [
                                {"limit": 15, "color": "#EA4228", "showTick": True, "tooltip": {"text": "Too low!"}},
                                {"limit": 17, "color": "#F5CD19", "showTick": True, "tooltip": {"text": "Low"}},
                                {"limit": 28, "color": "#5BE12C", "showTick": True, "tooltip": {"text": "OK"}},
                                {"limit": 30, "color": "#F5CD19", "showTick": True, "tooltip": {"text": "High"}},
                                {"color": "#EA4228", "tooltip": {"text": "Too high!"}}
                            ]
                        },
                        pointer={"color": "#345243", "length": 0.80, "width": 15},
                        labels={"valueLabel": {"style": {"fontSize": "30px"}}}, # Simplified labels
                        style={'width': '90%'}
                    )
                )
            ])),
            dmc.Paper(p="md", shadow="sm", withBorder=True, children=dmc.Stack([
                dmc.Text("Bandwidth Gauge (Radial)", fw=500, ta="center"),
                dmc.Center(
                    dg.DashGauge(
                        id="bandwidth-gauge-docs",
                        type="radial", # Changed type for variety
                        value=900,
                        maxValue=3000,
                        arc={
                            "nbSubArcs": 150,
                            "colorArray": ["#5BE12C", "#F5CD19", "#EA4228"],
                            "width": 0.3, # Adjusted width for radial
                            "padding": 0.003
                        },
                         style={'width': '90%'}
                    )
                )
            ])),
        ]
    ),
    dmc.Space(h="xl"),
    dmc.Paper(p="md", shadow="sm", withBorder=True, children=dmc.Stack([
        dmc.Text("Interactive Gauge", fw=500, ta="center"),
        dmc.Text("Use the slider to update the gauge value:", size="sm", ta="center"),
        dmc.Slider(
            id="gauge-slider-docs",
            min=0, max=100, step=1, value=40,
            labelTransitionProps={
                "transition": "skew-down",
                "duration": 150,
                "timingFunction": "linear",
            },
        ),
        dmc.Center(
            dg.DashGauge(
                id="interactive-gauge-docs",
                type="radial",
                value=50, # Initial value linked to slider
                arc={
                    "colorArray": ["#5BE12C", "#EA4228"],
                    "subArcs": [{"limit": 10}, {"limit": 30}, {}, {}, {}],
                    "padding": 0.02,
                    "width": 0.3
                },
                pointer={"elastic": True, "animationDelay": 0},
                labels={
                    "tickLabels": {"type": "inner", "ticks": [{"value": i} for i in range(20, 101, 20)]}
                },
                 style={'width': '300px', 'marginTop': '20px'} # Control size
            )
        )
    ]))
], fluid=True)

@callback(
    Output("interactive-gauge-docs", "value"),
    Input("gauge-slider-docs", "value")
)
def update_gauge_docs(value):
    return value
```



#### DashGauge Props

| Prop              | Type                       | Default      | Description                                                                                                                                  |
|-------------------|----------------------------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------|
| `id`              | string                     | -            | Component ID for Dash callbacks.                                                                                                             |
| `value`           | number                     | `33`         | The value the gauge should indicate.                                                                                                         |
| `type`            | string                     | `'grafana'`  | Style of the gauge. Choices: `'grafana'`, `'semicircle'`, `'radial'`.                                                                         |
| `minValue`        | number                     | `0`          | Minimum value of the gauge scale.                                                                                                            |
| `maxValue`        | number                     | `100`        | Maximum value of the gauge scale.                                                                                                            |
| `className`       | string                     | `dash-gauge` | CSS class name for the component.                                                                                                            |
| `style`           | object                     | `{}`         | Inline CSS styles for the component.                                                                                                         |
| `marginInPercent` | number or object           | `undefined`  | Sets the margin for the chart inside the SVG. Can be a single number or `{top, bottom, left, right}`.                                         |
| `arc`             | object                     | `{}`         | Configuration for the gauge arc (e.g., `width`, `padding`, `cornerRadius`, `colorArray`, `subArcs`, `gradient`). See `usage_gauge.py` for details. |
| `pointer`         | object                     | `{}`         | Configuration for the gauge pointer/needle (e.g., `type`, `color`, `length`, `width`, `animate`, `elastic`). See `usage_gauge.py` for details.   |
| `labels`          | object                     | `{}`         | Configuration for value and tick labels (e.g., `valueLabel`, `tickLabels`, `formatTextValue`, `hideMinMax`). See `usage_gauge.py` for details. |
| `setProps`        | func                       | -            | Dash-assigned callback function.                                                                                                             |

**Note:** For detailed examples of `arc`, `pointer`, and `labels` configurations, please refer to the `usage_gauge.py` file in the GitHub repository.

---

### DashRotaryKnob

An interactive knob component, useful for selecting values within a range (e.g., volume, tuning). Comes with multiple visual skins from the `react-rotary-knob-skin-pack`.



```python
# File: docs/dash_gauge/knob_example.py

import dash
from dash import html, dcc, callback, Input, Output, State
import dash_gauge as dg
import dash_mantine_components as dmc

# Based on usage_rotary_knob.py

component = dmc.Container([
    dmc.Title("DashRotaryKnob Example", order=3, ta="center", mb="lg"),
    dmc.Grid(
        gutter="xl",
        children=[
            dmc.GridCol(span={"md": 5, "sm": 12}, children=dmc.Stack(align="center", children=[
                dmc.Text("Interactive Knob", fw=500),
                dmc.Text(id="knob-value-display-docs", size="lg", mt="sm"),
                dg.DashRotaryKnob(
                    id="interactive-knob-docs",
                    skinName="s10",
                    value=50,
                    min=0,
                    max=100,
                    # format="{value}%", # Formatting might be better handled in display callback
                    style={"width": "150px", "height": "150px"} # Adjust size
                ),
            ])),
            dmc.GridCol(span={"md": 7, "sm": 12}, children=dmc.Stack([
                dmc.Text("Controls", fw=500),
                dmc.Slider(
                    id="knob-slider-docs",
                    label="Adjust with slider",
                    min=0, max=100, step=1, value=50,
                    marks=[{"value": i, "label": str(i)} for i in range(0, 101, 20)],
                    mb="lg"
                ),
                dmc.Select(
                    id="skin-selector-docs",
                    label="Change skin",
                    data=[{"label": f"Skin s{i}", "value": f"s{i}"} for i in range(1, 19)],
                    value="s10",
                    clearable=False,
                     mb="lg"
                ),

            ]))
        ]
    )
], fluid=True)

@callback(
    Output("knob-value-display-docs", "children"),
    Input("interactive-knob-docs", "value")
)
def update_knob_value_display_docs(value):
     if value is None:
        return "Current value: --"
     return f"Current value: {value:.1f}"

@callback(
    Output("interactive-knob-docs", "value"),
    Input("knob-slider-docs", "value"),
)
def update_knob_from_slider_docs(value):
    return value

@callback(
    Output("interactive-knob-docs", "skinName"),
    Input("skin-selector-docs", "value"),
)
def update_knob_skin_docs(skin_name):
    return skin_name

```


#### DashRotaryKnob Props

| Prop            | Type    | Default   | Description                                                                                                |
|-----------------|---------|-----------|------------------------------------------------------------------------------------------------------------|
| `id`            | string  | -         | Component ID for Dash callbacks.                                                                           |
| `value`         | number  | `0`       | The current value of the knob. Updated via interaction or callbacks.                                     |
| `min`           | number  | `0`       | Minimum value of the knob.                                                                                 |
| `max`           | number  | `100`     | Maximum value of the knob.                                                                                 |
| `step`          | number  | `1`       | Increment/decrement step size.                                                                             |
| `skinName`      | string  | `'s1'`    | Selects the visual appearance (e.g., `'s1'`, `'s5'`, `'s10'`, up to `'s18'`). See repository for examples. |
| `preciseMode`   | boolean | `true`    | If true, requires Shift key + drag for fine adjustments.                                                     |
| `unlockDistance`| number  | `0`       | Degrees the mouse must move vertically to "unlock" the knob for rotation.                                    |
| `className`     | string  | `''`      | CSS class name for the component.                                                                          |
| `style`         | object  | `{}`      | Inline CSS styles for the component.                                                                       |
| `setProps`      | func    | -         | Dash-assigned callback function.                                                                           |

**Note:** Explore all 18 skins by changing the `skinName` prop! See `usage_rotary_knob.py` for an interactive example. Only skins 1 & 10-15 look good 🤷‍ "no idea why the other skins don't alin correctly".

---

### DashThermostat

A component mimicking a thermostat interface for setting a target value, typically temperature.



```python
# File: docs/dash_gauge/thermostat_example.py

import dash
from dash import html, dcc, callback, Input, Output, State
import dash_gauge as dg
import dash_mantine_components as dmc

# Based on usage_thermostat.py, but simplified for docs

# Define some example colors
cool_colors = ['#dae8eb', '#2c8e98']
heat_colors = ['#cfac48', '#cd5401']

component = dmc.Container([
    dmc.Title("DashThermostat Example", order=3, ta="center", mb="lg"),
    dmc.Grid(
        gutter="xl",
        children=[
            dmc.GridCol(span={"md": 6, "sm": 12}, children=dmc.Stack(align="center", children=[
                 dmc.Text("Thermostat Control", fw=500),
                 dmc.Space(h=20),
                 dg.DashThermostat(
                    id='thermostat-docs',
                    value=21,
                    min=5,
                    max=35,
                    valueSuffix='°C',
                    track={'colors': cool_colors}, # Start with cool colors
                    style={'width': '250px', 'height': '350px', 'marginBottom': '20px'},
                 )
            ])),
             dmc.GridCol(span={"md": 6, "sm": 12}, children=dmc.Stack([
                 dmc.Text("Controls", fw=500),
                 dmc.Text("Set Temperature:", size="sm"),
                 dmc.Slider(
                     id="thermostat-slider-docs",
                     min=5, max=35, step=0.5, value=21,
                     marks=[{"value": v, "label": f"{v}°"} for v in range(5, 36, 5)],
                     style={"maxWidth": 300},
                     mb="lg"
                 ),
                 dmc.Text("Current Setting:", size="sm"),
                 dmc.Title(id="thermostat-value-display-docs", order=4, ta="center"),
                 dmc.Space(h="lg"),
                 dmc.Text("Toggle Disabled:", size="sm"),
                 dmc.Switch(id="thermostat-disable-switch-docs", label="Disable Thermostat", checked=False),
                 dmc.Space(h="lg"),
                 dmc.Text("Change Track Color:", size="sm"),
                  dmc.SegmentedControl(
                    id="thermostat-color-switch-docs",
                    value="cool",
                    data=[
                        {"label": "Cool Mode", "value": "cool"},
                        {"label": "Heat Mode", "value": "heat"},
                    ],
                    fullWidth=True,
                    mb="md",
                ),
             ]))
        ]
    )
], fluid=True)

@callback(
    Output('thermostat-docs', 'value'),
    Input('thermostat-slider-docs', 'value')
)
def update_thermostat_from_slider(value):
    return value

@callback(
    Output('thermostat-value-display-docs', 'children'),
    Input('thermostat-docs', 'value')
)
def display_thermostat_value(value):
    if value is None:
        return "-- °C"
    return f"{value:.1f} °C"

@callback(
    Output('thermostat-docs', 'disabled'),
    Input('thermostat-disable-switch-docs', 'checked')
)
def toggle_thermostat_disabled(checked):
    return checked

@callback(
    Output('thermostat-docs', 'track'),
    Input('thermostat-color-switch-docs', 'value'),
    State('thermostat-docs', 'track') # Get current track state if needed
)
def update_thermostat_track_color(mode, current_track):
    # Ensure track is a dictionary before modifying
    track_config = current_track if isinstance(current_track, dict) else {}
    if mode == 'cool':
        track_config['colors'] = cool_colors
    elif mode == 'heat':
        track_config['colors'] = heat_colors
    # Add other track properties if they exist, e.g., thickness
    # track_config['thickness'] = current_track.get('thickness', 0.2) # Example
    return track_config
```


#### DashThermostat Props

| Prop         | Type    | Default     | Description                                                                                             |
|--------------|---------|-------------|---------------------------------------------------------------------------------------------------------|
| `id`         | string  | -           | Component ID for Dash callbacks.                                                                        |
| `value`      | number  | Required    | The current set value (e.g., temperature). Updated via interaction or callbacks.                          |
| `min`        | number  | `0`         | Minimum value of the thermostat scale.                                                                    |
| `max`        | number  | `100`       | Maximum value of the thermostat scale.                                                                    |
| `valueSuffix`| string  | `'°'`       | Text to display after the value (e.g., `'°C'`, `'°F'`, `'%`).                                             |
| `disabled`   | boolean | `false`     | If true, disables user interaction with the thermostat.                                                   |
| `handle`     | object  | `undefined` | Configuration object for the draggable handle (e.g., `size`, `colors`). See `react-thermostat` docs.      |
| `track`      | object  | `undefined` | Configuration object for the background track (e.g., `colors`, `thickness`, `markers`). See `react-thermostat` docs. |
| `className`  | string  | `''`        | CSS class name for the component.                                                                       |
| `style`      | object  | `{}`        | Inline CSS styles for the component. Note: Component has min-width/height defaults.                     |
| `setProps`   | func    | -           | Dash-assigned callback function.                                                                        |

**Note:** The `usage_thermostat.py` example demonstrates complex interaction with custom styling and mode switching. It may require CSS files placed in an `assets` folder.

---

### DashRCJoystick

A virtual joystick component that reports direction, angle, and distance based on user interaction. Useful for controlling elements in simulations, games, or robotics dashboards.



```python
# File: docs/dash_gauge/joystick_example.py

import dash
from dash import html, dcc, callback, Input, Output, State
import dash_gauge as dg
import dash_mantine_components as dmc

# Based on usage_rc_joystick.py

component = dmc.Container([
    dmc.Title("DashRCJoystick Example", order=3, ta="center", mb="lg"),
    dmc.Grid(
        gutter="xl",
        children=[
            # Left side: Joystick Component
            dmc.GridCol(span={"md": 5, "sm": 12}, children=dmc.Stack(align="center", children=[
                dmc.Text("Interactive Joystick", fw=500),
                dg.DashRCJoystick(
                    id='my-joystick-docs',
                    directionCountMode='Nine', # Default to Nine for demo
                    baseRadius=75,
                    controllerRadius=35,
                    style={'marginTop': '20px'} # Add some space
                ),
            ])),

            # Right side: Displaying Joystick State & Controls
            dmc.GridCol(span={"md": 7, "sm": 12}, children=dmc.Stack([
                dmc.Text("Joystick State", fw=500),
                dmc.Text(id='joystick-output-direction-docs', size="sm"),
                dmc.Text(id='joystick-output-angle-docs', size="sm"),
                dmc.Text(id='joystick-output-distance-docs', size="sm"),
                dmc.Divider(my="md"),
                dmc.RadioGroup(
                    [dmc.Radio(label, value=value) for label, value in
                     [('5 Directions', 'Five'), ('9 Directions', 'Nine')]],
                    id='direction-mode-selector-docs',
                    value='Nine', # Initial value matches component
                    label="Direction Mode",
                    size="sm",
                    mb="md",
                ),
                dmc.Text("Base Radius:", size="sm", fw=500),
                dmc.Slider(
                    id='base-radius-slider-docs', min=50, max=150, step=5, value=75,
                    marks=[{"value": i, "label": str(i)} for i in range(50, 151, 25)],
                    mb="md"
                ),
                dmc.Text("Controller Radius:", size="sm", fw=500),
                dmc.Slider(
                    id='controller-radius-slider-docs', min=20, max=70, step=5, value=35,
                    marks=[{"value": i, "label": str(i)} for i in range(20, 71, 10)],
                    mb="md"
                 ),
            ]))
        ])
], fluid=True)


# Callback to display joystick state changes
@callback(
    Output('joystick-output-direction-docs', 'children'),
    Output('joystick-output-angle-docs', 'children'),
    Output('joystick-output-distance-docs', 'children'),
    Input('my-joystick-docs', 'direction'),
    Input('my-joystick-docs', 'angle'),
    Input('my-joystick-docs', 'distance')
)
def update_joystick_output_docs(direction, angle, distance):
    angle_str = f"Angle: {angle:.1f}°" if angle is not None else "Angle: N/A (Center)"
    distance_str = f"Distance: {distance:.2f}" if distance is not None else "Distance: N/A"
    direction_str = f"Direction: {direction}" if direction is not None else "Direction: N/A"
    return direction_str, angle_str, distance_str

# Callback to update joystick configuration from controls
@callback(
    Output('my-joystick-docs', 'directionCountMode'),
    Output('my-joystick-docs', 'baseRadius'),
    Output('my-joystick-docs', 'controllerRadius'),
    Input('direction-mode-selector-docs', 'value'),
    Input('base-radius-slider-docs', 'value'),
    Input('controller-radius-slider-docs', 'value'),
)
def update_joystick_config_docs(direction_mode, base_radius, controller_radius):
    return direction_mode, base_radius, controller_radius
```


#### DashRCJoystick Props

| Prop                 | Type    | Default      | Description                                                                                                                                 |
|----------------------|---------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------|
| `id`                 | string  | -            | Component ID for Dash callbacks.                                                                                                            |
| `baseRadius`         | number  | `75`         | Radius of the joystick's static base circle.                                                                                                  |
| `controllerRadius`   | number  | `35`         | Radius of the movable controller knob.                                                                                                      |
| `directionCountMode` | string  | `'Five'`     | Determines the reported directions. Choices: `'Five'` (Center, Top, Bottom, Left, Right), `'Nine'` (includes diagonals).                      |
| `insideMode`         | boolean | `false`      | If true, the controller knob stays within the base radius.                                                                                  |
| `throttle`           | number  | `0`          | Throttle time in milliseconds for `onChange` events. `0` means no throttle.                                                                   |
| `className`          | string  | `''`         | CSS class name for the container.                                                                                                           |
| `style`              | object  | `{}`         | Inline CSS styles for the container.                                                                                                        |
| `controllerClassName`| string  | `undefined`  | Additional CSS class for the controller knob.                                                                                               |
| **Read-only Props**  |         |              | *(These props are updated by the component and read in callbacks)*                                                                          |
| `angle`              | number  | `undefined`  | [Readonly] Current angle of the joystick (degrees). `undefined` when centered.                                                              |
| `direction`          | string  | `'Center'`   | [Readonly] Current direction string (e.g., 'Top', 'BottomLeft', 'Center'). Possible values depend on `directionCountMode`.                   |
| `distance`           | number  | `0`          | [Readonly] Current distance of the controller from the center (normalized 0-1 based on `baseRadius`).                                       |
| `setProps`           | func    | -            | Dash-assigned callback function.                                                                                                            |

**Note:** See `usage_rc_joystick.py` for an example controlling the joystick's appearance and reading its state.

---

---

<!-- /pip/dash_image_gallery — https://2plot.dev/pip/dash_image_gallery/llms.txt -->

`dash-image-gallery` is a Dash component library that provides a feature-rich, responsive image gallery with lightbox functionality. It offers extensive customization options including thumbnail navigation, fullscreen viewing, automatic slideshows, touch/swipe support, lazy loading, keyboard controls, and multiple layout configurations. Perfect for portfolios, product showcases, photo galleries, and any application requiring elegant image presentation.

### Installation

[Visit GitHub Repo](https://github.com/pip-install-python/dash_image_gallery)

⭐️ Star this component on GitHub! Stay up to date on new releases and browse the codebase.

```bash
pip install dash-image-gallery
```

---

### Quick Start

Create a basic image gallery with thumbnail navigation and fullscreen support.



```python
# File: docs/dash_image_gallery/introduction.py

from dash import *
from dash_image_gallery import DashImageGallery
import dash_mantine_components as dmc

component = dmc.Stack([
    DashImageGallery(
        id='input',
        items=[
            {
                "original": "https://cdn.britannica.com/78/43678-050-F4DC8D93/Starry-Night-canvas-Vincent-van-Gogh-New-1889.jpg",
                "thumbnail": "https://cdn.britannica.com/78/43678-050-F4DC8D93/Starry-Night-canvas-Vincent-van-Gogh-New-1889.jpg",
                "originalHeight": 300,
                "originalWidth": 300,
            },
            {
                "original": "https://mir-s3-cdn-cf.behance.net/project_modules/max_1200/5eeea355389655.59822ff824b72.gif",
                "thumbnail": "https://mir-s3-cdn-cf.behance.net/project_modules/max_1200/5eeea355389655.59822ff824b72.gif",
                "originalHeight": 300,
                "originalWidth": 300,
            },
            {
                "original": "https://www.theartstory.org/images20/hero/profile/van_gogh_vincent_525.jpg",
                "thumbnail": "https://www.theartstory.org/images20/hero/profile/van_gogh_vincent_525.jpg",
                "originalHeight": 300,
                "originalWidth": 300,
            },
            {
                "original": "/assets/images/03.jpg",
                "thumbnail": "/assets/images/03.jpg",
                "originalHeight": 300,
                "originalWidth": 300,
            },
        ],
        infinite=True,
        lazyLoad=False,
        showNav=True,
        showThumbnails=True,
        thumbnailPosition='bottom',
        showFullscreenButton=True,
        useBrowserFullscreen=True,
        useTranslate3D=True,
        showPlayButton=True,
        isRTL=False,
        showBullets=False,
        showIndex=True,
        autoPlay=True,
        disableThumbnailScroll=False,
        disableKeyDown=False,
        disableSwipe=False,
        disableThumbnailSwipe=False,
        onErrorImageURL=None,
        indexSeparator=' / ',
        slideDuration=450,
        swipingTransitionDuration=0,
        slideInterval=3000,
        slideOnThumbnailOver=True,
        flickThreshold=0.4,
        swipeThreshold=30,
        stopPropagation=False,
        startIndex=0,
        useWindowKeyDown=True,
    ),
])
```


---

### Image Configuration

Each image in the gallery is defined by an object in the `items` array. Here's the structure:

```python
items = [
    {
        'original': '/path/to/full-size-image.jpg',      # Required: Full-size image URL
        'thumbnail': '/path/to/thumbnail.jpg',           # Thumbnail image URL
        'fullscreen': '/path/to/fullscreen-image.jpg',   # Optional: High-res for fullscreen
        'originalAlt': 'Image description',              # Alt text for accessibility
        'thumbnailAlt': 'Thumbnail description',         # Thumbnail alt text
        'description': 'Caption text',                   # Image caption
        'originalTitle': 'Image title',                  # Title attribute
        'thumbnailLabel': 'Label',                       # Label displayed on thumbnail
        'originalWidth': 1920,                           # Original image width
        'originalHeight': 1080,                          # Original image height
        'thumbnailWidth': 200,                           # Thumbnail width
        'thumbnailHeight': 150,                          # Thumbnail height
    }
]
```

**Required Fields:**
- `original`: URL or path to the full-size image

**Optional Fields:**
- `thumbnail`: Thumbnail image (defaults to `original` if not provided)
- `fullscreen`: High-resolution image for fullscreen mode
- `description`: Caption text displayed below the image
- `originalAlt`, `thumbnailAlt`: Accessibility text
- Dimension fields for optimization

---

### Gallery Features

**Navigation Controls:**

The gallery provides multiple navigation methods:
- **Arrow Keys**: Navigate left/right through images
- **Navigation Arrows**: Click arrows on sides of image
- **Thumbnails**: Click thumbnail to jump to specific image
- **Bullets**: Optional bullet navigation dots
- **Touch/Swipe**: Swipe on mobile devices

**Display Modes:**

- **Fullscreen Mode**: Toggle fullscreen viewing with dedicated button
- **Slideshow Mode**: Automatic image progression with configurable interval
- **Thumbnail Positions**: Place thumbnails on top, bottom, left, or right
- **Index Display**: Show current image number (e.g., "3 / 12")

---

### Customizing Thumbnails

Control thumbnail behavior and positioning:

```python
ImageGallery(
    items=images,
    thumbnailPosition='right',      # Options: 'top', 'right', 'bottom', 'left'
    showThumbnails=True,            # Show/hide thumbnail strip
    disableThumbnailScroll=False,   # Auto-scroll to active thumbnail
    slideOnThumbnailOver=True,      # Change image on thumbnail hover
)
```

**Thumbnail Position Options:**
- `'bottom'` (default): Horizontal strip below image
- `'top'`: Horizontal strip above image
- `'right'`: Vertical strip on right side
- `'left'`: Vertical strip on left side

---

### Slideshow & Autoplay

Enable automatic slideshow with customizable timing:

```python
ImageGallery(
    items=images,
    autoPlay=True,           # Enable automatic slideshow
    slideInterval=3000,      # Time between slides (milliseconds)
    showPlayButton=True,     # Show play/pause control
    infinite=True,           # Loop back to first image
    slideDuration=450,       # Transition animation duration
)
```

---

### Performance Optimization

**Lazy Loading:**

Load images only when needed to improve initial page load:

```python
ImageGallery(
    items=images,
    lazyLoad=True,           # Enable lazy loading
    startIndex=0,            # Start at first image
)
```

**Transition Optimization:**

```python
ImageGallery(
    items=images,
    useTranslate3D=True,              # Use GPU-accelerated transitions
    slideDuration=450,                 # Smooth transition (450ms)
    swipingTransitionDuration=0,       # Instant swipe feedback
)
```

---

### Touch & Swipe Controls

Configure touch interaction behavior:

```python
ImageGallery(
    items=images,
    disableSwipe=False,            # Enable/disable image swiping
    disableThumbnailSwipe=False,   # Enable/disable thumbnail swiping
    swipeThreshold=30,             # % of width to trigger slide change
    flickThreshold=0.4,            # Velocity threshold for flick
)
```

---

### Keyboard Navigation

Control keyboard shortcuts:

```python
ImageGallery(
    items=images,
    disableKeyDown=False,      # Enable keyboard controls
    useWindowKeyDown=True,     # Listen globally vs. on element only
)
```

**Default Keyboard Shortcuts:**
- **Left Arrow**: Previous image
- **Right Arrow**: Next image
- **Esc**: Exit fullscreen
- **Space**: Play/pause slideshow (when enabled)

---

### Fullscreen Mode

Configure fullscreen viewing experience:

```python
ImageGallery(
    items=images,
    showFullscreenButton=True,    # Show fullscreen toggle
    useBrowserFullscreen=True,    # Use native browser fullscreen API
)
```

**Fullscreen Options:**
- `useBrowserFullscreen=True`: Native browser fullscreen (recommended)
- `useBrowserFullscreen=False`: CSS-based fullscreen (for compatibility)

---

### Advanced Customization

**Custom Rendering:**

Provide custom render functions for complete control:

```python
def render_custom_item(item):
    """Custom renderer for main image display"""
    return html.Div([
        html.Img(src=item['original'], className='custom-image'),
        html.P(item.get('description', ''), className='caption')
    ])

def render_custom_thumbnail(item):
    """Custom renderer for thumbnails"""
    return html.Div([
        html.Img(src=item['thumbnail']),
        html.Span(item.get('thumbnailLabel', ''))
    ])

ImageGallery(
    items=images,
    renderItem=render_custom_item,
    renderThumbInner=render_custom_thumbnail,
)
```

**Error Handling:**

Specify fallback image when loading fails:

```python
ImageGallery(
    items=images,
    onErrorImageURL='/assets/image-not-found.png',
)
```

---

### RTL Support

Enable right-to-left layout for RTL languages:

```python
ImageGallery(
    items=images,
    isRTL=True,  # Reverse navigation direction
)
```

---

### Component Properties

| Property           | Type        | Default      | Description                                                                                                     |
| :----------------- | :---------- | :----------- | :-------------------------------------------------------------------------------------------------------------- |
| **`id`**           | `string`    | **Required** | Unique identifier for the component used in Dash callbacks.                                                     |
| `items`            | `array`     | **Required** | Array of image objects. Each object requires `original` URL and can include thumbnail, alt text, etc.           |
| `infinite`         | `bool`      | `True`       | Enable infinite loop - gallery wraps from last image back to first.                                             |
| `lazyLoad`         | `bool`      | `False`      | Load images only when needed to improve performance.                                                            |
| `showNav`          | `bool`      | `True`       | Display left/right navigation arrows on the sides of images.                                                    |
| `showThumbnails`   | `bool`      | `True`       | Display thumbnail strip for quick navigation.                                                                   |
| `thumbnailPosition`| `string`    | `"bottom"`   | Position of thumbnail strip. Options: `"top"`, `"right"`, `"bottom"`, `"left"`.                                 |
| `showFullscreenButton` | `bool`  | `True`       | Display button to toggle fullscreen mode.                                                                       |
| `useBrowserFullscreen` | `bool`  | `True`       | Use native browser fullscreen API. If `False`, uses CSS-based fullscreen.                                       |
| `useTranslate3D`   | `bool`      | `True`       | Use GPU-accelerated `translate3d` transitions instead of `translate`.                                           |
| `showPlayButton`   | `bool`      | `True`       | Display play/pause button for slideshow control.                                                                |
| `isRTL`            | `bool`      | `False`      | Enable right-to-left layout and reverse navigation direction.                                                   |
| `showBullets`      | `bool`      | `False`      | Display bullet navigation dots below the image.                                                                 |
| `showIndex`        | `bool`      | `False`      | Display current image index (e.g., "3 / 12").                                                                   |
| `autoPlay`         | `bool`      | `False`      | Enable automatic slideshow on component mount.                                                                  |
| `disableThumbnailScroll` | `bool` | `False`     | Disable automatic scrolling of thumbnail container to active thumbnail.                                          |
| `disableKeyDown`   | `bool`      | `False`      | Disable keyboard navigation (arrow keys, esc).                                                                  |
| `disableSwipe`     | `bool`      | `False`      | Disable touch swipe gestures on main images.                                                                    |
| `disableThumbnailSwipe` | `bool` | `False`      | Disable touch swipe gestures on thumbnail strip.                                                                |
| `onErrorImageURL`  | `string`    | `None`       | Fallback image URL to display when an image fails to load.                                                      |
| `indexSeparator`   | `string`    | `" / "`      | Separator string for index display (e.g., "3 / 12").                                                            |
| `slideDuration`    | `number`    | `450`        | Duration of slide transition animation in milliseconds.                                                         |
| `swipingTransitionDuration` | `number` | `0`     | Transition duration while actively swiping (0 = instant feedback).                                               |
| `slideInterval`    | `number`    | `3000`       | Time between automatic slides in autoplay mode (milliseconds).                                                  |
| `slideOnThumbnailOver` | `bool`  | `False`      | Change to image when hovering over its thumbnail.                                                               |
| `flickThreshold`   | `number`    | `0.4`        | Velocity threshold for detecting a "flick" gesture (0-1 scale).                                                 |
| `swipeThreshold`   | `number`    | `30`         | Percentage of slide width that must be swiped to trigger slide change.                                          |
| `stopPropagation`  | `bool`      | `False`      | Stop event propagation for swipe events (prevents parent scrolling).                                            |
| `startIndex`       | `number`    | `0`          | Index of image to display initially (0-based).                                                                  |
| `useWindowKeyDown` | `bool`      | `True`       | Listen for keyboard events globally. If `False`, only when gallery is focused.                                  |
| `additionalClass`  | `string`    | `None`       | Additional CSS class name to apply to the gallery root element.                                                 |
| `renderItem`       | `function`  | `None`       | Custom render function for main image display. Receives item object as parameter.                               |
| `renderThumbInner` | `function`  | `None`       | Custom render function for thumbnail content. Receives item object as parameter.                                |
| `onImageError`     | `function`  | `None`       | Callback fired when main image fails to load. Receives event object.                                            |
| `onThumbnailError` | `function`  | `None`       | Callback fired when thumbnail fails to load. Receives event object.                                             |
| `onThumbnailClick` | `function`  | `None`       | Callback fired when thumbnail is clicked. Receives `(event, index)`.                                            |
| `onBulletClick`    | `function`  | `None`       | Callback fired when navigation bullet is clicked. Receives `(event, index)`.                                    |
| `onImageLoad`      | `function`  | `None`       | Callback fired when image loads successfully. Receives event object.                                            |
| `onSlide`          | `function`  | `None`       | Callback fired after slide transition completes. Receives `currentIndex`.                                       |
| `onBeforeSlide`    | `function`  | `None`       | Callback fired before slide transition starts. Receives `nextIndex`.                                            |
| `onScreenChange`   | `function`  | `None`       | Callback fired when fullscreen mode changes. Receives boolean (true = fullscreen).                              |
| `onPause`          | `function`  | `None`       | Callback fired when slideshow is paused. Receives `currentIndex`.                                               |
| `onPlay`           | `function`  | `None`       | Callback fired when slideshow starts playing. Receives `currentIndex`.                                          |
| `onClick`          | `function`  | `None`       | Callback fired when gallery is clicked. Receives event object.                                                  |
| `onTouchMove`      | `function`  | `None`       | Callback fired during touch move gesture. Receives event object.                                                |
| `onTouchEnd`       | `function`  | `None`       | Callback fired when touch gesture ends. Receives event object.                                                  |
| `onTouchStart`     | `function`  | `None`       | Callback fired when touch gesture starts. Receives event object.                                                |
| `onMouseOver`      | `function`  | `None`       | Callback fired on mouse over gallery. Receives event object.                                                    |
| `onMouseLeave`     | `function`  | `None`       | Callback fired when mouse leaves gallery. Receives event object.                                                |
| `setProps`         | `func`      | (Dash Internal) | Callback function to update component properties.                                                            |
| `loading_state`    | `object`    | (Dash Internal) | Object describing the loading state of the component or its props.                                           |

---

### Contributing

Contributions to dash-image-gallery are welcome! Please refer to the project's issues on GitHub for any feature requests or bug reports.

### License

This project is licensed under the MIT License.

---

<!-- /pip/dash_improve_my_llms — https://2plot.dev/pip/dash_improve_my_llms/llms.txt -->

> **Full documentation:** [https://llms.2plot.dev](https://llms.2plot.dev) — the dedicated dash-improve-my-llms documentation site, with the complete API reference and deeper examples. This page is the quick-start overview.



### Installation

[Visit GitHub Repo](https://github.com/pip-install-python/dash-improve-my-llms) · [PyPI](https://pypi.org/project/dash-improve-my-llms/)

```bash
pip install "dash-improve-my-llms[flask]"     # Dash's default backend
pip install "dash-improve-my-llms[fastapi]"   # Dash 4.2+
pip install "dash-improve-my-llms[quart]"     # Dash 4.2+ async
```

Requires `dash>=4.1`. The backend is detected from `app.server`; your code is
the same either way.

### Introduction

A Dash app is a JavaScript application. Ask for any page without running
JavaScript — which is what most crawlers, link previewers and LLM fetchers do —
and every URL returns the same empty `Loading...` shell. To a search engine, a
30-page documentation site looks like 30 identical thin pages. To an agent, it
looks like nothing at all.

`dash-improve-my-llms` fixes that at the framework level. You write each page's
content once, as Markdown, and the package serves it everywhere a
non-JavaScript consumer will look: rendered into the page's own initial HTML
before React mounts, as `/{page}/llms.txt`, in the site-wide `/llms.txt` index,
in `sitemap.xml` and `robots.txt`, and as an MCP resource on Dash 4.3+. One
source of truth, six surfaces, and the interactive app is untouched.

This very site runs it — the [/llms.txt](/llms.txt) index, the Markdown twin
behind every page, and the cross-host network directory that links the
`*.2plot.dev` satellites together are all this package at work.

### Full Documentation

**The complete documentation lives on its own site:
[llms.2plot.dev](https://llms.2plot.dev)** — every route, the access-control
tiers, network configuration, the MCP bridge, and the machine-readable twin at
[llms.2plot.dev/llms.txt](https://llms.2plot.dev/llms.txt). This page is a
summary; go there for the full reference.

### Quick Start

```python
import dash
from dash import Dash, html
from dash_improve_my_llms import add_llms_routes, RobotsConfig

app = Dash(__name__, use_pages=True)

# Absolute URLs in sitemap.xml, llms.txt and canonical tags come from this.
app._base_url = "https://myapp.com"
app._robots_config = RobotsConfig(block_ai_training=True)

app.layout = html.Div([dash.page_container])

add_llms_routes(app)   # ← the whole integration
```

Then give each page prose — a module-level `LLMS_DOC` string, or
`register_page_metadata(path, name=..., description=..., llms_doc=...)` when
pages are generated in a loop.

### What You Get

| Surface | What lands there |
|:--------|:-----------------|
| The page's own HTML | The prose, rendered into the initial response, before React mounts |
| `/{page}/llms.txt` | The same prose as Markdown, with links back to the site and network indexes |
| `/llms.txt` | An index of every page, plus the cross-host network directory |
| `/sitemap.xml` | Every non-hidden page |
| `/robots.txt` | Per-bot-class access policy |
| `dash.mcp` | The same prose as an MCP resource (Dash 4.3+) |

### Features

*   **Universal prerender** — crawler-ready static HTML for every page, with the interactive app untouched.
*   **`llms.txt` everywhere** — a Markdown twin of every page, plus a rendered viewer for humans who open it in a browser.
*   **Multi-backend** — Flask, FastAPI and Quart, auto-detected from `app.server`.
*   **Per-request access control** — visibility verdicts (`allow`/`gated`/`deny`) applied consistently across llms.txt, crawler HTML, prerender, sitemap and MCP.
*   **Cross-host network directory** — publish sibling sites so an agent landing on one host can find the rest; this is how the [2plot network](https://2plot.ai) hangs together.
*   **Bot management** — block AI-training crawlers while allowing AI-search citations, configurable per bot class.

---

<!-- /pip/dash_insta_stories — https://2plot.dev/pip/dash_insta_stories/llms.txt -->

`dash-insta-stories` is a Dash component library that brings Instagram and Snapchat-style stories to your Dash applications. It features automatic progression with customizable intervals, video and image support, custom headers and loaders, interactive navigation with tap/swipe controls, keyboard navigation, story preloading for smooth transitions, custom renderers for complex content, and responsive design with configurable dimensions.

### Installation

[Visit GitHub Repo](https://github.com/pip-install-python/dash_insta_stories)

⭐️ Star this component on GitHub! Stay up to date on new releases and browse the codebase.

```bash
pip install dash-insta-stories
```

---

### Quick Start

Display a basic Instagram-style story viewer with automatic progression. Tap the left or right side to navigate between stories.



```python
# File: docs/dash_insta_stories/introduction.py

from dash_insta_stories import DashInstaStories
from dash import *
import dash_mantine_components as dmc

# Define your stories
stories = [
    {
        "url": "/assets/images/02.jpg",
        "type": "image",
        "duration": 5000,
        "header": {
            "heading": "This is kinda like...",
            "subheading": "Snapchat 😜",
            "profileImage": "https://avatars.githubusercontent.com/u/120129682?v=4"
        },
        # "seeMore": lambda: {"url": "https://example.com"},
        "styles": {"background": "#f5f5f5"},
        "preloadResource": True
    },
    {
        "url": "/assets/images/03.jpg",
        "header": {
            "heading": "Mobile First! 📱",
            "subheading": "Design 👀",
            "profileImage": "https://avatars.githubusercontent.com/u/120129682?v=4"
        }
    },
    {
        "url": "/assets/images/04.jpg",
        "header": {
            "heading": "Kinda like a carousel",
            "subheading": "but not really...",
            "profileImage": "https://avatars.githubusercontent.com/u/120129682?v=4"
        }
    }
    # Add more stories as needed
]

component = dmc.Stack([
    dmc.Center(
        DashInstaStories(
            id='input',
            stories=stories,  # Pass the stories to DashInstaStories
            renderers=[],  # Pass the renderers to DashInstaStories
            defaultInterval=2200,  # Pass the defaultInterval to DashInstaStories
            loader=None,  # Pass the loader to DashInstaStories
            header=None,  # Pass the header to DashInstaStories
            storyContainerStyles={},  # Pass the storyContainerStyles to DashInstaStories
            width=360,  # Pass the width to DashInstaStories
            height=640,  # Pass the height to DashInstaStories
            storyStyles={},  # Pass the storyStyles to DashInstaStories
            progressContainerStyles={},  # Pass the progressContainerStyles to DashInstaStories
            progressWrapperStyles={},  # Pass the progressWrapperStyles to DashInstaStories
            progressStyles={},  # Pass the progressStyles to DashInstaStories
            loop=True,  # Pass the loop to DashInstaStories
            isPaused=False,  # Pass the isPaused to DashInstaStories
            currentIndex=None,  # Pass the currentIndex to DashInstaStories
            # onStoryStart=None,  # Pass the onStoryStart to DashInstaStories
            # onStoryEnd=None,  # Pass the onStoryEnd to DashInstaStories
            # onAllStoriesEnd=None,  # Pass the onAllStoriesEnd to DashInstaStories
            # onNext=None,  # Pass the onNext to DashInstaStories
            # onPrevious=None,  # Pass the onPrevious to DashInstaStories
            keyboardNavigation=True,  # Pass the keyboardNavigation to DashInstaStories
            preventDefault=False,  # Pass the preventDefault to DashInstaStories
            preloadCount=1,  # Pass the preloadCount to DashInstaStories
        )
    ),
], style={'width': '100%', 'overflow':'auto'})
```


---

### Simple Image Stories

Create a story viewer using an array of image URLs. The component automatically handles progression and provides tap controls for navigation.

```python
from dash import Dash
from dash_insta_stories import DashInstaStories

app = Dash(__name__)

stories = [
    'https://picsum.photos/400/600?image=1',
    'https://picsum.photos/400/600?image=2',
    'https://picsum.photos/400/600?image=3',
]

app.layout = DashInstaStories(
    id='simple-stories',
    stories=stories,
    width=360,
    height=640,
    defaultInterval=3000,  # 3 seconds per story
)
```

**Simple Stories Array:**

Pass an array of image URLs as strings for quick setup. Each story displays for the `defaultInterval` duration.

---

### Advanced Story Objects

Use story objects for fine-grained control over each story's appearance and behavior, including headers, custom durations, and video content.

```python
from dash_insta_stories import DashInstaStories

advanced_stories = [
    {
        'url': 'https://picsum.photos/400/600?image=10',
        'duration': 5000,  # 5 seconds
        'header': {
            'heading': 'Product Launch',
            'subheading': '2 hours ago',
            'profileImage': 'https://picsum.photos/100/100?image=20'
        }
    },
    {
        'url': 'https://example.com/video.mp4',
        'type': 'video',
        'duration': 10000,
        'header': {
            'heading': 'Behind the Scenes',
            'subheading': '5 hours ago',
            'profileImage': 'https://picsum.photos/100/100?image=21'
        }
    },
    {
        'url': 'https://picsum.photos/400/600?image=30',
        'duration': 4000,
        'seeMore': True,  # Adds "See More" button
        'header': {
            'heading': 'New Collection',
            'subheading': '1 day ago',
            'profileImage': 'https://picsum.photos/100/100?image=22'
        }
    }
]

app.layout = DashInstaStories(
    id='advanced-stories',
    stories=advanced_stories,
    width='100%',
    height='100vh',
    loop=True,  # Loop back to first story after last
)
```

**Story Object Properties:**

- **`url`**: Image or video URL (required)
- **`type`**: Set to `'video'` for video content (optional)
- **`duration`**: Custom duration in milliseconds (optional)
- **`header`**: Object with `heading`, `subheading`, and `profileImage` (optional)
- **`seeMore`**: Adds "See More" button at bottom (optional)
- **`seeMoreCollapsed`**: Custom component for collapsed state (optional)
- **`styles`**: Override default story styles (optional)
- **`preloadResource`**: Enable/disable preloading, defaults to true for images (optional)

---

### Interactive Callbacks

Track story progression and user interactions using Dash callbacks. The component provides callback properties for story events and navigation.

```python
from dash import Dash, html, callback, Input, Output
from dash_insta_stories import DashInstaStories

app = Dash(__name__)

app.layout = html.Div([
    DashInstaStories(
        id='interactive-stories',
        stories=[
            'https://picsum.photos/400/600?image=40',
            'https://picsum.photos/400/600?image=41',
            'https://picsum.photos/400/600?image=42',
        ],
        width=360,
        height=640,
    ),
    html.Div(id='story-info')
])

@callback(
    Output('story-info', 'children'),
    Input('interactive-stories', 'currentIndex'),
    prevent_initial_call=True
)
def display_story_info(current_index):
    if current_index is not None:
        return f'Currently viewing story {current_index + 1}'
    return 'No story selected'
```

**Available Callback Events:**

- **`onStoryStart`**: Triggered when a story begins
- **`onStoryEnd`**: Triggered when a story ends
- **`onAllStoriesEnd`**: Triggered when all stories complete
- **`onNext`**: Triggered when user navigates to next story
- **`onPrevious`**: Triggered when user navigates to previous story

---

### Playback Control

Control story playback programmatically using the `isPaused` and `currentIndex` properties.

```python
from dash import Dash, html, callback, Input, Output
import dash_mantine_components as dmc
from dash_insta_stories import DashInstaStories

app = Dash(__name__)

app.layout = html.Div([
    DashInstaStories(
        id='controlled-stories',
        stories=[
            'https://picsum.photos/400/600?image=50',
            'https://picsum.photos/400/600?image=51',
            'https://picsum.photos/400/600?image=52',
        ],
        width=360,
        height=640,
        isPaused=False,
        currentIndex=0,
    ),
    dmc.Group([
        dmc.Button('Pause', id='pause-btn', n_clicks=0),
        dmc.Button('Resume', id='resume-btn', n_clicks=0),
        dmc.Button('Jump to Story 3', id='jump-btn', n_clicks=0),
    ])
])

@callback(
    Output('controlled-stories', 'isPaused'),
    Input('pause-btn', 'n_clicks'),
    Input('resume-btn', 'n_clicks'),
    prevent_initial_call=True
)
def toggle_playback(pause_clicks, resume_clicks):
    ctx = callback_context
    if not ctx.triggered:
        return False

    button_id = ctx.triggered[0]['prop_id'].split('.')[0]
    return button_id == 'pause-btn'

@callback(
    Output('controlled-stories', 'currentIndex'),
    Input('jump-btn', 'n_clicks'),
    prevent_initial_call=True
)
def jump_to_story(n_clicks):
    if n_clicks:
        return 2  # Jump to third story (0-indexed)
    return 0
```

---

### Styling and Customization

Customize the appearance of stories, progress bars, and containers using style properties.

```python
from dash_insta_stories import DashInstaStories

app.layout = DashInstaStories(
    id='styled-stories',
    stories=[
        'https://picsum.photos/400/600?image=60',
        'https://picsum.photos/400/600?image=61',
    ],
    width=400,
    height=700,
    storyContainerStyles={
        'borderRadius': '20px',
        'overflow': 'hidden',
        'boxShadow': '0 4px 20px rgba(0,0,0,0.3)'
    },
    storyStyles={
        'objectFit': 'cover',
        'filter': 'brightness(0.95)'
    },
    progressContainerStyles={
        'padding': '10px'
    },
    progressStyles={
        'background': 'linear-gradient(to right, #667eea, #764ba2)',
        'height': '3px'
    }
)
```

**Available Style Properties:**

- **`storyContainerStyles`**: Outer container styles (border, shadow, etc.)
- **`storyStyles`**: Individual story content styles (filters, fit, etc.)
- **`progressContainerStyles`**: Container wrapping all progress bars
- **`progressWrapperStyles`**: Container for each progress bar
- **`progressStyles`**: Progress bar appearance

---

### Keyboard Navigation

Enable keyboard controls for desktop users to navigate stories using arrow keys.

```python
from dash_insta_stories import DashInstaStories

app.layout = DashInstaStories(
    id='keyboard-stories',
    stories=[
        'https://picsum.photos/400/600?image=70',
        'https://picsum.photos/400/600?image=71',
        'https://picsum.photos/400/600?image=72',
    ],
    width=360,
    height=640,
    keyboardNavigation=True,  # Enable keyboard controls
    preventDefault=False,     # Allow default browser behavior
)
```

**Keyboard Controls:**

- **Left Arrow**: Previous story
- **Right Arrow**: Next story
- **Up Arrow**: Open "See More" section (if available)
- **Escape / Down Arrow**: Close "See More" section

---

### Performance Optimization

Control story preloading to optimize performance and bandwidth usage.

```python
from dash_insta_stories import DashInstaStories

app.layout = DashInstaStories(
    id='optimized-stories',
    stories=[
        {
            'url': 'https://picsum.photos/400/600?image=80',
            'preloadResource': True  # Preload this story
        },
        {
            'url': 'https://example.com/large-video.mp4',
            'type': 'video',
            'preloadResource': False  # Don't preload video
        },
        {
            'url': 'https://picsum.photos/400/600?image=81',
            'preloadResource': True
        }
    ],
    width=360,
    height=640,
    preloadCount=2,  # Preload 2 stories ahead of current
)
```

**Preloading Options:**

- **`preloadCount`**: Number of stories to preload ahead (default: 1)
- **`preloadResource`**: Per-story preload setting (defaults: true for images, false for videos)

Images are preloaded by default for smooth transitions, while videos are not to conserve bandwidth.

---

### Component Properties

| Property                  | Type                | Default         | Description                                                                                                     |
| :------------------------ | :------------------ | :-------------- |:----------------------------------------------------------------------------------------------------------------|
| **`id`**                  | `string`            | **Required**    | Unique identifier for the component used in Dash callbacks.                                                     |
| **`stories`**             | `list`              | **Required**    | Array of image URLs (strings) or story objects with url, type, duration, header, etc.                          |
| `renderers`               | `list`              | `[]`            | Array of custom renderer objects for advanced content types.                                                    |
| `defaultInterval`         | `number`            | `1200`          | Default duration in milliseconds for which each story persists.                                                 |
| `loader`                  | `Component`         | Ripple loader   | Custom loader component displayed while story loads from URL.                                                   |
| `header`                  | `Component`         | Default header  | Custom header component displayed at top of each story. Receives header object from story data.                 |
| `storyContainerStyles`    | `dict`              | `{}`            | CSS styles object for the outer story container.                                                                |
| `width`                   | `number` or `string`| `360`           | Width of the component. Accepts numbers (pixels) or strings (e.g., '100%', '100vw', 'inherit').                 |
| `height`                  | `number` or `string`| `640`           | Height of the component. Accepts numbers (pixels) or strings (e.g., '100vh', '100%', 'inherit').                |
| `storyStyles`             | `dict`              | `{}`            | CSS styles object to override default story content styles.                                                     |
| `progressContainerStyles` | `dict`              | `{}`            | CSS styles object for the container wrapping all progress bars.                                                 |
| `progressWrapperStyles`   | `dict`              | `{}`            | CSS styles object for the container wrapping each individual progress bar.                                      |
| `progressStyles`          | `dict`              | `{}`            | CSS styles object for the progress bars themselves.                                                             |
| `loop`                    | `bool`              | `False`         | If true, loops back to first story after the last story ends.                                                   |
| `isPaused`                | `bool`              | `False`         | Controls story playback state. Set to true to pause, false to resume.                                           |
| `currentIndex`            | `number`            | `None`          | Current story index (0-based). Set this to jump to a specific story programmatically.                           |
| `onStoryStart`            | `func`              | `None`          | Callback function triggered when a story starts playing.                                                        |
| `onStoryEnd`              | `func`              | `None`          | Callback function triggered when a story finishes playing.                                                      |
| `onAllStoriesEnd`         | `func`              | `None`          | Callback function triggered when all stories in the array have completed.                                       |
| `onNext`                  | `func`              | `None`          | Callback function triggered when user navigates to the next story (tap/press right or arrow key).               |
| `onPrevious`              | `func`              | `None`          | Callback function triggered when user navigates to the previous story (tap/press left or arrow key).            |
| `keyboardNavigation`      | `bool`              | `False`         | If true, enables arrow key navigation. Also enables up arrow for "See More" and escape/down for closing.        |
| `preventDefault`          | `bool`              | `False`         | If true, disables default click behavior on the component.                                                      |
| `preloadCount`            | `number`            | `1`             | Number of stories to preload ahead of the current story index for smoother transitions.                         |
| `setProps`                | `func`              | (Dash Internal) | Callback function to update component properties.                                                               |
| `loading_state`           | `object`            | (Dash Internal) | Object describing the loading state of the component or its props.                                              |

---

### Contributing

Contributions to dash-insta-stories are welcome! Please refer to the project's issues on GitHub for any feature requests or bug reports.

### License

This project is licensed under the MIT License.

---

<!-- /pip/dash_leaflet2 — https://2plot.dev/pip/dash_leaflet2/llms.txt -->

> **Full documentation:** [https://leaflet.2plot.dev](https://leaflet.2plot.dev) — the dedicated dash-leaflet2 documentation site, with the complete API reference and deeper examples. This page is the quick-start overview.



### Installation

[Visit GitHub Repo](https://github.com/pip-install-python/dash-leaflet2)

```bash
pip install dash-leaflet2
```

### Introduction

`dash-leaflet2` is a **Leaflet 2-native rewrite** of Dash mapping components. The original `dash-leaflet` is frozen on react-leaflet, which is built around Leaflet 1.9's context/lifecycle model and has no Leaflet 2 line. `dash-leaflet2` wraps **Leaflet 2 core directly** (no react-leaflet) via a tiny React-context bridge, targeting Dash 4 and working toward dash-leaflet 1.x feature parity.

Shedding the react-leaflet abstraction layer unlocks Leaflet 2's headline features:

*   **Unified Pointer Events** — one event model for mouse, touch, and pen.
*   **`ResizeObserver`-based sizing** — no more gray tiles when a map lives inside tabs or accordions.
*   **ES6-class subclassing** and canvas/WebGL `BlanketOverlay` layers.
*   **Bundled everything** — Leaflet 2, marker images, iconify-icon, and a liquid-glass theme ship inside `dash_leaflet2.js`. No CDN, no JS build step at install time.

Version `0.1.0` ships **26 components**: `Map`, `TileLayer`, `Marker`, `Popup`, `Tooltip`, `LayersControl`, `BaseLayer`, `Overlay`, `GeoJSON` (with SuperCluster clustering), `EditControl`, `FeatureGroup`, `LayerGroup`, `Circle`, `CircleMarker`, `Polygon`, `Polyline`, `Rectangle`, `ImageOverlay`, `MiniMap`, `ScaleControl`, `FullScreenControl`, `AttributionControl`, `KeyboardControl`, `TextMarker`, `TileSelector`, and `EasyButton`.

```python
import dash_leaflet2 as dl2
```

> Status: **alpha**, tracking Leaflet `2.0.0-alpha.1`. APIs may change.

---

### Quick Start

The core trio — `dl2.Map` + `dl2.TileLayer` + `dl2.Marker` — with a rich `dl2.Popup` (any Dash component works as popup content). Markers support four icon modes: the default pin, a custom image (`icon`), any `emoji`, or 200k+ Iconify icons (`iconify`).

**Important:** always give the Map an explicit height via `style`, e.g. `style={"height": "500px", "width": "100%"}`.



```python
# File: docs/dash_leaflet2/quick_start.py

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import html

CENTER = [28.0206, -97.0544]

component = dmc.Paper(
    withBorder=True,
    p="md",
    children=[
        dmc.Stack(
            [
                dmc.Text("Map + TileLayer + Marker + Popup", fw=500),
                dmc.Text(
                    "The core trio: a Map with an OpenStreetMap TileLayer and a draggable "
                    "Marker hosting a rich Popup. Drag the marker — its position round-trips "
                    "back to Dash.",
                    size="sm",
                    c="dimmed",
                ),
                dl2.Map(
                    id="leaflet2-quickstart-map",
                    center=CENTER,
                    zoom=12,
                    style={"height": "500px", "width": "100%", "borderRadius": "8px", "overflow": "hidden"},
                    children=[
                        dl2.TileLayer(),
                        dl2.Marker(
                            id="leaflet2-quickstart-marker",
                            position=CENTER,
                            draggable=True,
                            children=[
                                dl2.Tooltip(children="Drag me!"),
                                dl2.Popup(
                                    children=html.Div(
                                        [
                                            html.B("dl2.Marker + dl2.Popup"),
                                            html.Br(),
                                            "Any Dash component works as popup content — "
                                            "it renders through a React portal.",
                                        ]
                                    ),
                                    maxWidth=260,
                                ),
                            ],
                        ),
                        dl2.Marker(
                            id="leaflet2-quickstart-emoji",
                            position=[28.045, -97.02],
                            emoji="🛥️",
                            iconSize=34,
                            popup="Emoji markers work out of the box.",
                        ),
                        dl2.Marker(
                            id="leaflet2-quickstart-iconify",
                            position=[28.0, -97.09],
                            iconify="mdi:lighthouse-on",
                            iconColor="orange",
                            iconSize=36,
                            popup="200k+ Iconify icons, lazy-loaded from the Iconify API.",
                        ),
                    ],
                ),
            ],
            gap="sm",
        )
    ],
)
```


---

### Viewport & Interaction Callbacks

The map writes state back to Dash through read-only props:

*   **`viewport`** — updated on every `moveend`/`zoomend` as `{center: [lat, lng], zoom, bearing, bounds: {north, south, east, west}}`.
*   **`clickData`** — the most recent map click as `{latlng: [lat, lng]}`.
*   **`n_movestart` / `n_moveend`** — counters for pan/fly transitions (drive a "flying…" indicator).

Draggable markers write their new `position` back too, and the `flyTo` prop goes the other way — trigger smooth `flyTo` / `setView` / `panTo` / `fitBounds` transitions from Python callbacks.



```python
# File: docs/dash_leaflet2/interactions.py

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import callback, Input, Output

CENTER = [28.0206, -97.0544]

component = dmc.Paper(
    withBorder=True,
    p="md",
    children=[
        dmc.Stack(
            [
                dmc.Text("Viewport + click events in Python callbacks", fw=500),
                dmc.Text(
                    "The Map writes its view state back to Dash on every moveend/zoomend "
                    "via the read-only `viewport` prop, and the last click via `clickData`. "
                    "Pan, zoom, and click the map.",
                    size="sm",
                    c="dimmed",
                ),
                dmc.Grid(
                    [
                        dmc.GridCol(
                            dl2.Map(
                                id="leaflet2-interactions-map",
                                center=CENTER,
                                zoom=11,
                                style={"height": "500px", "width": "100%", "borderRadius": "8px", "overflow": "hidden"},
                                children=[
                                    dl2.TileLayer(),
                                    dl2.Marker(
                                        id="leaflet2-interactions-marker",
                                        position=CENTER,
                                        draggable=True,
                                        tooltip="Drag me — position echoes below",
                                    ),
                                ],
                            ),
                            span={"base": 12, "md": 8},
                        ),
                        dmc.GridCol(
                            dmc.Stack(
                                [
                                    dmc.Text("Viewport (moveend / zoomend)", size="sm", fw=500),
                                    dmc.Code(
                                        "pan or zoom the map…",
                                        id="leaflet2-interactions-viewport",
                                        block=True,
                                        style={"minHeight": "130px"},
                                    ),
                                    dmc.Text("Last map click", size="sm", fw=500),
                                    dmc.Code("click the map…", id="leaflet2-interactions-click", block=True),
                                    dmc.Text("Marker position", size="sm", fw=500),
                                    dmc.Code("drag the marker…", id="leaflet2-interactions-position", block=True),
                                ],
                                gap="xs",
                            ),
                            span={"base": 12, "md": 4},
                        ),
                    ]
                ),
            ],
            gap="sm",
        )
    ],
)


@callback(
    Output("leaflet2-interactions-viewport", "children"),
    Input("leaflet2-interactions-map", "viewport"),
    prevent_initial_call=True,
)
def leaflet2_show_viewport(vp):
    if not vp:
        return "pan or zoom the map…"
    b = vp["bounds"]
    return (
        f"center: {vp['center'][0]:.4f}, {vp['center'][1]:.4f}\n"
        f"zoom:   {vp['zoom']}\n"
        f"bounds: N {b['north']:.3f}  S {b['south']:.3f}\n"
        f"        E {b['east']:.3f}  W {b['west']:.3f}"
    )


@callback(
    Output("leaflet2-interactions-click", "children"),
    Input("leaflet2-interactions-map", "clickData"),
    prevent_initial_call=True,
)
def leaflet2_show_click(click_data):
    if not click_data:
        return "click the map…"
    lat, lng = click_data["latlng"]
    return f"{lat:.5f}, {lng:.5f}"


@callback(
    Output("leaflet2-interactions-position", "children"),
    Input("leaflet2-interactions-marker", "position"),
    prevent_initial_call=True,
)
def leaflet2_show_position(position):
    if not position:
        return "drag the marker…"
    return f"{position[0]:.5f}, {position[1]:.5f}"
```


---

### LayersControl & GeoJSON

`dl2.LayersControl` renders Leaflet's layer picker: `dl2.BaseLayer` children register as mutually-exclusive base maps (radio), `dl2.Overlay` children as independent toggles (checkbox). Both `activeBase` and `activeOverlays` are **two-way** — they reflect user choices in callbacks and accept updates from Python.

`dl2.GeoJSON` renders a FeatureCollection from the `data` prop, reports the clicked feature's `properties` via `clickFeature`, and supports SuperCluster point clustering with `cluster=True` plus `pointToLayer` / `clusterToLayer` JS hooks and a `hideout` passthrough for styling without Python round-trips.



```python
# File: docs/dash_leaflet2/layers_geojson.py

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import callback, Input, Output

CENTER = [28.02, -97.05]

OSM = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
CARTO_LIGHT = "https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"
CARTO_DARK = "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"
CARTO_ATTR = (
    '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap</a> '
    '&copy; <a href="https://carto.com/attributions">CARTO</a>'
)

SENSORS = {
    "type": "FeatureCollection",
    "features": [
        {
            "type": "Feature",
            "properties": {"name": "Buoy 1"},
            "geometry": {"type": "Point", "coordinates": [-97.04, 28.04]},
        },
        {
            "type": "Feature",
            "properties": {"name": "Buoy 2"},
            "geometry": {"type": "Point", "coordinates": [-97.08, 28.01]},
        },
        {
            "type": "Feature",
            "properties": {"name": "Buoy 3"},
            "geometry": {"type": "Point", "coordinates": [-97.06, 28.06]},
        },
    ],
}

component = dmc.Paper(
    withBorder=True,
    p="md",
    children=[
        dmc.Stack(
            [
                dmc.Text("LayersControl + GeoJSON", fw=500),
                dmc.Text(
                    "Base layers are mutually exclusive radios; overlays are independent "
                    "checkboxes. activeBase and activeOverlays are two-way props — the control "
                    "reports user choices to Python and accepts them from callbacks. Click a "
                    "GeoJSON point to read its feature properties.",
                    size="sm",
                    c="dimmed",
                ),
                dl2.Map(
                    id="leaflet2-layers-map",
                    center=CENTER,
                    zoom=12,
                    style={"height": "500px", "width": "100%", "borderRadius": "8px", "overflow": "hidden"},
                    children=[
                        dl2.LayersControl(
                            id="leaflet2-layers-control",
                            position="topright",
                            children=[
                                dl2.BaseLayer(
                                    dl2.TileLayer(url=CARTO_LIGHT, attribution=CARTO_ATTR),
                                    name="Light",
                                    checked=True,
                                ),
                                dl2.BaseLayer(
                                    dl2.TileLayer(url=CARTO_DARK, attribution=CARTO_ATTR),
                                    name="Dark",
                                ),
                                dl2.BaseLayer(
                                    dl2.TileLayer(url=OSM),
                                    name="OSM",
                                ),
                                dl2.Overlay(
                                    dl2.Polygon(
                                        positions=[
                                            [28.05, -97.10],
                                            [28.06, -97.02],
                                            [28.01, -97.00],
                                            [28.00, -97.08],
                                        ],
                                        color="teal",
                                        fillOpacity=0.2,
                                        children=dl2.Tooltip(children="Harbor zone"),
                                    ),
                                    name="Harbor zone",
                                    checked=True,
                                ),
                                dl2.Overlay(
                                    dl2.GeoJSON(
                                        id="leaflet2-layers-geojson",
                                        data=SENSORS,
                                    ),
                                    name="Sensors",
                                    checked=True,
                                ),
                            ],
                        ),
                    ],
                ),
                dmc.Group(
                    [
                        dmc.Text("Active layers:", size="sm", fw=500),
                        dmc.Code("—", id="leaflet2-layers-active"),
                        dmc.Text("Clicked feature:", size="sm", fw=500),
                        dmc.Code("click a sensor point…", id="leaflet2-layers-feature"),
                    ],
                    gap="xs",
                ),
            ],
            gap="sm",
        )
    ],
)


@callback(
    Output("leaflet2-layers-active", "children"),
    Input("leaflet2-layers-control", "activeBase"),
    Input("leaflet2-layers-control", "activeOverlays"),
)
def leaflet2_show_active_layers(base, overlays):
    return f"base: {base} | overlays: {overlays}"


@callback(
    Output("leaflet2-layers-feature", "children"),
    Input("leaflet2-layers-geojson", "clickFeature"),
    prevent_initial_call=True,
)
def leaflet2_show_clicked_feature(props):
    if not props:
        return "click a sensor point…"
    return str(props)
```


---

### Component Properties

Props tagged `[MUTABLE]` accept updates from callbacks (Python → map); `[READONLY]` props are written back by the map (map → Python). Full prop lists live in each generated class's docstring — `help(dl2.Map)`.

#### Map Props

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| `id` | string | - | Component ID for Dash callbacks. |
| `children` | node | - | Child layers (TileLayer, Marker, ...) rendered into this map. |
| `center` | [number, number] | `[51.505, -0.09]` | Initial map center as `[lat, lng]`. [MUTABLE] |
| `zoom` | number | `13` | Initial zoom level. [MUTABLE] |
| `viewport` | dict | - | Current view state, written back on every moveend/zoomend: `{center, zoom, bearing, bounds: {north, south, east, west}}`. [READONLY] |
| `clickData` | dict | - | Most recent map click: `{latlng: [lat, lng]}`. [READONLY] |
| `flyTo` | dict | - | Python → map viewport transition: `{transition: 'flyTo'\|'setView'\|'panTo'\|'fitBounds'\|'flyToBounds'\|'panInsideBounds', center?, zoom?, bounds?, options?, n_clicks}`. [MUTABLE] |
| `maxBounds` | [[number, number], [number, number]] | - | Geographic bounds the view is constrained inside, as `[[south, west], [north, east]]`. [MUTABLE] |
| `minZoom` | number | - | Minimum zoom the user can zoom out to (most restrictive of Map/TileLayer wins). [MUTABLE] |
| `maxZoom` | number | - | Maximum zoom the user can zoom in to (smallest of Map/TileLayer wins). [MUTABLE] |
| `bearing` | number | - | Map rotation in degrees (CSS-based; keep 0 when drawing interactively). [MUTABLE] |
| `dragging` | boolean | `True` | Mouse / pointer drag panning. [MUTABLE] |
| `scrollWheelZoom` | boolean | `True` | Mouse-wheel zoom. [MUTABLE] |
| `doubleClickZoom` | boolean | `True` | Double-click-to-zoom. [MUTABLE] |
| `boxZoom` | boolean | `True` | Shift-drag box-zoom selection. [MUTABLE] |
| `pinchZoom` | boolean | `True` | Pinch-to-zoom on touch devices (v1's `touchZoom`). [MUTABLE] |
| `keyboard` | boolean | `True` | Pan/zoom with arrow keys and `+`/`-`. [MUTABLE] |
| `tapHold` | boolean | - | Mobile-Safari tap-hold-to-contextmenu emulation. [MUTABLE] |
| `n_movestart` | number | - | Counter bumped on every `movestart`. [READONLY] |
| `n_moveend` | number | - | Counter bumped on every `moveend`. [READONLY] |
| `preferCanvas` | boolean | `False` | Render all vector layers through the Canvas renderer. |
| `zoomControl` | boolean | `True` | Built-in +/- zoom buttons (constructor-only). |
| `attributionControl` | boolean | `True` | Built-in attribution control; set `False` to mount your own `dl2.AttributionControl`. Constructor-only. |
| `className` / `style` | string / object | - | CSS class / inline styles. **Set the map height via `style`.** |

#### TileLayer Props

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| `id` | string | - | Component ID for Dash callbacks. |
| `url` | string | OSM tiles | Tile URL template, e.g. `https://tile.openstreetmap.org/{z}/{x}/{y}.png`. Updating it swaps the basemap. [MUTABLE] |
| `attribution` | string | `© OpenStreetMap contributors` | Attribution HTML shown on the map. |
| `opacity` | number | `1` | Layer opacity, 0..1. [MUTABLE] |
| `minZoom` | number | `0` | Minimum zoom at which this layer is visible — below it Leaflet stops requesting tiles entirely. |
| `maxZoom` | number | `19` | Maximum zoom level for this tile layer. |
| `maxNativeZoom` | number | - | Max zoom the tile source actually has tiles for; Leaflet upscales past it instead of 404-ing. |
| `bounds` | [[number, number], [number, number]] | - | Bounds outside of which no tiles are requested, as `[[south, west], [north, east]]`. |
| `errorTileUrl` | string | - | Image shown in place of a tile that fails to load (a 1x1 transparent PNG data URL hides broken tiles). |
| `zIndex` | number | - | Explicit z-index for the layer's DOM pane when stacking tile layers. [MUTABLE] |
| `subdomains` | string or list | - | Subdomains substituted into the `{s}` placeholder — `['a','b','c']` or `'abc'`. |
| `detectRetina` | boolean | `False` | Request 2x-resolution tiles on hi-DPI displays. |
| `tms` | boolean | `False` | Invert Y coordinates for TMS-shaped tile pyramids. |
| `crossOrigin` | string | - | `crossOrigin` attribute on tile img elements; pass `"anonymous"` for canvas-readable tiles. Construction-time only. |
| `className` | string | - | CSS class name(s). |

> **New in 0.1.0 (pro-parity props):** `TileLayer` gained `minZoom`, `bounds`, `errorTileUrl`, `zIndex`, `subdomains`, `detectRetina`, and `tms`; `Map` gained `maxBounds` plus the full set of interaction handler toggles (`dragging`, `scrollWheelZoom`, `doubleClickZoom`, `boxZoom`, `pinchZoom`, `keyboard`, `tapHold`).

---

### Beyond the Basics

The library also ships `dl2.EditControl` (native Leaflet 2 draw/edit with GeoJSON round-trip — no leaflet-draw), `dl2.MiniMap`, `dl2.ScaleControl`, `dl2.FullScreenControl`, `dl2.TextMarker`, `dl2.EasyButton` (Iconify-icon buttons with `n_clicks`), and `dl2.TileSelector` (hover-highlight, click-to-toggle tile selection). See the [GitHub repository](https://github.com/pip-install-python/dash-leaflet2) for the full showcase.

---

<!-- /pip/dash_model_viewer — https://2plot.dev/pip/dash_model_viewer/llms.txt -->

> **Full documentation:** [https://modelviewer.2plot.dev](https://modelviewer.2plot.dev) — the dedicated dash-model-viewer documentation site, with the complete API reference and deeper examples. This page is the quick-start overview.



`dash-model-viewer` is a Dash component library that wraps Google's `model-viewer` web component, allowing you to easily display and interact with 3D models (.glb, .gltf) within your Python Dash dashboards. It features interactive controls, Augmented Reality (AR) support via WebXR, annotations, dynamic updates, and extensive customization options.

### Installation
[Visit GitHub Repo](https://github.com/pip-install-python/dash-model-viewer) 
```bash
pip install dash-model-viewer
```

---

### Quick Start

Embed a 3D model with basic controls and AR capability.



```python
# File: docs/dash_model_viewer/quick_start_example.py

import dash_mantine_components as dmc
from dash_model_viewer import DashModelViewer as ModelViewer

# Assume assets folder is configured by the main docs app
ASTRONAUT_SRC = "https://modelviewer.dev/shared-assets/models/Astronaut.glb"

component = dmc.Paper(
    p="md", shadow="sm", withBorder=True,
    children=[
        ModelViewer(
            id="3d-model",
            src="https://modelviewer.dev/shared-assets/models/Astronaut.glb",
            alt="A 3D model of an astronaut",
            cameraControls=True,
            touchAction="pan-y",
            ar=True,
            poster="https://modelviewer.dev/shared-assets/models/Astronaut.webp",
            style={"height": "65vh", "width": "100%"},
            arButtonText="View in AR"
        ),
    ]
)
```


---

### Camera Views via Hotspots

Configure hotspots to act as camera position presets. Clicking a hotspot with `orbit` and `target` defined will automatically animate the camera to that view. The component handles this interaction internally.



```python
# File: docs/dash_model_viewer/camera_views_example.py

import dash_mantine_components as dmc
from dash_model_viewer import DashModelViewer as ModelViewer
from dash import get_asset_url
import re

# Using pre-scaled values for documentation simplicity
# Original example used SCALE_FACTOR = 40
THOR_MODEL_SRC = get_asset_url("model_viewer/thor_and_the_midgard_serpent.glb") # Assumes served by docs app
THOR_POSTER_SRC = "assets/model_viewer/ThorAndTheMidgardSerpent.webp"

# --- Define a scaling factor ---
# Should be the same factor used for positions
SCALE_FACTOR = 40

# --- Helper Functions to Scale Target and Orbit Strings ---

def scale_target_string(target_str, factor):
    """Parses 'Xm Ym Zm', scales X, Y, Z, returns 'scaledXm scaledYm scaledZm'."""
    numbers = re.findall(r"[-+]?\d*\.?\d+", target_str) # Find numbers
    if len(numbers) == 3:
        try:
            x = float(numbers[0]) * factor
            y = float(numbers[1]) * factor
            z = float(numbers[2]) * factor
            # Keep sufficient precision and add 'm' suffix back
            return f"{x:.6f}m {y:.6f}m {z:.6f}m"
        except ValueError:
            return target_str # Return original on error
    return target_str # Return original if parsing fails

def scale_orbit_string(orbit_str, radius_factor):
    """Parses 'Thetadeg Phideg Radiusm', scales Radius, returns 'Thetadeg Phideg scaledRadiusm'."""
    parts = orbit_str.split()
    if len(parts) == 3:
        try:
            theta = parts[0] # Keep angle with 'deg'
            phi = parts[1]   # Keep angle with 'deg'
            # Extract number from radius string (e.g., '0.065m')
            radius_num_str = re.findall(r"[-+]?\d*\.?\d+", parts[2])[0]
            scaled_radius = float(radius_num_str) * radius_factor
            # Keep sufficient precision and add 'm' suffix back
            return f"{theta} {phi} {scaled_radius:.8f}m"
        except (ValueError, IndexError):
            return orbit_str # Return original on error
    return orbit_str # Return original if parsing fails


# --- Hotspot Structure for Camera Views ---
# Scaled position, target, and orbit radius values
camera_view_hotspots = [
    {
        "slot": "hotspot-0",
        "position": f"{-0.0569 * SCALE_FACTOR:.4f} {0.0969 * SCALE_FACTOR:.4f} {-0.1398 * SCALE_FACTOR:.4f}",
        "normal": "-0.5829775 0.2863482 -0.7603565",
        # Scale Target and Orbit Radius
        "orbit": scale_orbit_string("-50.94862deg 84.56856deg 0.06545582m", SCALE_FACTOR),
        "target": scale_target_string("-0.04384604m 0.07348397m -0.1213202m", SCALE_FACTOR),
        "text": "The Fighters",
        "children_classname": "view-button"
    },
    {
        "slot": "hotspot-1",
        "position": f"{-0.1997 * SCALE_FACTOR:.4f} {0.11766 * SCALE_FACTOR:.4f} {0.0056 * SCALE_FACTOR:.4f}",
        "normal": "-0.4421014 0.04410423 0.8958802",
        # Scale Target and Orbit Radius
        "orbit": scale_orbit_string("3.711166deg 92.3035deg 0.04335197m", SCALE_FACTOR),
        "target": scale_target_string("-0.1879433m 0.1157161m -0.01563221m", SCALE_FACTOR),
        "text": "Hold Tight!",
        "children_classname": "view-button"
    },
    {
        "slot": "hotspot-2",
        "position": f"{0.0608 * SCALE_FACTOR:.4f} {0.0566 * SCALE_FACTOR:.4f} {0.0605 * SCALE_FACTOR:.4f}",
        "normal": "0.2040984 0.7985359 -0.56629",
        # Scale Target and Orbit Radius
        "orbit": scale_orbit_string("42.72974deg 84.74043deg 0.07104211m", SCALE_FACTOR),
        "target": scale_target_string("0.0757959m 0.04128428m 0.07109568m", SCALE_FACTOR),
        "text": "The Encounter",
        "children_classname": "view-button"
    },
    {
        "slot": "hotspot-3",
        "position": f"{0.1989 * SCALE_FACTOR:.4f} {0.16711 * SCALE_FACTOR:.4f} {-0.0749 * SCALE_FACTOR:.4f}",
        "normal": "0.7045857 0.1997957 -0.6809117",
        # Scale Target and Orbit Radius
        "orbit": scale_orbit_string("-40.11996deg 88.17818deg 0.07090651m", SCALE_FACTOR),
        "target": scale_target_string("0.2011831m 0.1398312m -0.07917573m", SCALE_FACTOR),
        "text": "Catapult",
        "children_classname": "view-button"
    },
    {
        "slot": "hotspot-4",
        "position": f"{0.0677 * SCALE_FACTOR:.4f} {0.18906 * SCALE_FACTOR:.4f} {-0.0158 * SCALE_FACTOR:.4f}",
        "normal": "-0.008245394 0.6207898 0.7839338",
        # Scale Target and Orbit Radius
        "orbit": scale_orbit_string("-118.8446deg 98.83521deg 0.06m", SCALE_FACTOR),
        "target": scale_target_string("0.06528695m 0.1753406m -0.01964653m", SCALE_FACTOR),
        "text": "Thunder and Lightning",
        "children_classname": "view-button"
    },
    {
        "slot": "hotspot-5",
        "position": f"{-0.1418 * SCALE_FACTOR:.4f} {-0.041 * SCALE_FACTOR:.4f} {0.174 * SCALE_FACTOR:.4f}",
        "normal": "-0.4924125 0.4698265 0.7326617",
        # Scale Target and Orbit Radius
        "orbit": scale_orbit_string("-2.305313deg 110.1798deg 0.04504082m", SCALE_FACTOR),
        "target": scale_target_string("-0.1151219m -0.04192762m 0.1523764m", SCALE_FACTOR),
        "text": "Knock Knock",
        "children_classname": "view-button"
    },
    {
        "slot": "hotspot-6",
        "position": f"{0.08414419 * SCALE_FACTOR:.4f} {0.134 * SCALE_FACTOR:.4f} {-0.215 * SCALE_FACTOR:.4f}",
        "normal": "0.03777227 0.06876653 -0.9969176",
        # Scale Target and Orbit Radius
        "orbit": scale_orbit_string("-37.54149deg 82.16209deg 0.0468692m", SCALE_FACTOR),
        "target": scale_target_string("0.08566038m 0.1249514m -0.1939646m", SCALE_FACTOR),
        "text": "Lucky Shot",
        "children_classname": "view-button"
    },
    {
        "slot": "hotspot-7",
        "position": f"{0.14598 * SCALE_FACTOR:.4f} {0.03177 * SCALE_FACTOR:.4f} {-0.05945886 * SCALE_FACTOR:.4f}",
        "normal": "-0.9392524 0.2397608 -0.2456009",
        # Scale Target and Orbit Radius
        "orbit": scale_orbit_string("-142.3926deg 86.45934deg 0.06213665m", SCALE_FACTOR),
        "target": scale_target_string("0.1519967m 0.01904771m -0.05945886m", SCALE_FACTOR),
        "text": "Get Away!",
        "children_classname": "view-button"
    },
    {
        "slot": "hotspot-8",
        "position": f"{0.0094 * SCALE_FACTOR:.4f} {0.0894 * SCALE_FACTOR:.4f} {-0.15103 * SCALE_FACTOR:.4f}",
        "normal": "-0.3878782 0.4957891 -0.7770094",
        # Scale Target and Orbit Radius
        "orbit": scale_orbit_string("-118.6729deg 117.571deg 0.03905975m", SCALE_FACTOR),
        "target": scale_target_string("0.007600758m 0.06771782m -0.1386167m", SCALE_FACTOR),
        "text": "The Jump",
        "children_classname": "view-button"
    },
    {
        "slot": "hotspot-9",
        "position": f"{-0.0658 * SCALE_FACTOR:.4f} {0.1786 * SCALE_FACTOR:.4f} {-0.0183 * SCALE_FACTOR:.4f}",
        "normal": "0.7857152 0.4059967 0.46671",
        # Scale Target and Orbit Radius
        "orbit": scale_orbit_string("53.28236deg 95.91318deg 0.1102844m", SCALE_FACTOR),
        "target": scale_target_string("-0.07579391m 0.1393538m -0.00851791m", SCALE_FACTOR),
        "text": "The Beast",
        "children_classname": "view-button"
    },
    {
        "slot": "hotspot-10",
        "position": f"{0.02610224 * SCALE_FACTOR:.4f} {0.01458751 * SCALE_FACTOR:.4f} {-0.004978945 * SCALE_FACTOR:.4f}",
        "normal": "-0.602551 0.7856147 -0.1405055",
        # Scale Target and Orbit Radius
        "orbit": scale_orbit_string("-78.89725deg 77.17752deg 0.08451112m", SCALE_FACTOR),
        "target": scale_target_string("0.02610223m 0.0145875m -0.004978945m", SCALE_FACTOR),
        "text": "Treasure",
        "children_classname": "view-button"
    },
    {
        "slot": "hotspot-11",
        "position": f"{-0.1053838 * SCALE_FACTOR:.4f} {0.01610652 * SCALE_FACTOR:.4f} {0.1076345 * SCALE_FACTOR:.4f}",
        "normal": "-0.624763 0.5176854 0.5845283",
        # Scale Target and Orbit Radius
        "orbit": scale_orbit_string("10.89188deg 119.9775deg 0.03543022m", SCALE_FACTOR),
        "target": scale_target_string("-0.1053838m 0.01610652m 0.1076345m", SCALE_FACTOR),
        "text": "Desperation",
        "children_classname": "view-button"
    },
]

# Scaled initial view
initial_orbit = scale_orbit_string("-8.142746deg 68.967deg 0.6179899m", SCALE_FACTOR)
initial_target = scale_target_string("-0.003m 0.0722m 0.0391m", SCALE_FACTOR)


component = dmc.Paper(
    p="md", shadow="sm", withBorder=True,
    children=[
        dmc.Text("Click the buttons to change camera view.", size="sm", ta="center", mb="xs"),
        ModelViewer(
            id="hotspot-camera-view-demo",
            # --- Model ---
            src=get_asset_url("model_viewer/thor_and_the_midgard_serpent.glb"),
            alt="Thor and the Midgard Serpent",
            # poster=get_asset_url("ThorAndTheMidgardSerpent.webp"),
            # --- Controls & Interaction ---
            cameraControls=True,
            touchAction="none",
            # Use scaled initial view
            cameraOrbit=initial_orbit,
            cameraTarget=initial_target,
            fieldOfView="45deg",
            minFieldOfView="25deg",
            maxFieldOfView="45deg",
            interpolationDecay=200,
            # Min orbit radius uses percentage, likely okay without scaling
            minCameraOrbit="auto auto 5%",
            # --- AR & Rendering ---
            ar=True,
            toneMapping="aces",
            shadowIntensity=1,
            # --- Hotspots ---
            hotspots=camera_view_hotspots,  # Pass the fully scaled list
            # --- Style ---
            style={'width': '800px', 'height': '600px', 'margin': 'auto'}
        ),
         dmc.Text("Styling for '.view-button' needed in assets folder.", size="xs", c="dimmed", ta="center", mt="xs")
    ]
)
# Example CSS for assets/camera_views_styles.css:
# .view-button {
#   background: #ffffff;
#   border-radius: 4px;
#   border: none;
#   box-sizing: border-box;
#   box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
#   color: rgba(0, 0, 0, 0.8);
#   display: block;
#   font-family: Futura, Helvetica Neue, sans-serif;
#   font-size: 12px;
#   font-weight: 700;
#   max-width: 128px;
#   overflow-wrap: break-word;
#   padding: 0.5em 1em;
#   text-align: center;
#   width: max-content;
#   cursor: pointer;
# }
# .view-button:hover { background: #eee; }
```


**Note:** The example uses pre-scaled values for orbit/target suitable for the specific model. You might need to adjust these values or use scaling functions (like in the original `usage_camera_views.py`) for your own models.

---

### Dynamic Model Switching

Update the `src` property of the `DashModelViewer` component using a standard Dash callback to load different 3D models interactively.



```python
# File: docs/dash_model_viewer/dynamic_switching_example.py

import dash
from dash import html, callback, Input, Output, get_asset_url
import dash_mantine_components as dmc
from dash_model_viewer import DashModelViewer as ModelViewer

MODELS = {
    "Shoe": get_asset_url("model_viewer/MaterialsVariantsShoe.glb"),
    "Woman": get_asset_url("model_viewer/kara_-_detroit_become_human.glb"),
    "Horse": "https://modelviewer.dev/shared-assets/models/Horse.glb" # Added another option
}

component = dmc.Paper(
    p="md", shadow="sm", withBorder=True,
    children=[
        dmc.SegmentedControl(
            id="dyn-switch-model-select",
            data=list(MODELS.keys()),
            value="Astronaut",
            fullWidth=True,
            mb="md",
        ),
        ModelViewer(
            id="dynamic-switch-viewer",
            src=MODELS["Shoe"], # Initial model
            alt="A 3D model",
            cameraControls=True,
            ar=True,
            style={"height": "450px", "width": "100%"},
        )
    ]
)

@callback(
    Output('dynamic-switch-viewer', 'src'),
    Input('dyn-switch-model-select', 'value')
)
def update_dynamic_model(selected_model_name):
    return MODELS.get(selected_model_name, MODELS["Shoe"])
```


---

### Advanced: Dynamic Dimensions

This example demonstrates controlling hotspot *presence* via Dash callbacks and relies on **client-side JavaScript** (not included in this basic demo) to:
1. Calculate model dimensions using the `model-viewer` API.
2. Position the hotspots correctly based on the bounding box.
3. Draw SVG lines between hotspots to visualize dimensions.
4. Handle unit conversions.

The Python code sets up the necessary controls and toggles the hotspot list passed to the component.

**Requires:** Corresponding JavaScript in `assets/model_viewer_clientside.js` and CSS in `assets/dimensions_styles.css` for full functionality.



```python
# File: docs/dash_model_viewer/dynamic_dimensions_example.py

# --- START OF FILE dynamic_dimensions_example.py ---

import dash
from dash import html, dcc, callback, Input, Output, State, get_asset_url, clientside_callback, ClientsideFunction
import dash_mantine_components as dmc
from dash_model_viewer import DashModelViewer as ModelViewer

# Basic structure for hotspots - positions/text usually set by client-side JS
# Keep children_classname for CSS targeting if needed (.dot, .dim)
dimension_hotspots_structure = [
    # Dots for line endpoints
    {"slot": "hotspot-dot+X-Y+Z", "normal": "1 0 0", "text": "", "children_classname": "dot"},
    {"slot": "hotspot-dot+X-Y-Z", "normal": "1 0 0", "text": "", "children_classname": "dot"},
    {"slot": "hotspot-dot+X+Y-Z", "normal": "0 1 0", "text": "", "children_classname": "dot"},
    {"slot": "hotspot-dot-X+Y-Z", "normal": "0 1 0", "text": "", "children_classname": "dot"},
    {"slot": "hotspot-dot-X-Y-Z", "normal": "-1 0 0", "text": "", "children_classname": "dot"},
    {"slot": "hotspot-dot-X-Y+Z", "normal": "-1 0 0", "text": "", "children_classname": "dot"},
    # Hotspots to display dimension text
    {"slot": "hotspot-dim+X-Y", "normal": "1 0 0", "text": "", "children_classname": "dim"}, # e.g., Length Z
    {"slot": "hotspot-dim+X-Z", "normal": "1 0 0", "text": "", "children_classname": "dim"}, # e.g., Height Y
    {"slot": "hotspot-dim+Y-Z", "normal": "0 1 0", "text": "", "children_classname": "dim"}, # e.g., Width X (Top) - Might be unused by JS line drawing
    {"slot": "hotspot-dim-X-Z", "normal": "-1 0 0", "text": "", "children_classname": "dim"}, # e.g., Height Y
    {"slot": "hotspot-dim-X-Y", "normal": "-1 0 0", "text": "", "children_classname": "dim"}, # e.g., Length Z
]

# Ensure this path is correct relative to where assets are served in your docs app
CHAIR_SRC = get_asset_url("model_viewer/Froggy_rocking_chair.glb")

# Define unit options consistent with JS
unit_options = [
    {"label": "cm", "value": "cm"},
    {"label": "mm", "value": "mm"},
    {"label": "m", "value": "m"},
    {"label": "in", "value": "in"},
    {"label": "ft", "value": "ft"},
]

component = dmc.Paper(
    p="md", shadow="sm", withBorder=True,
    children=[
        dmc.Group( # Group controls for better layout
            [
                dmc.Checkbox(
                    id="dims-show-checkbox",
                    label="Show Dimensions",
                    checked=True, # Start checked
                ),
                dmc.RadioGroup(
                    id='dims-unit-select',
                    label="Units",
                    children=dmc.Group([dmc.Radio(label=opt['label'], value=opt['value']) for opt in unit_options]),
                    value='cm', # Default value
                    size="sm",
                    mt="xs" # Add some margin top if Checkbox is above
                ),
            ],
            mb="md", # Margin bottom for the group
            align="flex-end" # Align items nicely
        ),

        html.Div( # Container for relative positioning (needed for SVG overlay)
            id="dims-model-container",
            style={'position': 'relative', 'height': '450px', 'width': '100%', 'border': '1px dashed #ccc'},
            children=[
                ModelViewer(
                    id="dimension-demo-dynamic",
                    src=CHAIR_SRC, alt="Chair for dimensions",
                    cameraControls=True, cameraOrbit="-30deg auto auto",
                    ar=True, shadowIntensity=1,
                    # Hotspots are controlled by the callback below
                    hotspots=dimension_hotspots_structure, # Initial state based on checkbox
                    style={"height": "100%", "width": "100%", "position": 'absolute', 'top': 0, 'left': 0}
                ),
                # SVG overlay for lines will be added here by client-side JS
            ]
        ),
        dmc.Text(
            "Dimension calculation, positioning, text update, and line drawing require client-side JS.",
            size="xs", c="dimmed", ta="center", mt="sm"
        ),
        dmc.Text(
            "Hotspot *presence* is controlled server-side. Styling requires CSS.",
            size="xs", c="dimmed", ta="center", mt="xs"
        )
    ]
)

# Callback controls ONLY the presence of the hotspots passed to the component.
# The actual positioning, text update, and line drawing require client-side JS.
@callback(
    Output("dimension-demo-dynamic", "hotspots"),
    Input("dims-show-checkbox", "checked"),
)
def control_hotspot_visibility(is_checked):
    if is_checked:
        # print("Server: Sending hotspot structure") # Debug
        return dimension_hotspots_structure # Send structure to component/JS
    else:
        # print("Server: Sending empty hotspot list") # Debug
        return [] # Send empty list to hide


# UPDATED Client-side Callback
# Triggers the JS function to perform calculations, updates, and SVG drawing.
clientside_callback(
    ClientsideFunction(
        namespace='modelViewer',       # Namespace defined in model_viewer_clientside.js
        function_name='updateDimensions' # Function name defined in model_viewer_clientside.js
    ),
    Output("dimension-demo-dynamic", "alt"), # Dummy output, needed for any clientside callback
    # --- Inputs that trigger the JS ---
    Input("dimension-demo-dynamic", "src"),   # Trigger on model change
    Input("dims-show-checkbox", "checked"),   # Trigger on checkbox change (passes boolean)
    Input("dims-unit-select", "value"),       # Trigger on unit change (passes string like 'cm')
    Input("dimension-demo-dynamic", "hotspots"), # *** CRUCIAL: Trigger AFTER Python updates hotspots ***
    # --- States needed by the JS function ---
    State("dimension-demo-dynamic", "id"),    # Pass the ID of the ModelViewer component
    State("dims-model-container", "id"),      # Pass the ID of the container DIV
    prevent_initial_call=False # Allow to run on page load
)

# --- END OF FILE dynamic_dimensions_example.py ---


```


---

### Advanced: Interactive Hotspot Placement

This example demonstrates a setup for allowing users to dynamically add hotspots to a model. It uses Dash callbacks and `dcc.Store` to manage the application state (viewing vs. adding mode) and relies on **client-side JavaScript** to:
1. Capture the user's click intention when in "Place Hotspot" mode.
2. Use the `model-viewer` API (`positionAndNormalFromPoint`) to get the 3D position and surface normal at the center of the viewer (where the reticle is).
3. Send this data back to the server via a `dcc.Store`.
4. The server-side callback then updates the list of hotspots displayed by the component.

**Requires:** Corresponding JavaScript in `assets/model_viewer_clientside.js` and CSS for the reticle/hotspots in `assets/dynamic_hotspots.css` for full functionality.



```python
# File: docs/dash_model_viewer/dynamic_hotspots_example.py

# --- START OF FILE dynamic_hotspots_example.py ---

import dash
# ****** Add ClientsideFunction to imports ******
from dash import html, dcc, callback, Input, Output, State, no_update, clientside_callback, ClientsideFunction
import dash_mantine_components as dmc
from dash_model_viewer import DashModelViewer as ModelViewer
import time

# ****** Ensure this path is correct for your docs setup ******
# If your assets are in assets/model_viewer/, use get_asset_url
# from dash import get_asset_url
# ASTRONAUT_SRC = get_asset_url("model_viewer/Astronaut.glb")
# Otherwise, use the direct URL if served externally
ASTRONAUT_SRC = "https://modelviewer.dev/shared-assets/models/Astronaut.glb"


component = dmc.Paper(
    p="md", shadow="sm", withBorder=True,
    children=[
        # Stores to manage state and data between client and server
        dcc.Store(id='dyn-hotspot-store', data=[]),
        dcc.Store(id='dyn-mode-store', data='viewing'), # 'viewing' or 'adding'
        dcc.Store(id='dyn-new-hotspot-data-store', data=None), # Used by JS to send data

        # Controls
        dmc.Group(
            [
                dmc.Button("Set Hotspot", id="dyn-set-place-button"),
                dmc.Button("Cancel", id="dyn-cancel-button", variant="outline", style={'display': 'none'}),
                dmc.TextInput(
                    id="dyn-label-input",
                    placeholder="Enter hotspot label...",
                    style={'display': 'none', 'flexGrow': 1},
                    value="", # Set initial value
                ),
            ],
            mb="md"
        ),

        # Viewer Container
        html.Div(
            id="dyn-viewer-container",
            style={'position': 'relative', 'height': '450px', 'width': '100%', 'border': '1px dashed #ccc'},
            children=[
                ModelViewer(
                    id="dynamic-hotspots-viewer",
                    src=ASTRONAUT_SRC,
                    alt="Astronaut for adding hotspots",
                    cameraControls=True,
                    ar=True, # Ensure model-viewer includes necessary JS for positionAndNormalFromPoint
                    style={"height": "100%", "width": "100%", "position": 'absolute', 'top': 0, 'left': 0},
                    hotspots=[] # Start empty, updated by callback
                ),
                # Visual reticle - centered overlay, shown only in 'adding' mode
                html.Div(
                    id="dyn-reticle",
                    style={
                        'position': 'absolute', 'top': '50%', 'left': '50%',
                        'transform': 'translate(-50%, -50%)',
                        'width': '30px', 'height': '30px',
                        'border': '2px solid red', 'borderRadius': '50%',
                        'pointerEvents': 'none', 'display': 'none' # Controlled by callback
                    }
                )
            ]
        ),
        dmc.Text(
            "Click 'Set Hotspot', type label, aim reticle, click 'Place Hotspot'. Requires client-side JS.",
            size="xs", c="dimmed", ta="center", mt="sm"
        ),
        # ****** Add note about CSS ******
        dmc.Text(
            "Requires CSS for '.hotspot-dynamic' styling in assets folder.",
            size="xs", c="dimmed", ta="center", mt="xs"
        )
    ]
)


# --- Callbacks ---

# Callback 1: Toggle Add/Viewing Mode State (Handles UI Changes)
@callback(
    Output('dyn-mode-store', 'data'),
    Output('dyn-label-input', 'style'),
    Output('dyn-set-place-button', 'children'),
    Output('dyn-cancel-button', 'style'),
    Output('dyn-reticle', 'style'),
    Input('dyn-set-place-button', 'n_clicks'),
    Input('dyn-cancel-button', 'n_clicks'),
    State('dyn-mode-store', 'data'),
    prevent_initial_call=True
)
def toggle_add_mode(set_clicks, cancel_clicks, current_mode):
    button_id = dash.callback_context.triggered_id
    reticle_style = { # Base style, display controlled below
        'position': 'absolute', 'top': '50%', 'left': '50%',
        'transform': 'translate(-50%, -50%)', 'width': '30px', 'height': '30px',
        'border': '2px solid red', 'borderRadius': '50%', 'pointerEvents': 'none'
    }

    # --- Entering Add Mode ---
    if button_id == 'dyn-set-place-button' and current_mode == 'viewing':
        print("Entering Add Mode") # Debug
        reticle_style['display'] = 'block'
        return 'adding', {'display': 'inline-block', 'flexGrow': 1}, "Place Hotspot", {'display': 'inline-block'}, reticle_style

    # --- Exiting Add Mode (via Cancel or JS completion) ---
    # If 'Place Hotspot' is clicked while in 'adding' mode, this callback does nothing.
    # The clientside callback handles the action. Callback 2 handles resetting the UI AFTER data is received.
    elif button_id == 'dyn-cancel-button':
         print("Exiting Add Mode via Cancel") # Debug
         reticle_style['display'] = 'none'
         return 'viewing', {'display': 'none', 'flexGrow': 1}, "Set Hotspot", {'display': 'none'}, reticle_style
    elif button_id == 'dyn-set-place-button' and current_mode == 'adding':
         # This case is handled by the clientside callback below.
         # The server-side callback should do nothing here.
         print("Place Hotspot clicked - Clientside callback should handle this.") # Debug
         return no_update # Explicitly do nothing

    # Default case (shouldn't normally be reached for these inputs)
    return no_update


# ****** START: ADDED CLIENTSIDE CALLBACK ******
# Callback 1.5: Trigger JS to get hotspot data when "Place Hotspot" is clicked
clientside_callback(
    ClientsideFunction(
        namespace='modelViewer', # Namespace in your JS file
        function_name='handleAddHotspotClick' # Function name in your JS file
    ),
    Output('dyn-new-hotspot-data-store', 'data'), # JS function returns data here
    Input('dyn-set-place-button', 'n_clicks'), # Triggered by this button
    State('dynamic-hotspots-viewer', 'id'), # Pass viewer ID to JS
    State('dyn-mode-store', 'data'), # Pass current mode to JS
    State('dyn-label-input', 'value'), # Pass label text to JS
    prevent_initial_call=True
)
# ****** END: ADDED CLIENTSIDE CALLBACK ******


# Callback 2: Process New Hotspot Data (received from Client-Side via Store)
@callback(
    # Outputs to update the main hotspot list and reset the UI
    Output('dyn-hotspot-store', 'data', allow_duplicate=True),
    Output('dyn-mode-store', 'data', allow_duplicate=True),    # Reset mode back to viewing
    Output('dyn-label-input', 'value', allow_duplicate=True), # Clear input
    Output('dyn-label-input', 'style', allow_duplicate=True), # Hide input
    Output('dyn-set-place-button', 'children', allow_duplicate=True), # Reset button text
    Output('dyn-cancel-button', 'style', allow_duplicate=True),    # Hide Cancel button
    Output('dyn-reticle', 'style', allow_duplicate=True),            # Hide reticle
    # Triggered ONLY when the clientside callback updates this store
    Input('dyn-new-hotspot-data-store', 'data'),
    # State needed to update the list
    State('dyn-hotspot-store', 'data'),                      # Get current list
    prevent_initial_call=True
)
def add_new_hotspot(new_hotspot_data, current_hotspots):
    # Check if the trigger was just the initial None value or invalid data
    if new_hotspot_data is None or not isinstance(new_hotspot_data, dict):
        print(f"Dynamic Hotspots: Invalid or no new hotspot data received: {new_hotspot_data}")
        # Don't reset the UI if data is invalid, just don't add the hotspot
        # UI reset should only happen on SUCCESSFUL addition or explicit CANCEL.
        # However, if this callback IS triggered by bad data from JS somehow,
        # maybe we *should* reset? Let's keep the reset for now.
        reticle_style = { # Base style, display controlled below
            'position': 'absolute', 'top': '50%', 'left': '50%',
            'transform': 'translate(-50%, -50%)', 'width': '30px', 'height': '30px',
            'border': '2px solid red', 'borderRadius': '50%', 'pointerEvents': 'none', 'display':'none'
        }
        # Return no_update for hotspot list, but still reset UI
        return no_update, 'viewing', "", {'display': 'none', 'flexGrow': 1}, "Set Hotspot", {'display': 'none'}, reticle_style

    print(f"Dynamic Hotspots (Server): Received new hotspot data: {new_hotspot_data}")

    # Add the new hotspot to the list
    if not isinstance(current_hotspots, list):
         current_hotspots = [] # Initialize if store is empty/invalid

    # Add a default class if needed for styling new hotspots
    # Ensure the keys from JS match what ModelViewer expects
    validated_hotspot = {
        "slot": new_hotspot_data.get("slot", f"hs-err-{time.time()}"),
        "position": new_hotspot_data.get("position", "0 0 0"),
        "normal": new_hotspot_data.get("normal"), # Optional
        "text": new_hotspot_data.get("text", ""),
        "children_classname": new_hotspot_data.get('children_classname', 'hotspot-dynamic')
    }
    current_hotspots.append(validated_hotspot)
    print(f"Dynamic Hotspots (Server): Updated list: {current_hotspots}")


    # Reset UI elements back to viewing state AFTER successful add
    reticle_style = { # Base style, display controlled below
        'position': 'absolute', 'top': '50%', 'left': '50%',
        'transform': 'translate(-50%, -50%)', 'width': '30px', 'height': '30px',
        'border': '2px solid red', 'borderRadius': '50%', 'pointerEvents': 'none', 'display':'none'
    }
    return current_hotspots, 'viewing', "", {'display': 'none', 'flexGrow': 1}, "Set Hotspot", {'display': 'none'}, reticle_style


# Callback 3: Update ModelViewer's 'hotspots' prop when the store changes
@callback(
    Output('dynamic-hotspots-viewer', 'hotspots'),
    Input('dyn-hotspot-store', 'data') # Triggered by Callback 2 updating the store
)
def update_viewer_hotspots_list(hotspot_list):
    print(f"Dynamic Hotspots (Server): Updating viewer component with hotspots: {hotspot_list}")
    # Ensure it's always a list, even if store somehow becomes None
    return hotspot_list if isinstance(hotspot_list, list) else []


# --- END OF FILE dynamic_hotspots_example.py ---
```


---

### Component Properties 

| Property           | Type                                | Default                                    | Description                                                                                                     |
| :----------------- | :---------------------------------- | :----------------------------------------- |:----------------------------------------------------------------------------------------------------------------|
| **`id`**           | `string`                            | **Required**                               | Unique identifier for the component.                                                                            |
| **`src`**          | `string`                            | **Required**                               | URL to the 3D model file (.glb, .gltf). Can be absolute or relative to assets folder.                           |
| **`alt`**          | `string`                            | **Required**                               | Alternative text description for accessibility.                                                                 |
| `style`            | `object`                            | `{}`                                       | Standard CSS styles for the outer container.                                                                    |
| `cameraControls`   | `bool`                              | `True`                                     | Enable user interaction to control the camera (orbit, zoom, pan).                                               |
| `touchAction`      | `'pan-y'`, `'pan-x'`, `'none'`      | `'pan-y'`                                  | How touch gestures interact with the model (vertical pan, horizontal pan, or none).                             |
| `cameraOrbit`      | `string`                            | `undefined`                                | Sets the initial/current camera position (`theta phi radius`, e.g., `0deg 75deg 1.5m`).                         |
| `cameraTarget`     | `string`                            | `undefined`                                | Sets the point the camera looks at (`X Y Z`, e.g., `0m 1m 0m`).                                                 |
| `fieldOfView`      | `string`                            | `'auto'`                                   | Camera's vertical field of view (e.g., `'45deg'`).                                                              |
| `minFieldOfView`   | `string`                            | `'25deg'`                                  | Minimum vertical field of view allowed.                                                                         |
| `maxFieldOfView`   | `string`                            | `'auto'`                                   | Maximum vertical field of view allowed.                                                                         |
| `interpolationDecay`| `number` or `string`                | `50`                                       | Controls the speed of camera transitions (higher is faster decay, slower transition). 0 is instant.             |
| `minCameraOrbit`   | `string`                            | `'auto auto auto'`                         | Sets minimum bounds for camera orbit (`theta phi radius`, use 'auto' for no limit).                             |
| `maxCameraOrbit`   | `string`                            | `'auto auto auto'`                         | Sets maximum bounds for camera orbit.                                                                           |
| `poster`           | `string`                            | `undefined`                                | URL of an image to show before the model loads. Can be absolute or relative to assets.                          |
| `ar`               | `bool`                              | `True`                                     | Enables AR features and displays the AR button if supported.                                                    |
| `arModes`          | `string`                            | `"webxr scene-viewer quick-look"`          | Space-separated list of preferred AR modes.                                                                     |
| `arScale`          | `'auto'`, `'fixed'`                 | `'auto'`                                   | Controls model scaling in AR ('auto' tries world scale, 'fixed' uses model's scene units).                      |
| `arButtonText`     | `string`                            | `'View in your space'`                     | Text displayed on the default AR button.                                                                        |
| `customArPrompt`   | `node` (string or Dash component)   | `null`                                     | Custom content to show while initializing AR (replaces default hand icon). Pass Dash components directly.       |
| `customArFailure`  | `node` (string or Dash component)   | `null`                                     | Custom content to show if AR fails to start or track (replaces default message). Pass Dash components directly. |
| `toneMapping`      | `'neutral'`, `'aces'`, ...         | `'neutral'`                                | Adjusts the color grading/tone mapping (see `model-viewer` docs for options like 'agx').                        |
| `shadowIntensity`  | `number` or `string`                | `0`                                        | Controls the opacity of the model's shadow (0 to 1).                                                            |
| `hotspots`         | `array`                             | `[]`                                       | List of hotspot configuration objects (see structure below).                                                    |
| `variantName`      | `string`                            | `null`                                     | Selects a specific model variant if the GLTF file defines variants. Use `null` or `'default'` for default.      |
| `setProps`         | `func`                              | (Dash Internal)                            | Callback function to update component properties.                                                               |
| `loading_state`    | `object`                            | (Dash Internal)                            | Object describing the loading state of the component or its props.                                              |

**Hotspot Object Structure:**

Each object in the `hotspots` array represents a `div` placed inside the `model-viewer` and can have the following keys:

*   `slot`: (String, Required) A unique name for the hotspot's `slot` attribute (e.g., `"hotspot-1"`, `"hotspot-visor"`). Used for targeting with CSS (`.hotspot[slot='...']`).
*   `position`: (String, Required) The 3D coordinates `"X Y Z"` where the hotspot should be placed in model space (e.g., `"0 1.75 0.35"`).
*   `normal`: (String, Optional) The surface normal vector `"X Y Z"` at the position (e.g., `"0 0 1"`). Influences the hotspot's orientation relative to the surface.
*   `text`: (String, Optional) Text content to display *inside* the hotspot's `div` element.
*   `children_classname`: (String, Optional) A CSS class name to add *in addition* to `.hotspot` on the hotspot's `div` element for custom styling (e.g., `"view-button"`).
*   `orbit`: (String, Optional) If provided, clicking this hotspot will internally update the model viewer's camera orbit to this value (e.g., `"45deg 60deg 2m"`). Used for [Camera Views via Hotspots](#camera-views-via-hotspots).
*   `target`: (String, Optional) If provided along with `orbit`, clicking this hotspot updates the camera target (e.g., `"0m 1m 0m"`).
*   `fov`: (String, Optional) If provided along with `orbit`/`target`, clicking updates the field of view (e.g., `"30deg"`). Defaults to `45deg` for camera view hotspots if not specified.

---

### Client-Side Scripting

For interactions beyond the built-in capabilities (like dynamic dimension drawing or complex hotspot logic), leverage Dash's `clientside_callback` mechanism.

1.  Create a JavaScript file in your `assets` folder (e.g., `assets/model_viewer_clientside.js`).
2.  Define functions within a namespace (e.g., `window.dash_clientside.clientside.modelViewer`).
3.  Use `dash.clientside_callback` and `dash.ClientsideFunction` in Python to trigger these JS functions based on Dash Inputs/States.
4.  Your JavaScript function can access the `model-viewer` DOM element using its `id` and interact with its powerful [JavaScript API](https://modelviewer.dev/docs/index.html#javascript-api).

Refer to the "Advanced" examples (`dynamic_dimensions_example.py`, `dynamic_hotspots_example.py`) and their corresponding `usage_*.py` files in the GitHub repository for implementation patterns.

---

### Styling

Style the component and its hotspots using CSS in your `assets` folder.

*   Target the viewer container: `#your-viewer-id { border: 1px solid blue; }`
*   Target all hotspots: `.hotspot { background-color: rgba(0, 0, 0, 0.5); color: white; padding: 4px 8px; border-radius: 4px; }`
*   Target specific hotspots: `.hotspot[slot='hotspot-visor'] { background-color: red; }`
*   Target custom hotspot classes: `.view-button { cursor: pointer; border: 1px solid white; }`
*   Style the AR button: `button[slot='ar-button'] { background-color: purple; color: white; }`

See the CSS files associated with the usage examples in the GitHub repo for more detailed styling examples.

---

### Acknowledgements

*   This component is built upon Google's [`model-viewer` web component](https://modelviewer.dev/).
*   Developed using the [Plotly Dash](https://dash.plotly.com/) framework.

---

<!-- /pip/dash_mui_charts — https://2plot.dev/pip/dash_mui_charts/llms.txt -->

# MUI Charts

> **Full documentation:** [https://muicharts.2plot.dev](https://muicharts.2plot.dev) — the dedicated dash-mui-charts documentation site, with the complete API reference and deeper examples. This page is the quick-start overview.


`dash-mui-charts` brings [MUI X](https://mui.com/x/react-charts/) to Plotly Dash — **13 components** as of 1.4.0: nine chart types, three tree views and a TimeClock, with an optional Pro tier for zoom, pan, brush and heatmaps.

This page is the catalogue summary with a taste of the charts; the full interactive documentation — 40+ pages with ~250 live examples covering every component and prop — lives at [muicharts.2plot.dev](https://muicharts.2plot.dev).

| Component | License | Key Features |
|:----------|:--------|:-------------|
| **LineChart** | Free + Pro | Line/area, stacking, zoom/pan (Pro), brush (Pro), reference lines, dateFormat (1.1.0) |
| **BarChart** | Free + Pro | Vertical/horizontal, stacked/normalized/diverging offsets, dataset mode, zoom + toolbar (Pro) |
| **CandlestickChart** | Free | OHLC candles, volume overlay, support/resistance reference lines, click events |
| **PieChart** | Free | Pie, donut, nested/concentric, gauge-arc, arc labels |
| **ScatterChart** | Free | Multi-series, z-axis color mapping, voronoi interaction |
| **CompositeChart** | Free + Pro | Layer scatter + line, zoom/pan (Pro), biaxial axes |
| **Heatmap** | Pro | Matrix visualization, continuous/piecewise color scales |
| **SparklineChart** | Free | Compact inline charts for KPI cards and tables |
| **LiveTradingChart** | Free + Pro | Real-time OHLCV candlestick streaming, volume, forecast, alerts |
| **TreeView** | Free | Data-driven items, expansion/selection control, checkbox propagation, inline label editing |
| **SimpleTreeView** | Free | Declarative tree built from children — dogfooded as the docs site's own sidebar |
| **TreeViewPro** | Pro | Kebab submenus, dividers and per-node menus (1.4.0) |
| **TimeClock** | Free | Clock-face time picking, controlled/uncontrolled, 12h/24h, pairs with DMC time inputs |

## Installation

[Visit GitHub Repo](https://github.com/pip-install-python/dash-mui-charts) · [PyPI](https://pypi.org/project/dash-mui-charts/)

```bash
pip install dash-mui-charts
```

For Pro features, set your license key in `.env`:
```
MUI_PRO_API_KEY=your-license-key-here
```

---

## Line Charts



```python
# File: docs/dash_mui_charts/line_basic.py

import os
import random
import math
import dash_mantine_components as dmc
from dash_mui_charts import LineChart

MUI_KEY = os.getenv('MUI_PRO_API_KEY', '')
random.seed(42)
months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
revenue = [random.randint(30, 90) for _ in months]
expenses = [random.randint(20, 65) for _ in months]
profit = [r - e for r, e in zip(revenue, expenses)]
temp_data = [round(18 + 12 * math.sin(2 * math.pi * i / 12 - math.pi / 2) + random.uniform(-2, 2), 1) for i in range(12)]

# Extra datasets
random.seed(77)
organic = [random.randint(10, 30) for _ in months]
paid = [random.randint(8, 25) for _ in months]
referral = [random.randint(5, 15) for _ in months]

# Curve comparison data
random.seed(11)
curve_data = [random.randint(20, 80) for _ in range(8)]
curve_labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun', 'Mon']

GLASS = {
    "background": "light-dark(rgba(255,255,255,0.55), rgba(30,30,30,0.55))",
    "backdropFilter": "blur(16px) saturate(1.8)",
    "WebkitBackdropFilter": "blur(16px) saturate(1.8)",
    "border": "1px solid light-dark(rgba(255,255,255,0.5), rgba(255,255,255,0.08))",
}

component = dmc.Stack([
    dmc.Text("Line Charts", fw=700, size="xl"),
    dmc.Text("Versatile line and area charts with stacking, curves, reference lines, and biaxial axes.", size="sm", c="dimmed"),

    # --- Multi-Series ---
    dmc.Paper(
        dmc.Stack([
            dmc.Text("Multi-Series Line Chart", fw=600),
            dmc.Text("Revenue vs expenses with profit area fill.", size="sm", c="dimmed"),
            LineChart(
                id="mc-line-basic",
                licenseKey=MUI_KEY,
                series=[
                    {'data': revenue, 'label': 'Revenue ($k)', 'color': '#1976d2', 'curve': 'monotoneX', 'showMark': True},
                    {'data': expenses, 'label': 'Expenses ($k)', 'color': '#f57c00', 'curve': 'monotoneX', 'showMark': True},
                    {'data': profit, 'label': 'Profit ($k)', 'color': '#388e3c', 'curve': 'monotoneX', 'area': True},
                ],
                xAxis=[{'data': months, 'scaleType': 'point', 'label': 'Month'}],
                yAxis=[{'label': 'Amount ($k)'}],
                grid={'horizontal': True, 'vertical': False},
                height=350,
            ),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),

    # --- Stacked Area ---
    dmc.Paper(
        dmc.Stack([
            dmc.Text("Stacked Area Chart", fw=600),
            dmc.Text("Stacked areas showing cumulative traffic sources.", size="sm", c="dimmed"),
            LineChart(
                id="mc-line-stacked",
                licenseKey=MUI_KEY,
                series=[
                    {'data': organic, 'label': 'Organic', 'color': '#66bb6a', 'area': True, 'stack': 'traffic', 'curve': 'monotoneX'},
                    {'data': paid, 'label': 'Paid', 'color': '#42a5f5', 'area': True, 'stack': 'traffic', 'curve': 'monotoneX'},
                    {'data': referral, 'label': 'Referral', 'color': '#ab47bc', 'area': True, 'stack': 'traffic', 'curve': 'monotoneX'},
                ],
                xAxis=[{'data': months, 'scaleType': 'point'}],
                yAxis=[{'label': 'Visitors (k)'}],
                grid={'horizontal': True},
                height=300,
            ),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),

    # --- Curve Interpolation Comparison ---
    dmc.Paper(
        dmc.Stack([
            dmc.Text("Curve Interpolation", fw=600),
            dmc.Text("Same data rendered with different curve algorithms.", size="sm", c="dimmed"),
            dmc.SimpleGrid(cols={"base": 1, "md": 2}, children=[
                dmc.Stack([
                    dmc.Badge("linear", color="blue", variant="light", size="sm"),
                    LineChart(
                        id="mc-line-curve-linear",
                        licenseKey=MUI_KEY,
                        series=[{'data': curve_data, 'label': 'Value', 'color': '#1976d2', 'curve': 'linear', 'showMark': True}],
                        xAxis=[{'data': curve_labels, 'scaleType': 'point'}],
                        height=180, grid={'horizontal': True},
                    ),
                ], gap=4),
                dmc.Stack([
                    dmc.Badge("monotoneX", color="green", variant="light", size="sm"),
                    LineChart(
                        id="mc-line-curve-mono",
                        licenseKey=MUI_KEY,
                        series=[{'data': curve_data, 'label': 'Value', 'color': '#388e3c', 'curve': 'monotoneX', 'showMark': True}],
                        xAxis=[{'data': curve_labels, 'scaleType': 'point'}],
                        height=180, grid={'horizontal': True},
                    ),
                ], gap=4),
                dmc.Stack([
                    dmc.Badge("natural", color="orange", variant="light", size="sm"),
                    LineChart(
                        id="mc-line-curve-natural",
                        licenseKey=MUI_KEY,
                        series=[{'data': curve_data, 'label': 'Value', 'color': '#f57c00', 'curve': 'natural', 'showMark': True}],
                        xAxis=[{'data': curve_labels, 'scaleType': 'point'}],
                        height=180, grid={'horizontal': True},
                    ),
                ], gap=4),
                dmc.Stack([
                    dmc.Badge("step", color="violet", variant="light", size="sm"),
                    LineChart(
                        id="mc-line-curve-step",
                        licenseKey=MUI_KEY,
                        series=[{'data': curve_data, 'label': 'Value', 'color': '#7b1fa2', 'curve': 'step', 'showMark': True}],
                        xAxis=[{'data': curve_labels, 'scaleType': 'point'}],
                        height=180, grid={'horizontal': True},
                    ),
                ], gap=4),
            ]),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),

    # --- Reference Lines ---
    dmc.Paper(
        dmc.Stack([
            dmc.Text("Reference Lines", fw=600),
            dmc.Text("Temperature curve with comfort zone boundaries and a vertical launch marker.", size="sm", c="dimmed"),
            LineChart(
                id="mc-line-ref",
                licenseKey=MUI_KEY,
                series=[
                    {'data': temp_data, 'label': 'Temperature (°C)', 'color': '#ef5350', 'curve': 'natural', 'area': True},
                ],
                xAxis=[{'data': months, 'scaleType': 'point', 'label': 'Month'}],
                yAxis=[{'label': '°C', 'min': 0, 'max': 40}],
                referenceLines=[
                    {'y': 25, 'label': 'Upper comfort', 'lineStyle': {'stroke': '#ff9800', 'strokeDasharray': '5 3'}},
                    {'y': 18, 'label': 'Lower comfort', 'lineStyle': {'stroke': '#2196f3', 'strokeDasharray': '5 3'}},
                    {'x': 'Jun', 'label': 'Summer', 'lineStyle': {'stroke': '#9e9e9e', 'strokeDasharray': '3 3'}},
                ],
                grid={'horizontal': True},
                height=320,
            ),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),

    # --- Biaxial ---
    dmc.Paper(
        dmc.Stack([
            dmc.Text("Biaxial (Dual Y-Axis)", fw=600),
            dmc.Text("Revenue on the left axis, growth rate on the right. Independent scales for different units.", size="sm", c="dimmed"),
            LineChart(
                id="mc-line-biaxial",
                licenseKey=MUI_KEY,
                series=[
                    {'data': revenue, 'label': 'Revenue ($k)', 'color': '#1976d2', 'curve': 'monotoneX', 'showMark': True, 'yAxisId': 'left'},
                    {'data': [round(random.uniform(-5, 20), 1) for _ in months], 'label': 'Growth (%)', 'color': '#ef5350', 'curve': 'monotoneX', 'area': True, 'yAxisId': 'right'},
                ],
                xAxis=[{'data': months, 'scaleType': 'point'}],
                yAxis=[
                    {'id': 'left', 'label': 'Revenue ($k)', 'position': 'left'},
                    {'id': 'right', 'label': 'Growth (%)', 'position': 'right'},
                ],
                grid={'horizontal': True},
                height=320,
            ),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),
], gap="lg")
```


---

## Line Charts — Pro Features



```python
# File: docs/dash_mui_charts/line_pro.py

import os
import random
import math
from datetime import datetime, timedelta
import dash_mantine_components as dmc
from dash_mui_charts import LineChart

MUI_KEY = os.getenv('MUI_PRO_API_KEY', '')
random.seed(99)

# --- Signal vs Noise (100 points) ---
n = 100
x_vals = list(range(n))
signal = [round(50 + 20 * math.sin(2 * math.pi * i / 25) + random.gauss(0, 5), 1) for i in range(n)]
noise = [round(50 + random.gauss(0, 12), 1) for i in range(n)]

# --- Simulated 2-month hourly sensor data for slider preview ---
random.seed(42)
start_date = datetime(2025, 1, 1)
hours = 24 * 45  # 45 days
timestamps = []
temperature = []
humidity = []
for h in range(hours):
    dt = start_date + timedelta(hours=h)
    timestamps.append(int(dt.timestamp() * 1000))
    day_of_year = dt.timetuple().tm_yday
    hour = dt.hour
    seasonal = 2.0 + 8.0 * math.sin(2 * math.pi * (day_of_year - 30) / 365)
    daily_cycle = 4.0 * math.sin(2 * math.pi * (hour - 6) / 24)
    temp = seasonal + daily_cycle + random.gauss(0, 1.2)
    temperature.append(round(temp, 1))
    hum_base = 65 - 1.5 * (temp - 5)
    humidity.append(round(max(20, min(95, hum_base + random.gauss(0, 3))), 1))

# --- Brush data ---
random.seed(55)
brush_data = [random.randint(10, 90) for _ in range(50)]

GLASS = {
    "background": "light-dark(rgba(255,255,255,0.55), rgba(30,30,30,0.55))",
    "backdropFilter": "blur(16px) saturate(1.8)",
    "WebkitBackdropFilter": "blur(16px) saturate(1.8)",
    "border": "1px solid light-dark(rgba(255,255,255,0.5), rgba(255,255,255,0.08))",
}

component = dmc.Stack([
    dmc.Text("Line Charts — Pro Features", fw=700, size="xl"),
    dmc.Badge("Requires MUI Pro License", color="violet", variant="light", size="sm"),

    # --- Zoom + Pan + Slider ---
    dmc.Paper(
        dmc.Stack([
            dmc.Text("Zoom, Pan & Toolbar", fw=600),
            dmc.Text("Scroll to zoom, drag to pan. Toolbar provides zoom in/out and export. 100 data points.", size="sm", c="dimmed"),
            LineChart(
                id="mc-line-zoom",
                licenseKey=MUI_KEY,
                series=[
                    {'data': signal, 'label': 'Signal', 'color': '#1976d2', 'curve': 'monotoneX'},
                    {'data': noise, 'label': 'Noise', 'color': '#ef5350', 'curve': 'linear', 'showMark': False},
                ],
                xAxis=[{
                    'id': 'x-axis', 'data': x_vals, 'scaleType': 'linear', 'label': 'Sample Index',
                    'zoom': {'minSpan': 10, 'maxSpan': 100, 'panning': True},
                }],
                yAxis=[{'label': 'Value', 'min': 0, 'max': 100}],
                initialZoom=[{'axisId': 'x-axis', 'start': 0, 'end': 40}],
                showSlider=True,
                showToolbar=True,
                grid={'horizontal': True},
                height=400,
            ),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),

    # --- Zoom Slider with Preview (Time Series) ---
    dmc.Paper(
        dmc.Stack([
            dmc.Text("Zoom Slider with Preview", fw=600),
            dmc.Group([
                dmc.Text("45 days of simulated hourly sensor data. The slider shows a miniature preview of the full dataset.", size="sm", c="dimmed"),
                dmc.Badge("slider.preview", color="teal", variant="light", size="xs"),
            ]),
            LineChart(
                id="mc-line-slider-preview",
                licenseKey=MUI_KEY,
                height=420,
                series=[{
                    'id': 'temperature',
                    'data': temperature,
                    'label': 'Temperature (°C)',
                    'color': '#ef5350',
                    'showMark': False,
                    'area': True,
                    'curve': 'natural',
                }],
                xAxis=[{
                    'id': 'time-axis',
                    'data': timestamps,
                    'scaleType': 'time',
                    'label': 'Date',
                    'tickMinStep': 3600 * 1000 * 24,
                    'tickLabelStyle': {'angle': 35, 'fontSize': 11, 'textAnchor': 'start'},
                    'height': 50,
                    'zoom': {
                        'minSpan': 2,
                        'panning': True,
                        'filterMode': 'discard',
                        'slider': {'enabled': True, 'preview': True},
                    },
                }],
                yAxis=[{'label': '°C', 'width': 55, 'domainLimit': 'nice'}],
                grid={'horizontal': True},
                margin={'left': 65, 'right': 20, 'top': 20, 'bottom': 70},
                initialZoom=[{'axisId': 'time-axis', 'start': 0, 'end': 25}],
            ),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),

    # --- Biaxial with Slider Preview ---
    dmc.Paper(
        dmc.Stack([
            dmc.Text("Biaxial Chart with Slider Preview", fw=600),
            dmc.Text("Temperature and humidity on dual axes. filterMode='discard' auto-adjusts y-axis to visible range.", size="sm", c="dimmed"),
            LineChart(
                id="mc-line-biaxial-preview",
                licenseKey=MUI_KEY,
                height=420,
                series=[
                    {'id': 'temp', 'data': temperature, 'label': 'Temperature (°C)', 'color': '#ef5350', 'showMark': False, 'curve': 'natural', 'yAxisId': 'temp-axis'},
                    {'id': 'hum', 'data': humidity, 'label': 'Humidity (%)', 'color': '#42a5f5', 'showMark': False, 'curve': 'natural', 'yAxisId': 'hum-axis'},
                ],
                xAxis=[{
                    'id': 'biaxial-time',
                    'data': timestamps,
                    'scaleType': 'time',
                    'tickMinStep': 3600 * 1000 * 24,
                    'tickLabelStyle': {'angle': 35, 'fontSize': 11, 'textAnchor': 'start'},
                    'height': 50,
                    'zoom': {
                        'minSpan': 2, 'panning': True, 'filterMode': 'discard',
                        'slider': {'enabled': True, 'preview': True},
                    },
                }],
                yAxis=[
                    {'id': 'temp-axis', 'label': '°C', 'position': 'left', 'width': 50, 'domainLimit': 'nice', 'labelStyle': {'fill': '#ef5350'}},
                    {'id': 'hum-axis', 'label': '%', 'position': 'right', 'width': 50, 'domainLimit': 'nice', 'labelStyle': {'fill': '#42a5f5'}},
                ],
                grid={'horizontal': True},
                margin={'left': 60, 'right': 60, 'top': 20, 'bottom': 70},
                initialZoom=[{'axisId': 'biaxial-time', 'start': 30, 'end': 60}],
            ),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),

    # --- Brush Selection ---
    dmc.Paper(
        dmc.Stack([
            dmc.Text("Brush Selection", fw=600),
            dmc.Text("Click and drag to select a range. The brush overlay shows selected values.", size="sm", c="dimmed"),
            LineChart(
                id="mc-line-brush",
                licenseKey=MUI_KEY,
                series=[
                    {'data': brush_data, 'label': 'Metric A', 'color': '#7b1fa2', 'curve': 'monotoneX'},
                ],
                xAxis=[{'data': list(range(50)), 'scaleType': 'linear', 'label': 'Day'}],
                brushConfig={'enabled': True, 'preventTooltip': True},
                brushOverlay='values',
                grid={'horizontal': True},
                height=300,
            ),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),

    # --- Highlighting ---
    dmc.Paper(
        dmc.Stack([
            dmc.Text("Item & Axis Highlighting", fw=600),
            dmc.Text("Hover to see per-series highlight scoping — the active series stays vivid while others fade.", size="sm", c="dimmed"),
            LineChart(
                id="mc-line-highlight",
                licenseKey=MUI_KEY,
                series=[
                    {'data': [random.randint(100, 250) for _ in range(12)], 'label': 'Sales 2023', 'color': '#1976d2', 'curve': 'monotoneX', 'showMark': True,
                     'highlightScope': {'highlight': 'series', 'fade': 'global'}},
                    {'data': [random.randint(120, 280) for _ in range(12)], 'label': 'Sales 2024', 'color': '#388e3c', 'curve': 'monotoneX', 'showMark': True,
                     'highlightScope': {'highlight': 'series', 'fade': 'global'}},
                    {'data': [random.randint(80, 200) for _ in range(12)], 'label': 'Sales 2025', 'color': '#f57c00', 'curve': 'monotoneX', 'showMark': True,
                     'highlightScope': {'highlight': 'series', 'fade': 'global'}},
                ],
                xAxis=[{'data': ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], 'scaleType': 'point'}],
                yAxis=[{'label': 'Revenue ($k)'}],
                axisHighlight={'x': 'band', 'y': 'line'},
                grid={'horizontal': True},
                height=350,
            ),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),
], gap="lg")
```


---

## Tick Configuration & Date Formatting

New in **1.1.0** — `dateFormat` and `dateTickFormat` let you format time-scale axes directly from Python without writing JavaScript. Use `scaleType: 'time'` with epoch-ms timestamps, control tick density with `tickMinStep`, and rotate labels with `tickLabelStyle`.

| Prop | Description |
|:-----|:------------|
| `dateFormat` | Format string for **tooltip** labels (e.g. `"MMM d, YYYY"`) |
| `dateTickFormat` | Format string for **axis tick** labels (e.g. `"M/d"`) |
| `tickMinStep` | Minimum ms between ticks — use `86400000 * 7` for weekly ticks |
| `tickNumber` | Approximate target tick count (D3 rounds for readability) |
| `tickLabelStyle` | `{angle, fontSize, textAnchor}` for rotated labels |

**Format tokens:** `YYYY` (2025), `MMM` (Jan), `MM` (01), `M` (1), `dd` (01), `d` (1), `HH` (00–23), `mm` (00–59)



```python
# File: docs/dash_mui_charts/tick_hover.py

"""Tick Configuration & Date Formatting — v1.1.0 dateFormat/dateTickFormat."""
import os
import math
import random
import json
from datetime import datetime, timedelta

import dash_mantine_components as dmc
from dash import html, callback, Input, Output
from dash_mui_charts import LineChart

MUI_KEY = os.getenv('MUI_PRO_API_KEY', '')

GLASS = {
    "background": "light-dark(rgba(255,255,255,0.55), rgba(30,30,30,0.55))",
    "backdropFilter": "blur(16px) saturate(1.8)",
    "WebkitBackdropFilter": "blur(16px) saturate(1.8)",
    "border": "1px solid light-dark(rgba(255,255,255,0.5), rgba(255,255,255,0.08))",
}

# ---------------------------------------------------------------------------
# Data — 90-day sensor readings, two sensors
# ---------------------------------------------------------------------------
random.seed(42)

def _make_sensor(start, n, base, noise, trend):
    vals = []
    for i in range(n):
        vals.append(round(base + trend * i + noise * math.sin(i * 0.3) + random.gauss(0, noise * 0.3), 1))
    return vals

_start = datetime(2025, 1, 1)
_n = 90

# Epoch-ms timestamps — required for scaleType: 'time'
timestamps = [
    int((_start + timedelta(days=i)).timestamp()) * 1000
    for i in range(_n)
]

sensor_a = _make_sensor(_start, _n, base=68, noise=5,   trend=0.04)
sensor_b = _make_sensor(_start, _n, base=55, noise=3.5, trend=0.02)

WARNING_THRESHOLD = 78
# Day-45 event marker (epoch-ms)
event_ts = int((_start + timedelta(days=45)).timestamp()) * 1000


# ---------------------------------------------------------------------------
# Chart 1 — dateFormat / dateTickFormat (v1.1.0) + reference lines
# ---------------------------------------------------------------------------
chart_time_scale = dmc.Paper(dmc.Stack([
    dmc.Group([
        dmc.Text("Time-Scale Axis (v1.1.0 dateFormat)", fw=600),
        dmc.Badge("1.1.0", color="teal", variant="light", size="sm"),
    ]),
    dmc.Text(
        "Use scaleType: 'time' with epoch-ms timestamps. "
        "dateFormat controls the tooltip label; dateTickFormat controls axis tick labels. "
        "tickMinStep limits density; tickLabelStyle.angle rotates labels for readability.",
        size="sm", c="dimmed",
    ),
    LineChart(
        id="mc-th-time",
        licenseKey=MUI_KEY,
        height=340,
        series=[
            {
                "data":  sensor_a,
                "label": "Sensor A (°F)",
                "color": "#1976d2",
                "curve": "monotoneX",
                "showMark": False,
                "highlightScope": {"highlight": "series", "fade": "global"},
            },
            {
                "data":  sensor_b,
                "label": "Sensor B (°F)",
                "color": "#7b1fa2",
                "curve": "monotoneX",
                "showMark": False,
                "highlightScope": {"highlight": "series", "fade": "global"},
            },
        ],
        xAxis=[{
            "id":        "mc-th-x",
            "data":      timestamps,
            "scaleType": "time",
            # v1.1.0 built-in date formatting — no JavaScript needed
            "dateFormat":     "MMM d, YYYY",   # tooltip label
            "dateTickFormat": "M/d",            # axis tick labels
            "tickMinStep":    86400 * 1000 * 7, # min 1 week between ticks
            "tickNumber":     10,
            "tickLabelStyle": {"angle": 35, "fontSize": 11, "textAnchor": "start"},
            "height":         70,               # extra space for angled labels
        }],
        yAxis=[{"label": "Temperature (°F)", "min": 48, "max": 85}],
        grid={"horizontal": True},
        axisHighlight={"x": "line"},
        tooltip={"trigger": "axis"},
        margin={"left": 65, "right": 20, "top": 20, "bottom": 90},
        referenceLines=[
            {
                "y":          WARNING_THRESHOLD,
                "label":      "Warning (78°F)",
                "lineStyle":  {"stroke": "#ff9800", "strokeWidth": 2, "strokeDasharray": "5 5"},
                "labelStyle": {"fill": "#ff9800", "fontWeight": "bold"},
                "labelAlign": "end",
            },
            {
                "x":          event_ts,
                "label":      "Calibration",
                "lineStyle":  {"stroke": "#9c27b0", "strokeWidth": 2, "strokeDasharray": "4 4"},
                "labelStyle": {"fill": "#9c27b0"},
                "labelAlign": "start",
            },
        ],
    ),
    dmc.Alert(
        dmc.Code(
            'xAxis=[{"scaleType": "time", "data": epoch_ms, '
            '"dateFormat": "MMM d, YYYY", "dateTickFormat": "M/d", '
            '"tickMinStep": 86400_000 * 7}]'
        ),
        color="blue", variant="light",
    ),
], gap="sm"), p="lg", radius="md", style=GLASS)


# ---------------------------------------------------------------------------
# Chart 2 — axis highlight + click event
# ---------------------------------------------------------------------------
chart_click = dmc.Paper(dmc.Stack([
    dmc.Text("Axis Highlight & Click Events", fw=600),
    dmc.Text(
        "axisHighlight: {'x': 'line'} draws a crosshair; "
        "tooltip: {'trigger': 'axis'} shows all series. "
        "Click any point to capture data via the clickData output prop.",
        size="sm", c="dimmed",
    ),
    LineChart(
        id="mc-th-click",
        licenseKey=MUI_KEY,
        height=300,
        series=[
            {
                "data":  sensor_a,
                "label": "Sensor A",
                "color": "#1976d2",
                "curve": "monotoneX",
                "showMark": True,
                "highlightScope": {"highlight": "item", "fade": "global"},
            },
            {
                "data":  sensor_b,
                "label": "Sensor B",
                "color": "#7b1fa2",
                "curve": "monotoneX",
                "showMark": True,
                "highlightScope": {"highlight": "item", "fade": "global"},
            },
        ],
        xAxis=[{
            "data":      timestamps,
            "scaleType": "time",
            "dateFormat":     "MMM d",
            "dateTickFormat": "M/d",
            "tickMinStep":    86400 * 1000 * 14,
            "tickNumber":     6,
        }],
        yAxis=[{"label": "°F"}],
        grid={"horizontal": True},
        axisHighlight={"x": "line", "y": "line"},
        tooltip={"trigger": "item"},
        margin={"left": 55, "right": 20, "top": 20, "bottom": 50},
    ),
    dmc.Text("Click output:", size="xs", c="dimmed"),
    html.Pre(
        id="mc-th-click-out",
        children="Click a point to see data...",
        style={
            "fontSize": "11px", "margin": 0,
            "padding": "8px 12px", "borderRadius": "6px",
            "background": "light-dark(#f8f9fa, #1a1b1e)",
            "border": "1px solid light-dark(#dee2e6, #373a40)",
        },
    ),
], gap="sm"), p="lg", radius="md", style=GLASS)


component = dmc.Stack([
    dmc.Text("Tick Configuration & Date Formatting", fw=700, size="xl"),
    dmc.Text(
        "Best practices for time-scale axes — epoch-ms data, "
        "built-in date formatting (v1.1.0), angled ticks, reference lines, and click events.",
        size="sm", c="dimmed",
    ),
    chart_time_scale,
    chart_click,
], gap="lg")


# ---------------------------------------------------------------------------
# Callback — click event display
# ---------------------------------------------------------------------------
@callback(
    Output("mc-th-click-out", "children"),
    Input("mc-th-click",      "clickData"),
    prevent_initial_call=True,
)
def show_click(data):
    if not data:
        return "Click a point to see data..."
    return json.dumps(data, indent=2)
```


---

## Pie Charts



```python
# File: docs/dash_mui_charts/pie_charts.py

import random
import dash_mantine_components as dmc
from dash_mui_charts import PieChart

random.seed(77)

PALETTE = ['#1976d2', '#388e3c', '#f57c00', '#d32f2f', '#7b1fa2', '#00838f', '#c62828', '#4527a0']
GLASS = {
    "background": "light-dark(rgba(255,255,255,0.55), rgba(30,30,30,0.55))",
    "backdropFilter": "blur(16px) saturate(1.8)",
    "WebkitBackdropFilter": "blur(16px) saturate(1.8)",
    "border": "1px solid light-dark(rgba(255,255,255,0.5), rgba(255,255,255,0.08))",
}

market_data = [
    {'id': 0, 'value': 35, 'label': 'Chrome', 'color': '#4285f4'},
    {'id': 1, 'value': 25, 'label': 'Safari', 'color': '#007aff'},
    {'id': 2, 'value': 18, 'label': 'Firefox', 'color': '#ff7139'},
    {'id': 3, 'value': 12, 'label': 'Edge', 'color': '#0078d7'},
    {'id': 4, 'value': 10, 'label': 'Other', 'color': '#9e9e9e'},
]

sales_data = [
    {'id': i, 'value': random.randint(15, 50), 'label': cat}
    for i, cat in enumerate(['Electronics', 'Clothing', 'Food', 'Books', 'Toys'])
]

inner_data = [
    {'id': 0, 'value': 60, 'label': 'Desktop', 'color': '#1565c0'},
    {'id': 1, 'value': 35, 'label': 'Mobile', 'color': '#2e7d32'},
    {'id': 2, 'value': 5, 'label': 'Tablet', 'color': '#e65100'},
]
outer_data = [
    {'id': 0, 'value': 30, 'label': 'Windows', 'color': '#42a5f5'},
    {'id': 1, 'value': 20, 'label': 'macOS', 'color': '#66bb6a'},
    {'id': 2, 'value': 10, 'label': 'Linux', 'color': '#ef5350'},
    {'id': 3, 'value': 20, 'label': 'iOS', 'color': '#ab47bc'},
    {'id': 4, 'value': 15, 'label': 'Android', 'color': '#ffa726'},
    {'id': 5, 'value': 5, 'label': 'iPadOS', 'color': '#26c6da'},
]

component = dmc.Stack([
    dmc.Text("Pie Charts", fw=700, size="xl"),
    dmc.Text("Pie, donut, nested, and gauge-arc variations.", size="sm", c="dimmed"),

    dmc.SimpleGrid(cols={"base": 1, "md": 2}, children=[
        dmc.Paper(
            dmc.Stack([
                dmc.Text("Pie Chart", fw=600),
                dmc.Text("Browser market share with arc labels.", size="sm", c="dimmed"),
                PieChart(id="mc-pie-basic", data=market_data, arcLabel='formattedValue', arcLabelMinAngle=25, colors=PALETTE, height=280),
            ], gap="sm"),
            p="lg", radius="md", style=GLASS,
        ),
        dmc.Paper(
            dmc.Stack([
                dmc.Text("Donut Chart", fw=600),
                dmc.Text("Sales by category with inner radius cutout.", size="sm", c="dimmed"),
                PieChart(id="mc-pie-donut", data=sales_data, innerRadius=60, cornerRadius=4, paddingAngle=2, colors=PALETTE, height=280),
            ], gap="sm"),
            p="lg", radius="md", style=GLASS,
        ),
    ]),

    dmc.Paper(
        dmc.Stack([
            dmc.Text("Nested Concentric Pie", fw=600),
            dmc.Text("Inner ring: device type. Outer ring: OS breakdown.", size="sm", c="dimmed"),
            PieChart(
                id="mc-pie-nested",
                series=[
                    {'data': inner_data, 'innerRadius': 0, 'outerRadius': 70, 'highlightScope': {'fade': 'global', 'highlight': 'item'}},
                    {'data': outer_data, 'innerRadius': 80, 'outerRadius': 110, 'highlightScope': {'fade': 'global', 'highlight': 'item'}},
                ],
                height=300,
            ),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),

    dmc.Paper(
        dmc.Stack([
            dmc.Text("Gauge-Style Arc", fw=600),
            dmc.Text("Half-circle gauge using startAngle/endAngle.", size="sm", c="dimmed"),
            dmc.SimpleGrid(cols=2, children=[
                PieChart(
                    id=f"mc-pie-gauge-{i}",
                    data=[
                        {'id': 0, 'value': val, 'label': label, 'color': color},
                        {'id': 1, 'value': 100 - val, 'color': 'light-dark(#e0e0e0, #424242)'},
                    ],
                    startAngle=-90, endAngle=90, innerRadius=50, outerRadius=80, arcLabel='value', height=160,
                )
                for i, (val, label, color) in enumerate([
                    (72, 'CPU', '#1976d2'), (88, 'Disk', '#f57c00'),
                ])
            ]),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),
], gap="lg")
```


---

## Scatter Charts



```python
# File: docs/dash_mui_charts/scatter_charts.py

import random
import dash_mantine_components as dmc
from dash_mui_charts import ScatterChart

random.seed(55)

GLASS = {
    "background": "light-dark(rgba(255,255,255,0.55), rgba(30,30,30,0.55))",
    "backdropFilter": "blur(16px) saturate(1.8)",
    "WebkitBackdropFilter": "blur(16px) saturate(1.8)",
    "border": "1px solid light-dark(rgba(255,255,255,0.5), rgba(255,255,255,0.08))",
}


def make_cluster(cx, cy, n=30, spread=15):
    return [{'x': round(cx + random.gauss(0, spread), 1), 'y': round(cy + random.gauss(0, spread), 1), 'id': i} for i in range(n)]

cluster_a = make_cluster(150, 200, 40)
cluster_b = make_cluster(300, 150, 35)
cluster_c = make_cluster(220, 350, 30)

z_data = [{'x': round(random.uniform(0, 100), 1), 'y': round(random.uniform(0, 100), 1), 'z': round(random.uniform(0, 100), 1), 'id': i} for i in range(60)]

component = dmc.Stack([
    dmc.Text("Scatter Charts", fw=700, size="xl"),
    dmc.Text("Multi-series scatter plots with clustering, z-axis coloring, and voronoi interaction.", size="sm", c="dimmed"),

    dmc.Paper(
        dmc.Stack([
            dmc.Text("Multi-Series Clusters", fw=600),
            dmc.Text("Three distinct clusters with voronoi hover detection.", size="sm", c="dimmed"),
            ScatterChart(
                id="mc-scatter-clusters",
                series=[
                    {'id': 'cluster-a', 'label': 'Group A', 'data': cluster_a, 'color': '#1976d2', 'markerSize': 5},
                    {'id': 'cluster-b', 'label': 'Group B', 'data': cluster_b, 'color': '#388e3c', 'markerSize': 5},
                    {'id': 'cluster-c', 'label': 'Group C', 'data': cluster_c, 'color': '#f57c00', 'markerSize': 5},
                ],
                xAxis=[{'label': 'Feature X', 'min': 50, 'max': 400}],
                yAxis=[{'label': 'Feature Y', 'min': 50, 'max': 450}],
                voronoiMaxRadius=40,
                grid={'horizontal': True, 'vertical': True},
                height=380,
            ),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),

    dmc.Paper(
        dmc.Stack([
            dmc.Text("Z-Axis Color Mapping", fw=600),
            dmc.Text("Third dimension mapped to a continuous color scale.", size="sm", c="dimmed"),
            ScatterChart(
                id="mc-scatter-zaxis",
                series=[
                    {'id': 'z-series', 'label': 'Intensity', 'data': z_data, 'markerSize': 7, 'highlightScope': {'highlight': 'item', 'fade': 'global'}},
                ],
                zAxis=[{'colorMap': {'type': 'continuous', 'min': 0, 'max': 100, 'color': ['#e3f2fd', '#1565c0']}}],
                xAxis=[{'label': 'X', 'min': -5, 'max': 105}],
                yAxis=[{'label': 'Y', 'min': -5, 'max': 105}],
                voronoiMaxRadius=25,
                grid={'horizontal': True, 'vertical': True},
                height=380,
            ),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),
], gap="lg")
```


---

## Composite Charts



```python
# File: docs/dash_mui_charts/composite_charts.py

import os
import random
import math
import dash_mantine_components as dmc
from dash_mui_charts import CompositeChart

MUI_KEY = os.getenv('MUI_PRO_API_KEY', '')
random.seed(33)

GLASS = {
    "background": "light-dark(rgba(255,255,255,0.55), rgba(30,30,30,0.55))",
    "backdropFilter": "blur(16px) saturate(1.8)",
    "WebkitBackdropFilter": "blur(16px) saturate(1.8)",
    "border": "1px solid light-dark(rgba(255,255,255,0.5), rgba(255,255,255,0.08))",
}


n = 50
scatter_data = [{'x': i, 'y': round(20 + 0.4 * i + random.gauss(0, 4), 1), 'id': i} for i in range(n)]
trend_line = [round(20 + 0.4 * i, 1) for i in range(n)]

component = dmc.Stack([
    dmc.Text("Composite Charts", fw=700, size="xl"),
    dmc.Text("Layer scatter points and line series on a single chart surface.", size="sm", c="dimmed"),

    dmc.Paper(
        dmc.Stack([
            dmc.Text("Scatter + Trend Line", fw=600),
            dmc.Text("Sensor readings with a linear regression overlay and reference limits.", size="sm", c="dimmed"),
            CompositeChart(
                id="mc-composite-trend", licenseKey=MUI_KEY,
                series=[
                    {'type': 'scatter', 'id': 'readings', 'label': 'Readings', 'data': scatter_data, 'markerSize': 4, 'color': '#1976d2'},
                    {'type': 'line', 'id': 'trend', 'label': 'Trend', 'data': trend_line, 'curve': 'linear', 'showMark': False, 'color': '#ff7043'},
                ],
                xAxis=[{'id': 'x', 'data': list(range(n)), 'scaleType': 'linear', 'label': 'Sample', 'zoom': {'minSpan': 10, 'panning': True}}],
                yAxis=[{'label': 'Value'}],
                referenceLines=[
                    {'y': 35, 'label': 'Upper limit', 'lineStyle': {'stroke': '#d32f2f', 'strokeDasharray': '6 3'}},
                    {'y': 15, 'label': 'Lower limit', 'lineStyle': {'stroke': '#1565c0', 'strokeDasharray': '6 3'}},
                ],
                initialZoom=[{'axisId': 'x', 'start': 0, 'end': 60}],
                showSlider=True, voronoiMaxRadius=20,
                grid={'horizontal': True},
                height=400,
            ),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),

    dmc.Paper(
        dmc.Stack([
            dmc.Text("Biaxial Composite", fw=600),
            dmc.Text("Temperature scatter on left axis, humidity line on right axis.", size="sm", c="dimmed"),
            CompositeChart(
                id="mc-composite-biaxial", licenseKey=MUI_KEY,
                series=[
                    {'type': 'scatter', 'id': 'temp', 'label': 'Temperature (°C)',
                     'data': [{'x': i, 'y': round(22 + random.gauss(0, 3), 1), 'id': i} for i in range(24)],
                     'markerSize': 5, 'color': '#ef5350', 'yAxisId': 'left'},
                    {'type': 'line', 'id': 'humidity', 'label': 'Humidity (%)',
                     'data': [round(60 + 15 * math.sin(2 * math.pi * i / 24) + random.uniform(-3, 3), 1) for i in range(24)],
                     'curve': 'natural', 'showMark': False, 'color': '#42a5f5', 'area': True, 'yAxisId': 'right'},
                ],
                xAxis=[{'data': list(range(24)), 'scaleType': 'linear', 'label': 'Hour'}],
                yAxis=[{'id': 'left', 'label': '°C', 'position': 'left'}, {'id': 'right', 'label': '%', 'position': 'right'}],
                grid={'horizontal': True},
                height=350,
            ),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),
], gap="lg")
```


---

## Heatmap Charts



```python
# File: docs/dash_mui_charts/heatmap_charts.py

import os
import random
import dash_mantine_components as dmc
from dash_mui_charts import Heatmap

MUI_KEY = os.getenv('MUI_PRO_API_KEY', '')
random.seed(88)

GLASS = {
    "background": "light-dark(rgba(255,255,255,0.55), rgba(30,30,30,0.55))",
    "backdropFilter": "blur(16px) saturate(1.8)",
    "WebkitBackdropFilter": "blur(16px) saturate(1.8)",
    "border": "1px solid light-dark(rgba(255,255,255,0.5), rgba(255,255,255,0.08))",
}


days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
hours = [f'{h}:00' for h in range(6, 22)]

activity_data = []
for xi, day in enumerate(days):
    for yi, hour in enumerate(hours):
        base = 30 if xi < 5 and 3 <= yi <= 10 else 10
        val = max(0, min(100, base + random.randint(-10, 40)))
        activity_data.append([xi, yi, val])

labels = ['Revenue', 'Traffic', 'Conversions', 'Ad Spend', 'Satisfaction']
corr_data = []
for i in range(len(labels)):
    for j in range(len(labels)):
        val = 1.0 if i == j else round(random.uniform(-0.3, 0.95), 2)
        corr_data.append([i, j, val])

component = dmc.Stack([
    dmc.Text("Heatmap Charts", fw=700, size="xl"),
    dmc.Group([
        dmc.Text("Matrix visualization with continuous and piecewise color scales.", size="sm", c="dimmed"),
        dmc.Badge("Pro Feature", color="violet", variant="light", size="sm"),
    ]),

    dmc.Paper(
        dmc.Stack([
            dmc.Text("Website Activity Heatmap", fw=600),
            dmc.Text("Visitor activity by day and hour. Darker = more active.", size="sm", c="dimmed"),
            Heatmap(
                id="mc-heatmap-activity", licenseKey=MUI_KEY,
                data=activity_data,
                xAxis={'data': days, 'label': 'Day'},
                yAxis={'data': hours, 'label': 'Hour'},
                colorScale={'type': 'continuous', 'min': 0, 'max': 100, 'colors': ['#e3f2fd', '#1565c0']},
                height=400,
            ),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),

    dmc.Paper(
        dmc.Stack([
            dmc.Text("Correlation Matrix", fw=600),
            dmc.Text("Business metric correlations with diverging color scale.", size="sm", c="dimmed"),
            Heatmap(
                id="mc-heatmap-corr", licenseKey=MUI_KEY,
                data=corr_data,
                xAxis={'data': labels},
                yAxis={'data': labels},
                colorScale={'type': 'piecewise', 'thresholds': [-0.5, 0, 0.5], 'colors': ['#d32f2f', '#ffcdd2', '#c8e6c9', '#2e7d32']},
                height=350,
            ),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),
], gap="lg")
```


---

## Sparkline Charts



```python
# File: docs/dash_mui_charts/sparkline_charts.py

import random
import dash_mantine_components as dmc
from dash_mui_charts import SparklineChart

random.seed(66)

GLASS = {
    "background": "light-dark(rgba(255,255,255,0.55), rgba(30,30,30,0.55))",
    "backdropFilter": "blur(16px) saturate(1.8)",
    "WebkitBackdropFilter": "blur(16px) saturate(1.8)",
    "border": "1px solid light-dark(rgba(255,255,255,0.5), rgba(255,255,255,0.08))",
}

def spark_data(n=12, base=50, spread=20):
    return [max(0, round(base + random.uniform(-spread, spread), 1)) for _ in range(n)]

revenue_spark = spark_data(12, 60, 15)
users_spark = spark_data(12, 200, 50)
errors_spark = [random.randint(0, 8) for _ in range(12)]
cpu_spark = spark_data(20, 45, 25)

component = dmc.Stack([
    dmc.Text("Sparkline Charts", fw=700, size="xl"),
    dmc.Text("Compact inline charts for dashboards, tables, and KPI cards.", size="sm", c="dimmed"),

    dmc.SimpleGrid(cols={"base": 1, "sm": 2, "md": 4}, children=[
        dmc.Paper(
            dmc.Stack([
                dmc.Text("Revenue", size="sm", c="dimmed"),
                dmc.Group([dmc.Text(f"${sum(revenue_spark):.0f}k", fw=700, size="xl"), dmc.Badge("+12%", color="green", variant="light", size="sm")], justify="space-between"),
                SparklineChart(id="mc-spark-revenue", data=revenue_spark, plotType='line', color='#1976d2', area=True, curve='monotoneX', height=40, showTooltip=True),
            ], gap="xs"),
            p="md", radius="md", style=GLASS,
        ),
        dmc.Paper(
            dmc.Stack([
                dmc.Text("Active Users", size="sm", c="dimmed"),
                dmc.Group([dmc.Text(f"{users_spark[-1]:.0f}", fw=700, size="xl"), dmc.Badge("+8%", color="green", variant="light", size="sm")], justify="space-between"),
                SparklineChart(id="mc-spark-users", data=users_spark, plotType='line', color='#388e3c', area=True, curve='monotoneX', height=40, showTooltip=True),
            ], gap="xs"),
            p="md", radius="md", style=GLASS,
        ),
        dmc.Paper(
            dmc.Stack([
                dmc.Text("Errors", size="sm", c="dimmed"),
                dmc.Group([dmc.Text(f"{errors_spark[-1]}", fw=700, size="xl"), dmc.Badge("-3%", color="red", variant="light", size="sm")], justify="space-between"),
                SparklineChart(id="mc-spark-errors", data=errors_spark, plotType='bar', color='#ef5350', height=40, showTooltip=True),
            ], gap="xs"),
            p="md", radius="md", style=GLASS,
        ),
        dmc.Paper(
            dmc.Stack([
                dmc.Text("CPU Load", size="sm", c="dimmed"),
                dmc.Group([dmc.Text(f"{cpu_spark[-1]:.0f}%", fw=700, size="xl"), dmc.Badge("stable", color="gray", variant="light", size="sm")], justify="space-between"),
                SparklineChart(id="mc-spark-cpu", data=cpu_spark, plotType='line', color='#f57c00', curve='linear', height=40, showTooltip=True),
            ], gap="xs"),
            p="md", radius="md", style=GLASS,
        ),
    ]),

    dmc.Paper(
        dmc.Stack([
            dmc.Text("Sparkline Variations", fw=600),
            dmc.Text("Different plot types, curves, and configurations.", size="sm", c="dimmed"),
            dmc.SimpleGrid(cols=3, children=[
                dmc.Stack([
                    dmc.Text("Line (monotoneX)", size="xs", c="dimmed", ta="center"),
                    SparklineChart(id="mc-spark-v1", data=spark_data(), plotType='line', color='#1976d2', curve='monotoneX', height=60, area=True, showTooltip=True),
                ], gap="xs"),
                dmc.Stack([
                    dmc.Text("Line (step)", size="xs", c="dimmed", ta="center"),
                    SparklineChart(id="mc-spark-v2", data=spark_data(), plotType='line', color='#7b1fa2', curve='step', height=60, showTooltip=True),
                ], gap="xs"),
                dmc.Stack([
                    dmc.Text("Bar", size="xs", c="dimmed", ta="center"),
                    SparklineChart(id="mc-spark-v3", data=spark_data(8, 30, 20), plotType='bar', color='#388e3c', height=60, showTooltip=True),
                ], gap="xs"),
            ]),
        ], gap="sm"),
        p="lg", radius="md", style=GLASS,
    ),
], gap="lg")
```


---

## Live Trading Chart

Real-time OHLCV candlestick streaming with volume bars, a forecast line with uncertainty bands, and automatic swing-point alert labels. All simulation parameters are controllable at runtime.

| Feature | Description |
|:--------|:------------|
| **Candlesticks** | OHLCV bars with green/red coloring |
| **Volume** | Optional volume histogram (bottom panel) |
| **Forecast** | Forward projection with uncertainty shading |
| **Alerts** | Configurable via `alertProbability` (chance per tick) and `alertThresholdPct` (minimum % move) |
| **Alert colors** | `alertUpColor` / `alertDownColor` for label styling |
| **Zoom / Slider** | Pro — range slider preview |



```python
# File: docs/dash_mui_charts/live_trading.py

"""Live Trading Chart — real-time OHLCV candlestick simulation with swing-point alerts."""
import os
import dash_mantine_components as dmc
from dash import html, callback, Input, Output, State, ctx
from dash_mui_charts import LiveTradingChart

MUI_KEY = os.getenv('MUI_PRO_API_KEY', '')

GLASS = {
    "background": "light-dark(rgba(255,255,255,0.55), rgba(30,30,30,0.55))",
    "backdropFilter": "blur(16px) saturate(1.8)",
    "WebkitBackdropFilter": "blur(16px) saturate(1.8)",
    "border": "1px solid light-dark(rgba(255,255,255,0.5), rgba(255,255,255,0.08))",
}

component = dmc.Stack([
    dmc.Text("Live Trading Chart", fw=700, size="xl"),
    dmc.Text(
        "Real-time candlestick simulation with OHLCV data, volume bars, "
        "forecast line with uncertainty bands, and swing-point alert labels. "
        "Alerts only fire at confirmed local highs and lows — not on every candle.",
        size="sm", c="dimmed",
    ),

    dmc.Paper(dmc.Stack([

        # ── Controls Row 1: Buttons + Toggles ─────────────────────────────
        dmc.Group([
            dmc.Group([
                dmc.Button("Start", id="mc-lt-start", color="green", size="sm"),
                dmc.Button("Stop",  id="mc-lt-stop",  color="yellow", size="sm", variant="outline"),
                dmc.Button("Reset", id="mc-lt-reset", color="red",    size="sm", variant="outline"),
            ], gap="xs"),
            dmc.Group([
                dmc.Switch(id="mc-lt-volume",  label="Volume",       checked=True,  size="sm"),
                dmc.Switch(id="mc-lt-labels",  label="Price Labels", checked=False, size="sm"),
                dmc.Switch(id="mc-lt-slider",  label="Zoom Preview", checked=True,  size="sm"),
            ], gap="md"),
        ], justify="space-between"),

        # ── Controls Row 2: Sliders ────────────────────────────────────────
        dmc.SimpleGrid(cols={"base": 1, "sm": 2, "lg": 4}, children=[
            dmc.Stack([
                dmc.Text("Speed (ms/tick)", size="xs", fw=500),
                dmc.Slider(
                    id="mc-lt-speed", value=200, min=50, max=1000, step=50,
                    marks=[
                        {"value": 50,   "label": "50"},
                        {"value": 200,  "label": "200"},
                        {"value": 500,  "label": "500"},
                        {"value": 1000, "label": "1s"},
                    ],
                ),
            ], gap=4),
            dmc.Stack([
                dmc.Text("Volatility", size="xs", fw=500),
                dmc.Slider(
                    id="mc-lt-volatility", value=2.0, min=0.5, max=8.0, step=0.5,
                    marks=[
                        {"value": 0.5, "label": "0.5"},
                        {"value": 2.0, "label": "2.0"},
                        {"value": 8.0, "label": "8.0"},
                    ],
                ),
            ], gap=4),
            dmc.Stack([
                dmc.Text("Drift", size="xs", fw=500),
                dmc.Slider(
                    id="mc-lt-drift", value=0.1, min=-0.5, max=0.5, step=0.05,
                    marks=[
                        {"value": -0.5, "label": "-0.5"},
                        {"value": 0,    "label": "0"},
                        {"value": 0.5,  "label": "+0.5"},
                    ],
                ),
            ], gap=4),
            dmc.Stack([
                dmc.Text("Window", size="xs", fw=500),
                dmc.Slider(
                    id="mc-lt-window", value=80, min=30, max=200, step=10,
                    marks=[
                        {"value": 30,  "label": "30"},
                        {"value": 80,  "label": "80"},
                        {"value": 200, "label": "200"},
                    ],
                ),
            ], gap=4),
        ]),

        # ── Stats Row ──────────────────────────────────────────────────────
        dmc.Group([
            dmc.Stack([
                dmc.Text("Price",  size="xs", c="dimmed"),
                dmc.Text(id="mc-lt-price",  children="$100.00", fw=700, size="lg"),
            ], gap=0, align="center"),
            dmc.Stack([
                dmc.Text("Ticks",  size="xs", c="dimmed"),
                dmc.Text(id="mc-lt-ticks",  children="0",       fw=700, size="lg"),
            ], gap=0, align="center"),
            dmc.Stack([
                dmc.Text("Alerts", size="xs", c="dimmed"),
                dmc.Text(id="mc-lt-alerts", children="0",       fw=700, size="lg"),
            ], gap=0, align="center"),
            dmc.Stack([
                dmc.Text("Status", size="xs", c="dimmed"),
                dmc.Text(id="mc-lt-status", children="Stopped", fw=700, size="lg", c="dimmed"),
            ], gap=0, align="center"),
        ], gap="xl"),

        # ── Chart ──────────────────────────────────────────────────────────
        LiveTradingChart(
            id="mc-lt-chart",
            licenseKey=MUI_KEY,
            height=520,
            running=False,
            intervalMs=200,
            seed=42,
            windowSize=80,
            forecastSize=20,
            initialPrice=100,
            volatility=0.02,
            drift=0.001,
            forecastVolatility=1.5,
            # Alert tuning — low probability + high threshold keeps labels sparse
            alertProbability=0.03,   # 3% chance per tick (vs default 8%)
            alertThresholdPct=3.0,   # minimum 3% move to qualify
            alertUpColor="#4caf50",
            alertDownColor="#f44336",
            showVolume=True,
            showLabels=False,
            showSlider=True,
            volumeHeightPct=20,
            margin={"left": 75, "right": 30, "top": 20, "bottom": 50},
        ),

        # ── Alert History ──────────────────────────────────────────────────
        dmc.Text("Recent Alerts", fw=600, size="sm"),
        html.Pre(
            id="mc-lt-log",
            children="Alerts will appear here once running...",
            style={
                "fontSize": "11px", "maxHeight": "130px",
                "overflow": "auto", "margin": 0,
                "padding": "8px 12px", "borderRadius": "6px",
                "background": "light-dark(#f8f9fa, #1a1b1e)",
                "border": "1px solid light-dark(#dee2e6, #373a40)",
            },
        ),

    ], gap="md"), p="lg", radius="md", style=GLASS),
], gap="md")


# ── Callbacks ─────────────────────────────────────────────────────────────────

@callback(
    Output("mc-lt-chart", "running"),
    Input("mc-lt-start", "n_clicks"),
    Input("mc-lt-stop",  "n_clicks"),
    prevent_initial_call=True,
)
def toggle_running(_start, _stop):
    return ctx.triggered_id == "mc-lt-start"


@callback(
    Output("mc-lt-chart", "resetTrigger"),
    Input("mc-lt-reset", "n_clicks"),
    prevent_initial_call=True,
)
def reset_chart(n):
    return n or 0


@callback(Output("mc-lt-chart", "intervalMs"),  Input("mc-lt-speed",      "value"))
def set_speed(val):      return val or 200

@callback(Output("mc-lt-chart", "volatility"),  Input("mc-lt-volatility", "value"))
def set_volatility(val): return (val or 2.0) / 100

@callback(Output("mc-lt-chart", "drift"),       Input("mc-lt-drift",      "value"))
def set_drift(val):      return (val or 0) / 100

@callback(Output("mc-lt-chart", "windowSize"),  Input("mc-lt-window",     "value"))
def set_window(val):     return val or 80

@callback(Output("mc-lt-chart", "showVolume"),  Input("mc-lt-volume",     "checked"))
def set_volume(v):       return v if v is not None else True

@callback(Output("mc-lt-chart", "showLabels"),  Input("mc-lt-labels",     "checked"))
def set_labels(v):       return v if v is not None else False

@callback(Output("mc-lt-chart", "showSlider"),  Input("mc-lt-slider",     "checked"))
def set_slider(v):       return v if v is not None else True


@callback(
    Output("mc-lt-price",  "children"),
    Output("mc-lt-price",  "c"),
    Output("mc-lt-ticks",  "children"),
    Output("mc-lt-status", "children"),
    Output("mc-lt-status", "c"),
    Input("mc-lt-chart",   "currentPrice"),
    Input("mc-lt-chart",   "tickCount"),
    State("mc-lt-chart",   "running"),
)
def update_stats(price, ticks, running):
    p = price if price is not None else 100.0
    return (
        f"${p:,.2f}",
        "green" if p >= 100 else "red",
        str(ticks or 0),
        "Running" if running else "Stopped",
        "green" if running else "dimmed",
    )


@callback(
    Output("mc-lt-alerts", "children"),
    Output("mc-lt-log",    "children"),
    Input("mc-lt-chart",   "alertHistory"),
)
def update_alerts(alerts):
    if not alerts:
        return "0", "Alerts will appear here once running..."
    recent = list(reversed(alerts[-15:]))
    lines = [
        f"[Tick {a.get('tick', '?'):>5}]  "
        f"{'UP' if a.get('type') == 'up' else 'DN'}  "
        f"${a.get('price', 0):>8.2f}  ({a.get('message', '')})"
        for a in recent
    ]
    return str(len(alerts)), "\n".join(lines)
```


---

## LineChart Properties

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| `series` | list | Required | Array of series objects with `data`, `label`, `color`, `curve`, `area`, `stack` |
| `xAxis` | list | `None` | X-axis config: `data`, `scaleType`, `label`, `zoom` (Pro) |
| `yAxis` | list | `None` | Y-axis config: `label`, `position`, `min`, `max` |
| `referenceLines` | list | `None` | Horizontal/vertical reference lines |
| `licenseKey` | string | `""` | MUI Pro license key (for zoom, brush, slider) |
| `initialZoom` | list | `None` | Initial zoom state (Pro) |
| `showSlider` | bool | `False` | Show zoom range slider (Pro) |
| `showToolbar` | bool | `False` | Show zoom/export toolbar (Pro) |
| `brushConfig` | dict | `None` | Brush selection config (Pro) |
| `grid` | dict | `None` | Grid lines: `{horizontal: bool, vertical: bool}` |
| `height` | int | `300` | Chart height in pixels |
| `colors` | list | `None` | Custom color palette |
| `axisHighlight` | dict | `None` | `{x: 'none'/'line'/'band', y: 'none'/'line'}` |
| `tooltip` | dict | `None` | `{trigger: 'axis'/'item'/'none'}` |
| `clickData` | dict | Output | Click event data |
| `highlightedItem` | dict | `None` | Controlled highlight state |
| `dateFormat` | string | `None` | **1.1.0** — Tooltip date format for `scaleType: 'time'` (e.g. `"MMM d, YYYY"`) |
| `dateTickFormat` | string | `None` | **1.1.0** — Axis tick date format for `scaleType: 'time'` (e.g. `"M/d"`) |

---

## PieChart Properties

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| `data` | list | `None` | Array of `{id, value, label, color}` objects |
| `series` | list | `None` | Multi-series for nested pies |
| `innerRadius` | int | `0` | Donut hole radius (>0 = donut) |
| `outerRadius` | int | `None` | Outer radius |
| `cornerRadius` | int | `0` | Rounded slice corners |
| `paddingAngle` | int | `0` | Gap between slices (degrees) |
| `startAngle` | int | `0` | Arc start angle |
| `endAngle` | int | `360` | Arc end angle |
| `arcLabel` | string | `None` | `'value'`, `'label'`, `'formattedValue'` |
| `arcLabelMinAngle` | int | `0` | Min angle to show label |
| `height` | int | `300` | Chart height |

---

## ScatterChart Properties

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| `series` | list | Required | Array of series with `data: [{x, y, z?, id}]` |
| `zAxis` | list | `None` | Z-axis color mapping config |
| `voronoiMaxRadius` | int | `None` | Proximity hover radius |
| `xAxis` | list | `None` | X-axis config |
| `yAxis` | list | `None` | Y-axis config |
| `height` | int | `300` | Chart height |

---

## Heatmap Properties (Pro)

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| `data` | list | Required | Array of `[x_index, y_index, value]` |
| `xAxis` | dict | Required | `{data: [...], label: "..."}` |
| `yAxis` | dict | Required | `{data: [...], label: "..."}` |
| `colorScale` | dict | Required | `{type, min, max, colors}` or `{type, thresholds, colors}` |
| `licenseKey` | string | Required | MUI Pro license key |
| `height` | int | `300` | Chart height |

---

## LiveTradingChart Properties

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| `running` | bool | `False` | Start / stop the simulation tick |
| `intervalMs` | int | `200` | Milliseconds between ticks |
| `windowSize` | int | `80` | Number of visible candles |
| `forecastSize` | int | `20` | Candles projected ahead |
| `initialPrice` | float | `100` | Starting price |
| `volatility` | float | `0.02` | Per-tick price volatility (e.g. `0.02` = 2%) |
| `drift` | float | `0.001` | Per-tick price drift (positive = upward trend) |
| `forecastVolatility` | float | `1.5` | Uncertainty band width multiplier |
| `seed` | int | `None` | Random seed for reproducibility |
| `showVolume` | bool | `True` | Show volume histogram |
| `showLabels` | bool | `False` | Show price labels on candles |
| `showSlider` | bool | `False` | Show zoom range slider (Pro) |
| `volumeHeightPct` | int | `20` | Volume panel height as % of chart height |
| `alertProbability` | float | `0.08` | Probability of an alert firing per tick (lower = fewer labels) |
| `alertThresholdPct` | float | `2.0` | Minimum % price change required to trigger an alert |
| `alertUpColor` | string | `"#4caf50"` | Label color for upward alert moves |
| `alertDownColor` | string | `"#f44336"` | Label color for downward alert moves |
| `margin` | dict | `None` | Chart margins `{left, right, top, bottom}` |
| `licenseKey` | string | `""` | MUI Pro license key |
| `resetTrigger` | int | `None` | Increment to reset simulation |
| `currentPrice` | float | Output | Current simulated price |
| `tickCount` | int | Output | Number of ticks elapsed |
| `alertHistory` | list | Output | Array of `{type, tick, price, message}` alert objects |

---

## Contributing

Contributions welcome! Visit the [GitHub repo](https://github.com/pip-install-python/dash-mui-charts/issues).

## License

MIT License (component). MUI X Pro license required for Pro features.

---

<!-- /pip/dash_mui_scheduler — https://2plot.dev/pip/dash_mui_scheduler/llms.txt -->

> **Full documentation:** [https://muischeduler.2plot.dev](https://muischeduler.2plot.dev) — the dedicated dash-mui-scheduler documentation site, with the complete API reference and deeper examples. This page is the quick-start overview.



### Installation

[Visit GitHub Repo](https://github.com/pip-install-python/dash-mui-scheduler) · [PyPI](https://pypi.org/project/dash-mui-scheduler/)

```bash
pip install dash-mui-scheduler
```

### Introduction

`dash-mui-scheduler` is the successor to `dash-fullcalendar` — it replaces it in this catalogue. It wraps the [MUI X Scheduler](https://mui.com/x/react-scheduler/) for Plotly Dash, so you can build event calendars and resource timelines in pure Python.

Events cross the Dash ↔ Python boundary as plain dictionaries with ISO-string dates, and every user interaction (create / move / resize / delete) round-trips straight back into your callbacks. Dark mode is automatic — the components follow the surrounding Dash Mantine Components color scheme.

The suite includes:

| Component | Plan | What it is |
|:----------|:-----|:-----------|
| `EventCalendar` | **Community (MIT)** | Day / week / month / agenda calendar with drag-and-drop, resizing, resources, and an editing dialog. No license key. |
| `EventCalendarPremium` | Premium | The calendar plus the recurrence engine (RRULE events + exception dates). |
| `EventTimeline` | Premium | A resource-row, Gantt-style timeline across configurable zoom presets. |
| `RadialLineChart` | Premium (preview) | Polar line/area charts for trends along periodic values. |
| `RadialBarChart` | Premium (preview) | Polar bar charts for comparing values along periodic categories. |

> The underlying MUI X Scheduler is in **beta** (`@mui/x-scheduler@9.0.0-beta.0`); this wrapper pins the beta exactly, so the upstream API may change before its stable release.

### Quick Start

Pass a list of event dicts — each needs at least `id`, `title`, `start`, and `end` (ISO strings). Optional keys include `description`, `color`, `allDay`, `resource`, and per-event `draggable` / `resizable` / `readOnly` overrides.



```python
# File: docs/dash_mui_scheduler/quickstart_example.py

"""Quick-start EventCalendar example with sample events."""
from datetime import datetime, timedelta

import dash_mantine_components as dmc
import dash_mui_scheduler as dms

# Anchor the sample events to the current week so the calendar
# always opens with something to look at.
_monday = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
_monday -= timedelta(days=_monday.weekday())


def _iso(day_offset: int, hour: int, minute: int = 0) -> str:
    return (_monday + timedelta(days=day_offset, hours=hour, minutes=minute)).isoformat()


# Events are plain dicts with ISO-string dates.
# Required keys: id, title, start, end. Everything else is optional.
events = [
    {
        "id": "1",
        "title": "Team Standup",
        "start": _iso(0, 9),
        "end": _iso(0, 9, 30),
        "color": "blue",
    },
    {
        "id": "2",
        "title": "Design Review",
        "start": _iso(1, 13),
        "end": _iso(1, 14, 30),
        "color": "purple",
        "description": "Review the new dashboard mockups.",
    },
    {
        "id": "3",
        "title": "Client Call",
        "start": _iso(2, 10),
        "end": _iso(2, 11),
        "color": "teal",
    },
    {
        "id": "4",
        "title": "Sprint Demo",
        "start": _iso(3, 15),
        "end": _iso(3, 16),
        "color": "green",
    },
    {
        "id": "5",
        "title": "Conference Day",
        "start": _iso(4, 0),
        "end": _iso(4, 23, 59),
        "allDay": True,
        "color": "orange",
    },
]

component = dmc.Paper(
    dms.EventCalendar(
        id="mui-scheduler-quickstart-cal",
        events=events,
        defaultView="week",
        height=620,
    ),
    withBorder=True,
    p="md",
)
```


### Events & Callbacks

The `events` prop is **both an input and an output**. Seed it with your data; the calendar writes the full new array back whenever the user creates, moves, resizes, or deletes an event. For a lighter-weight signal, `lastAction` reports just the most recent change:

```python
{"type": "create" | "update" | "delete" | "move" | "resize" | "change",
 "event": {...},            # the affected event (or None)
 "event_timestamp": ...}
```

Other stateful props (`view`, `visibleDate`, `preferences`, `visibleResources`) each come as a controlled prop plus an uncontrolled `defaultX` variant, and are written back on change — so you can read the active view or the date the user navigated to in a callback.



```python
# File: docs/dash_mui_scheduler/interactive_example.py

"""Interactive example: round-tripping event edits through callbacks.

`events` is BOTH an input and an output — the calendar writes the full
array back on every create / move / resize / delete. `lastAction` is a
convenience output describing just the most recent change.
"""
import json
from datetime import datetime, timedelta

import dash_mantine_components as dmc
import dash_mui_scheduler as dms
from dash import callback, Input, Output

_monday = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
_monday -= timedelta(days=_monday.weekday())


def _iso(day_offset: int, hour: int, minute: int = 0) -> str:
    return (_monday + timedelta(days=day_offset, hours=hour, minutes=minute)).isoformat()


events = [
    {"id": "a", "title": "Drag me around", "start": _iso(0, 10), "end": _iso(0, 11), "color": "indigo"},
    {"id": "b", "title": "Resize my edges", "start": _iso(2, 13), "end": _iso(2, 15), "color": "pink"},
    {"id": "c", "title": "Click to edit or delete", "start": _iso(4, 9), "end": _iso(4, 10), "color": "amber"},
]

component = dmc.Stack(
    [
        dms.EventCalendar(
            id="mui-scheduler-interactive-cal",
            events=events,
            defaultView="week",
            height=560,
            eventCreation={"interaction": "click", "duration": 60},
        ),
        dmc.Group(
            [
                dmc.Badge("try it", color="teal", variant="light"),
                dmc.Text(
                    "Drag, resize, create (click an empty slot), edit, or delete an event — "
                    "every change lands in a Dash callback.",
                    size="sm",
                    c="dimmed",
                ),
            ],
            gap="xs",
        ),
        dmc.SimpleGrid(
            cols={"base": 1, "md": 2},
            children=[
                dmc.Paper(
                    [
                        dmc.Text("lastAction", fw=600, size="sm", mb="xs"),
                        dmc.Code(
                            "Interact with the calendar…",
                            id="mui-scheduler-last-action-output",
                            block=True,
                        ),
                    ],
                    withBorder=True,
                    p="md",
                ),
                dmc.Paper(
                    [
                        dmc.Text("events (round-tripped)", fw=600, size="sm", mb="xs"),
                        dmc.Text(
                            f"{len(events)} events",
                            id="mui-scheduler-event-count-output",
                            size="sm",
                            c="dimmed",
                        ),
                    ],
                    withBorder=True,
                    p="md",
                ),
            ],
        ),
    ],
    gap="md",
)


@callback(
    Output("mui-scheduler-last-action-output", "children"),
    Input("mui-scheduler-interactive-cal", "lastAction"),
    prevent_initial_call=True,
)
def show_last_action(last_action):
    if not last_action:
        return "Interact with the calendar…"
    # lastAction = {"type": "create"|"update"|"delete"|"move"|"resize"|"change",
    #               "event": {...}, "event_timestamp": ...}
    return json.dumps(
        {"type": last_action.get("type"), "event": last_action.get("event")},
        indent=2,
        default=str,
    )


@callback(
    Output("mui-scheduler-event-count-output", "children"),
    Input("mui-scheduler-interactive-cal", "events"),
    prevent_initial_call=True,
)
def show_event_count(current_events):
    current_events = current_events or []
    titles = ", ".join(e.get("title", "?") for e in current_events)
    return f"{len(current_events)} events — {titles}"
```


### Premium Components

`EventCalendarPremium`, `EventTimeline`, and the radial charts wrap MUI X **Premium** packages. They all accept a `licenseKey` prop; without a valid MUI X Premium license key they still render, but with a watermark. All Premium components share the same `@mui/x-license` singleton, so a single key covers everything. The Dash wrapper code in this package is MIT-licensed; the underlying MUI X Premium libraries are not.

#### EventCalendarPremium — recurrence

Same API as `EventCalendar`, plus `licenseKey` and support for recurring events via `rrule` (an RFC-5545 RRULE string or an object) and `exDates` (exception dates):

```python
import os
import dash_mui_scheduler as dms

dms.EventCalendarPremium(
    id="mui-scheduler-premium-cal",
    licenseKey=os.environ["MUI_X_LICENSE_KEY"],
    events=[{
        "id": 1, "title": "Standup",
        "start": "2026-07-06T09:00:00", "end": "2026-07-06T09:15:00",
        "rrule": "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
    }],
    defaultVisibleDate="2026-07-06",
)
```

#### EventTimeline

A Gantt-style timeline that lays events out in resource rows across configurable zoom presets. It shares the calendar's event/resource model (`events`, `resources`, `lastAction`, drag/resize props) and swaps the `view`/`views` props for `preset` / `defaultPreset` / `presets`, plus a `resourceColumnLabel` for the resource column header.

#### Radial Charts (Premium preview)

`RadialLineChart` and `RadialBarChart` are polar charts from `@mui/x-charts-premium`. Pass row-oriented `dataset` rows plus `series` that reference columns via `dataKey`; `rotationAxis` replaces the cartesian x-axis and `radiusAxis` replaces the y-axis. Clicking the chart reports the hit axis item via the `clickData` output. They are `Unstable_` previews — production-ready, but their API may shift.



```python
# File: docs/dash_mui_scheduler/radial_chart_example.py

"""RadialLineChart example (Premium preview).

Renders with a watermark unless a MUI X Premium `licenseKey` is provided.
Set the MUI_X_LICENSE_KEY environment variable to remove it.
"""
import os

import dash_mantine_components as dmc
import dash_mui_scheduler as dms
from dash import callback, Input, Output

# Row-oriented data; each series references a column via `dataKey`.
dataset = [
    {"month": "Jan", "london": 49, "paris": 51},
    {"month": "Feb", "london": 38, "paris": 41},
    {"month": "Mar", "london": 40, "paris": 47},
    {"month": "Apr", "london": 44, "paris": 52},
    {"month": "May", "london": 49, "paris": 63},
    {"month": "Jun", "london": 45, "paris": 49},
    {"month": "Jul", "london": 45, "paris": 62},
    {"month": "Aug", "london": 50, "paris": 53},
    {"month": "Sep", "london": 49, "paris": 47},
    {"month": "Oct", "london": 69, "paris": 61},
    {"month": "Nov", "london": 59, "paris": 55},
    {"month": "Dec", "london": 56, "paris": 58},
]

component = dmc.Paper(
    dmc.Stack(
        [
            dms.RadialLineChart(
                id="mui-scheduler-radial-lines",
                height=420,
                licenseKey=os.environ.get("MUI_X_LICENSE_KEY", ""),
                dataset=dataset,
                # rotationAxis = angular (x-like) axis, radiusAxis = radial (y-like) axis
                series=[
                    {"dataKey": "london", "label": "London (mm)", "curve": "natural", "showMark": True},
                    {"dataKey": "paris", "label": "Paris (mm)", "curve": "natural", "showMark": True},
                ],
                rotationAxis=[{"scaleType": "point", "dataKey": "month", "disableLine": True}],
                radiusAxis=[{"disableLine": True}],
                grid={"rotation": True, "radius": True},
            ),
            dmc.Text(
                "Click the chart to inspect an axis item.",
                id="mui-scheduler-radial-click-output",
                size="sm",
                c="dimmed",
                ta="center",
            ),
        ],
        gap="xs",
    ),
    withBorder=True,
    p="md",
)


@callback(
    Output("mui-scheduler-radial-click-output", "children"),
    Input("mui-scheduler-radial-lines", "clickData"),
    prevent_initial_call=True,
)
def show_click(click_data):
    if not click_data:
        return "Click the chart to inspect an axis item."
    return (
        f"Clicked {click_data.get('axisValue')} — "
        f"series values: {click_data.get('seriesValues')}"
    )
```


### EventCalendar Properties

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| `id` | string | - | Component ID for Dash callbacks. |
| `events` | list of dicts | - | The events to render. Each needs `id`, `title`, `start`, `end` (ISO strings); optional `description`, `timezone`, `resource`, `rrule`, `exDates`, `allDay`, `readOnly`, `color`, `draggable`, `resizable`, `className`. **Input AND output** — written back on every edit. |
| `lastAction` | dict | - | **Output.** The most recent change: `{type, event, event_timestamp}`. |
| `view` | `'day'` \| `'week'` \| `'month'` \| `'agenda'` | - | Controlled active view. Also an **output** (updated on view change). |
| `defaultView` | string | `'week'` | Uncontrolled initial view. |
| `views` | list of strings | `['day','week','month','agenda']` | Which views are offered. |
| `visibleDate` | string | - | Controlled visible date (ISO string). Also an **output** on navigation. |
| `defaultVisibleDate` | string | today | Uncontrolled initial visible date. |
| `eventColor` | string | `'teal'` | Default event palette: `red`, `pink`, `purple`, `indigo`, `blue`, `teal`, `green`, `lime`, `amber`, `orange`, `grey`. Overridden per resource and per event. |
| `eventCreation` | bool \| dict | - | `False` disables creation; a dict sets `{interaction: 'click'\|'double-click', duration}` (minutes). |
| `resources` | list of dicts | - | Resources events can be assigned to (supports nested `children`). |
| `visibleResources` | dict | - | Controlled resource visibility map `{resourceId: bool}`. Also an **output**. |
| `defaultVisibleResources` | dict | `{}` | Uncontrolled initial resource visibility (all visible). |
| `shouldEventRequireResource` | boolean | `False` | Require every event to be assigned to a resource. |
| `areEventsDraggable` | boolean | `True` | Allow drag-to-reschedule. |
| `areEventsResizable` | bool \| `'start'` \| `'end'` | `True` | Allow resize (or restrict to one edge). |
| `canDragEventsFromTheOutside` | boolean | `False` | Allow external events to be dragged in. |
| `canDropEventsToTheOutside` | boolean | `False` | Allow events to be dragged out of the calendar. |
| `readOnly` | boolean | - | Global read-only mode (disables create / drag / resize / dialog). |
| `showCurrentTimeIndicator` | boolean | `True` | Show the current-time indicator line in time views. |
| `scrollToCurrentTime` | boolean | `False` | Scroll day/week views so the current time is centered on first render. |
| `displayTimezone` | string | `'default'` | Render timezone: IANA name, `'default'`, `'locale'`, or `'UTC'`. Render-only. |
| `preferences` | dict | - | Controlled user preferences `{ampm, weekStartsOn, showWeekends, showWeekNumber, isSidePanelOpen, showEmptyDaysInAgenda}`. Also an **output**. |
| `defaultPreferences` | dict | - | Uncontrolled initial preferences (same shape). |
| `preferencesMenuConfig` | `False` \| dict | - | Which items appear in the preferences menu, or `False` to hide it. |
| `localeText` | dict | - | Override UI label strings (partial map of translation keys). |
| `eventDialogVariant` | `'drawer'` \| `'dialog'` | `'drawer'` | Event editor presentation: responsive drawer or floating dialog. |
| `eventDialogTopOffset` | number | `0` | Desktop drawer inset from the top (px) — e.g. your fixed header height. |
| `responsiveSidePanel` | boolean | `True` | Side panel opens on wide screens, collapses below `mobileBreakpoint`. |
| `mobileBreakpoint` | number | `768` | Width (px) below which the UI switches to its mobile layout. |
| `height` | number \| string | `600` | Height of the wrapping container. |
| `className` | string | - | CSS class applied to the wrapping div. |
| `sx` | dict | - | MUI `sx` styling object (object form only). |

**Note:** `EventCalendarPremium` accepts all of the above plus `licenseKey`. `EventTimeline` accepts `licenseKey`, `preset` / `defaultPreset` / `presets`, and `resourceColumnLabel` in place of the `view` props; the radial charts take `dataset`, `series`, `rotationAxis`, `radiusAxis`, `grid`, `axisHighlight`, `colors`, `hideLegend`, `margin`, `showToolbar`, `slotProps`, and the `clickData` output. See the component docstrings for full details.

---

<!-- /pip/dash_nle_timeline — https://2plot.dev/pip/dash_nle_timeline/llms.txt -->

> 📚 **Full documentation:** [github.com/pip-install-python/dash-nle-timeline](https://github.com/pip-install-python/dash-nle-timeline) — complete API reference and examples in the repository. This page is the quick-start overview.

### Installation

[Visit GitHub Repo](https://github.com/pip-install-python/dash-nle-timeline) · [PyPI](https://pypi.org/project/dash-nle-timeline/)

```bash
pip install dash-nle-timeline
```

### Introduction

A calendar or scheduler widget thinks in *dates*. A video editor needs an integer **frame** grid, a real draggable playhead, snapping to clip edges / markers / grid, audio waveforms, and edits expressed as data. `dash-nle-timeline` is that — a purpose-built **non-linear-editor (NLE) timeline** for Dash. Only small JSON travels over callbacks (the control plane); your media stays on the media plane (a `<video>`, an audio engine, a server render) driven *off* the timeline's outputs.

The package ships two components:

*   **`DashNleTimeline`** — frame ruler, stacked track lanes (`video` / `audio` / `camera`), clip rectangles, a draggable playhead, snapping, trim/resize/split/delete, waveforms from precomputed `peaks`, `ctrl/⌘ + wheel` zoom, keyboard shortcuts, and an imperative `command` prop.
*   **`DashNleScene`** — a Hybrid DOM + Canvas preview compositor driven by `playhead`, with overlay `layers`, a `tileset` background, and a `children` slot that pairs with [`dash-leaflet2`](https://github.com/pip-install-python/dash-leaflet2) for map-focused editing (fly a map from a camera keyframe track while compositing video on top).

### Quick Start

Frames — not seconds — are the canonical unit (`seconds = frame / fps`). Give the timeline an explicit height via `style`, define `tracks`, then place `clips` on them by `trackId`. Audio lanes paint waveforms from precomputed `peaks` — the component paints bars only, it never decodes audio.



```python
# File: docs/dash_nle_timeline/quick_start.py

import math

import dash_nle_timeline as nle
import dash_mantine_components as dmc

# Precomputed audio peaks (magnitude format: one positive value per bucket, 0..1).
# In a real app you would compute these server-side from the audio file.
PEAKS = [abs(math.sin(i / 7)) * 0.8 + 0.15 for i in range(220)]

component = dmc.Paper(
    withBorder=True,
    p="md",
    children=dmc.Stack(
        [
            dmc.Text("A static three-track timeline — scrub the playhead, "
                     "drag clips, trim edges, or press S to split.", size="sm", c="dimmed"),
            nle.DashNleTimeline(
                id="nle-quick-start-tl",
                fps=30,
                duration=300,
                pixelsPerSecond=90,
                tracks=[
                    {"id": "v1", "kind": "video", "label": "V1"},
                    {"id": "v2", "kind": "video", "label": "V2"},
                    {"id": "a1", "kind": "audio", "label": "A1"},
                ],
                clips=[
                    {"id": "c1", "trackId": "v1", "start": 0, "duration": 90,
                     "label": "intro.mp4", "color": "indigo"},
                    {"id": "c2", "trackId": "v1", "start": 100, "duration": 120,
                     "label": "main.mp4", "color": "teal"},
                    {"id": "c3", "trackId": "v2", "start": 60, "duration": 80,
                     "label": "overlay.png", "color": "orange"},
                    {"id": "c4", "trackId": "a1", "start": 0, "duration": 220,
                     "label": "music.wav",
                     "peaks": PEAKS, "peaksFormat": "magnitude", "peaksPerFrame": 1},
                ],
                markers=[
                    {"id": "m1", "frame": 100, "label": "cut", "color": "gold"},
                ],
                style={"width": "100%", "height": "260px"},
            ),
        ],
        gap="sm",
    ),
)
```


---

### Python Owns Clips — Apply lastEdit in a Callback

This is **the** core pattern of the component. Every committed edit (move, resize, trim, split, delete, select, seek) is reported as a single `lastEdit` dict. It carries a `timestamp`, so a Dash `Input` on it always fires — even for repeated identical edits.

The component applies edits to its own optimistic copy of `clips` (so they persist visually without a round-trip), but **Python is the source of truth**: a callback reads `lastEdit`, applies it to the clip list, and writes `clips` back. Writing back is how you validate, override, or persist edits — reject an edit by returning the unmodified list.



```python
# File: docs/dash_nle_timeline/edits_callback.py

import json

import dash_nle_timeline as nle
import dash_mantine_components as dmc
from dash import callback, Input, Output, State, no_update

component = dmc.Paper(
    withBorder=True,
    p="md",
    children=dmc.Stack(
        [
            dmc.Text("Move, resize, trim, or split a clip — every committed edit "
                     "arrives in Python as one `lastEdit` dict.", size="sm", c="dimmed"),
            nle.DashNleTimeline(
                id="nle-edits-tl",
                fps=30,
                duration=240,
                tracks=[
                    {"id": "v1", "kind": "video", "label": "V1"},
                    {"id": "v2", "kind": "video", "label": "V2"},
                ],
                clips=[
                    {"id": "c1", "trackId": "v1", "start": 0, "duration": 70,
                     "label": "shot A", "color": "indigo"},
                    {"id": "c2", "trackId": "v1", "start": 90, "duration": 80,
                     "label": "shot B", "color": "teal"},
                    {"id": "c3", "trackId": "v2", "start": 40, "duration": 60,
                     "label": "title", "color": "orange"},
                ],
                style={"width": "100%", "height": "200px"},
            ),
            dmc.Text("Last edit event:", size="sm", fw=500),
            dmc.Code(id="nle-edits-out", block=True,
                     children="(interact with the timeline)"),
        ],
        gap="sm",
    ),
)


@callback(
    Output("nle-edits-out", "children"),
    Input("nle-edits-tl", "lastEdit"),
    prevent_initial_call=True,
)
def show_edit(edit):
    return json.dumps(edit, indent=2)


# Python owns `clips`: apply each reported edit to the list and feed it back.
# The component edits its own copy optimistically, so writing clips back is how
# you validate, override, or persist the result.
@callback(
    Output("nle-edits-tl", "clips"),
    Input("nle-edits-tl", "lastEdit"),
    State("nle-edits-tl", "clips"),
    prevent_initial_call=True,
)
def apply_edit(edit, clips):
    if not edit:
        return no_update
    by_id = {c["id"]: dict(c) for c in clips}
    edit_type = edit.get("type")
    clip = by_id.get(edit.get("clipId"))

    if edit_type == "move" and clip:
        clip["start"] = edit["start"]
        clip["trackId"] = edit.get("trackId", clip["trackId"])
    elif edit_type in ("resize", "trim") and clip:
        for key in ("start", "duration", "inPoint"):
            if key in edit:
                clip[key] = edit[key]
    elif edit_type == "delete" and clip:
        del by_id[clip["id"]]
    else:
        # select / seek / split / paste etc. — nothing to write back here
        return no_update
    return list(by_id.values())
```


---

### Playback and the Playhead

Setting `playing=True` starts a `requestAnimationFrame` loop that advances `playhead` at `fps` (writes throttled by `playheadThrottleMs`). The component plays no media itself — drive a real `<video>`, an audio engine, or a `DashNleScene` off the `playhead` output. The `playhead` prop round-trips: the component writes it on scrub/seek/playback, and a callback may write it to move the playhead programmatically. `loop=True` wraps back to frame 0 at `duration`.



```python
# File: docs/dash_nle_timeline/playback.py

import dash_nle_timeline as nle
import dash_mantine_components as dmc
from dash import callback, Input, Output, State

FPS = 30
DURATION = 240

component = dmc.Paper(
    withBorder=True,
    p="md",
    children=dmc.Stack(
        [
            dmc.Text("Playback is a requestAnimationFrame loop driven by the "
                     "`playing` prop — the component plays no media itself, it just "
                     "advances `playhead` at the configured fps.", size="sm", c="dimmed"),
            nle.DashNleTimeline(
                id="nle-playback-tl",
                fps=FPS,
                duration=DURATION,
                loop=True,
                tracks=[
                    {"id": "v1", "kind": "video", "label": "V1"},
                ],
                clips=[
                    {"id": "c1", "trackId": "v1", "start": 0, "duration": 100,
                     "label": "scene 1", "color": "indigo"},
                    {"id": "c2", "trackId": "v1", "start": 110, "duration": 110,
                     "label": "scene 2", "color": "teal"},
                ],
                style={"width": "100%", "height": "160px"},
            ),
            dmc.Group(
                [
                    dmc.Button("Play", id="nle-playback-btn", color="teal", size="sm"),
                    dmc.Text("frame 0  |  0.000s", id="nle-playback-out",
                             size="sm", ff="monospace", c="dimmed"),
                ],
                gap="md",
            ),
        ],
        gap="sm",
    ),
)


@callback(
    Output("nle-playback-tl", "playing"),
    Output("nle-playback-btn", "children"),
    Input("nle-playback-btn", "n_clicks"),
    State("nle-playback-tl", "playing"),
    prevent_initial_call=True,
)
def toggle_play(_n, playing):
    now_playing = not bool(playing)
    return now_playing, ("Pause" if now_playing else "Play")


@callback(
    Output("nle-playback-out", "children"),
    Input("nle-playback-tl", "playhead"),
)
def show_playhead(frame):
    frame = frame or 0
    return f"frame {frame}  |  {frame / FPS:.3f}s"
```


Keyboard shortcuts (enabled by default via `keyboard`): Space play/pause, arrows step (Shift = 1s), `S` split at playhead, `Delete`, `⌘/Ctrl+C/V/D` copy/paste/duplicate, `⌘/Ctrl+A` select all, Home/End, `+`/`-` zoom. The imperative `command` prop dispatches the same actions from Python (`play`, `pause`, `seek`, `splitAtPlayhead`, `zoomToFit`, …), de-duped by a unique `id`.

---

### DashNleScene

`DashNleScene` is the preview side: a compositor stage that renders, at the current `playhead`, a background — a `dash-leaflet2` `Map` in `children` and/or a native XYZ `tileset` — then overlay `layers` (image / video / text) with per-layer `[start, end)` visibility windows and a screen-space `camera`. Sync `playhead` and `playing` from a `DashNleTimeline`, and drive the `tileset.viewport` from a `camera`-kind track's `cameraKeyframes` to fly the map while compositing video on top.

```python
import dash_nle_timeline as nle

scene = nle.DashNleScene(
    id="nle-scene",
    fps=30,
    playhead=0,                       # sync from DashNleTimeline
    resolution=[1280, 720],
    tileset={
        "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
        "viewport": {"center": [48.8566, 2.3522], "zoom": 12},
    },
    layers=[
        {"id": "title", "kind": "text", "text": "Paris flyover",
         "rect": [0.05, 0.05, 0.9, 0.2], "start": 0, "end": 90},
    ],
    style={"width": "100%"},
)
```

For full map interactivity (pan/zoom/controls/`flyTo`) put a `dash-leaflet2` `Map` in `children` instead of using `tileset`.

---

### DashNleTimeline Props

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| `id` | string | - | Component ID for Dash callbacks. |
| `fps` | number | `30` | Frames per second — defines the integer-frame grid and frame↔second conversions. |
| `duration` | number | `300` | Total timeline length in integer frames. |
| `playhead` | number | - | Current playhead position in integer frames. Written on scrub/seek/playback; writable from callbacks. |
| `playing` | boolean | - | When `True`, a rAF loop advances `playhead` at `fps`. The component plays no media itself. |
| `loop` | boolean | `False` | Loop back to 0 when the playhead reaches `duration`. |
| `playheadThrottleMs` | number | `60` | Min ms between `playhead` writes during playback. |
| `tracks` | list of dicts | - | Track lanes, top-to-bottom. Keys: `id`*, `kind` (`'video'`/`'audio'`/`'camera'`), `label`, `height`, `locked`, `muted`, `color`. |
| `clips` | list of dicts | - | Clips on tracks. Keys: `id`*, `trackId`*, `start`*, `duration`* (frames), `inPoint`, `label`, `color`, `src`, `peaks`, `peaksFormat` (`'minmax'`/`'magnitude'`), `peaksPerFrame`, `thumbnails`, `locked`. Python is the source of truth. |
| `lastEdit` | dict | - | [Readonly] Most recent committed edit. Keys: `type`* (`move`/`resize`/`trim`/`split`/`delete`/`select`/`seek`/`copy`/`paste`), `timestamp`*, `clipId`, `clipIds`, `trackId`, `start`, `duration`, `inPoint`, `frame`, `resultIds`, `snapped`. |
| `markers` | list of dicts | - | Labeled time points: visual guides + snap targets. Keys: `id`*, `frame`*, `label`, `color`. |
| `cameraKeyframes` | list of dicts | - | Keyframes for `'camera'` tracks. Keys: `id`*, `trackId`*, `time`*, `center`* `[lat, lng]`, `zoom`*, `bearing`, `easing`, `label`. |
| `selectedClipIds` | list of strings | - | Currently selected clip ids. Round-trips; writable from Python. |
| `selectedClipId` | string | - | [Readonly] The single selected clip id, or `None` when zero/many selected. |
| `command` | dict | - | Imperative command, de-duped by `id`. Keys: `id`*, `type`* (`play`/`pause`/`togglePlay`/`seek`/`stepFrame`/`splitAtPlayhead`/`deleteSelected`/`copy`/`paste`/`duplicate`/`selectAll`/`zoomToFit`/`zoomTo`/`scrollToFrame`), `payload`. |
| `pixelsPerSecond` | number | `120` | Horizontal zoom as pixels per second. |
| `minPixelsPerSecond` | number | `8` | Lower clamp for wheel-zoom. |
| `maxPixelsPerSecond` | number | `4000` | Upper clamp for wheel-zoom. |
| `scrollX` | number | - | Horizontal scroll offset of the lane viewport, in CSS px from frame 0. |
| `snapping` | boolean | `True` | Master enable for snapping during move/resize/trim. |
| `snapTargets` | list | `['clips','playhead','markers']` | Snap-eligible line categories: `'playhead'`, `'grid'`, `'markers'`, `'clips'`. |
| `snapThreshold` | number | `8` | Snap activation distance in screen px. |
| `gridFrames` | number | `0` | Grid spacing in frames for `'grid'` snapping. `0` = off. |
| `allowCrossTrackMove` | boolean | `True` | Allow dragging a clip onto another lane of the same kind. |
| `multiSelect` | boolean | `True` | Allow shift/cmd-click multi-selection. |
| `keyboard` | boolean | `True` | Enable all keyboard shortcuts. |
| `readOnly` | boolean | `False` | View-only: disable editing (scrub + selection + zoom still work). |
| `controls` | boolean | `True` | Show the built-in transport bar above the ruler. |
| `theme` | string | `'dark'` | Chrome color theme: `'dark'` or `'light'`. |
| `headerWidth` | number | `140` | Width of the left track-header column in px. |
| `rulerHeight` | number | `28` | Height of the time-ruler row in px. |
| `className` | string | - | CSS class name(s) for the root element. |
| `style` | object | - | Inline CSS styles — give the timeline an explicit width/height. |

### DashNleScene Props

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| `id` | string | - | Component ID for Dash callbacks. |
| `children` | node | - | Background slot — e.g. a `dash-leaflet2` `Map`. Rendered behind the tileset and overlay layers, filling the stage. |
| `playhead` | number | - | Current playhead in integer frames (typically synced from a `DashNleTimeline`). Layers with a `[start, end)` window show/hide on it. |
| `playing` | boolean | `False` | When `True`, video layers play natively; when `False` they seek to `playhead / fps` (frame-accurate scrub). Sync from the timeline. |
| `fps` | number | `30` | Maps the playhead to seconds for video layers. |
| `layers` | list of dicts | - | Overlay layers, bottom-to-top by `z` then array order. Keys: `id`*, `kind` (`'image'`/`'video'`/`'text'`), `src`, `text`, `rect` `[x,y,w,h]` (0..1), `opacity`, `rotation`, `start`, `end`, `srcStart`, `z`, `color`, `fontSize`. |
| `tileset` | dict | - | Native XYZ tileset background. Keys: `url`*, `viewport`* (`{center: [lat,lng], zoom}`), `tileSize` (256), `maxZoom` (19), `opacity` (1), `subdomains` (`['a','b','c']`). |
| `camera` | dict | - | Screen-space pan/zoom of the overlay layer group: `{x: 0, y: 0, zoom: 1}`. |
| `resolution` | list | `[1280, 720]` | Output stage resolution `[width, height]` in px (sets the aspect ratio). |
| `background` | string | `'#000000'` | Stage background CSS color (behind everything). |
| `className` | string | - | CSS class name(s) for the root element. |
| `style` | object | - | Inline CSS styles for the component. |

*\* = required key inside the dict.*

---

<!-- /pip/dash_pannellum — https://2plot.dev/pip/dash_pannellum/llms.txt -->

> **Full documentation:** [https://pannellum.2plot.dev](https://pannellum.2plot.dev) — the dedicated dash-pannellum documentation site, with the complete API reference and deeper examples. This page is the quick-start overview.



`dash-pannellum` is a Dash component library that integrates the Pannellum panorama viewer into your Dash applications. It allows you to display interactive 360° panoramas, including equirectangular images, cube maps, and 360° videos. The component features tour mode with multiple scenes and hotspots, customizable camera controls, multi-resolution panorama support, and keyboard navigation for an immersive viewing experience.

Since 0.2.0 the viewer is also fully **imperative**: the `lookAt` prop pans and zooms the live camera, `loadScene` switches tour scenes, and `callbackHotspots` moves markers in real time — all without rebuilding the viewer. As of **0.4.0**, the `orientation` prop turns on gyroscope look-around on mobile devices, so the device itself becomes the camera.

### Installation

[Visit GitHub Repo](https://github.com/pip-install-python/dash_pannellum)

⭐️ Star this component on GitHub! Stay up to date on new releases and browse the codebase.

```bash
pip install dash-pannellum
```

> **Note:** `dash-pannellum` 0.1.0+ requires **Dash 4.2+** and is built against React 18. The Pannellum/video.js runtime loads from CDN at first render, so the browser needs internet access.

---

### Quick Start

Display an interactive 360° equirectangular panorama with camera controls and adjustable viewing parameters.



```python
# File: docs/dash_pannellum/simple.py

from dash import *
import dash_mantine_components as dmc
from dash_pannellum import DashPannellum


component = dmc.SimpleGrid(
    cols={"base": 1, "sm": 1, "lg": 4},
    children=[
        dmc.Paper(dmc.Stack([
            html.Div(id="view-pannellum"),
            html.Label(id="pannellum-output"),
            ]),
            id="intro-wrapper-dem",
            style={"gridColumn": "1 / 4"},
        ),
        dmc.Stack(
            [
                dmc.TextInput(
                    id='pannellum-panorama-url',
                    value='/assets/images/art_museum.jpg',
                    label='Panorama URL',
                    placeholder='Enter URL',
                ),
                dmc.Select(
                        label="Select Panorama Type",
                        placeholder="Select one",
                        id="panorama-type",
                        value="equirectangular",
                        data=[
                            {"value": "equirectangular", "label": "Equirectangular"},
                            {"value": "tour", "label": "Tour"},
                            {"value": "multires", "label": "MultiRes"},
                            {"value": "video", "label": "Video"},
                        ],
                    ),
                dmc.NumberInput(
                            id='pannellum-haov', label="haov prop", hideControls=True, mb=10, value=360, min=0, max=360,
                        ),
                dmc.NumberInput(
                            id='pannellum-vaov', label="vaov prop", hideControls=True, mb=10, value=180, min=0, max=180,
                        ),
                dmc.NumberInput(
                            id='pannellum-vOffset', label="vOffset prop", hideControls=True, mb=10, value=1, min=-90, max=90,
                        ),
                    # dynamicWidth
                # dmc.Checkbox(
                #     id="pannellum-custom-controls", label="customControls", checked=False, mb=10
                # ),
                dmc.Checkbox(
                    id="pannellum-show-center-dot", label="showCenterDot", checked=True, mb=10
                ),
                dmc.Checkbox(
                    id="pannellum-autoload", label="autoLoad", checked=False, mb=10
                ),

            ],
            style={'overflow-y': 'auto', 'max-height': '500px'},
        ),
        dcc.Interval(id='interval-component', interval=100, n_intervals=0)
    ],
    spacing="2rem",
)

@callback(
    Output("view-pannellum", "children"),
    Input("pannellum-panorama-url", "value"),
    Input("panorama-type", "value"),
    Input("pannellum-haov", "value"),
    Input("pannellum-vaov", "value"),
    Input("pannellum-vOffset", "value"),
    # Input("pannellum-custom-controls", "checked"),
    Input("pannellum-show-center-dot", "checked"),
    Input("pannellum-autoload", "checked"),
)
def update_pannellum_output(url, panorama_type, haov, vaov, vOffset, showCenterDot, autoload):
    print('selected props')
    print(url, panorama_type, haov, vaov, vOffset, showCenterDot, autoload)
    if not url:
        return html.Div("No URL provided")
    elif panorama_type == "equirectangular":
        config = {
            "type": "equirectangular",
            "panorama": f"{url}",
            "haov": float(haov),
            "vaov": float(vaov),
            "vOffset": float(vOffset),
        }

        return DashPannellum(
            id=f"pannellum-example",
            tour={"default": {"firstScene": "scene1"}, "scenes": {"scene1": config}},
            showCenterDot=showCenterDot,
            autoLoad=autoload,
            width='100%',
            height='400px',
        )
    elif panorama_type == 'tour':
        tour_config = {
            "default": {
                "firstScene": "circle",
                "author": "Pip Install Python",
                "sceneFadeDuration": 1000,
            },
            "scenes": {
                "circle": {
                    "title": "Dash Pannellum",
                    "hfov": 110,
                    "pitch": -3,
                    "yaw": 117,
                    "type": "equirectangular",
                    "panorama": f"{url}",
                    "hotSpots": [
                        {
                            "pitch": -2.1,
                            "yaw": 132.9,
                            "type": "scene",
                            "text": "Spring House or Dairy",
                            "sceneId": "house"
                        }
                    ]
                },
                "house": {
                    "title": "Spring House or Dairy",
                    "hfov": 110,
                    "yaw": 5,
                    "type": "equirectangular",
                    "panorama": "https://pannellum.org/images/bma-0.jpg",
                    "hotSpots": [
                        {
                            "pitch": -0.6,
                            "yaw": 37.1,
                            "type": "scene",
                            "text": "Mason Circle",
                            "sceneId": "circle",
                            "targetYaw": -23,
                            "targetPitch": 2
                        }
                    ]
                }
            }
        }

        return DashPannellum(
            id='pannellum-example',
            tour=tour_config,
            showCenterDot=showCenterDot,
            width='100%',
            height='400px',
            autoLoad=autoload
        )
    elif panorama_type == 'multires':
        multiRes_config = {
            "basePath": "https://pannellum.org/images/multires/library",
            "path": "/%l/%s%y_%x",
            "fallbackPath": "/fallback/%s",
            "extension": "jpg",
            "tileResolution": 512,
            "maxLevel": 6,
            "cubeResolution": 8432,
        }

        return DashPannellum(
            id='pannellum-example',
            multiRes=multiRes_config,
            showCenterDot=showCenterDot,
            width='100%',
            height='400px',
            autoLoad=autoload
        )
    elif panorama_type == 'video':
        video_config = {
            "sources": [
                {"src": "https://bitmovin-a.akamaihd.net/content/playhouse-vr/progressive.mp4", "type": "video/mp4"},
            ],
            "poster": "https://bitmovin-a.akamaihd.net/content/playhouse-vr/poster.jpg"
        }

        return DashPannellum(
            id='pannellum-example',
            video=video_config,
            showCenterDot=showCenterDot,
            width='100%',
            height='400px',
            autoLoad=autoload
        )
    return html.Div("Something went wrong")


@callback(
    Output('pannellum-output', 'children'),
    Input('pannellum-example', 'pitch'),
    Input('pannellum-example', 'yaw'),
    Input('interval-component', 'n_intervals'),
    prevent_initial_call=True
)
def update_video_output(pitch, yaw, n):
    if pitch is not None and yaw is not None:
        return f'Camera Position - Pitch: {pitch:.2f}, Yaw: {yaw:.2f}'
    return 'Camera Position - Pitch: 0.00, Yaw: 0.00'
```


---

### Basic Panorama

Create a simple panorama viewer with basic configuration.



```python
# File: docs/dash_pannellum/basic.py

from dash import html
import dash_pannellum

component = html.Div([
    dash_pannellum.DashPannellum(
        id='panorama',
        tour={
            "default": {
                "firstScene": "scene1",
                "sceneFadeDuration": 1000
            },
            "scenes": {
                "scene1": {
                    "title": "Example Panorama",
                    "hfov": 110,
                    "pitch": -3,
                    "yaw": 117,
                    "type": "equirectangular",
                    "panorama": "/assets/images/landscape.jpg"
                }
            }
        },
        autoLoad=True,
        width='100%',
        height='400px',
    )
])
```


---

### Tour Mode

Tour mode enables navigation between multiple connected panorama scenes with interactive hotspots. Users can click hotspots to jump between different viewpoints, creating an immersive multi-scene experience.



```python
# File: docs/dash_pannellum/tours.py

from dash import html
import dash_pannellum

component = html.Div([
    dash_pannellum.DashPannellum(
        id='panorama',
        tour={
                "default": {
                    "firstScene": "circle",
                    "author": "Pip Install Python",
                    "sceneFadeDuration": 1000,
                },
                "scenes": {
                    "circle": {
                        "title": "Dash Pannellum",
                        "hfov": 110,
                        "pitch": -3,
                        "yaw": 117,
                        "type": "equirectangular",
                        "panorama": "https://pannellum.org/images/alma.jpg",
                        "hotSpots": [
                            {
                                "pitch": -2.1,
                                "yaw": 132.9,
                                "type": "scene",
                                "text": "Spring House or Dairy",
                                "sceneId": "house"
                            }
                        ]
                    },
                    "house": {
                        "title": "Spring House or Dairy",
                        "hfov": 110,
                        "yaw": 5,
                        "type": "equirectangular",
                        "panorama": "https://pannellum.org/images/bma-0.jpg",
                        "hotSpots": [
                            {
                                "pitch": -0.6,
                                "yaw": 37.1,
                                "type": "scene",
                                "text": "Mason Circle",
                                "sceneId": "circle",
                                "targetYaw": -23,
                                "targetPitch": 2
                            }
                        ]
                    }
                }
            },
        autoLoad=True,
        width='100%',
        height='400px',
    )
])
```


**Tour Configuration:**

Each tour consists of a `default` object and a `scenes` dictionary:

- **`default.firstScene`**: ID of the initial scene to display
- **`default.sceneFadeDuration`**: Transition duration in milliseconds between scenes
- **`scenes`**: Dictionary of scene configurations, each with its own panorama and hotspots

**Hotspot Configuration:**

- `pitch`: Vertical position in degrees
- `yaw`: Horizontal position in degrees
- `type`: `"scene"` for scene navigation hotspots
- `text`: Tooltip text displayed on hover
- `sceneId`: Target scene ID to navigate to
- `targetYaw` (optional): Camera yaw after transition
- `targetPitch` (optional): Camera pitch after transition

---

### Partial Panorama

Display panoramas that don't cover the full 360° horizontally or 180° vertically by specifying viewing extents using horizontal angle of view (haov), vertical angle of view (vaov), and vertical offset (vOffset).



```python
# File: docs/dash_pannellum/partial_panorama.py

from dash import html
import dash_pannellum

component = html.Div([
    dash_pannellum.DashPannellum(
        id='partial-panorama-component',
        tour={"default": {"firstScene": "scene1"}, "scenes": {"scene1":
                    {
                        "type": "equirectangular",
                        "panorama": "https://archive.org/download/SalinaKansas1916Postcard/Salina%20Kansas%2C%201916%20Postcard%2C%20Front.jpg",
                        "haov": 149.87,
                        "vaov": 54.15,
                        "vOffset": 1.17
                    }
                }
              },
        width='100%',
        height='400px',
        autoLoad=True
    )
])
```


**Viewing Parameters:**

- **`haov`**: Horizontal angle of view in degrees (e.g., 149.87)
- **`vaov`**: Vertical angle of view in degrees (e.g., 54.15)
- **`vOffset`**: Vertical offset in degrees, useful when panorama is not vertically centered (e.g., 1.17)

These parameters allow precise control over which portion of the panorama is visible and how it's framed within the viewer.

---

### 360° Video Panorama

Display interactive 360° video content with standard video controls.



```python
# File: docs/dash_pannellum/video.py

from dash import html
import dash_pannellum

component = html.Div([
    dash_pannellum.DashPannellum(
        id='panorama',
        video={
            "sources": [
                {"src": "https://cdn.bitmovin.com/content/assets/playhouse-vr/progressive.mp4", "type": "video/mp4"},
            ],
            "poster": "https://cdn.bitmovin.com/content/assets/playhouse-vr/poster.jpg"
        },
        autoLoad=True,
        width='100%',
        height='400px',
    )
])
```


**Video Configuration:**

- **`sources`**: Array of video source objects with `src` (URL) and `type` (MIME type)
- **`poster`**: URL to poster image displayed before video loads

The component supports standard HTML5 video formats. Users can interact with the video using typical video controls while maintaining the ability to pan around the 360° view.

---

### Using Callbacks

Track the viewer's current state (camera position, zoom, loaded status, active scene) using Dash callbacks. The component provides read-only properties that update as the user interacts with the panorama. View-state updates are throttled to 4 per second and change-detected, so idle viewers fire nothing.

```python
from dash import callback, Input, Output

@callback(
    Output('output-div', 'children'),
    Input('panorama', 'loaded'),
    Input('panorama', 'pitch'),
    Input('panorama', 'yaw'),
    Input('panorama', 'hfov'),
    Input('panorama', 'currentScene')
)
def update_output(loaded, pitch, yaw, hfov, current_scene):
    """Display current panorama state"""
    if loaded and pitch is not None and yaw is not None:
        return f'Scene: {current_scene}, Pitch: {pitch:.2f}°, Yaw: {yaw:.2f}°, Zoom: {hfov:.1f}°'
    return 'Loading panorama...'
```

**Available Read-Only Callback Properties:**

- **`loaded`**: Boolean indicating if panorama has finished loading
- **`pitch`**: Current vertical camera angle (-90° to 90°)
- **`yaw`**: Current horizontal camera angle (-180° to 180°)
- **`hfov`**: Current horizontal field of view — the zoom level, in degrees
- **`currentScene`**: ID of the active scene in tour mode
- **`lastClickedHotspot`**: `name` of the last clicked callback hotspot (see [Callback Hotspots](#callback-hotspots))
- **`orientationSupported`** / **`orientationActive`**: gyroscope truth props (see [Gyroscope Look-Around](#gyroscope-look-around))

> **Warning:** These properties are **outputs only** — use them as callback `Input`s, never as `Output`s. Writing to `pitch`/`yaw`/`hfov` does not move the camera. To drive the camera, write the `lookAt` prop instead; set the initial orientation in the scene config.

---

### Driving the Camera with `lookAt`

The `lookAt` prop (added in 0.2.0) is an **imperative camera write**: set `{pitch, yaw, hfov, animated}` from any callback and the live viewer pans and zooms in place — **no rebuild, no flash, no camera reset**. Omitted fields keep their current value, and `animated` is the transition duration in milliseconds. Combined with the read-only `pitch`/`yaw`/`hfov` outputs, this gives you a full "fly-to" pattern from buttons, clicked hotspots, or any external event.



```python
# File: docs/dash_pannellum/camera_control.py

import dash_mantine_components as dmc
from dash import callback, ctx, Input, Output
from dash_pannellum import DashPannellum

# Named camera targets. Omitted fields keep their current value,
# so "Zoom In" only touches hfov and leaves pitch/yaw alone.
TARGETS = {
    "pannellum-lookat-btn-house": {"pitch": -2.1, "yaw": 132.9, "hfov": 60},
    "pannellum-lookat-btn-sky": {"pitch": 55, "hfov": 100},
    "pannellum-lookat-btn-zoom": {"hfov": 50},
    "pannellum-lookat-btn-reset": {"pitch": -3, "yaw": 117, "hfov": 110},
}

component = dmc.Stack(
    [
        DashPannellum(
            id="pannellum-lookat-viewer",
            tour={
                "default": {"firstScene": "circle"},
                "scenes": {
                    "circle": {
                        "type": "equirectangular",
                        "panorama": "https://pannellum.org/images/alma.jpg",
                        "hfov": 110,
                        "pitch": -3,
                        "yaw": 117,
                    }
                },
            },
            autoLoad=True,
            width="100%",
            height="400px",
        ),
        dmc.Group(
            [
                dmc.Button("Spring House", id="pannellum-lookat-btn-house", size="xs"),
                dmc.Button("Look Up", id="pannellum-lookat-btn-sky", size="xs"),
                dmc.Button("Zoom In", id="pannellum-lookat-btn-zoom", size="xs"),
                dmc.Button(
                    "Reset View",
                    id="pannellum-lookat-btn-reset",
                    size="xs",
                    variant="outline",
                ),
            ]
        ),
        dmc.Text(id="pannellum-lookat-readout", size="sm", c="dimmed"),
    ],
    gap="sm",
)


@callback(
    Output("pannellum-lookat-viewer", "lookAt"),
    Input("pannellum-lookat-btn-house", "n_clicks"),
    Input("pannellum-lookat-btn-sky", "n_clicks"),
    Input("pannellum-lookat-btn-zoom", "n_clicks"),
    Input("pannellum-lookat-btn-reset", "n_clicks"),
    prevent_initial_call=True,
)
def fly_to(*_):
    """Write lookAt — the live viewer pans/zooms in place, no rebuild."""
    return {**TARGETS[ctx.triggered_id], "animated": 1000}


@callback(
    Output("pannellum-lookat-readout", "children"),
    Input("pannellum-lookat-viewer", "pitch"),
    Input("pannellum-lookat-viewer", "yaw"),
    Input("pannellum-lookat-viewer", "hfov"),
    prevent_initial_call=True,
)
def readout(pitch, yaw, hfov):
    """pitch / yaw / hfov are read-only Inputs that report the camera back."""
    if pitch is None or yaw is None:
        return "Camera: waiting for viewer..."
    hfov_text = f", hfov {hfov:.1f}°" if hfov is not None else ""
    return f"Camera: pitch {pitch:.1f}°, yaw {yaw:.1f}°{hfov_text}"
```


**`lookAt` keys:**

- **`pitch`** (optional): Target vertical angle in degrees
- **`yaw`** (optional): Target horizontal angle in degrees
- **`hfov`** (optional): Target zoom (horizontal field of view) in degrees
- **`animated`** (optional): Transition duration in ms (default 1000; use a small value like 220 for joystick-style continuous steering)

For high-frequency steering (a joystick, keyboard, or game loop), skip the server round-trip and write from a clientside callback or plain JS:

```js
window.dash_clientside.set_props('panorama', {lookAt: {yaw: bearing, animated: 220}});
```

`lookAt` works in image panorama (tour) mode; it does not reach `multiRes` or `video` viewers. A `lookAt` write that lands while the viewer is still booting is dropped — initial orientation belongs in the scene config.

**Related imperative props** — the same no-rebuild philosophy applies to:

- **`loadScene`**: set to a scene ID from the tour config to switch scenes without rebuilding the viewer. Unknown IDs and the already-active scene are ignored, so it's safe to reflect. Pair with `preloadScenes` (default on) for instant jumps.
- **`callbackHotspots`**: move or replace hotspot markers live (see next section).

---

### Callback Hotspots

`callbackHotspots` adds hotspots that report clicks back to Python — separate from the tour's own `hotSpots`. Keys are scene IDs; each entry's `name` is written to the read-only `lastClickedHotspot` prop on click:

```python
from dash import callback, Input, Output
from dash_pannellum import DashPannellum

DashPannellum(
    id='panorama',
    tour=tour,
    callbackHotspots={
        "lobby": [
            {"pitch": -1.2, "yaw": 122.0, "type": "info",
             "text": "Reception desk", "name": "reception"},
        ],
    },
)

@callback(
    Output('info-panel', 'children'),
    Input('panorama', 'lastClickedHotspot'),
    prevent_initial_call=True,
)
def on_hotspot(name):
    return INFO[name]
```

**Live updates with per-name diffing (0.3.1):** outputting a new `callbackHotspots` dict from a callback never rebuilds the viewer, and the update is diffed **per `name`**:

- Unchanged hotspots are left untouched
- **Position-only changes move the existing DOM node in place** — the marker keeps its hover state and stays clickable mid-flight
- Changes to `text`/`type`/`cssClass` recreate just that one hotspot
- Names that disappear are removed

This means markers can drift every tick as real DOM hotspots:

```python
@callback(Output('panorama', 'callbackHotspots'), Input('tick', 'n_intervals'))
def drift(n):
    return {"main": [project_to_yaw_pitch(e) for e in world.entities]}
```

Keep `name` stable per entity — it's the diff key. If you encode a tick counter into `name`, every update becomes a remove-and-re-add again.

**Tip — authoring hotspot coordinates:** set `showCenterDot=True` (a crosshair at screen center), stream `pitch`/`yaw` into a readout callback, aim the dot at the target, and copy the values into your hotspot config. Remove the dot for production.

---

### Gyroscope Look-Around

*New in 0.4.0.* The `orientation` prop requests **gyroscope steering** — point the phone around and the panorama follows. It's imperative (no rebuild): set it to `True` to engage, `False` to release. Two read-only props report the truth: `orientationSupported` (the device/browser can gyro-steer at all) and `orientationActive` (the gyro is steering right now — `False` if permission was denied, and Pannellum pauses it while the user drags the panorama).



```python
# File: docs/dash_pannellum/gyro.py

import dash_mantine_components as dmc
from dash import callback, clientside_callback, Input, Output
from dash_pannellum import DashPannellum

component = dmc.Stack(
    [
        DashPannellum(
            id="pannellum-gyro-viewer",
            tour={
                "default": {"firstScene": "house"},
                "scenes": {
                    "house": {
                        "type": "equirectangular",
                        "panorama": "https://pannellum.org/images/bma-0.jpg",
                        "hfov": 110,
                    }
                },
            },
            autoLoad=True,
            width="100%",
            height="400px",
        ),
        dmc.Group(
            [
                dmc.Switch(
                    id="pannellum-gyro-switch",
                    label="Gyroscope look-around",
                    checked=False,
                ),
                dmc.Badge(
                    "Checking sensors...",
                    id="pannellum-gyro-supported",
                    color="gray",
                    variant="light",
                ),
                dmc.Badge(
                    "Gyro idle",
                    id="pannellum-gyro-active",
                    color="gray",
                    variant="light",
                ),
            ]
        ),
        dmc.Text(
            "Gyro steering needs a mobile device with motion sensors, served over "
            "HTTPS. On desktop the switch is a no-op and the panorama stays "
            "drag-to-look — nothing breaks.",
            size="sm",
            c="dimmed",
        ),
    ],
    gap="sm",
)

# The orientation write MUST be clientside: iOS 13+ only shows the
# motion-permission prompt inside a user-gesture window, so the prop has
# to be set synchronously on the tap — a server round-trip is too late.
clientside_callback(
    "function(on) { return Boolean(on); }",
    Output("pannellum-gyro-viewer", "orientation"),
    Input("pannellum-gyro-switch", "checked"),
    prevent_initial_call=True,
)


@callback(
    Output("pannellum-gyro-supported", "children"),
    Output("pannellum-gyro-supported", "color"),
    Output("pannellum-gyro-active", "children"),
    Output("pannellum-gyro-active", "color"),
    Input("pannellum-gyro-viewer", "orientationSupported"),
    Input("pannellum-gyro-viewer", "orientationActive"),
)
def gyro_status(supported, active):
    """orientationSupported / orientationActive are read-only truth props."""
    if supported:
        supported_badge = ("Gyro supported", "green")
    else:
        supported_badge = ("Not supported on this device", "gray")
    if active:
        active_badge = ("Gyro steering", "teal")
    else:
        active_badge = ("Gyro idle", "gray")
    return (*supported_badge, *active_badge)
```


**iOS requires a clientside callback on a direct tap.** iOS 13+ only shows the motion-permission prompt inside a user-gesture window, so the `orientation` write must happen synchronously on the tap — a server callback round-trip is too late:

```python
from dash import clientside_callback, Input, Output

clientside_callback(
    "function(on) { return Boolean(on); }",
    Output('panorama', 'orientation'),
    Input('gyro-switch', 'checked'),
    prevent_initial_call=True,
)
```

**Gyro support constraints** — Pannellum gates orientation on all three of:

1. `DeviceOrientationEvent` being available (motion sensors)
2. A **mobile user agent**
3. **Literal `https:`** — Pannellum checks `location.protocol`, so plain-HTTP `localhost` reports unsupported even though browsers would allow the sensor there. Use a TLS dev cert or a tunnel to test on a phone.

On desktop the feature degrades gracefully: `orientationSupported` stays `False`, the switch is a no-op, and the panorama remains drag-to-look. Turn `orientation` off before running directed `lookAt` sequences — a camera fighting the gyroscope feels broken.

---

### Keyboard Controls

The component supports keyboard navigation for improved user experience:

- **Arrow Keys**: Pan the view (←→ horizontal, ↑↓ vertical)
- **Shift**: Zoom in
- **Control/Ctrl**: Zoom out
- **F**: Toggle fullscreen mode

---

### Component Properties

| Property               | Type      | Default     | Description                                                                                                                       |
| :--------------------- | :-------- | :---------- | :-------------------------------------------------------------------------------------------------------------------------------- |
| **`id`**               | `string`  | **Required** | Unique identifier for the component used in Dash callbacks.                                                                       |
| `width`                | `string`  | `'600px'`   | Width of the panorama viewer (any CSS size, e.g., '100%', '800px').                                                               |
| `height`               | `string`  | `'400px'`   | Height of the panorama viewer (any CSS size, e.g., '400px', '100vh').                                                             |
| `tour`                 | `dict`    | `None`      | Tour mode config: `{default: {firstScene, ...}, scenes: {sceneId: {...}}}`. A single panorama is a tour with one scene. A scene may set `panoramaCanvasId` (DOM id of a `<canvas>`) instead of `panorama` for dynamic canvas mode. Live — changing it rebuilds the viewer. |
| `multiRes`             | `dict`    | `None`      | Multi-resolution (tiled) panorama config: `basePath`, `path`, `fallbackPath`, `extension`, `tileResolution`, `maxLevel`, `cubeResolution`. Live. |
| `video`                | `dict`    | `None`      | 360° video config: `{sources: [{src, type}], poster}`. Rendered through video.js. Live.                                            |
| `autoLoad`             | `bool`    | `True`      | If true, the panorama loads automatically. If false, user must click to load.                                                     |
| `compass`              | `bool`    | `False`     | If true, displays a compass heading indicator in the viewer.                                                                      |
| `northOffset`          | `number`  | `0`         | Offset, in degrees, of the center of the panorama from North.                                                                     |
| `customControls`       | `bool`    | `False`     | If true, hides the built-in zoom/fullscreen controls so you can build your own with Dash components.                              |
| `showCenterDot`        | `bool`    | `False`     | If true, displays a center dot — useful as a crosshair when authoring hotspot positions.                                          |
| `useHttpStreaming`     | `bool`    | `False`     | If true, loads the video.js HTTP streaming plugin so HLS/DASH sources (e.g. live streams) can play as 360° video.                 |
| `callbackHotspots`     | `dict`    | `{}`        | Hotspots that report clicks to Dash: `{sceneId: [{pitch, yaw, type, text, name}]}`. Diffed per-name in place — no rebuild (0.3.1). |
| `lookAt`               | `dict`    | `None`      | Imperative camera write: `{pitch, yaw, hfov, animated}` pans/zooms the live viewer with no rebuild. Omitted keys keep their value; `animated` is ms (default 1000). Image panorama modes only. |
| `loadScene`            | `string`  | `None`      | Imperative tour scene switch by scene ID — no rebuild. Unknown IDs and the active scene are ignored.                              |
| `preloadScenes`        | `bool`    | `True`      | Prefetch the other scenes' panoramas once the viewer is up, so tour jumps don't show a loading box.                               |
| `hideLoadingSpinner`   | `bool`    | `False`     | Suppress Pannellum's "Loading..." box for this viewer.                                                                            |
| `dynamicUpdate`        | `bool`    | `False`     | Re-upload the panorama texture every frame — required for live `panoramaCanvasId` canvas scenes. Leave false for static panoramas. |
| `orientation`          | `bool`    | `False`     | Request gyroscope look-around (0.4.0). Mobile + HTTPS only; on iOS, set from a clientside callback on a direct user tap.          |
| `orientationSupported` | `bool`    | (read-only) | True when the device/browser can drive the camera from the gyroscope (motion sensors + mobile browser + literal `https:`).       |
| `orientationActive`    | `bool`    | (read-only) | True while gyro look-around is actively steering (false if permission was denied or while the user drags).                        |
| `loaded`               | `bool`    | (read-only) | Indicates whether the panorama has finished loading.                                                                              |
| `pitch`                | `number`  | (read-only) | Current vertical camera angle in degrees (-90 to 90). Throttled to 4 updates/s.                                                   |
| `yaw`                  | `number`  | (read-only) | Current horizontal camera angle in degrees (-180 to 180). Throttled to 4 updates/s.                                               |
| `hfov`                 | `number`  | (read-only) | Current horizontal field of view (zoom) in degrees. Throttled to 4 updates/s.                                                     |
| `currentScene`         | `string`  | (read-only) | ID of the currently active scene in tour mode.                                                                                    |
| `lastClickedHotspot`   | `string`  | (read-only) | `name` of the last clicked callback hotspot.                                                                                      |

Read-only props are callback **Inputs** — never write to them from an `Output`. Use `lookAt`, `loadScene` and `callbackHotspots` to act on a running viewer; changing `tour`/`multiRes`/`video` (and other "live" config props) tears down and re-initializes it.

**Multi-Resolution Configuration:**

When using `multiRes` for high-quality panoramas, the configuration requires:

- `basePath`: Base URL path to tile directory
- `path`: Tile path pattern (e.g., `"/%l/%s%y_%x"`)
- `fallbackPath`: Fallback path for missing tiles
- `extension`: Image file extension (e.g., `"jpg"`)
- `tileResolution`: Resolution of each tile in pixels
- `maxLevel`: Maximum zoom level available
- `cubeResolution`: Total resolution of cube faces

---

### Contributing

Contributions to dash-pannellum are welcome! Please refer to the project's issues on GitHub for any feature requests or bug reports.

### License

This project is licensed under the MIT License.

---

<!-- /pip/dash_planet — https://2plot.dev/pip/dash_planet/llms.txt -->

`dash-planet` is a Dash component library that provides an interactive orbital menu system for creating engaging circular navigation interfaces. It displays content elements (satellites) in a circular orbit around a central element with smooth spring-based animations. The component offers both free and premium tiers, supporting basic orbital menus with up to 3 satellites for free, and unlimited satellites with advanced features like draggable elements, semicircle layouts, and enhanced animation controls with a premium API key.

### Installation

[Visit GitHub Repo](https://github.com/pip-install-python/dash_planet)

⭐️ Star this component on GitHub! Stay up to date on new releases and browse the codebase.

```bash
pip install dash-planet
```

---

### Quick Start

Create a basic interactive orbital menu with a central avatar and satellite action buttons.



```python
# File: docs/dash_planet/introduction.py

import dash
from dash import html, Input, Output, State, callback, dcc, ALL
from dash_planet import DashPlanet
from dash_iconify import DashIconify
import json
import dash_mantine_components as dmc
import os
from dotenv import load_dotenv
from pathlib import Path

env_path = Path('.') / '.env'
load_dotenv(env_path)

API_KEY = os.getenv("API_KEY")

# Set React version
# _dash_renderer._set_react_version("18.3.1")

# Get API URL from environment or use default
# app = dash.Dash(__name__, suppress_callback_exceptions=True)

# Enable CORS for development
# app.enable_dev_tools(
#     dev_tools_hot_reload=True,
#     dev_tools_props_check=True,
#     dev_tools_serve_dev_bundles=True,
#     dev_tools_prune_errors=False
# )

# Rest of your styles remain the same...
styles = {
    "container": {
        "width": "100%",
        "height": "100vh",
        "display": "flex",
        "flexDirection": "column",
        "alignItems": "center",
        "justifyContent": "center",
        "padding": "20px",
    },
    "header": {
        "textAlign": "center",
        "marginBottom": "20px",
        "width": "100%",
        "maxWidth": "600px",
    },
    "apiInput": {
        "width": "100%",
        "maxWidth": "400px",
        "padding": "8px",
        "marginBottom": "10px",
        "border": "1px solid #ccc",
        "borderRadius": "4px",
    },
    "description": {
        "fontSize": "14px",
        "color": "#666",
        "marginBottom": "20px",
        "textAlign": "center",
    },
    "demoArea": {
        "display": "flex",
        "width": "100%",
        "height": "500px",  # Fixed height for demo area
        "backgroundColor": "white",
        "justifyContent": "center",
        "alignItems": "center",
        "position": "relative",
    },
    "planet": {
        "height": "120px",
        "width": "120px",
        "borderRadius": "50%",
        "backgroundColor": "#1976d2",
        "display": "flex",
        "justifyContent": "center",
        "alignItems": "center",
        "color": "white",
        "cursor": "pointer",
        "transition": "all 0.3s",
        "position": "relative",
    },
    "satellite": {
        "height": "40px",
        "width": "40px",
        # 'borderRadius': '50%',
        # 'backgroundColor': '#ff4081',
        "display": "flex",
        "justifyContent": "center",
        "alignItems": "center",
        "color": "white",
        "cursor": "pointer",
        "zIndex": 1,
    },
    "gridColumn": {
        "height": "300px",
        "width": "100%",
        "display": "flex",
        "justifyContent": "center",
        "alignItems": "center",
        "position": "relative",
        "padding": "20px",
        "boxSizing": "border-box",
    },
}


def generate_satellites(count):
    """Generate satellite elements with icons"""
    icons = [
        "fxemoji:email",
        "noto:calendar",
        "emojione-v1:bar-chart",
        "emojione:gear",
        "flat-color-icons:file",
        "emojione-v1:open-folder",
        "twemoji:heart-on-fire",
        "flat-color-icons:home",
    ]
    return [
        html.Div(
            [
                DashIconify(
                    icon=icons[i % len(icons)], width=40, height=40, color="white"
                )
            ],
            style=styles["satellite"],
            id={"type": "satellite", "index": i},
        )
        for i in range(count)
    ]


forms_props = dmc.GridCol(
    children=[

        dmc.Paper(
            children=[
                dmc.Stack(
                    [
                        dmc.Text("Props Control Panel", size="xl", fw=700, ta="center"),
                        dmc.Grid(
                            [
                                dmc.GridCol(
                                    dmc.Stack(
                                        [
                                            # Physics Controls
                                            dmc.Text(
                                                "Physics", fw=700, size="sm", c="dimmed"
                                            ),
                                            dmc.Group(
                                                [
                                                    dmc.NumberInput(
                                                        id="mass-input",
                                                        label="Mass",
                                                        value=1,
                                                        min=1,
                                                        max=10,
                                                        step=0.5,
                                                        style={"width": 100},
                                                    ),
                                                    dmc.NumberInput(
                                                        id="tension-input",
                                                        label="Tension",
                                                        value=200,
                                                        min=100,
                                                        max=1000,
                                                        step=50,
                                                        style={"width": 100},
                                                    ),
                                                    dmc.NumberInput(
                                                        id="friction-input",
                                                        label="Friction",
                                                        value=32,
                                                        min=1,
                                                        max=50,
                                                        step=1,
                                                        style={"width": 100},
                                                    ),
                                                ]
                                            ),
                                        ]
                                    ),
                                    span={"xs": 12, "md": 6},
                                ),
                                dmc.GridCol(
                                    dmc.Stack(
                                        [
                                            # Orbit Controls
                                            dmc.Text(
                                                "Orbit",
                                                fw=700,
                                                size="sm",
                                                c="dimmed",
                                                mt="md",
                                            ),
                                            dmc.Group(
                                                [
                                                    dmc.NumberInput(
                                                        id="orbit-radius-input",
                                                        label="Radius",
                                                        value=80,
                                                        min=40,
                                                        max=200,
                                                        step=10,
                                                        style={"width": 100},
                                                    ),
                                                    dmc.NumberInput(
                                                        id="rotation-input",
                                                        label="Rotation",
                                                        value=0,
                                                        min=0,
                                                        max=360,
                                                        step=15,
                                                        style={"width": 100},
                                                    ),
                                                ]
                                            ),
                                        ]
                                    ),
                                    span={"xs": 12, "md": 6},
                                ),
                            ],
                            grow=True,
                        ),
                        dmc.Grid(
                            [
                                dmc.GridCol(
                                    dmc.Stack(
                                        [
                                            dmc.Text(
                                                "Rotation Animation (Works in production not in Docs)",
                                                fw=700,
                                                size="sm",
                                                c="dimmed",
                                                mt="md",
                                            ),
                                            dmc.Group(
                                                [
                                                    dmc.Switch(
                                                        id="animate-rotation-input",
                                                        label="Animate Rotation",
                                                        checked=False,
                                                    ),
                                                    dmc.NumberInput(
                                                        id="rotation-speed-input",
                                                        label="Speed",
                                                        value=2,
                                                        min=0.1,
                                                        max=10,
                                                        step=0.1,
                                                        style={"width": 100},
                                                    ),
                                                ]
                                            ),
                                        ]
                                    ),
                                    span={"xs": 12, "md": 6},
                                ),
                                dmc.GridCol(
                                    dmc.Stack(
                                        [
                                            # Animation Controls
                                            dmc.Text(
                                                "Animation",
                                                fw=700,
                                                size="sm",
                                                c="dimmed",
                                                mt="md",
                                            ),
                                            dmc.Group(
                                                [
                                                    dmc.Switch(
                                                        id="bounce-input",
                                                        label="Bounce",
                                                        checked=True,
                                                    ),
                                                    dmc.Switch(
                                                        id="hide-orbit-input",
                                                        label="Hide Orbit",
                                                        checked=True,
                                                    ),
                                                ]
                                            ),
                                        ]
                                    ),
                                    span={"xs": 12, "md": 6},
                                ),
                            ]
                        ),
                        dmc.Grid(
                            [
                                dmc.GridCol(
                                    # Satellite Orientation
                                    dmc.Select(
                                        id="satellite-orientation-input",
                                        label="Satellite Orientation",
                                        data=[
                                            {"value": "DEFAULT", "label": "Default"},
                                            {"value": "INSIDE", "label": "Inside"},
                                            {"value": "OUTSIDE", "label": "Outside"},
                                            {"value": "READABLE", "label": "Readable"},
                                        ],
                                        value="DEFAULT",
                                        style={"width": "100%"},
                                    ),
                                    span={"xs": 12, "md": 12},
                                )
                            ]
                        ),
                        dcc.Interval(
                            id="rotation-interval",
                            interval=50,  # 50ms = 20fps
                            disabled=True,
                        ),
                    ]
                ),
            ],
            p="md",
            shadow="sm",
            radius="md",
            withBorder=True,
            style={"maxWidth": "100%", "width": "100%"},
        )
    ],
    style={
        "height": "100%",
        "display": "flex",
        "justifyContent": "center",
        "alignItems": "center",
        "position": "relative",
        "overflow": "auto",
    },
    span={"base": 12, "xl": 4},
)


# Create layout
component = dmc.Box(
    [
        dmc.Stack(
            [
                html.H1("DashPlanet Demo", style={"marginBottom": "10px"}),
                html.P(
                    [
                        "Free tier includes up to 3 satellites. ",
                        "Enter an API key to unlock all features.",
                    ],
                    style=styles["description"],
                ),
                dmc.Group(
                    [
                        dcc.Input(
                            id="api-key-input",
                            type="text",
                            placeholder="Enter your API key to check if it works",
                            value="",
                            style={'display': 'none'}
                        ),
                        dmc.Switch(
                            id="use-env-api-key",
                            label="Use Environment API Key",
                            checked=False,
                        ),
                    ],
                    justify="center",
                ),
                html.Div(
                    id="api-key-status", style={"color": "#666", "marginBottom": "10px"}
                ),

            ],
            justify="center",
            align="center",
            gap="md",
        ),
        dmc.Space(h=20),
        dmc.Grid(
            children=[
                forms_props,
                dmc.GridCol(
                    dmc.Stack(
                        [
                            dmc.Space(h=50),
                            dmc.Box(
                                DashPlanet(
                                    id="demo-planet",
                                    centerContent=dmc.Indicator(
                                        dmc.Avatar(
                                            size="lg",
                                            radius="xl",
                                            src="https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/avatars/avatar-3.png",
                                        ),
                                        inline=True,
                                        offset=7,
                                        position="bottom-end",
                                        color="red",
                                        withBorder=True,
                                        size=16,
                                    ),
                                    open=True,
                                    orbitRadius=80,
                                    hideOrbit=True,
                                    bounce=True,
                                    bounceOnOpen=True,
                                    rotation=0,
                                    dragablePlanet=True,
                                    dragableSatellites=True,
                                    satelliteOrientation="DEFAULT",
                                    children=generate_satellites(8),
                                    mass=4,
                                    tension=500,
                                    friction=19,
                                    apiKey="",
                                ),
                                style={
                                    "width": "100%",
                                    "maxWidth": "500px",
                                    "margin": "0 auto",
                                    "display": "flex",
                                    "justifyContent": "center",
                                    "alignItems": "center",
                                    "minHeight": "300px",
                                }
                            ),
                            dmc.Space(h=150),
                            html.Div(
                                [
                                    # API validation status
                                    html.Div(id="validation-status"),
                                    dmc.Text(
                                        "Click a satellite to see its function",
                                        id="action-text",
                                        ta="center",
                                    ),
                                ]
                            ),
                        ],
                        mt="100px",
                    ),
                    span={"base": 12, "xl": 4},
                    style={
                        "height": "100%",
                        "display": "flex",
                        "justifyContent": "center",
                        "alignItems": "center",
                        "position": "relative",
                        "padding": "20px",
                        "boxSizing": "border-box",
                    },
                ),
                dmc.GridCol(
                    dmc.Card(
                        [
                            dmc.Text("Features", size="xl", fw=700, mb="md"),
                            dmc.Stack(
                                [
                                    dmc.Group(
                                        [
                                            DashIconify(icon="mdi:check", width=20),
                                            dmc.Text("Free Tier: Up to 3 satellites"),
                                        ],
                                        gap="xs",
                                    ),
                                    dmc.Group(
                                        [
                                            DashIconify(icon="mdi:star", width=20),
                                            dmc.Text("Premium: Unlimited satellites"),
                                        ],
                                        gap="xs",
                                    ),
                                    dmc.Group(
                                        [
                                            DashIconify(icon="fxemoji:crescentmoon", width=20),
                                            dmc.Text("Premium: Semicircle Menu layout"),
                                        ],
                                        gap="xs",
                                    ),
                                    dmc.Group(
                                        [
                                            DashIconify(icon="mdi:animation", width=20),
                                            dmc.Text("Premium: Enhanced animation controls"),
                                        ],
                                        gap="xs",
                                    ),
                                    dmc.Group(
                                        [
                                            DashIconify(icon="fluent-emoji:sparkling-heart", width=20),
                                            dmc.Text("Supports independent Dash Components development"),
                                        ],
                                        gap="xs",
                                    ),
                                    dmc.Divider(),
                                    dmc.HoverCard(
                                        withArrow=True,
                                        width=200,
                                        shadow="md",
                                        children=[
                                            dmc.HoverCardTarget(
                                                dmc.Group(
                                                    [
                                                        DashIconify(icon="cib:buy-me-a-coffee", width=20),
                                                        dmc.Anchor(
                                                            "Buy a DashPlanet API key",
                                                            href="https://plotly.pro/product/prod_SY2xOUihEmOKda",
                                                            target="_blank",
                                                            size="md",
                                                        ),
                                                    ],
                                                    gap="xs",
                                                )
                                            ),
                                            dmc.HoverCardDropdown(
                                                dmc.Image(
                                                    radius="md",
                                                    src="/assets/images/tippy.png",
                                                )
                                            ),
                                        ],
                                    ),
                                ],
                                gap="sm",
                            ),
                        ],
                        p="xl",
                        shadow="sm",
                        radius="md",
                        withBorder=True,
                        style={"width": "100%"}
                    ),
                    style={
                        "display": "flex",
                        "justifyContent": "center",
                        "alignItems": "center",
                        "position": "relative",
                        "padding": "20px",
                        "boxSizing": "border-box",
                    },
                    span={"base": 12, "xl": 4},
                ),
            ],
            gutter="xl",
        ),
    ]
)


# Update callbacks
@callback(
    [
        Output("demo-planet", "apiKey"),
        Output("api-key-input", "disabled"),
        Output("api-key-status", "children"),
        Output("api-key-status", "style"),
    ],
    [
        Input("api-key-input", "value"),
        Input("use-env-api-key", "checked")
    ],
    prevent_initial_call=True,
)
def update_api_key(api_key, use_env_key):
    """Update API key based on input or environment variable"""
    if use_env_key:
        return API_KEY, True, "Demo using a paid API key", {"color": "#4CAF50"}

    if not api_key:
        return None, False, "Using free tier", {"color": "#666"}

    return api_key, False, f"Using API key: {api_key[:8]}...", {"color": "#4CAF50"}


@callback(
    Output("demo-planet", "open"),
    Input("demo-planet", "n_clicks"),
    prevent_initial_call=True,
)
def toggle_planet(n_clicks):
    """Toggle planet open/closed state"""
    if n_clicks is None:
        return dash.no_update
    return n_clicks % 2 == 1


# Add this callback to handle satellite clicks
@callback(
    Output("action-text", "children"),
    Input({"type": "satellite", "index": ALL}, "n_clicks"),
    prevent_initial_call=True,
)
def handle_satellite_click(clicks):
    ctx = dash.callback_context
    if not ctx.triggered:
        return "Click a satellite to see its function"

    triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
    satellite_index = json.loads(triggered_id)["index"]

    # Map indices to actions
    actions = [
        "Compose new email",
        "Open calendar",
        "View analytics",
        "Open settings",
        "New document",
        "Browse files",
        "Favorite item",
        "Return home",
    ]

    return f"Selected: {actions[satellite_index % len(actions)]}"


# Add these callbacks after your existing callbacks:
@callback(
    Output("demo-planet", "mass"),
    Input("mass-input", "value"),
    prevent_initial_call=True,
)
def update_mass(value):
    return value


@callback(
    Output("demo-planet", "tension"),
    Input("tension-input", "value"),
    prevent_initial_call=True,
)
def update_tension(value):
    return value


@callback(
    Output("demo-planet", "friction"),
    Input("friction-input", "value"),
    prevent_initial_call=True,
)
def update_friction(value):
    return value


@callback(
    Output("demo-planet", "orbitRadius"),
    Input("orbit-radius-input", "value"),
    prevent_initial_call=True,
)
def update_orbit_radius(value):
    return value


@callback(
    Output("demo-planet", "rotation"),
    Input("rotation-input", "value"),
    prevent_initial_call=True,
)
def update_rotation(value):
    return value


@callback(
    Output("demo-planet", "bounce"),
    Input("bounce-input", "checked"),
    prevent_initial_call=True,
)
def update_bounce(checked):
    return checked


@callback(
    Output("demo-planet", "hideOrbit"),
    Input("hide-orbit-input", "checked"),
    prevent_initial_call=True,
)
def update_hide_orbit(checked):
    return checked


@callback(
    Output("demo-planet", "satelliteOrientation"),
    Input("satellite-orientation-input", "value"),
    prevent_initial_call=True,
)
def update_satellite_orientation(value):
    return value


@callback(
    [Output("rotation-interval", "disabled"), Output("rotation-input", "disabled")],
    Input("animate-rotation-input", "checked"),
)
def toggle_animation(animate):
    return not animate, animate


# Callback to update rotation based on the interval
@callback(
    Output("demo-planet", "rotation", allow_duplicate=True),
    [Input("rotation-interval", "n_intervals"), Input("rotation-speed-input", "value")],
    State("demo-planet", "rotation"),
    prevent_initial_call=True,
)
def update_rotation(n_intervals, speed, current_rotation):
    if current_rotation is None:
        current_rotation = 0
    # Calculate new rotation angle
    new_rotation = (current_rotation + speed) % 360
    return new_rotation


# Callback to handle manual rotation input
@callback(
    Output("rotation-input", "disabled", allow_duplicate=True),
    Input("animate-rotation-input", "checked"),
    prevent_initial_call=True,
)
def toggle_rotation_input(animate):
    return animate
```


---

### Basic Code Example

Here's a minimal example to get started with DashPlanet:

```python
from dash import Dash
from dash_planet import DashPlanet
import dash_mantine_components as dmc
from dash_iconify import DashIconify

app = Dash(__name__)

app.layout = DashPlanet(
    id='my-planet',
    centerContent=dmc.Avatar(
        size="lg",
        radius="xl",
        src="path/to/avatar.png"
    ),
    children=[
        dmc.ActionIcon(
            DashIconify(icon="clarity:settings-line", width=20, height=20),
            size="lg",
            variant="filled",
            id="action-icon-1",
        ),
        dmc.ActionIcon(
            DashIconify(icon="mdi:email", width=20, height=20),
            size="lg",
            variant="filled",
            id="action-icon-2",
        ),
        dmc.ActionIcon(
            DashIconify(icon="mdi:bell", width=20, height=20),
            size="lg",
            variant="filled",
            id="action-icon-3",
        ),
    ],
    orbitRadius=80,
    rotation=0,
)

if __name__ == '__main__':
    app.run_server(debug=True)
```

---

### Semicircle Menu Layout

Premium feature that displays satellites in a semicircle layout instead of full circular orbit. Perfect for creating arc-shaped menus and navigation bars.



```python
# File: docs/dash_planet/semicircle_example.py

import dash
from dash import html, Input, Output, State, callback, dcc
from dash_planet import DashPlanet
from dash_iconify import DashIconify
import dash_mantine_components as dmc
import os
from dotenv import load_dotenv
from pathlib import Path

env_path = Path('.') / '.env'
load_dotenv(env_path)

API_URL = os.getenv("API_URL")
API_KEY = os.getenv("API_KEY")

styles = {
    'root': {
        'display': 'flex',
        'flex': '1',
        'width': '100%',
        'justifyContent': 'center',
        'alignItems': 'center',
        'flexDirection': 'column',
        'position': 'relative',
        'gap': '20px'
    },
    'satellite': {
        'height': '40px',
        'width': '40px',
        'display': 'flex',
        'justifyContent': 'center',
        'alignItems': 'center',
        'color': 'white',
        'cursor': 'pointer',
        'zIndex': 1
    }
}


def generate_satellites(count, empty_divs=5):
    """Generate satellite elements with icons"""
    icons = [
        "fxemoji:email",
        "noto:calendar",
        "emojione-v1:bar-chart",
        "emojione:gear",
        "flat-color-icons:file",
        "emojione-v1:open-folder",
        "twemoji:heart-on-fire",
        "flat-color-icons:home",
    ]

    empty_div_style = {
        'width': '40px',
        'height': '40px',
        'transition': 'transform 0.3s ease-in-out'
    }

    return [
        html.Div([
            DashIconify(icon=icons[i % len(icons)], width=40, height=40, color="white")
        ], style=styles['satellite'], id={'type': 'satellite', 'index': i})
        for i in range(count)
    ] + [html.Div(style=empty_div_style) for _ in range(empty_divs)]


component = dmc.Box([
    dcc.Input(
        id='api-key-input',
        type='text',
        value='O3iEIQMkVzbbdgs-ZSfBotNt3WoLhqGjID0fMrhuN64',
        style={'display': 'none'}
    ),

    dmc.Grid([
    # Add form controls
    dmc.GridCol([
        dmc.Paper([
            dmc.Text("Menu Semicircle Controls", size="lg", fw=500, ta="center", mb="md"),
            dmc.NumberInput(
                id="menu-planet-empty-divs-input",
                label="Number of Empty Divs",
                value=5,
                min=0,
                max=10,
                step=1,
                mb="sm"
            ),
            dmc.NumberInput(
                id="menu-planet-rotation-input",
                label="Rotation (degrees)",
                value=0,
                min=0,
                max=360,
                step=45,
                mb="sm"
            ),
            dmc.NumberInput(
                id="menu-planet-orbit-radius-input",
                label="Orbit Radius",
                value=80,
                min=40,
                max=200,
                step=10,
                mb="sm"
            ),
        ], p="md", shadow="sm", radius="md", withBorder=True, style={"width": "100%"})
    ], span={'base': 12, 'lg': 5}),

    dmc.GridCol([
        dmc.Box(
            DashPlanet(
                id='menu-planet',
                centerContent=dmc.Indicator(
                    dmc.Avatar(
                        size="lg",
                        radius="xl",
                        src="https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/avatars/avatar-3.png",
                    ),
                    inline=True,
                    offset=7,
                    position="bottom-end",
                    color="red",
                    withBorder=True,
                    size=16,
                ),
                open=True,
                orbitRadius=80,
                hideOrbit=True,
                bounce=True,
                bounceOnOpen=True,
                rotation=90,
                dragablePlanet=True,
                dragableSatellites=True,
                satelliteOrientation='DEFAULT',
                children=generate_satellites(3),
                mass=4,
                tension=500,
                friction=19,
                apiKey=API_KEY
            ),
            style={
                "width": "100%",
                "maxWidth": "500px",
                "margin": "0 auto",
                "display": "flex",
                "justifyContent": "center",
                "alignItems": "center",
                "minHeight": "300px",
            }
        )
    ], style={
        'display': 'flex',
        'flex': '1',
        'width': '100%',
        'justifyContent': 'center',
        'alignItems': 'center',
        'flexDirection': 'column',
        'position': 'relative',
        'gap': '20px',
        'padding': '20px',
        'boxSizing': 'border-box'
    }, span={'base': 12, 'lg': 7}),
        ], gutter="md")
])


@callback(
    Output('menu-planet', 'children'),
    Output('menu-planet', 'rotation'),
    Input('menu-planet-empty-divs-input', 'value'),
    Input('menu-planet-rotation-input', 'value')
)
def update_satellites(empty_divs, rotation):
    return generate_satellites(3, empty_divs), rotation

@callback(
    Output("menu-planet", "orbitRadius"),
    Input("menu-planet-orbit-radius-input", "value"),
    prevent_initial_call=True,
)
def update_orbit_radius(value):
    return value
```


---

### Free vs Premium Features

DashPlanet offers both free and premium tiers to suit different project needs.

| **Free Tier**                         | **Premium Features** |
|:-----------------------------------------|:----------------|
| ✓ Up to 3 satellite elements in orbit    | 🌟 Unlimited satellite elements |
| ✓ Basic orbital animation                | 🌙 Semicircle Menu layout |
| ✓ Customizable orbit radius and rotation | ⚡ Enhanced animation controls |
| ✓ Click-to-toggle functionality          | 💎 Draggable satellites and center element |
|                                          | 🎯 Bounce animations with directional control |
|                                          | 🔄 Advanced satellite orientation options |

**Get Premium Access:** [Buy DashPlanet API Key](https://plotly.pro/product/prod_SY2xOUihEmOKda)

To use premium features, provide your API key:

```python
DashPlanet(
    apiKey="your-api-key-here",
    # Premium features now available
    dragableSatellites=True,
    bounce=True,
    bounceDirection="TOP",
)
```

---

### Working with Callbacks

**Toggle Orbital Menu:**

Control the visibility of satellite elements using the `open` property and `n_clicks` callback.

```python
from dash import Input, Output, callback

@callback(
    Output("my-planet", "open"),
    Input("my-planet", "n_clicks")
)
def toggle_planet(n_clicks):
    """Toggle satellite visibility on center element click"""
    if n_clicks is None:
        return False
    return n_clicks % 2 == 1
```

**Dynamic Rotation Control:**

Update the orbital rotation angle dynamically based on user input.

```python
@callback(
    Output("my-planet", "rotation"),
    Input("rotation-slider", "value")
)
def update_rotation(value):
    """Rotate the entire orbital system"""
    return value
```

**Satellite Click Handling:**

Respond to clicks on individual satellite elements by giving each satellite a unique ID.

```python
@callback(
    Output("output-div", "children"),
    Input("satellite-1", "n_clicks"),
    Input("satellite-2", "n_clicks"),
    Input("satellite-3", "n_clicks"),
)
def handle_satellite_clicks(n1, n2, n3):
    """Handle clicks on different satellites"""
    ctx = callback_context
    if not ctx.triggered:
        return "Click a satellite"

    button_id = ctx.triggered[0]["prop_id"].split(".")[0]
    return f"Clicked: {button_id}"
```

---

### Customizing Satellite Elements

Satellites can be any valid Dash component, allowing for rich, interactive menu items.

**Using Icons:**

```python
from dash_iconify import DashIconify
from dash import html

satellites = [
    html.Div([
        DashIconify(
            icon="mdi:email",
            width=40,
            height=40,
            color="#3b82f6"
        )
    ], style={'width': '40px', 'height': '40px'})
    for _ in range(3)
]
```

**Using Mantine Components:**

```python
import dash_mantine_components as dmc

satellites = [
    dmc.ActionIcon(
        dmc.ThemeIcon(DashIconify(icon="mdi:home")),
        size="lg",
        variant="filled",
        color="blue",
    ),
    dmc.ActionIcon(
        dmc.ThemeIcon(DashIconify(icon="mdi:settings")),
        size="lg",
        variant="filled",
        color="green",
    ),
]
```

---

### Animation Controls

Fine-tune the spring physics animation using `mass`, `tension`, and `friction` properties.

```python
DashPlanet(
    mass=4,           # Higher mass = slower, heavier animation
    tension=500,      # Higher tension = stiffer spring, faster animation
    friction=19,      # Higher friction = more damping, less bounce
)
```

**Animation Parameter Guide:**

- **`mass`**: Controls the "weight" of the animation (default: 1)
  - Lower values (0.5-1): Light, quick animations
  - Higher values (2-5): Heavy, slower animations

- **`tension`**: Controls spring stiffness (default: 500)
  - Lower values (100-300): Looser, more elastic
  - Higher values (500-1000): Tighter, snappier

- **`friction`**: Controls damping/resistance (default: 17)
  - Lower values (5-15): More bouncy, oscillating
  - Higher values (20-30): Smoother, more damped

---

### Styling and Appearance

**Component Styling:**

Apply custom styles to the container using the `style` prop:

```python
DashPlanet(
    style={
        'backgroundColor': '#f8f9fa',
        'borderRadius': '50%',
        'boxShadow': '0 4px 6px rgba(0, 0, 0, 0.1)',
        'padding': '20px',
    }
)
```

**Hiding the Orbit Line:**

Toggle the visibility of the orbital path line:

```python
DashPlanet(
    hideOrbit=True,  # Hide the circular orbit line
)
```

**Satellite Orientation (Premium):**

Control how satellites rotate as they orbit:

- **`DEFAULT`**: No rotation, satellites maintain upright position
- **`INSIDE`**: Satellites face toward the center
- **`OUTSIDE`**: Satellites face away from the center
- **`READABLE`**: Satellites rotate to remain readable (top half upright, bottom half inverted)

```python
DashPlanet(
    apiKey="your-key",
    satelliteOrientation="READABLE",
)
```

---

### Browser Support

DashPlanet is compatible with modern browsers that support CSS transforms and React Spring animations:

- Chrome (latest)
- Firefox (latest)
- Safari (latest)
- Edge (latest)

**Minimum Requirements:**
- `dash` ≥ 2.0.0
- `react` ≥ 18.3.1

---

### Component Properties

| Property           | Type        | Default      | Description                                                                                                     |
| :----------------- | :---------- | :----------- | :-------------------------------------------------------------------------------------------------------------- |
| **`id`**           | `string`    | **Required** | Unique identifier for the component used in Dash callbacks.                                                     |
| `centerContent`    | `node`      | `None`       | Content displayed in the center of the orbit (e.g., avatar, button, icon).                                      |
| `children`         | `node`      | `None`       | Satellite elements to be displayed in orbit around the center. Can be any valid Dash components.                |
| `open`             | `bool`      | `False`      | Controls visibility of satellite elements. Set to `True` to show satellites, `False` to hide.                   |
| `orbitRadius`      | `number`    | `120`        | Radius of the orbit in pixels. Determines how far satellites are from the center.                               |
| `rotation`         | `number`    | `0`          | Initial rotation angle of the orbital system in degrees. Use to offset starting positions.                      |
| `hideOrbit`        | `bool`      | `False`      | If `True`, hides the circular orbit line. Satellites still orbit, but the path is invisible.                    |
| `mass`             | `number`    | `1`          | Mass parameter for spring physics animation. Higher values create heavier, slower animations.                   |
| `tension`          | `number`    | `500`        | Spring tension parameter. Higher values create stiffer, faster animations.                                      |
| `friction`         | `number`    | `17`         | Spring friction parameter. Higher values create more damping and reduce bounce.                                 |
| `style`            | `dict`      | `{}`         | Standard CSS styles to apply to the component container.                                                        |
| `className`        | `string`    | `None`       | CSS class name to apply to the component container.                                                            |
| `n_clicks`         | `number`    | `0`          | Number of times the center element has been clicked. Updates automatically on click.                            |
| `apiKey`           | `string`    | `None`       | API key for unlocking premium features. Purchase at [plotly.pro](https://plotly.pro/product/prod_SY2xOUihEmOKda) |
| `dragablePlanet`   | `bool`      | `False`      | **(Premium)** Enable dragging of the center element. Requires valid `apiKey`.                                   |
| `dragableSatellites` | `bool`    | `False`      | **(Premium)** Enable dragging of satellite elements. Requires valid `apiKey`.                                   |
| `bounce`           | `bool`      | `False`      | **(Premium)** Enable bounce animation effect. Requires valid `apiKey`.                                          |
| `bounceDirection`  | `string`    | `"TOP"`      | **(Premium)** Direction of bounce animation. Options: `"TOP"`, `"BOTTOM"`, `"LEFT"`, `"RIGHT"`. Requires `apiKey`. |
| `satelliteOrientation` | `string` | `"DEFAULT"` | **(Premium)** How satellites rotate in orbit. Options: `"DEFAULT"`, `"INSIDE"`, `"OUTSIDE"`, `"READABLE"`. Requires `apiKey`. |
| `setProps`         | `func`      | (Dash Internal) | Callback function to update component properties.                                                            |
| `loading_state`    | `object`    | (Dash Internal) | Object describing the loading state of the component or its props.                                           |

**Note:** Premium features marked with **(Premium)** require a valid API key. Free tier is limited to 3 satellite elements maximum.

---

### Contributing

Contributions to dash-planet are welcome! Please refer to the project's issues on GitHub for any feature requests or bug reports.

### License

This project is licensed under the MIT License.

---

<!-- /pip/dash_pos_printer — https://2plot.dev/pip/dash_pos_printer/llms.txt -->

# POS Printer

> Cloud-based receipt printing for Dash applications using Star Micronics printers and CloudPRNT

The full text of this page is available to signed-in users.

Create a free account at https://accounts.2plot.ai/sign-in, open https://2plot.dev/pip/dash_pos_printer, and use its "Copy for llm" button — it copies a link that works in your AI assistant, with no sign-in required at their end.

The rest of this site is readable without an account: https://2plot.dev/llms.txt

---

<!-- /pip/dash_widgetbot — https://2plot.dev/pip/dash_widgetbot/llms.txt -->

# WidgetBot

`dash-widgetbot` integrates Discord chat into your Dash applications using a pure Python hook-based architecture — no React build required.

- **DiscordCrate** — Floating chat button (the one in the bottom-right corner of this page)
- **DiscordWidget** — Inline iframe embed for placing chat anywhere in your layout
- **Slash Commands** — AI-powered `/ai`, `/ask`, `/gen`, `/status` commands
- **Webhook API** — Send messages to Discord from Python

> **Two layers, two deployment stories.** The Crate/Widget is a client-side iframe with no server-side Discord API calls — drop it in and it just works in dev and prod. The slash-command/bot layer is where production pain lives (outbound proxies, rate limits, gunicorn tuning, global vs guild command scope). See the **Production Deployment Guide** section below for the full playbook.

## Installation

[Visit GitHub Repo](https://github.com/pip-install-python/dash-widgetbot)

```bash
pip install dash-widgetbot
pip install dash-widgetbot[bot,ai]   # with Discord bot + Gemini AI
```

---

## Overview



```python
# File: docs/dash_widgetbot/introduction.py

import os
import dash_mantine_components as dmc
from dash_iconify import DashIconify
from dash_widgetbot import discord_widget_container

component = dmc.Stack([
    dmc.Alert(
        children=dmc.Stack([
            dmc.Text(
                "The floating Discord button in the bottom-right corner of this page is a "
                "DiscordCrate — a global chat widget that appears on every page. "
                "Try clicking it to open the community chat!",
                size="sm",
            ),
            dmc.Text(
                "Below is a DiscordWidget — an inline embed you can place anywhere in your layout.",
                size="sm",
                fw=500,
            ),
        ], gap="xs"),
        title="Two Components",
        color="indigo",
        icon=DashIconify(icon="ic:baseline-discord", width=20),
    ),
    dmc.SimpleGrid(
        cols=2,
        children=[
            dmc.Paper(
                dmc.Stack([
                    dmc.Group([
                        DashIconify(icon="tabler:message-circle", width=24, color="#5865f2"),
                        dmc.Text("DiscordCrate", fw=700, size="lg"),
                    ], gap="xs"),
                    dmc.Text("Floating chat button", size="sm", c="dimmed"),
                    dmc.List([
                        dmc.ListItem("Global — appears on every page", icon=DashIconify(icon="tabler:check", width=16, color="teal")),
                        dmc.ListItem("Toggle, notify, navigate commands", icon=DashIconify(icon="tabler:check", width=16, color="teal")),
                        dmc.ListItem("Slash command bridge (/ai, /ask)", icon=DashIconify(icon="tabler:check", width=16, color="teal")),
                        dmc.ListItem("Event listeners (sentMessage, signIn)", icon=DashIconify(icon="tabler:check", width=16, color="teal")),
                    ], size="sm", spacing="xs"),
                ], gap="sm"),
                withBorder=True, p="lg",
            ),
            dmc.Paper(
                dmc.Stack([
                    dmc.Group([
                        DashIconify(icon="tabler:layout-bottombar", width=24, color="#5865f2"),
                        dmc.Text("DiscordWidget", fw=700, size="lg"),
                    ], gap="xs"),
                    dmc.Text("Inline iframe embed", size="sm", c="dimmed"),
                    dmc.List([
                        dmc.ListItem("Placed anywhere in your layout", icon=DashIconify(icon="tabler:check", width=16, color="teal")),
                        dmc.ListItem("No floating button", icon=DashIconify(icon="tabler:check", width=16, color="teal")),
                        dmc.ListItem("Read-only events (message, signIn)", icon=DashIconify(icon="tabler:check", width=16, color="teal")),
                        dmc.ListItem("No command dispatch", icon=DashIconify(icon="tabler:x", width=16, color="gray")),
                    ], size="sm", spacing="xs"),
                ], gap="sm"),
                withBorder=True, p="lg",
            ),
        ],
    ),
    dmc.Paper(
        discord_widget_container(
            server=os.getenv("WIDGETBOT_SERVER", ""),
            channel=os.getenv("WIDGETBOT_CHANNEL", ""),
            width="100%",
            height="350px",
        ),
        withBorder=True, p="xs", radius="md",
    ),
], gap="md")
```


---

## Inline Widget Embed



```python
# File: docs/dash_widgetbot/widget_example.py

import os
import dash_mantine_components as dmc
from dash_widgetbot import discord_widget_container

component = dmc.Stack([
    dmc.Text("Inline Discord Widget", fw=600, size="lg"),
    dmc.Text(
        "discord_widget_container() creates a cross-origin iframe pointing to the "
        "WidgetBot shard. Unlike the Crate, it renders directly in your layout with "
        "no floating button. Configure server, channel, dimensions, and shard URL.",
        size="sm", c="dimmed",
    ),
    dmc.Paper(
        discord_widget_container(
            server=os.getenv("WIDGETBOT_SERVER", ""),
            channel=os.getenv("WIDGETBOT_CHANNEL", ""),
            width="100%",
            height="400px",
        ),
        withBorder=True, p="xs", radius="md",
    ),
    dmc.Code(
        """from dash_widgetbot import discord_widget_container

widget = discord_widget_container(
    server=os.getenv("WIDGETBOT_SERVER"),
    channel=os.getenv("WIDGETBOT_CHANNEL"),
    width="100%",
    height="400px",
    shard="https://e-business.widgetbot.co",  # optional custom shard
)""",
        block=True,
    ),
], gap="md")
```


---

## Sending Messages (Webhook)



```python
# File: docs/dash_widgetbot/webhook_example.py

import os
import time
import dash_mantine_components as dmc
from dash import html, callback, Input, Output, State, clientside_callback, no_update
from dash_iconify import DashIconify
from dash_widgetbot.webhook import send_webhook_message

component = dmc.Stack([
    dmc.Text("Send Messages via Webhook", fw=600, size="lg"),
    dmc.Text(
        "Use send_webhook_message() to post messages to Discord from your Dash app. "
        "Messages are sent via the Discord webhook API and appear in the channel.",
        size="sm", c="dimmed",
    ),
    dmc.Paper(
        dmc.Stack([
            dmc.Textarea(
                id="wb-webhook-content",
                label="Message",
                placeholder="Type a message to send to Discord...",
                value="Hello from pip-docs+ documentation!",
                minRows=2,
                autosize=True,
            ),
            dmc.Group([
                dmc.TextInput(
                    id="wb-webhook-username",
                    placeholder="Bot display name (optional)",
                    label="Username",
                    style={"flex": 1},
                ),
                dmc.TextInput(
                    id="wb-webhook-avatar",
                    placeholder="https://cdn.discordapp.com/embed/avatars/0.png",
                    label="Avatar URL (optional)",
                    style={"flex": 1},
                ),
            ]),
            dmc.Group([
                dmc.Button(
                    "Send to Discord",
                    id="wb-webhook-send-btn",
                    leftSection=DashIconify(icon="tabler:send", width=18),
                    color="indigo",
                    loading=False,
                ),
                html.Div(id="wb-webhook-result"),
            ]),
            dmc.Text(
                "Open the floating Discord chat (bottom-right) to see your message appear.",
                size="xs", c="dimmed", fs="italic",
            ),
        ], gap="sm"),
        withBorder=True, p="lg",
    ),
    dmc.Code(
        """from dash_widgetbot.webhook import send_webhook_message

result = send_webhook_message(
    "Hello from Dash!",
    webhook_url=os.getenv("DISCORD_WEBHOOK_URL"),  # auto from .env
    username="My Bot",          # optional display name
    avatar_url="https://...",   # optional avatar
    thread_id="...",            # optional thread
)
# result: {"success": True, "message_id": "123...", "status_code": 200}""",
        block=True,
    ),
], gap="md")


@callback(
    Output("wb-webhook-result", "children"),
    Input("wb-webhook-send-btn", "n_clicks"),
    State("wb-webhook-content", "value"),
    State("wb-webhook-username", "value"),
    State("wb-webhook-avatar", "value"),
    running=[(Output("wb-webhook-send-btn", "loading"), True, False)],
    prevent_initial_call=True,
)
def send_message(_n, content, username, avatar_url):
    if not content:
        return dmc.Badge("Enter a message first", color="yellow", variant="light", size="sm")

    result = send_webhook_message(
        content,
        username=username or None,
        avatar_url=avatar_url or None,
    )

    if result["success"]:
        return dmc.Badge(
            f"Sent! Open the Discord chat to see it.",
            color="green", variant="light", size="sm",
        )
    return dmc.Badge(
        f"Error: {result.get('error', 'Unknown')[:60]}",
        color="red", variant="light", size="sm",
    )
```


---

## Command Bridge Patterns



```python
# File: docs/dash_widgetbot/commands_example.py

import dash_mantine_components as dmc
from dash_iconify import DashIconify

component = dmc.Stack([
    dmc.Text("Command Bridge Patterns", fw=600, size="lg"),
    dmc.Text(
        "The Crate is controlled via a store-based bridge. Python callbacks write "
        "command dicts to a dcc.Store, and clientside JS dispatches them to the "
        "Crate API. All helpers return command dicts with a _ts timestamp to "
        "prevent Dash deduplication.",
        size="sm", c="dimmed",
    ),
    dmc.Alert(
        "Try clicking the floating Discord button (bottom-right) and typing "
        "/status to see the command bridge in action!",
        title="Live Demo",
        color="teal",
        icon=DashIconify(icon="tabler:terminal-2", width=20),
    ),
    dmc.SimpleGrid(
        cols=2,
        children=[
            dmc.Paper(
                dmc.Stack([
                    dmc.Text("Toggle Open/Close", fw=600, size="sm"),
                    dmc.Code(
                        """from dash_widgetbot import STORE_IDS, crate_toggle

@callback(
    Output(STORE_IDS["command"], "data",
           allow_duplicate=True),
    Input("my-button", "n_clicks"),
    prevent_initial_call=True,
)
def toggle(_n):
    return crate_toggle()      # toggle
    # return crate_toggle(True)  # force open
    # return crate_toggle(False) # force close""",
                        block=True,
                    ),
                ], gap="xs"),
                withBorder=True, p="md",
            ),
            dmc.Paper(
                dmc.Stack([
                    dmc.Text("Show Notification", fw=600, size="sm"),
                    dmc.Code(
                        """from dash_widgetbot import crate_notify

return crate_notify(
    "Hello from Dash!",
    timeout=5000,        # auto-dismiss in 5s
    avatar="https://...",  # custom avatar
)""",
                        block=True,
                    ),
                ], gap="xs"),
                withBorder=True, p="md",
            ),
            dmc.Paper(
                dmc.Stack([
                    dmc.Text("Navigate Channel", fw=600, size="sm"),
                    dmc.Code(
                        """from dash_widgetbot import crate_navigate

# Switch to a different channel
return crate_navigate("CHANNEL_ID")

# Switch server + channel
return crate_navigate(
    "CHANNEL_ID", guild="SERVER_ID"
)""",
                        block=True,
                    ),
                ], gap="xs"),
                withBorder=True, p="md",
            ),
            dmc.Paper(
                dmc.Stack([
                    dmc.Text("Hide / Show", fw=600, size="sm"),
                    dmc.Code(
                        """from dash_widgetbot import crate_hide, crate_show

# Hide the Crate button entirely
return crate_hide()

# Show it again
return crate_show()""",
                        block=True,
                    ),
                ], gap="xs"),
                withBorder=True, p="md",
            ),
        ],
    ),
    dmc.Paper(
        dmc.Stack([
            dmc.Text("All Bridge Helpers", fw=600, size="sm"),
            dmc.Table(
                [
                    dmc.TableThead(dmc.TableTr([
                        dmc.TableTh("Helper"),
                        dmc.TableTh("Description"),
                    ])),
                    dmc.TableTbody([
                        dmc.TableTr([dmc.TableTd(dmc.Code("crate_toggle(is_open)")), dmc.TableTd("Toggle or set open/closed")]),
                        dmc.TableTr([dmc.TableTd(dmc.Code("crate_notify(content, timeout)")), dmc.TableTd("Show notification bubble")]),
                        dmc.TableTr([dmc.TableTd(dmc.Code("crate_navigate(channel, guild)")), dmc.TableTd("Switch to a channel")]),
                        dmc.TableTr([dmc.TableTd(dmc.Code("crate_send_message(msg)")), dmc.TableTd("Send message on behalf of user")]),
                        dmc.TableTr([dmc.TableTd(dmc.Code("crate_hide() / crate_show()")), dmc.TableTd("Hide/show the button")]),
                        dmc.TableTr([dmc.TableTd(dmc.Code("crate_login() / crate_logout()")), dmc.TableTd("Auth controls")]),
                        dmc.TableTr([dmc.TableTd(dmc.Code("crate_update_options(**opts)")), dmc.TableTd("Update config at runtime")]),
                        dmc.TableTr([dmc.TableTd(dmc.Code("crate_set_color(var, val)")), dmc.TableTd("Set CSS variable")]),
                    ]),
                ],
            ),
        ], gap="sm"),
        withBorder=True, p="lg",
    ),
], gap="md")
```


---

## Slash Commands & Event Bridge



```python
# File: docs/dash_widgetbot/events_example.py

import dash_mantine_components as dmc
from dash_iconify import DashIconify

component = dmc.Stack([
    dmc.Text("Slash Commands & Event Bridge", fw=600, size="lg"),
    dmc.Text(
        "WidgetBot doesn't support native Discord slash commands. dash-widgetbot "
        "implements a message-parsing bridge that intercepts /command text, builds "
        "a fake interaction, and dispatches to registered handlers.",
        size="sm", c="dimmed",
    ),
    dmc.Alert(
        children=dmc.Stack([
            dmc.Text("Try these in the floating Discord chat (bottom-right):", size="sm", fw=500),
            dmc.List([
                dmc.ListItem([dmc.Code("/status"), " — Show app info"]),
                dmc.ListItem([dmc.Code("/ai <prompt>"), " — Generate AI content with Gemini"]),
                dmc.ListItem([dmc.Code("/ask <question>"), " — Ask the AI a question"]),
                dmc.ListItem([dmc.Code("/ai <prompt>"), " + attach an image — Multimodal AI"]),
            ], size="sm"),
        ], gap="xs"),
        title="Live Slash Commands",
        color="indigo",
        icon=DashIconify(icon="tabler:terminal-2", width=20),
    ),
    dmc.Paper(
        dmc.Stack([
            dmc.Text("How the Bridge Works", fw=600, size="sm"),
            dmc.Code(
                """# 1. User types "/ai explain this image" in the Crate
# 2. WidgetBot fires sentMessage via postMessage
# 3. widgetbot_fix.js intercepts and forwards to Dash store:
window.dash_clientside.set_props('_widgetbot-crate-event', {
    data: {type: 'sentMessage', content: '/ai explain...', ...}
})

# 4. Python callback parses the command:
@callback(
    Output("_crate-slash-result", "data"),
    Output("_widgetbot-crate-command", "data", allow_duplicate=True),
    Input("_widgetbot-crate-event", "data"),
    prevent_initial_call=True,
)
def _handle_crate_slash(event_data):
    content = event_data.get("content", "")
    if not content.startswith("/"):
        return no_update, no_update
    # Parse "/ai prompt" → cmd_name="ai", rest="prompt"
    parts = content[1:].split(None, 1)
    cmd_name, rest = parts[0], parts[1] if len(parts) > 1 else ""
    # Build fake interaction and dispatch to handler...

# 5. Handler generates AI response via Gemini
# 6. Response posted to Discord channel via bot token
# 7. Message appears in the Crate widget""",
                block=True,
            ),
        ], gap="sm"),
        withBorder=True, p="lg",
    ),
    dmc.Paper(
        dmc.Stack([
            dmc.Text("Store IDs (Event System)", fw=600, size="sm"),
            dmc.Text(
                "add_discord_crate() returns 6 store IDs for the command/event bridge:",
                size="sm", c="dimmed",
            ),
            dmc.Table(
                [
                    dmc.TableThead(dmc.TableTr([
                        dmc.TableTh("Key"),
                        dmc.TableTh("Store ID"),
                        dmc.TableTh("Direction"),
                        dmc.TableTh("Purpose"),
                    ])),
                    dmc.TableTbody([
                        dmc.TableTr([dmc.TableTd("config"), dmc.TableTd(dmc.Code("_widgetbot-crate-config")), dmc.TableTd("Read"), dmc.TableTd("Initial config (read-only)")]),
                        dmc.TableTr([dmc.TableTd("command"), dmc.TableTd(dmc.Code("_widgetbot-crate-command")), dmc.TableTd("Write"), dmc.TableTd("Send commands to Crate")]),
                        dmc.TableTr([dmc.TableTd("event"), dmc.TableTd(dmc.Code("_widgetbot-crate-event")), dmc.TableTd("Read"), dmc.TableTd("Generic events (sentMessage, etc.)")]),
                        dmc.TableTr([dmc.TableTd("message"), dmc.TableTd(dmc.Code("_widgetbot-crate-message")), dmc.TableTd("Read"), dmc.TableTd("Incoming Discord messages")]),
                        dmc.TableTr([dmc.TableTd("user"), dmc.TableTd(dmc.Code("_widgetbot-crate-user")), dmc.TableTd("Read"), dmc.TableTd("Sign-in / sign-out state")]),
                        dmc.TableTr([dmc.TableTd("status"), dmc.TableTd(dmc.Code("_widgetbot-crate-status")), dmc.TableTd("Read"), dmc.TableTd("Ready + open state")]),
                    ]),
                ],
            ),
        ], gap="sm"),
        withBorder=True, p="lg",
    ),
], gap="md")
```


---

## Setup & Configuration — Development

Baseline initialization order and environment variable contract. Suitable for local dev against a test guild with an ngrok (or cloudflared) tunnel for the interactions endpoint.



```python
# File: docs/dash_widgetbot/setup_guide.py

import dash_mantine_components as dmc
from dash_iconify import DashIconify

component = dmc.Stack([
    dmc.Text("Setup & Configuration", fw=600, size="lg"),
    dmc.Alert(
        children="All hook registrations must happen BEFORE dash.Dash() is created. "
                 "Hooks are processed at app creation time, not at request time.",
        title="Critical: Initialization Order",
        color="red",
        icon=DashIconify(icon="tabler:alert-triangle", width=20),
    ),
    dmc.Paper(
        dmc.Stack([
            dmc.Text("Initialization Order", fw=600, size="sm"),
            dmc.Code(
                """from dotenv import load_dotenv
load_dotenv()

# 1. Register Crate hooks BEFORE Dash()
from dash_widgetbot import add_discord_crate
store_ids = add_discord_crate(
    server=os.getenv("WIDGETBOT_SERVER"),
    channel=os.getenv("WIDGETBOT_CHANNEL"),
    color=os.getenv("WIDGETBOT_COLOR", "#5865f2"),
    shard=os.getenv("WIDGETBOT_SHARD", ""),
)

# 2. Register interactions endpoint BEFORE Dash()
from dash_widgetbot import add_discord_interactions
if os.getenv("DISCORD_PUBLIC_KEY"):
    add_discord_interactions()

# 3. Create Dash app AFTER hooks
import dash
app = dash.Dash(__name__)

# 4. Register command handlers AFTER app
from dash_widgetbot import register_command, sync_discord_endpoint
register_command("ask", my_handler, ephemeral=True)
sync_discord_endpoint()""",
                block=True,
            ),
        ], gap="sm"),
        withBorder=True, p="lg",
    ),
    dmc.Paper(
        dmc.Stack([
            dmc.Text("Environment Variables", fw=600, size="sm"),
            dmc.Table(
                [
                    dmc.TableThead(dmc.TableTr([
                        dmc.TableTh("Variable"),
                        dmc.TableTh("Required"),
                        dmc.TableTh("Description"),
                    ])),
                    dmc.TableTbody([
                        dmc.TableTr([dmc.TableTd(dmc.Code("WIDGETBOT_SERVER")), dmc.TableTd("Yes"), dmc.TableTd("Discord guild snowflake ID")]),
                        dmc.TableTr([dmc.TableTd(dmc.Code("WIDGETBOT_CHANNEL")), dmc.TableTd("Yes"), dmc.TableTd("Default channel snowflake ID")]),
                        dmc.TableTr([dmc.TableTd(dmc.Code("WIDGETBOT_COLOR")), dmc.TableTd("No"), dmc.TableTd("Button hex color (default #5865f2)")]),
                        dmc.TableTr([dmc.TableTd(dmc.Code("WIDGETBOT_SHARD")), dmc.TableTd("No"), dmc.TableTd("Custom WidgetBot shard URL")]),
                        dmc.TableTr([dmc.TableTd(dmc.Code("DISCORD_PUBLIC_KEY")), dmc.TableTd("Bot"), dmc.TableTd("Ed25519 public key for slash commands")]),
                        dmc.TableTr([dmc.TableTd(dmc.Code("DISCORD_APPLICATION_ID")), dmc.TableTd("Bot"), dmc.TableTd("Discord application ID")]),
                        dmc.TableTr([dmc.TableTd(dmc.Code("DISCORD_BOT_TOKEN")), dmc.TableTd("Bot"), dmc.TableTd("Bot token for API calls")]),
                        dmc.TableTr([dmc.TableTd(dmc.Code("DISCORD_WEBHOOK_URL")), dmc.TableTd("Bot"), dmc.TableTd("Webhook URL for sending messages")]),
                        dmc.TableTr([dmc.TableTd(dmc.Code("GEMINI_API_KEY")), dmc.TableTd("AI"), dmc.TableTd("Google Gemini API key for /ai, /ask")]),
                        dmc.TableTr([dmc.TableTd(dmc.Code("GEMINI_MODEL")), dmc.TableTd("No"), dmc.TableTd("Gemini model name (default gemini-2.0-flash)")]),
                    ]),
                ],
            ),
        ], gap="sm"),
        withBorder=True, p="lg",
    ),
    dmc.Paper(
        dmc.Stack([
            dmc.Text("Package Extras", fw=600, size="sm"),
            dmc.Code(
                """pip install dash-widgetbot           # Core (Crate + Widget)
pip install dash-widgetbot[bot]      # + Discord bot (PyNaCl, requests)
pip install dash-widgetbot[ai]       # + Gemini AI (google-genai)
pip install dash-widgetbot[realtime] # + Socket.IO transport
pip install dash-widgetbot[all]      # Everything""",
                block=True,
            ),
        ], gap="sm"),
        withBorder=True, p="lg",
    ),
], gap="md")
```


---

## Production Deployment Guide

Everything a dev run doesn't teach you. The Crate is trivial to ship; the slash-command/bot layer exposes a stack of production-only issues — Cloudflare IP blocks on shared PaaS IPs, `urllib3<2` for HTTPS proxy compatibility, Docker-build vs runtime proxy scope, gunicorn `graceful_timeout` vs daemon-thread Discord follow-up PATCHes, Discord's per-app command-registration rate limit, and the global-vs-guild scope decision.



```python
# File: docs/dash_widgetbot/production_guide.py

"""Production deployment guide for dash-widgetbot.

Captures the lessons learned deploying the Crate + slash-command layers to
a PaaS (Render, Railway, Fly.io) behind Cloudflare-fronted Discord APIs.
Read alongside `setup_guide.py` which covers the baseline env-var contract.
"""

import dash_mantine_components as dmc
from dash_iconify import DashIconify


def _alert(title, text, color, icon):
    return dmc.Alert(
        children=text,
        title=title,
        color=color,
        icon=DashIconify(icon=icon, width=20),
    )


def _paper(title, *children):
    return dmc.Paper(
        dmc.Stack([dmc.Text(title, fw=600, size="sm"), *children], gap="sm"),
        withBorder=True,
        p="lg",
    )


_TWO_LAYERS_TABLE = dmc.Table(
    [
        dmc.TableThead(
            dmc.TableTr(
                [
                    dmc.TableTh(""),
                    dmc.TableTh("Layer 1 — Crate / Widget"),
                    dmc.TableTh("Layer 2 — Slash Commands / Bot"),
                ]
            )
        ),
        dmc.TableTbody(
            [
                dmc.TableTr([dmc.TableTd("Transport"), dmc.TableTd("Client-side iframe"), dmc.TableTd("HTTPS POST from Discord → your server")]),
                dmc.TableTr([dmc.TableTd("Server-side Discord API calls"), dmc.TableTd("None"), dmc.TableTd("Command registration + follow-up PATCH")]),
                dmc.TableTr([dmc.TableTd("Public endpoint required"), dmc.TableTd("No"), dmc.TableTd("Yes (HTTPS, valid cert, publicly reachable)")]),
                dmc.TableTr([dmc.TableTd("Dev vs prod differences"), dmc.TableTd("Identical"), dmc.TableTd("Significant (proxy, gunicorn, registration strategy)")]),
                dmc.TableTr([dmc.TableTd("Required env vars"), dmc.TableTd("WIDGETBOT_SERVER, WIDGETBOT_CHANNEL"), dmc.TableTd("+ DISCORD_PUBLIC_KEY, DISCORD_APPLICATION_ID, DISCORD_BOT_TOKEN")]),
                dmc.TableTr([dmc.TableTd("Extras install"), dmc.TableTd("dash-widgetbot"), dmc.TableTd("dash-widgetbot[bot,ai]")]),
            ]
        ),
    ],
    striped=True,
    withTableBorder=True,
)


_TROUBLESHOOTING_TABLE = dmc.Table(
    [
        dmc.TableThead(
            dmc.TableTr(
                [
                    dmc.TableTh("Symptom"),
                    dmc.TableTh("Likely Cause"),
                    dmc.TableTh("Fix"),
                ]
            )
        ),
        dmc.TableTbody(
            [
                dmc.TableTr(
                    [
                        dmc.TableTd("429 response with Cloudflare HTML from Discord"),
                        dmc.TableTd("PaaS shared IP on Cloudflare blocklist"),
                        dmc.TableTd("Route discord.com through a proxy (QuotaGuard, Fixie, etc.) via HTTP(S)_PROXY + NO_PROXY"),
                    ]
                ),
                dmc.TableTr(
                    [
                        dmc.TableTd("SSL handshake error when proxy is set"),
                        dmc.TableTd("urllib3 v2 changed HTTPS proxy CONNECT behavior"),
                        dmc.TableTd("Pin urllib3<2 in requirements.txt"),
                    ]
                ),
                dmc.TableTr(
                    [
                        dmc.TableTd("Docker build fails reaching pip / npm registries"),
                        dmc.TableTd("Proxy env vars leak into build stage"),
                        dmc.TableTd("Unset HTTP_PROXY / HTTPS_PROXY at the top of the Dockerfile"),
                    ]
                ),
                dmc.TableTr(
                    [
                        dmc.TableTd("Gemini / other APIs slow, timing out, or burning proxy quota"),
                        dmc.TableTd("Global proxy routes non-Discord traffic"),
                        dmc.TableTd("Add those domains to the NO_PROXY list"),
                    ]
                ),
                dmc.TableTr(
                    [
                        dmc.TableTd("Worker killed mid-generation on deploy / restart"),
                        dmc.TableTd("gunicorn graceful_timeout too short for daemon PATCH"),
                        dmc.TableTd("gunicorn.conf.py → graceful_timeout=120, workers=1"),
                    ]
                ),
                dmc.TableTr(
                    [
                        dmc.TableTd("429 on command registration every deploy"),
                        dmc.TableTd("Re-PUTing identical payload on every boot"),
                        dmc.TableTd("Hash the payload, cache the signature, skip PUT when unchanged"),
                    ]
                ),
                dmc.TableTr(
                    [
                        dmc.TableTd("Slash commands appear in one guild but not another"),
                        dmc.TableTd("Guild-scoped registration"),
                        dmc.TableTd("Use the global endpoint /applications/{id}/commands — ~1h propagation"),
                    ]
                ),
                dmc.TableTr(
                    [
                        dmc.TableTd("Command fires but nothing posts back to channel"),
                        dmc.TableTd("Bot missing channel permissions"),
                        dmc.TableTd("Server Settings → Integrations → bot → Channels → grant Send Messages + Embed Links"),
                    ]
                ),
                dmc.TableTr(
                    [
                        dmc.TableTd("Dev Portal rejects Interactions Endpoint URL"),
                        dmc.TableTd("Server not listening, PyNaCl missing, or public key mismatch"),
                        dmc.TableTd("Confirm server is live, pip install dash-widgetbot[bot], DISCORD_PUBLIC_KEY matches the Dev Portal"),
                    ]
                ),
            ]
        ),
    ],
    striped=True,
    withTableBorder=True,
)


component = dmc.Stack(
    [
        dmc.Text("Production Deployment & Troubleshooting", fw=600, size="lg"),
        _alert(
            "Two Independent Layers",
            "The Crate / Widget iframe is a drop-in with no server-side Discord API calls — "
            "it behaves identically in dev and prod. The Bot + slash-command layer is where "
            "production-only issues live: outbound routing, rate limits, proxy config, and "
            "worker-lifecycle timing.",
            "blue",
            "tabler:stack-2",
        ),

        _paper("Layer 1 vs Layer 2", _TWO_LAYERS_TABLE),

        _paper(
            "Development — Slash-command setup",
            dmc.List(
                [
                    dmc.ListItem("Discord Dev Portal → New Application — save Application ID + Public Key"),
                    dmc.ListItem("Bot tab → add bot, generate token (DISCORD_BOT_TOKEN)"),
                    dmc.ListItem("OAuth2 → URL Generator → scopes: applications.commands + bot — invite to your test guild"),
                    dmc.ListItem("Expose localhost publicly: ngrok http 8504 (or cloudflared tunnel)"),
                    dmc.ListItem("Dev Portal → General Information → Interactions Endpoint URL → paste ngrok URL + /api/discord/interactions"),
                    dmc.ListItem("Discord sends a test PING — the save only sticks if Ed25519 verification passes (requires PyNaCl)"),
                    dmc.ListItem("Guild-scoped registration is fine here; it propagates instantly while iterating"),
                ],
                size="sm",
            ),
        ),

        _paper(
            "Production — Outbound proxy for discord.com",
            dmc.Text(
                "Many PaaS hosts (Render, Railway, Heroku free tier, Fly.io edge) share IPs "
                "Cloudflare has blocklisted. Symptoms: 429 responses with a Cloudflare HTML "
                "error page, or a 1020 Access Denied. Route ONLY discord.com through a proxy "
                "(QuotaGuard-style). Everything else goes direct — the NO_PROXY list matters.",
                size="sm",
            ),
            dmc.Code(
                """# run.py — runs BEFORE Dash() so hooks see the env
import os
_proxy_url = os.getenv("QUOTAGUARD_URL")
if _proxy_url:
    os.environ["HTTP_PROXY"]  = _proxy_url
    os.environ["HTTPS_PROXY"] = _proxy_url
    os.environ["NO_PROXY"] = ",".join([
        "generativelanguage.googleapis.com",  # Gemini direct
        "googleapis.com",
        "pypistats.org",
        "pypi.org",
        "github.com",
        "api.github.com",
        "registry.npmjs.org",
        "localhost",
        "127.0.0.1",
    ])""",
                block=True,
            ),
            dmc.Text(
                "Companion fix 1: pin urllib3<2 in requirements.txt — v2 breaks HTTPS proxy SSL handshakes.",
                size="sm",
                c="dimmed",
            ),
            dmc.Text(
                "Companion fix 2: unset HTTP(S)_PROXY at the top of the Dockerfile so pip/npm registries are reachable during build.",
                size="sm",
                c="dimmed",
            ),
            dmc.Code(
                """# Dockerfile (top of file)
ENV HTTP_PROXY=""
ENV HTTPS_PROXY=""
ENV http_proxy=""
ENV https_proxy=""
""",
                block=True,
            ),
        ),

        _paper(
            "Production — Gunicorn tuning for daemon-thread handlers",
            dmc.Text(
                "Slash-command handlers return a type-5 deferred response in <50ms, then run "
                "Gemini generation + Discord follow-up PATCH in a daemon thread (15-45s). "
                "gunicorn's default graceful_timeout=30 kills these mid-PATCH on rolling deploys.",
                size="sm",
            ),
            dmc.Code(
                """# gunicorn.conf.py
workers          = 1       # shared _command_handlers registry stays in one process
worker_class     = "sync"
timeout          = 120     # request timeout
graceful_timeout = 120     # SIGTERM → SIGKILL grace window for in-flight daemons
bind             = "0.0.0.0:8550"
keepalive        = 5
accesslog        = "-"
errorlog         = "-"
""",
                block=True,
            ),
        ),

        _paper(
            "Production — Command registration strategy",
            dmc.List(
                [
                    dmc.ListItem("Use the GLOBAL endpoint PUT /applications/{app_id}/commands — not /guilds/{gid}/commands — so one registration covers every guild the bot joins"),
                    dmc.ListItem("Hash the command payload + app_id, cache the signature on disk (e.g. /tmp/pip_docs_discord_cmds.hash); skip the PUT when unchanged to avoid Discord 429s"),
                    dmc.ListItem("Expose DISCORD_FORCE_COMMAND_SYNC=1 as an escape hatch for the cache when intentionally re-registering"),
                    dmc.ListItem("Global registration propagates to guilds in up to ~1 hour on first publish / changes — plan around that cache window"),
                    dmc.ListItem("Run the PUT once at import time (before the Flask server starts), not inside a request handler"),
                ],
                size="sm",
            ),
        ),

        _paper("Troubleshooting matrix", _TROUBLESHOOTING_TABLE),

        _alert(
            "Sanity-check order for a broken production bot",
            "1. Is /api/discord/interactions reachable over HTTPS?  "
            "2. Does the signature verify (PyNaCl installed, public key matches)?  "
            "3. Are outbound requests to discord.com returning 2xx (proxy + NO_PROXY)?  "
            "4. Are commands registered globally, and is it <1h since the last change?  "
            "5. Does the bot have Send Messages + Embed Links in the target channel?",
            "teal",
            "tabler:checklist",
        ),
    ],
    gap="md",
)
```


---

## Crate Properties — `add_discord_crate()`

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| `server` | string | Required | Discord server (guild) snowflake ID |
| `channel` | string | `""` | Default channel snowflake ID |
| `color` | string | `"#5865f2"` | Button hex color |
| `location` | list | `None` | `[vertical, horizontal]` position |
| `notifications` | bool | `True` | Show notification bubbles |
| `indicator` | bool | `True` | Show unread dot indicator |
| `timeout` | int | `10000` | ms before notification auto-dismiss |
| `defer` | bool | `False` | Lazy-load Crate |
| `pages` | list | `None` | Route paths for visibility scoping |
| `prefix` | string | `""` | Namespace for multi-instance |
| `shard` | string | `""` | Custom shard URL |

---

## Widget Properties — `discord_widget_container()`

| Property | Type | Default | Description |
|:---------|:-----|:--------|:------------|
| `server` | string | Required | Discord server (guild) snowflake ID |
| `channel` | string | `""` | Channel snowflake ID |
| `width` | string | `"100%"` | CSS width |
| `height` | string | `"600px"` | CSS height |
| `shard` | string | `""` | Custom WidgetBot shard URL |

---

## Contributing

Contributions to dash-widgetbot are welcome! Visit the [GitHub repo](https://github.com/pip-install-python/dash-widgetbot/issues) for issues and feature requests.

## License

MIT License.

---

<!-- /privacy — https://2plot.dev/privacy/llms.txt -->

# Privacy

**2plot.dev** is the documentation site and package catalogue for the Dash
component libraries published by Pip Install Python LLC.

This page describes what the software actually does, taken from the code that
does it. It is written to be accurate rather than comprehensive, and it is not
a substitute for legal advice.

## What is recorded when you read a page

Every request that reaches a page is written to a local ledger with:

- the **path** you asked for,
- your **User-Agent** string,
- a **session identifier** — a keyed one-way hash of your IP address and
  User-Agent, used to recognise repeat requests within a visit without storing
  an account or a cookie,
- **where you are**, as far as Cloudflare tells this site: your country
  (`CF-IPCountry`), and — where the site receives them — your city, region and
  approximate coordinates. Whatever of those arrives is recorded; nothing is
  inferred or looked up to fill a gap.

**Your IP address is not stored.** It is used, in memory, for two things
during the request and then discarded: to derive the session identifier above,
and to let the site tell an automated client from a person. The session
identifier is a **keyed one-way hash** — the key is not derivable from the
result, so the identifier cannot be turned back into your address.

**Nothing about you is sent to a third party to work out where you are.** Your
location comes from headers Cloudflare already attaches to the request as it
reaches this site — country, and where available city, region and approximate
coordinates. Where those headers are absent, the site records less rather than
asking anyone.

**Retention.** The traffic ledger is pruned to the last **3 days**, and the
host it runs on has an ephemeral filesystem, so a redeploy clears it earlier
than that.

## What is recorded when a crawler or AI agent reads a document

When an automated client fetches one of the machine-readable documents
(`/llms.txt` and its tiered variants, `robots.txt`, `sitemap.xml`, a page's
own `llms.txt`), the site records one row per document served: the path, which
tier was served, the vendor the User-Agent identifies, whether that vendor's
published IP ranges could verify it, the response size and status.

**The requesting IP address is not stored on these rows.**

## What is never counted

Requests whose User-Agent carries the token `2plot-internal` are the 2plot
network's own machinery — health checks, deploy verification, the smoke
battery. They are discarded before anything else happens and appear in no
count on this site.

Requests to `/healthz`, static assets and Dash's internal endpoints are not
recorded as visits.

## What leaves this host

Once an hour, an **aggregate** summary of the day's traffic is sent to
`2plot.ai`, the network hub: totals for human and automated requests, distinct
visitor counts, the busiest paths, a country breakdown, and per-vendor crawler
figures. **No IP addresses and no session identifiers are included.**

## Accounts, email and payment

- **Accounts** are handled by [Clerk](https://clerk.com). If you sign in, Clerk
  holds your account details under its own privacy policy; this site receives
  an identifier and the email address associated with it.
- **Email** is sent through [Resend](https://resend.com).
- **Advertising** shown on this site is served by the network's own ad
  component from this host. It does not embed a third-party ad network and
  sets no advertising cookies.
- **Agent access keys** (`k2p_…`) issued for machine access to the corpus are
  tied to a signed-in account and can be revoked by you at any time from the
  control board.

## Local storage

The site stores your **colour-scheme preference** in your browser's local
storage so the theme survives a reload. It never leaves your browser.

## Contact

Questions, corrections, or a request to remove something:
**pipinstallpython@gmail.com**

---

<!-- /terms — https://2plot.dev/terms/llms.txt -->

# Terms of Use

**2plot.dev** is operated by Pip Install Python LLC. By using the site you
accept the terms below.

## Licensing — three things, three licences

Getting this wrong in either direction is easy, so it is spelled out.

**The documentation is [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).**
The prose, the guides, the component documentation, and the **code examples
inside them** — everything served under `/pip`, `/llms.txt` and the per-page
machine documents. You may share and adapt it, including commercially,
provided you give appropriate credit to Pip Install Python LLC, link to the
licence, and indicate if you changed anything. You do not need to ask.

The examples are deliberately inside that grant. Documentation whose examples
cannot be used is not documentation — copy them into your projects freely.

**This site's own source code is proprietary.** © 2026 Pip Install Python LLC,
all rights reserved. The application that serves 2plot.dev — its modules,
callbacks, templates, styles and configuration — is not licensed for reuse,
and viewing it where it is visible does not grant one. Ask if you want to:
**pipinstallpython@gmail.com**.

**The `dash-*` packages carry their own licences.** They are distributed on
PyPI and GitHub, each licence travels with its package, and nothing on this
page changes, replaces or overrides one. If this page and a package's licence
ever appear to disagree, the package's licence governs the package.

## Automated access

Machine-readable versions of this documentation are published deliberately, at
`/llms.txt` and its tiered variants, and per-page at `/<page>/llms.txt`. You
are welcome to read them.

- `robots.txt` states this site's position per crawler. Please honour it.
- Automated clients should send a **descriptive User-Agent**. Requests that
  identify themselves are treated better than requests that do not.
- **Agent access keys** (`k2p_…`) are issued to a signed-in account, are
  personal to that account, and may be revoked at any time — by you from the
  control board, or by the operator in case of abuse.
- Please do not hammer the site. Automated access that degrades service for
  other readers may be rate-limited or blocked.

## Advertising

Some pages carry advertising for products and services relevant to Dash
developers. Advertisements are served by this site itself and are marked as
such.

## Accounts

Some pages require a free account, handled by [Clerk](https://clerk.com). You
are responsible for what happens under your account. Accounts used to abuse
the site or its machine lane may be suspended.

## No warranty

The site, its documentation and its packages are provided **"as is", without
warranty of any kind**, express or implied. The documentation may be wrong or
out of date; the packages may contain defects. To the fullest extent permitted
by law, Pip Install Python LLC is not liable for any loss or damage arising
from your use of the site, its documentation or its packages.

## Changes

These terms may change. The current version is always the one on this page,
and material changes are noted in the site's
[changelog](/changelog).

## Contact

**pipinstallpython@gmail.com**
