Skip to content

Commit ad8c856

Browse files
committed
Refactor Text to mirror Lit structure and fix specificity
- Restructure Text component with wrapper div + section (like Card) - Add Text styles to componentSpecificStyles - Use :where() for heading resets to match Lit's low specificity - Add PARITY.md documenting Lit/React visual parity approach
1 parent ac72d50 commit ad8c856

File tree

3 files changed

+184
-18
lines changed

3 files changed

+184
-18
lines changed

PARITY.md

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# Visual Parity: Lit and React Renderers
2+
3+
This document describes the approach used to achieve visual parity between the Lit (Shadow DOM) and React (Light DOM) renderers.
4+
5+
## Structural Mirroring
6+
7+
### The Challenge
8+
9+
Lit components use Shadow DOM, where each component has an encapsulated DOM tree with its own scoped styles. The typical structure is:
10+
11+
```
12+
#shadow-root
13+
<section class="theme-classes">
14+
<slot></slot> ← children projected here
15+
</section>
16+
```
17+
18+
The shadow host element (the custom element itself) acts as the `:host` and can have its own styles.
19+
20+
React uses Light DOM where everything exists in the global DOM. To achieve parity, we mirror Lit's two-element structure.
21+
22+
### The Solution
23+
24+
Each React component renders a wrapper div (representing `:host`) plus a section (the internal element):
25+
26+
```tsx
27+
// React component structure
28+
<div className="a2ui-card"> {/* ← :host equivalent */}
29+
<section className="theme-classes"> {/* ← internal element */}
30+
{children} {/* ← ::slotted(*) equivalent */}
31+
</section>
32+
</div>
33+
```
34+
35+
This mirroring allows CSS selectors to target the same conceptual elements in both renderers.
36+
37+
## CSS Selector Transformation
38+
39+
### Shadow DOM to Light DOM
40+
41+
Lit's Shadow DOM selectors need transformation for React's global CSS:
42+
43+
| Lit (Shadow DOM) | React (Light DOM) |
44+
|-----------------------|------------------------------------------|
45+
| `:host` | `.a2ui-surface .a2ui-{component}` |
46+
| `section` | `.a2ui-surface .a2ui-{component} section`|
47+
| `::slotted(*)` | `.a2ui-surface .a2ui-{component} section > *` |
48+
| `element` (e.g., `h2`)| `:where(.a2ui-surface .a2ui-{component}) element` |
49+
50+
### Example: Card Component
51+
52+
Lit's card.ts static styles:
53+
```css
54+
:host {
55+
display: block;
56+
flex: var(--weight);
57+
min-height: 0;
58+
overflow: auto;
59+
}
60+
61+
section {
62+
height: 100%;
63+
width: 100%;
64+
min-height: 0;
65+
overflow: auto;
66+
}
67+
68+
section ::slotted(*) {
69+
height: 100%;
70+
width: 100%;
71+
}
72+
```
73+
74+
React's componentSpecificStyles equivalent:
75+
```css
76+
.a2ui-surface .a2ui-card {
77+
display: block;
78+
flex: var(--weight);
79+
min-height: 0;
80+
overflow: auto;
81+
}
82+
83+
.a2ui-surface .a2ui-card section {
84+
height: 100%;
85+
width: 100%;
86+
min-height: 0;
87+
overflow: auto;
88+
}
89+
90+
.a2ui-surface .a2ui-card section > * {
91+
height: 100%;
92+
width: 100%;
93+
}
94+
```
95+
96+
## CSS Specificity Matching
97+
98+
### The Problem
99+
100+
Shadow DOM provides natural style encapsulation with low specificity. A selector like `h2` inside Shadow DOM has specificity `(0,0,0,1)`.
101+
102+
In React's global CSS, we need contextual selectors to scope styles:
103+
```css
104+
.a2ui-surface .a2ui-text h2 { ... }
105+
```
106+
107+
This has specificity `(0,0,2,1)` — much higher than Lit's `(0,0,0,1)`.
108+
109+
### Why It Matters
110+
111+
Utility classes like `.typography-w-500` have specificity `(0,0,1,0)`. In Lit:
112+
- `h2 { font: inherit; }` = `(0,0,0,1)` — loses to utility class
113+
- `.typography-w-500` = `(0,0,1,0)`**wins**, font-weight: 500 applied
114+
115+
In React (without fix):
116+
- `.a2ui-surface .a2ui-text h2 { font: inherit; }` = `(0,0,2,1)`**wins**
117+
- `.typography-w-500` = `(0,0,1,0)` — loses, font-weight reset to 400
118+
119+
### The Solution: `:where()`
120+
121+
The `:where()` pseudo-class has zero specificity contribution. Wrapping contextual selectors in `:where()` matches Lit's low specificity:
122+
123+
```css
124+
/* Before: specificity (0,0,2,1) — too high */
125+
.a2ui-surface .a2ui-text h1,
126+
.a2ui-surface .a2ui-text h2 { ... }
127+
128+
/* After: specificity (0,0,0,1) — matches Lit */
129+
:where(.a2ui-surface .a2ui-text) h1,
130+
:where(.a2ui-surface .a2ui-text) h2 { ... }
131+
```
132+
133+
Now utility classes can override element resets, just like in Lit.
134+
135+
### When to Use `:where()`
136+
137+
Use `:where()` when the Lit component has element selectors that should be overridable by utility classes:
138+
139+
- **Use `:where()`**: Element resets like `h1, h2 { font: inherit; }`
140+
- **Don't use `:where()`**: Structural styles on `:host` or `section` that define component behavior
141+
142+
## File Organization
143+
144+
- **`src/styles/index.ts`**: Contains `structuralStyles` (from Lit) and `componentSpecificStyles` (React-specific)
145+
- **Component files**: Render the mirrored structure with appropriate class names
146+
- **`injectStyles()`**: Injects both structural and component-specific styles into the document
147+
148+
## Testing Parity
149+
150+
The `renderers/visual-parity` directory contains side-by-side comparisons:
151+
1. Load the same fixture in both Lit and React
152+
2. Compare rendered output visually and via computed styles
153+
3. Use browser DevTools to verify CSS specificity matches

