I have these kind of files that i develop with claude code so it knows how i like it. Interesting to see @CyCy that you have it build the form from scratch in code. I have it create the .yaml file with specific settings (like no spacing above and below and stuff like that) and then add roles and attack those in CSS. Not sure which is better tbh.
As an example, this is my frontend dev guide, which is still in development! I’m also not certain it’s the best way to use AI for frontend in Anvil and I havent read all of it cause I just always tell the ai after im done with a new task to rewrite the file with the new best-practice learnings:
AnkiBuddy Frontend Development Guide
This doc = practical reference. For CSS/JS/Python philosophy and rules, see responsive-styling.md.
Goal: Give an AI (or developer) enough context to one-shot a new component without rediscovering Anvil quirks.
1. Standard Reusable Roles & Classes
ALWAYS check this catalog first. Reuse an existing role before creating a new one.
If none fit, create a new role using the design tokens from §2.
Roles marked (niche) are used in one specific context; all others are general-purpose.
Button Roles
Every button needs a role. Pick from this catalog.
primary-button — High Emphasis CTA
- Look: Solid blue background, white text, pill shape
- Use for: Main actions — “Registrieren”, “Speichern”, “Erinnern”
- YAML:
{role: primary-button, text: Speichern}
secondary-button — Medium Emphasis
- Look: Light blue tonal background (
--primary-10), dark text, pill shape
- Use for: Alternative actions — “Einloggen”, “Abbrechen”, gift button
- YAML:
{role: secondary-button, text: Einloggen}
text-button — Low Emphasis
- Look: No background, primary blue text, transparent
- Use for: Utility actions — “Mehr erfahren”, referral link
- YAML:
{role: text-button, text: Mehr erfahren}
- Note: Has negative margin on first/last child in FlowPanel for optical edge alignment
icon-button — Icon-Only Circular
- Look: Transparent circle, icon centered via absolute positioning
- Use for: Close ✕, settings, any icon-only action
- YAML:
{role: icon-button, icon: '_/theme/Icons/lc_x.svg', text: '', tooltip: Schließen}
- Compose with:
secondary-icon-button for tinted circle, lc-card-button for white pill
secondary-icon-button — Tinted Circle (compose with icon-button)
- Look:
--primary-10 circle background
- YAML:
{role: [icon-button, secondary-icon-button], icon: ...}
lc-card-button — White Pill with Shadow (niche)
- Look: White background, subtle shadow, pill shape (
border-radius: 100px)
- Icon variant:
{role: [icon-button, lc-card-button], icon: '_/theme/Icons/lc_edit.svg'}
- Text variant:
{role: lc-card-button, text: 'Antwort verstecken'} — auto-detects via :not(.anvil-role-icon-button) and adds padding/color/font
- State modifiers (compose via array):
lc-highlight — heavy shadow to draw attention: [icon-button, lc-card-button, lc-highlight]
lc-approved — green surface for approved cards: [icon-button, lc-card-button, lc-approved]
lc-warning — error color + full opacity when disabled: [lc-card-button, lc-warning]
- Python toggle example:
self.btn.role = "lc-card-button" if ok else ["lc-card-button", "lc-warning"]
appbar-button — Responsive Appbar (niche)
- Look: 14px desktop, 11px mobile
- Use for: Header bar buttons (referral link)
Icon Conventions
| Source |
Format |
When |
| Font Awesome |
fa:clock-o |
Generic/universal icons |
| Custom SVG |
_/theme/Icons/lc_*.svg |
Branded/domain-specific icons |
| No icon |
icon: '' or omit |
Text-only buttons |
SVG naming: lc_ prefix = learning card icons. All use stroke="#354052" (dark slate), stroke-width="2", stroke-linecap="round". For white-on-colored-bg icons: stroke="#FFFFFF".
Typography Roles
Apply these to Labels. No media queries needed — font-size tokens handle responsiveness.
| Role |
Font Size |
Weight |
Color |
Use |
type-hero |
48→32px |
bold |
--primary |
Landing page headline |
type-lead |
36→24px |
normal |
--text-tertiary |
Landing page subheading |
type-title |
20→18px |
bold |
inherited |
Section headers, deck names |
type-subtitle |
16px |
medium |
--on-surface |
Panel headers, form titles |
type-body |
14px |
normal |
inherited |
Body text (DEFAULT) |
type-caption |
12px |
normal |
--text-tertiary |
Captions, hints, secondary |
YAML: {role: type-title, text: 'Section Title'}
Card Roles
| Role |
Look |
Use |
outlined-card |
Border, surface bg, 12px radius |
Default cards |
elevated-card |
Shadow, surface bg, 12px radius |
Prominent cards |
tonal-card |
Surface-variant bg, 12px radius |
Subtle/grouped content |
Form Input Roles
| Role |
Look |
Use |
outlined |
Transparent bg, outline border, 4px radius |
Standard text input |
outlined-error |
Error color border |
Validation error state |
input-error |
Error border on default bg |
Validation error (filled) |
form-dropdown (niche) |
Styled select with custom arrow, F5F7F9 bg |
User profile popup |
form-textbox (niche) |
Styled input, F5F7F9 bg, 8px radius |
User profile popup |
Link Roles
| Role |
Look |
Use |
link-tertiary |
12px, --text-tertiary, underline on hover |
Footer links, de-emphasized |
Visibility Roles
Use these for responsive show/hide. Applied to any component.
| Role |
Effect |
hide-at-mobile-horizontal-and-smaller |
Hidden ≤768px, visible above |
hide-at-tablet-and-smaller |
Hidden ≤992px, visible above |
hide-at-ultrawide-and-smaller |
Hidden ≤1280px, visible above |
show-at-mobile-horizontal-and-smaller |
Visible ≤768px, hidden above |
show-at-mobile-vertical-only |
Visible ≤480px, hidden above |
These automatically hide the .flow-panel-item wrapper via :has() when inside FlowPanels.
Layout Utility Roles
| Role |
Effect |
Use |
shadow-escape |
overflow: visible on .anvil-panel-col |
ColumnPanel children with box-shadows |
no-wrap |
flex-wrap: nowrap on .flow-panel-gutter |
FlowPanel rows that shouldn’t wrap |
text-nowrap |
text-wrap: nowrap on element + spans |
Prevent text wrapping |
rich-text-wrap |
white-space: normal + word-break |
Allow RichText wrapping |
column-panel-max-w-730 |
max-width: 730px |
Content width constraint |
Other Standard Roles
| Role |
Component |
Look |
Use |
separator |
Label |
1px horizontal line |
Section dividers (set text: '') |
btn-transparent (niche) |
Button |
Transparent bg, tight padding |
Referral form variants |
popup-section-header (niche) |
Label |
18px bold, #212121 |
User profile popup headers |
popup-field-label (niche) |
Label |
16px bold, 70% opacity |
User profile field labels |
popup-section-divider (niche) |
Label |
1px border-bottom |
User profile section dividers |
popup-privacy-box (niche) |
ColumnPanel |
Subtle gray bg, 8px radius |
Privacy message container |
pdf-upload-alert (niche) |
Alert |
400px max-width, white bg |
PDF upload confirmation |
3. Component How-To Guides
Why this section exists: Anvil wraps every component in layers of HTML divs, adds
Bootstrap 3 classes, injects inline styles, and applies default spacing. You can’t just
write CSS targeting your component — you must understand Anvil’s internal DOM structure
and work with (or around) it. Each component type has its own quirks.
This section documents each component’s real DOM output and the standard CSS patterns
for styling them. The “Gotchas” lists are the Anvil-specific traps we’ve already hit;
the workarounds are proven.
ColumnPanel
What it is: Vertical stacking container. Each child is wrapped in .anvil-panel-section.
Anvil renders:
div.anvil-role-{role} .column-panel
└── div.anvil-panel-section ← one per child
└── div.anvil-panel-section-container
└── div.anvil-panel-section-gutter
└── div.anvil-panel-row
└── div.anvil-panel-col (overflow-x: hidden!)
└── div.col-padding.col-padding-{col_spacing}
└── [YOUR COMPONENT]
Standard YAML:
name: pnl_my_panel
properties: {col_spacing: none, role: my-panel}
type: ColumnPanel
layout_properties: {spacing_above: none, spacing_below: none}
CSS — spacing between children:
/* Adjacent sibling margin pattern (established convention) */
.anvil-role-my-panel > .anvil-panel-section + .anvil-panel-section {
margin-top: var(--gap-component); /* or --gap-element, --section-gap */
}
When to use col_spacing: none: Always, when you want CSS control. Default is medium (15px padding on every column wrapper). Setting none gives you 0 1px padding — essentially zero.
Common use cases:
| Pattern |
CSS |
When |
| Vertical stack with gaps |
Adjacent sibling margins (above) |
Most common — form sections, card content |
| Flex column with gap |
display: flex; flex-direction: column; gap: ... on .column-panel |
When you need flex features (e.g., flex-grow on a child, equal-height items) |
| Simple wrapper |
No CSS needed |
Just grouping children for visibility toggling |
Gotchas:
.anvil-panel-col has overflow-x: hidden by default. Add overflow: visible if children need to escape (shadows, dropdowns). Use the shadow-escape role.
- Every child component gets
anvil-spacing-above-small by default (4px margin). Set spacing_above: none in layout_properties, or use a broad reset (see §4).
FlowPanel
What it is: Horizontal flex container. Children are wrapped in .flow-panel-item.
Anvil renders:
div.anvil-role-{role} .flow-panel .flow-spacing-{gap} .vertical-align-{vertical_align}
└── div.flow-panel-gutter (ACTUAL flex container)
├── div.flow-panel-item (flex: 0 1 auto)
│ └── [COMPONENT 1]
└── div.flow-panel-item
└── [COMPONENT 2]
Standard YAML:
name: pnl_my_row
properties: {align: left, gap: none, role: my-row, vertical_align: middle}
type: FlowPanel
layout_properties: {spacing_above: none, spacing_below: none}
CSS:
.anvil-role-my-row > .flow-panel-gutter {
gap: var(--gap-element);
}
Key YAML properties and what they control:
| YAML |
Renders as |
Notes |
gap: none |
.flow-spacing-none |
Always use. Disables Anvil’s margin system so CSS gap works |
align: justify |
inline justify-content: space-between |
Inline style — can’t override without !important |
align: left |
inline justify-content: flex-start |
Use YAML for this, not CSS |
vertical_align: middle |
.vertical-align-middle → align-items: center |
Can use YAML (sets class) or CSS |
Gotchas:
- The role wrapper is
display: block, NOT flex. The div.anvil-role-{role} element has display: block. CSS gap, align-items, justify-content on the role selector do nothing. Always target > .flow-panel-gutter for any flex property. Same for child selectors: use > .flow-panel-gutter > .flow-panel-item, never > .flow-panel-item.
- Always set
gap: none. Without it, Anvil adds margins to .flow-panel-item that conflict with CSS gap.
align is an inline style. Anvil injects justify-content directly on the gutter. Use YAML to set it, not CSS.
- Items don’t stretch by default.
.flow-panel-item has flex: 0 1 auto. To stretch a child (e.g., input filling space), target the wrapper: .flow-panel-gutter > .flow-panel-item:first-child { flex: 1; min-width: 0; }.
- Hiding children: Must also hide the
.flow-panel-item wrapper, or it takes up gap space. Use :has(): .flow-panel-item:has(> .anvil-role-hidden) { display: none; }.
- flex-wrap: FlowPanels wrap by default. Add
flex-wrap: nowrap on the gutter if you need a non-wrapping row (e.g., header with close button). Or use the no-wrap utility role.
GridPanel
What it is: Bootstrap 3 grid (12-column). Children placed in rows/columns.
Anvil renders:
div.anvil-role-{role} .grid-panel
└── div.row (style="margin-bottom: 10px") ← inline JS!
├── div.col-xs-6.col-sm-6.col-md-6.col-lg-6 (padding: 0 15px)
│ └── [BUTTON 1]
└── div.col-xs-6...
└── [BUTTON 2]
└── div.row (style="margin-bottom: 10px")
└── ...
Standard YAML:
name: pnl_my_grid
properties: {role: my-grid}
type: GridPanel
layout_properties: {spacing_above: none, spacing_below: none}
# Children specify position:
- name: btn_top_left
layout_properties: {col_xs: 0, row: ROW1, width_xs: 6}
type: Button
CSS — compact gutters:
.anvil-role-my-grid {
overflow: visible;
}
.anvil-role-my-grid > .row {
margin-left: calc(var(--gap-element) / -2);
margin-right: calc(var(--gap-element) / -2);
margin-bottom: var(--gap-element) !important; /* override inline JS */
}
.anvil-role-my-grid > .row:last-child {
margin-bottom: 0 !important;
}
.anvil-role-my-grid > .row > [class*="col-"] {
padding-left: calc(var(--gap-element) / 2);
padding-right: calc(var(--gap-element) / 2);
overflow: visible;
}
Gotchas:
- Row
margin-bottom is inline JS (default 10px from row_spacing property). Requires !important to override.
- Bootstrap default gutters are 15px. Must override both row negative margins and column padding.
.anvil-panel-col has overflow-x: hidden — add overflow: visible if children have shadows/borders that get clipped.
- Use
.row selector, not [data-anvil-gridpanel-row] — the data attribute selector doesn’t match in practice.
Button
Anvil renders:
div.anvil-role-{role} .anvil-button .anvil-inlinable
└── button.btn.btn-default (style="max-width:100%; overflow:hidden")
├── i.anvil-component-icon.left.left-icon
│ └── img (style="height:1em; vertical-align:text-bottom")
├── span.button-text
└── i.anvil-component-icon.right.left-icon (display:none) ← DUPLICATE
└── img
Standard YAML (text button):
name: btn_action
properties: {role: primary-button, text: Do Something}
type: Button
event_bindings: {click: btn_action_click}
layout_properties: {spacing_above: none, spacing_below: none}
Standard YAML (icon button):
name: btn_close
properties: {role: icon-button, icon: '_/theme/Icons/lc_x.svg', text: '', tooltip: Schließen}
type: Button
Standard YAML (text + right icon):
name: btn_submit
properties: {role: primary-button, icon: '_/theme/Icons/lc_arrow_right.svg', icon_align: right, text: Erinnern}
type: Button
… (had to cut for length in the post) …
Image
Anvil renders:
div.anvil-role-{role} .anvil-image (style="height:Xpx; overflow:hidden")
└── img (style="height:100%; width:100%; max-width:100%; max-height:100%")
Gotchas:
Timer
No HTML output. Invisible component. Just triggers tick events at the specified interval (seconds). Set interval: 0 to disable.
name: timer_poll
properties: {interval: 0}
type: Timer
event_bindings: {tick: timer_poll_tick}
layout_properties: {spacing_above: none, spacing_below: none}
4. Standard Utility Roles
These roles go in theme.css as reusable solutions to common Anvil fights.
Instead of writing the same CSS workaround per-component, compose the utility role in YAML.
compact-grid — Override Bootstrap 15px Gutters
Problem: GridPanel uses Bootstrap’s 15px column padding. For card grids and compact layouts, this is too much.
CSS (add to theme.css):
.anvil-role-compact-grid {
overflow: visible;
}
.anvil-role-compact-grid > .row {
margin-left: calc(var(--gap-element) / -2);
margin-right: calc(var(--gap-element) / -2);
margin-bottom: var(--gap-element) !important;
}
.anvil-role-compact-grid > .row:last-child {
margin-bottom: 0 !important;
}
.anvil-role-compact-grid > .row > [class*="col-"] {
padding-left: calc(var(--gap-element) / 2);
padding-right: calc(var(--gap-element) / 2);
overflow: visible;
}
Usage:
properties: {role: [my-timing-grid, compact-grid]}
type: GridPanel
Effect: Uniform --gap-element (8px) gap between all grid cells, both horizontal and vertical.
stretch-first — First FlowPanel Child Fills Remaining Space
Problem: In a FlowPanel with an input + button, the input’s .flow-panel-item wrapper has flex: 0 1 auto — it doesn’t stretch. Setting flex: 1 on the input alone doesn’t help because the wrapper constrains it.
CSS (add to theme.css):
.anvil-role-stretch-first > .flow-panel-gutter > .flow-panel-item:first-child {
flex: 1;
min-width: 0;
}
Usage:
properties: {align: left, gap: none, role: [my-email-row, stretch-first], vertical_align: middle}
type: FlowPanel
Effect: First child fills all available space; second child (e.g., button) stays at its natural width.
reset-spacing — Zero Anvil Default Margins
Problem: Every component inside a ColumnPanel gets anvil-spacing-above-small (4px margin) by default. When you control spacing via section gaps, these margins compound and create “weird spacing.”
CSS (add to theme.css):
.anvil-role-reset-spacing .anvil-component {
margin: 0;
}
Usage:
properties: {col_spacing: none, role: [my-panel, reset-spacing]}
type: ColumnPanel
How it works: Anvil’s spacing classes (.anvil-spacing-above-small) have 1-class specificity (0,1,0). Our selector .anvil-role-reset-spacing .anvil-component has 2-class specificity (0,2,0) — it wins.
Limitation: Does NOT work for <input> elements. Theme.css has input-specific spacing rules like input.anvil-component.anvil-spacing-below-small with element + 2-class specificity (0,2,1), which beats our selector. For inputs, you still need margin: 0 !important on the input role.
Composable Roles — Best Practice
Roles compose via arrays. Anvil applies every role in the array as a CSS class on the same wrapper element (div.anvil-role-X.anvil-role-Y). This means each role’s CSS rules stack — use this to build up component styling from reusable pieces instead of creating monolithic one-off roles.
Pattern: base + variant + modifier. Structure roles in layers:
- Base role — core surface/shape (e.g.
lc-card-button = white pill with shadow)
- Variant role — changes the rendering mode (e.g.
icon-button = icon-only circular)
- Modifier role — toggles visual state (e.g.
lc-warning, lc-approved, lc-highlight)
# Icon card button (base + variant)
role: [icon-button, lc-card-button]
# Icon card button with approval state (base + variant + modifier)
role: [icon-button, lc-card-button, lc-approved]
# Text card button (base only — no icon-button variant)
role: lc-card-button
# Text card button with warning state (base + modifier)
role: [lc-card-button, lc-warning]
Python toggles modifiers at runtime by swapping the role array:
# Toggle warning state
self.btn.role = "lc-card-button" if ok else ["lc-card-button", "lc-warning"]
CSS detects variants using :not() on sibling roles. Since all roles are classes on the same element, CSS can condition on presence/absence:
/* Base — always applies */
.anvil-role-lc-card-button > button.btn { background: white; }
/* Text variant — only when icon-button is NOT also applied */
.anvil-role-lc-card-button:not(.anvil-role-icon-button) > button.btn { padding: 8px 16px; }
/* Modifier — independent of variant */
.anvil-role-lc-warning > button.btn:disabled { color: var(--error); }
Never create monolithic roles that bake variant + state into one name (e.g. my-button-disabled-warning). Instead, keep each concern in its own role so it can be independently reused and combined.
Layout utility roles
Utility roles handle generic Anvil workarounds. Put the semantic role first, utility roles after:
# ColumnPanel with CSS-controlled spacing
properties: {col_spacing: none, role: [reminder-expanded, reset-spacing]}
# GridPanel with compact gutters
properties: {role: [reminder-timing-grid, compact-grid]}
# FlowPanel with stretching input
properties: {gap: none, role: [reminder-email-row, stretch-first], vertical_align: middle}
The component-specific CSS (borders, colors, etc.) uses .anvil-role-reminder-*. The utility roles handle the generic Anvil workarounds.
5. The Anvil Tax — Quick Reference
Why we fight Anvil: Anvil generates HTML with Bootstrap 3 defaults, injects inline
styles via JavaScript, and applies framework CSS that conflict with modern design patterns.
Every component gets default 4px spacing, buttons get max-width: 100%, grids have 15px
gutters, inputs have extra margins, and images always get height: 100%. The table below
documents these defaults and our standard overrides, so you don’t rediscover them.
YAML Boilerplate Cheat Sheet
Every component type has standard YAML properties you should set:
| Component |
Standard Properties |
Why |
| ColumnPanel |
col_spacing: none, role: my-panel |
Zero padding, enable CSS control |
| FlowPanel |
gap: none, align: {left|justify|...}, role: my-row, vertical_align: middle |
Disable margin system, set justify-content, enable CSS gap |
| GridPanel |
role: my-grid |
Enable CSS gutter overrides |
| All children |
layout_properties: {spacing_above: none, spacing_below: none} |
Zero Anvil’s default 4px margins |
Inline Styles Anvil Injects
| Component |
Property |
Inline Style |
Can YAML Control? |
Override Strategy |
| FlowPanel |
align |
justify-content: ... |
Yes |
Use YAML |
| GridPanel rows |
row_spacing |
margin-bottom: 10px |
No useful values |
!important |
| Button |
— |
max-width: 100% |
No |
Already fixed globally |
Image <img> |
— |
height: 100% |
No |
!important |
| TextBox |
font props |
font-size, font-weight, etc. |
Yes |
Use YAML or CSS role |
CSS Resets You’ll Likely Need
| Situation |
Rule |
Why |
| TextBox in FlowPanel |
margin: 0 !important on input.anvil-role-* |
Bootstrap .form-control margin-bottom + theme.css input spacing |
| Components in ColumnPanel |
.anvil-role-panel .anvil-component { margin: 0; } |
Anvil default anvil-spacing-above-small = 4px |
| GridPanel gutters |
margin-left/right on .row, padding-left/right on [class*="col-"] |
Bootstrap default 15px |
| GridPanel row spacing |
margin-bottom: ... !important on .row |
Inline JS from row_spacing |
| Link underline |
Target .link-text not <a> |
text-decoration doesn’t propagate to block children |
| ColumnPanel column overflow |
overflow: visible on [class*="col-"] or use shadow-escape role |
.anvil-panel-col has overflow-x: hidden |
Anvil Spacing Classes Reference
| YAML Value |
Class Added |
Margin |
none |
anvil-spacing-above-none |
0px |
small (default) |
anvil-spacing-above-small |
4px |
medium |
anvil-spacing-above-medium |
8px |
large |
anvil-spacing-above-large |
16px |
6. Common Pitfalls
Hard-won lessons from building components. Each one cost us at least one round-trip.
Don’t hardcode colors or sizes
Always use design tokens. #666666 → var(--text-secondary). 8px gap → var(--gap-element).
Don’t target broad icon selectors
.anvil-component-icon matches BOTH the visible .left and the hidden .right duplicate. Always specify .left or .right:
/* Un-hides the duplicate */
.my-btn .anvil-component-icon { display: inline-block; }
/* Targets only the visible one */
.my-btn .anvil-component-icon.left { ... }
Don’t forget the .flow-panel-item wrapper
Setting flex: 1 on a child inside a FlowPanel does nothing if the wrapper has flex: 0 1 auto. Target the wrapper.
Don’t put layout properties in container properties:
spacing_above, spacing_below are layout properties (how parent positions the child). Setting them on the container’s own properties: block creates “Orphaned Container Properties” warnings:
# Orphaned — GridPanel doesn't own spacing_above
properties: {spacing_above: none, role: my-grid}
# Correct — layout_properties tells the parent
layout_properties: {spacing_above: none, spacing_below: none}
Always check for !important necessity
Before using !important, check:
- Is it an Anvil inline style? (justify-content, margin-bottom, height:100%) →
!important justified
- Is it a specificity issue? → increase specificity instead
- Document why in a CSS comment
SVG icons in <img> tags can’t be recolored
Anvil renders icon property as <img src="...">, not inline SVG. CSS color doesn’t work. Options:
- Edit the SVG file’s
stroke/fill color
- Use
filter: brightness(0) invert(1) for white
- Use
opacity for muting
scroll_into_view() after visibility changes
After toggling visible = True on a component, call component.scroll_into_view(smooth=True) to ensure it’s in the viewport. Anvil API: scroll_into_view(smooth=False, align="center").
Inputs always need margin: 0 !important
The reset-spacing utility role works for most components but NOT for <input> elements. Theme.css has input-specific spacing rules with higher specificity. Always add margin: 0 !important to input role CSS.
Logic-driven style changes: toggle CSS classes from Python
Best practice: When a style change is driven by application logic (loading states, error states, mode switches) rather than screen size, toggle CSS classes from Python via dom_nodes. CSS media queries handle responsive layout; Python class toggles handle behavioral state.
classList is a standard DOM API (it’s a DOMTokenList, i.e. an array-like). Use .add(), .remove(), .toggle(), .contains().
# Show loading state — disable scroll
self.skeleton_loader.visible = True
self.dom_nodes['my-container'].classList.add('no-scroll')
# Hide loading state — re-enable scroll
self.skeleton_loader.visible = False
self.dom_nodes['my-container'].classList.remove('no-scroll')
/* CSS — simple class toggle */
#my-container.no-scroll { overflow: hidden; }
Don’t try to derive behavioral state purely from CSS (e.g. :has() to detect child presence, sibling selectors to infer visibility). These approaches are fragile — Anvil’s display: none leaves elements in the DOM, custom components don’t propagate roles, and selector specificity battles with the framework. If Python already knows the state, let Python tell CSS directly via a class.