diff --git a/README.md b/README.md index 42aa987..f5b8ef2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/TODO.md b/docs/TODO.md index 65b919e..d3ffd75 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts index d831850..be96378 100644 --- a/packages/widget/src/element.ts +++ b/packages/widget/src/element.ts @@ -1582,6 +1582,54 @@ export class BitLoginAuthElement extends HTMLElement { private render(): void { const successOverlay = this.pendingSuccessLabel ? this.renderSuccessOverlay(this.pendingSuccessLabel) : ""; this.root.innerHTML = `
${this.renderScreen()}${successOverlay}
`; + 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 {