renderers/react/src/components/content/Text.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ function applyMarkdownTheme(html: string, markdownTheme: Types.Theme['markdown']
6666
/**
6767
* Text component - renders text content with markdown support.
6868
*
69+
* Structure mirrors Lit's Text component:
70+
* <div class="a2ui-text"> ← :host equivalent
71+
* <section class="..."> ← theme classes
72+
* <h2>...</h2> ← rendered markdown content
73+
* </section>
74+
* </div>
75+
*
6976
* Text is parsed as markdown and rendered as HTML (matches Lit renderer behavior).
7077
* Supports usageHint values: h1, h2, h3, h4, h5, caption, body
7178
*
@@ -144,13 +151,14 @@ export const Text = memo(function Text({ node, surfaceId }: A2UIComponentProps<T
144151
return null;
145152
}
146153

147-
// Always use <section> wrapper with markdown rendering (matches Lit structure)
148154
return (
149-
<section
150-
className={classMapToString(classes)}
151-
style={additionalStyles}
152-
dangerouslySetInnerHTML={renderedContent}
153-
/>
155+
<div className="a2ui-text">
156+
<section
157+
className={classMapToString(classes)}
158+
style={additionalStyles}
159+
dangerouslySetInnerHTML={renderedContent}
160+
/>
161+
</div>
154162
);
155163
});
156164

renderers/react/src/styles/index.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -62,26 +62,31 @@ export const componentSpecificStyles: string = `
6262
}
6363
6464
/* =========================================================================
65-
* Text (matches Lit text.ts Shadow DOM styles)
65+
* Text (from Lit text.ts static styles)
6666
* ========================================================================= */
6767
68-
/* Ensure markdown paragraph margins are reset (matches Lit structural styles) */
69-
.a2ui-surface section p {
70-
margin: 0;
68+
/* :host { display: block; flex: var(--weight); } */
69+
.a2ui-surface .a2ui-text {
70+
display: block;
71+
flex: var(--weight);
7172
}
7273
73-
/* Match Lit Text's h1-h5 reset - prevents browser defaults from affecting text */
74-
/* Lit has: h1, h2, h3, h4, h5 { line-height: inherit; font: inherit; } */
75-
/* Note: Do NOT reset margin here - margins are controlled by theme classes (layout-mb-*) */
76-
.a2ui-surface section h1,
77-
.a2ui-surface section h2,
78-
.a2ui-surface section h3,
79-
.a2ui-surface section h4,
80-
.a2ui-surface section h5 {
74+
/* h1, h2, h3, h4, h5 { line-height: inherit; font: inherit; } */
75+
/* Use :where() to match Lit's low specificity (0,0,0,1 - just element) */
76+
:where(.a2ui-surface .a2ui-text) h1,
77+
:where(.a2ui-surface .a2ui-text) h2,
78+
:where(.a2ui-surface .a2ui-text) h3,
79+
:where(.a2ui-surface .a2ui-text) h4,
80+
:where(.a2ui-surface .a2ui-text) h5 {
8181
line-height: inherit;
8282
font: inherit;
8383
}
8484
85+
/* Ensure markdown paragraph margins are reset */
86+
.a2ui-surface .a2ui-text p {
87+
margin: 0;
88+
}
89+
8590
/* =========================================================================
8691
* TextField (matches Lit text-field.ts Shadow DOM styles)
8792
* ========================================================================= */

0 commit comments

Comments
 (0)