{% 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 VISIBLE", not "render a working control". Reserved pre-init state (icvoss/django-brickwork#272, supersedes the unconditional hidden attribute this control shipped with through 3.11.0): the root ships with the bw-theme-switch--pre-init class instead, which sets visibility: hidden while leaving the control in normal flow. This reserves the control's own true, label-dependent box at first paint, so bwThemeSwitch's later reveal (swapping the class off at init) produces a visible-appearance change only, never a box-size change, and consequently no layout-shift entry: the #272 measurement showed the OLD hidden-attribute shape collapsing the control to zero footprint until init, then growing the header actions row into its real, label-dependent width on reveal, scoring CLS 0.16 to 0.19 on a consumer's visible-header placement (control experiments there confirmed the switch was the entire shift). Class-based rather than attribute-based is load-bearing, not a style preference, but the reason is geometry, NOT cascade priority: the `hidden` ATTRIBUTE is `display: none` in the UA sheet, out of flow by definition, so there is no way to keep the box in flow while using it. `visibility: hidden` achieves that, and only a class (or an inline style) can carry it here. This package's own compiled CSS ships no @layer at all (verified against dist/brickwork.css), so bw-theme-switch--pre-init's own `!important` is the SAME scoped-floor pattern index.css:42 already uses for the `[hidden]` attribute ("hidden always means hidden without a consumer preflight"), applied for the identical reason: an ordinary consumer rule of equal or later specificity, still just as unlayered as this one, could otherwise override a bare `visibility: hidden` and leave a descendant (the compact trigger, say) visible and focusable while the rest of the control stays reserved-but- invisible. That gap is real independent of layering, and closing it needed more than `!important` (icvoss/django-brickwork#272 review): `visibility` only INHERITS, and an inherited value always loses to a value specified on the element itself, regardless of `!important` on the ancestor (`!important` only arbitrates between rules matching the SAME element, never against inheritance from a different one). Verified directly in a browser: a plain `.child{visibility:visible}` descendant of a `!important`-hidden ancestor computes to visible and is genuinely focusable. `.bw-theme-switch--pre-init` therefore also matches every descendant (`.bw-theme-switch--pre-init *` in components.css), forcing each one's OWN visibility rather than relying on inheritance, so a single ordinary consumer rule like `.bw-theme-switch__trigger { visibility: visible; }` has nothing weaker to beat. Defeating this deliberately still takes an equally forceful rule (a consumer's own `!important` on the same selector, or a later, higher-priority layer); it can no longer happen by accident. The accepted trade (issue ask 2): a no-JS visitor now gets an INVISIBLE RESERVED BOX in the header instead of no box at all. The control still renders nothing usable without JS (visibility: hidden takes the whole subtree out of the accessibility tree and off pointer/keyboard reach, exactly as the hidden attribute did), so the #117 ruling's floor ("a switch that visibly does nothing is worse than absent") still holds; what changes for an existing consumer is that the control's own footprint is now always present in a header's layout math, at every viewport, whether or not JS ever runs. A consumer measuring header height or writing a snapshot test against the no-JS render sees a taller header than before this fix. 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: pre-init (no-JS floor, always, by deliberate design: a theme switch that visibly does nothing is worse than absent; bw-theme-switch--pre-init reserves the control's box in flow while keeping it invisible, #272) vs revealed (JS init swaps the class off, box already reserved so the reveal changes appearance only); 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 invisible (bw-theme-switch--pre-init). 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, the compact disclosure's own open/close/dismissal routes and target sizes, and the reveal producing no layout-shift entry (#272), 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 %}
{{ valid_values|json_script:values_element_id }} {% if layout == 'compact' %}
{{ label }} {% bw_icon "chevron-down" size="sm" decorative=True css_class="bw-theme-switch__caret" %}
{% endif %} {% for group in groups %}
{{ group.legend }} {% for option in group.options %} {% endfor %} {% if group.locked %}

{% translate "Set by your account preferences." %}

{% endif %}
{% endfor %} {% if layout == 'compact' %}
{% endif %}