widget: expose ::part() hooks for structural theming

The widget renders in a shadow root, so hosts could recolor it via the
:host custom properties but couldn't restyle structure (padding, borders,
box-shadow, button shape) -- which is why an embed can look pasted in.

Stamp a stable set of shadow parts once per render in exposeParts():
card; button (+button-primary/button-secondary); link; input; checkbox;
heading; text; notice (+notice-warn/-info/-error). Mapped by existing
class in one place rather than inline on ~60 rendered elements, so new
buttons/notices stay exposed automatically instead of drifting.

Documented as a contract in the README "Theming" section, alongside the
existing custom-property theming (still the first tool for color/radius/
font; parts are the escape hatch for structure). Verified in-browser that
bitlogin-auth::part(...) matches from light-DOM CSS through the shadow
boundary.

Addresses the "CSS parts" item of the Tessera integration feedback
(docs/TODO.md).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-08-11 19:31:40 -04:00
1 parent 86d7ee0114
commit 1b39e0f60c
3 files changed
+89 -4

No files matched your search

+30
View File
@@ -278,6 +278,36 @@ them is a valid choice — the widget just uses its own look. See BitRoad's
`bitlogin-auth { ... }` rule in `src/styles.css` for a real integration that
maps every one of these to its own design tokens.
**Structural theming with `::part()`.** Custom properties cover colors,
radius, font, and width — reach for them first. When you need to restyle
_structure_ the tokens don't expose (padding, borders, box-shadow, a
different button shape), the widget exposes a stable set of shadow parts you
can target from light-DOM CSS:
```css
bitlogin-auth::part(card) { box-shadow: 0 8px 30px rgba(0,0,0,.12); border: 1px solid #e5e7eb; }
bitlogin-auth::part(button) { border-radius: 8px; font-weight: 600; }
bitlogin-auth::part(button-primary) { text-transform: uppercase; letter-spacing: .02em; }
bitlogin-auth::part(input) { border-width: 2px; }
bitlogin-auth::part(notice-warn) { border-left: 3px solid currentColor; }
```
The exposed parts (kept stable as a contract):
| Part | Element |
| --- | --- |
| `card` | the outer widget panel |
| `button`, `button-primary`, `button-secondary` | action buttons (both variants also carry the generic `button`) |
| `link` | link-style buttons and inline links |
| `input`, `checkbox` | form fields |
| `heading` | screen headings |
| `text` | secondary / hint text |
| `notice`, `notice-warn`, `notice-info`, `notice-error` | inline message boxes (variants also carry the generic `notice`) |
Parts are an escape hatch for matching a host design system without the
widget looking pasted in; they don't expose the widget's internal layout or
let a host reach past these named anchors.
## Deployment
The demo site deploys to Vercel: `vercel.json` at the repo root sets the
+11 -4
View File
@@ -397,10 +397,17 @@ how much they cost.
`dead`-flag path already prevented the infinite hang; this makes the *cause*
legible and unifies the message). Covered by `worker/workerClient.test.ts`;
README gotcha paragraphs updated to say "rejects loudly," not "hangs forever."
- **Styling hooks / CSS parts.** The widget renders in a shadow root, so a host
can only frame it, not theme it. A few `::part()` hooks (button, panel, text)
would let it match a host design system instead of looking pasted in. Tessera
worked around this by wrapping it in a bordered panel with its own header.
- **[DONE 2026-08-11] Styling hooks / CSS parts.** The widget renders in a
shadow root, so a host could recolor it via the `:host` custom properties but
couldn't restyle structure. **Shipped:** a stable set of `::part()` hooks —
`card`, `button` (+`button-primary`/`button-secondary`), `link`, `input`,
`checkbox`, `heading`, `text`, `notice` (+`notice-warn`/`-info`/`-error`) —
stamped centrally per render in `element.ts` `exposeParts()` (mapped by class
so new elements stay exposed without drift). Documented as a contract in the
README "Theming" section. Verified in-browser that
`bitlogin-auth::part(...)` matches through the shadow boundary. Custom
properties remain the first tool for color/radius/font; parts are the escape
hatch for structure (padding/border/shadow/shape).
- **[RESOLVED — already the behavior] Session-change event on restore.**
Confirmed: `bitlogin-login` already fires on silent session restore across a
page load, not just interactive login, so a host listener needn't distinguish
+48
View File
@@ -1582,6 +1582,54 @@ export class BitLoginAuthElement extends HTMLElement {
private render(): void {
const successOverlay = this.pendingSuccessLabel ? this.renderSuccessOverlay(this.pendingSuccessLabel) : "";
this.root.innerHTML = `<div class="card">${this.renderScreen()}${successOverlay}</div>`;
this.exposeParts();
}
/**
* Stamps `part` attributes onto the freshly rendered markup so a host page can
* theme the widget through the shadow boundary with `bitlogin-auth::part(...)`
* -- structural styling (padding, borders, shadows, layout) that the `:host`
* CSS custom properties (see styles.ts) can't reach. Custom properties remain
* the first tool for colours/radius/font; parts are the escape hatch for
* matching a host design system without the widget looking pasted in.
*
* Done here, once per render, rather than inline on ~60 rendered elements:
* the markup is regenerated on every render(), and mapping by existing class
* keeps new buttons/notices exposed automatically instead of drifting. The
* exposed set is a documented contract (README "Theming") -- keep the names
* stable. Purely decorative nodes (success overlay, spinner) stay unexposed.
*/
private exposeParts(): void {
const set = (el: Element, ...parts: string[]) => el.setAttribute("part", parts.join(" "));
const card = this.root.querySelector(".card");
if (card) set(card, "card");
this.root.querySelectorAll("button, a").forEach((el) => {
if (el.classList.contains("primary")) set(el, "button", "button-primary");
else if (el.classList.contains("secondary")) set(el, "button", "button-secondary");
else if (el.classList.contains("link") || el.classList.contains("link-inline")) set(el, "link");
else set(el, "button");
});
this.root.querySelectorAll("input").forEach((el) => {
set(el, el.type === "checkbox" ? "checkbox" : "input");
});
this.root.querySelectorAll("h2").forEach((el) => set(el, "heading"));
this.root.querySelectorAll(".sub, .small, .field-hint").forEach((el) => set(el, "text"));
this.root.querySelectorAll(".notice").forEach((el) => {
const variant = el.classList.contains("warn")
? "notice-warn"
: el.classList.contains("error")
? "notice-error"
: el.classList.contains("info")
? "notice-info"
: null;
if (variant) set(el, "notice", variant);
else set(el, "notice");
});
}
private renderSuccessOverlay(label: string): string {