{% load i18n brickwork_icons %} {% comment %} The live root-level axis switch (icvoss/django-brickwork#117). Private render target of the {% bw_theme_switch %} tag (templatetags/brickwork_theming.py): never {% include %} directly, the tag validates axes=, builds each option group, and resolves per-axis lock state. Root-level: bwThemeSwitch (frontend/src/js/theme_switch.js) writes the attributes this control offers onto , matching where the shell already reads bw_theme/bw_density/bw_dir/bw_brand from context and where every derived colour resolves. No-JS floor (BR-BW-HTMX-001, the one deliberate departure the #117 ruling states explicitly): the server-rendered page is ALREADY correctly themed, so a theme switch with no JS is a control that visibly does nothing, worse than absent. The floor here is "render nothing", not "render a working control": the fieldset ships with the hidden attribute (the same hidden-until-init shape _alert.html's dismiss button and dismissible.js use) and bwThemeSwitch removes it at init, exactly the reveal-at-init pattern frontend/src/js/dismissible.js already runs. Persistence (SHL-003 applied here, generalising frontend/src/js/sidebar_collapse.js's rule, per the #117 ruling): an axis with no server-resolved preference this request is a free client toggle that persists to localStorage. An axis the resolver DID assert renders as a disabled fieldset (data-bw-locked on the group, disabled on every radio in it): a real server preference exists, and a client default must never clobber it. The disabled group's matching radio is `checked` HERE, in the server render (group.locked_value, resolved from the bw_theme/bw_density/ bw_dir/bw_brand context vars, the same ones shell/base.html itself reads), never left for bwThemeSwitch to compute from at JS init time: with more than one switch instance on a page (ordinary, not a misuse) sharing an axis, an earlier-initialising UNLOCKED sibling can already have changed 's attribute by the time a locked instance's own init runs, so a locked axis reading the live DOM at runtime is order-dependent and can show the wrong value. The server-rendered `checked` state is the one source of truth this branch needs. Required context (built by the tag; never author this list by hand): instance_id: a unique-per-render id (uuid4-derived), so more than one switch on a page never collides on radio name or fieldset id. label: the control's own accessible name (a translated default from the tag, or the caller's override). groups: one entry per requested axis: {axis, legend, name, locked, locked_value, options: [{value, label}, ...]}. `name` is already namespaced to this instance; `locked` gates the disabled state; `locked_value` is the axis's server-resolved current value, "" when unlocked (below); `options` is the axis's closed vocabulary (or the caller's brands= mapping for the brand axis). valid_values: {axis: [value, ...]}, the SAME closed set `options` above renders from. Emitted separately as a json_script (review fix, #117) so bwThemeSwitch validates against a payload the server computed, never against whatever radios happen to be in the DOM: the two are only guaranteed to agree because both come from this one dict, not because the client re-derives one from the other. values_element_id: instance_id + "-values", the json_script element's own id; bwThemeSwitch reads data-bw-theme-switch-values on the root (below) to find it, rather than reconstructing the suffix client-side. layout: "inline" (default) | "compact" (ADR-060, icvoss/django-brickwork#235). "inline" renders the fieldsets directly, byte-identical to the render before this option existed. "compact" wraps the SAME fieldset loop (below, written once and shared by both branches) in a native
/ disclosure, for a header-safe collapsed presentation: the issue's own evidence is a content-heavy header that cannot fit the full three-fieldset control until roughly 1240px, and phone-width option targets measuring 53x21..100x21 px, both well under the 44px floor. placement: "start" | "end" (default "end"), only meaningful with layout="compact"; anchors the compact panel to the trigger's start or end edge, the same vocabulary bw_dropdown/_account_menu already use. Compact is an APG Disclosure, deliberately with NO ARIA menu roles anywhere (the _account_menu.html doctrine run forwards, BR-BW-HTMX-006): a native
/ already carries the correct semantics and keyboard behaviour (Tab to the summary, Enter/Space toggles) with no hand-rolled role="menu"/aria-haspopup/aria-expanded needed. The trigger reuses the bw_button "secondary md" chrome (_dropdown.html's own composition) and the chevron-down caret; the panel is _dropdown.html's own dropdown-shaped panel tokens (surface-raised, elevation-3 at z-dropdown, radius-lg), deliberately WITHOUT the --bw-component-menu-min-width floor _dropdown.html/ _account_menu.html borrow: that token is sized for a vertical list of text menu items, wider than this panel's own three-fieldset content, and forcing it wider than the trigger pushed the panel off the start edge of the viewport under the documented placement="end" recipe (a bug found verifying #235). The panel sizes to its own content instead. Selecting a radio inside the compact panel never closes it: unlike a command menu (bw_dropdown's closeOnSelect), a visitor may want to flip more than one axis in a single visit, so the panel stays open until the trigger is toggled, Escape is pressed, or a click/tap lands outside it (bwThemeSwitch, frontend/src/js/theme_switch.js). The panel's legends render in full-strength ink, not the inline layout's fg-muted (a bug found verifying #235): fg-muted clears AA body contrast against the inline layout's surface-sunken backdrop but falls under 4.5:1 against the raised panel's measurably lighter surface in both themes, the same reason bw-dropdown__item/ bw-account-menu__item never use muted text on a raised surface either. role="group" (review fix, #117): aria-label on a bare
with no ARIA role has no semantic target and screen readers ignore it; role="group" is the generic grouping role that DOES accept aria-label, so the control's own accessible name (the label context var) actually reaches assistive tech. Each axis's own
/ pair supplies that axis's name natively, so the individual radio groups need no additional role. Tokens (CSS lane): plain radios, no bespoke chrome; the control borrows the existing form-field spacing tokens (--bw-density-*), never a component of its own, since it is deliberately unstyled beyond what a native fieldset of radios already provides. States: hidden (no-JS floor, always, by deliberate design: a theme switch that visibly does nothing is worse than absent) vs revealed (JS init); per-axis locked (a server-resolved preference exists: disabled radios, the current value checked, a "set by your account preferences" note) vs unlocked (a free client toggle persisting to localStorage). layout="compact" adds its own open/closed presentation on top: closed (default, the native [open] attribute absent) vs open (the disclosure panel visible, toggled by the summary, Escape, or a click/tap outside it); layout="inline" has no such state, the fieldsets are always in the flow. Accessibility: role="group" with an explicit aria-label (a bare div's aria-label has no semantic target without a role); each axis is its own native
/, so the axis name reaches assistive tech without extra ARIA. layout="compact" adds a native
/ disclosure around the SAME fieldsets, deliberately with NO ARIA menu roles: this is an APG Disclosure, not a menu (role="menu" would mandate arrow-key handling nothing here provides), so native details semantics carry Tab-to-summary and Enter/Space-toggles with no hand-rolled ARIA. Dismissal has three routes once JS has run (summary toggle, Escape with focus returned to the trigger, click/tap outside), all inert on the no-JS floor where the whole control stays hidden. Every compact option meets the 44px touch-target floor (--bw-size-touch-target-min on .bw-theme-switch__option, the #212 label-extension route: the clickable label grows, the visible radio/text stay their normal size). Covered by theme_switch.spec.mjs, a dedicated suite driving no-JS absence, reveal-at-init, per-axis lock state, invalid-stored-value rejection, multi-instance collision safety, the locked-vs-unlocked race, and the compact disclosure's own open/close/dismissal routes and target sizes, plus axe.spec.mjs against theme-switch-*.html/theme-switch-js-*.html/ theme-switch-invalid-root-js-*.html/theme-switch-compact-open-*.html, both themes. Responsive: no breakpoint switch; layout is a consumer-chosen option, not a media query, and no .bw-theme-switch* selector carries width-dependent CSS in either layout. {% endcomment %}