diff --git a/.gitignore b/.gitignore
index f6815e3ba..9b1a4436b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@ docs/api
examples/storybook
.history/
docs/form-examples/solid-ui.js
+.tsbuildinfo
diff --git a/README.md b/README.md
index b5642385b..e9b9d2b45 100644
--- a/README.md
+++ b/README.md
@@ -17,9 +17,12 @@ See [Forms introduction](./docs/FormsReadme.md) for UI vocabulary implementation
- [Use Directly in Browser](#use-directly-in-a-browser)
- [UMD Bundle](#umd-bundle-global-variable)
- [ESM Bundle](#esm-bundle-import-as-module)
-- [Development](#development-new-components)
+- [Web Components](#web-components)
+ - [solid-ui-header](#solid-ui-header)
+- [Development](#development)
- [Testing](#adding-tests)
- [Further Documentation](#further-documentation)
+- [Generative AI usage](#generative-ai-usage)
## Getting started
@@ -66,6 +69,8 @@ Solid-UI provides both **UMD** and **ESM** bundles for direct browser usage. Bot
### UMD Bundle (Global Variable)
+If you use the legacy UMD bundle (`solid-ui.js` / `solid-ui.min.js`), `rdflib` must define `window.$rdf` before `solid-ui` loads. If `rdflib` is missing, `solid-ui` will throw `ReferenceError: $rdf is not defined`.
+
Load via `
+
+
+
+
+```
+
+Or via a CDN that supports npm packages:
+
+```html
+
+
+
+
+
+```
+
+## TypeScript
+
+Types are included. Import the exported interfaces alongside the element class:
+
+```typescript
+import { Header } from 'solid-ui/components/header'
+import type { HeaderMenuItem, HeaderAccountMenuItem, HeaderAuthState } from 'solid-ui/components/header'
+
+const header = document.querySelector('solid-ui-header') as Header
+header.authState = 'logged-in' satisfies HeaderAuthState
+```
+
+## solid-ui-login-button
+
+The login button is a self-contained component with its own README: [`src/v2/components/loginButton/README.md`](../loginButton/README.md).
+
+The header automatically imports and registers it — no separate import is needed.
+
+---
+
+## API
+
+Properties/attributes:
+
+- `logo`: URL string for the brand image (default: Solid emblem URL).
+- `helpIcon`: URL string for the help icon, default from icons asset.
+- `brandLink`: URL string for the brand link (default: `#`).
+- `layout`: `desktop` or `mobile`. Mobile layout hides the brand logo link and does not render the help menu.
+- `theme`: `light` or `dark`.
+- `authState`: `logged-out` or `logged-in`.
+- `loginAction`: object with a `label` for the login button. When `authState` is `logged-out` this is rendered as a `` which handles the full OIDC flow; supplying a `url` instead opts out of the built-in flow and renders a plain link.
+- `signUpAction`: object for the logged-out Sign Up action. The `label` field sets the button text and the `url` field (default: `https://solidproject.org/get_a_pod`) is the destination opened in a new tab when the button is clicked.
+- `accountLabel`: label for the logged-in dropdown trigger (default: `Accounts`).
+- `accountAvatar`: avatar URL used as the logged-in dropdown icon.
+- `accountMenu`: array of account entries for the logged-in dropdown.
+- `logoutLabel`: string label for the logout button at the bottom of the logged-in dropdown (default: `Log out`). Set to `null` to hide it.
+
+Slots:
+
+- `title` (default content is `Solid`).
+- `login-action` to override the logged-out Log in action.
+- `sign-up-action` to override the logged-out Sign Up action.
+- `account-trigger` to override the logged-in Accounts trigger.
+- `account-menu` for custom logged-in account entries.
+- `help-menu` for help related actions rendered inside the help icon dropdown on desktop layout.
+
+The `helpMenuList` property also renders inside the same help icon dropdown menu on desktop layout.
+
+## Auth Modes
+
+### Logged-out (with built-in login flow)
+
+Use `auth-state="logged-out"` to render the `` and a Sign Up action. The login button opens an IDP selection popup and drives the full OIDC login flow without any extra wiring. On a successful login the header automatically sets `auth-state="logged-in"` and emits `auth-action-select`:
+
+```html
+
+
+```
+
+If you want a fully custom login UI you can override the slot:
+
+```html
+
+
+
+```
+
+### Logged-in
+
+```html
+
+
+```
+
+The built-in logout button automatically transitions the header from `logged-in` to `logged-out`, and emits a bubbling, composed `logout-select` event with `detail: { role: 'logout' }`.
+
+The component also dispatches `auth-action-select` for logged-out actions and `account-menu-select` for logged-in account choices.
+When `authState` is `logged-in`, the dropdown always renders the configured `logoutLabel` as the last item.
+
+## Styles
+
+Customization is supported through CSS variables:
+- `--header-bg`, `--header-text`, `--header-border`, etc.
+
+The brand logo link is only rendered when the incoming `layout` is `desktop`.
+The help menu trigger and dropdown are only rendered when the incoming `layout` is `desktop`.
+
+## Testing
+
+Unit test file: `src/v2/components/header/header.test.ts`
+
+Run tests:
+
+```bash
+npm test -- --runInBand --testPathPatterns=src/v2/components/header/header.test.ts
+```
+
+Run full suite:
+
+```bash
+npm test
+```
+
+## Build
+
+```bash
+npm run build
+```
+
+Webpack emits the runtime bundles to `dist/components/header/index.*`. A post-build script generates `dist/components/header/index.d.ts` as a thin re-export wrapper so that the public package layout does not expose internal source paths.
diff --git a/src/v2/components/header/header.test.ts b/src/v2/components/header/header.test.ts
new file mode 100644
index 000000000..4d1e71f36
--- /dev/null
+++ b/src/v2/components/header/header.test.ts
@@ -0,0 +1,215 @@
+import { Header } from './Header'
+import './index'
+
+describe('SolidUIHeaderElement', () => {
+ beforeEach(() => {
+ document.body.innerHTML = ''
+ Object.defineProperty(window, 'open', {
+ configurable: true,
+ writable: true,
+ value: jest.fn()
+ })
+ })
+
+ it('is defined as a custom element', () => {
+ const defined = customElements.get('solid-ui-header')
+ expect(defined).toBe(Header)
+ })
+
+ it('renders a header with logo and menu slots', async () => {
+ const header = new Header()
+ header.setAttribute('logo', 'https://example.com/logo.png')
+ header.setAttribute('help-icon', 'https://example.com/help.png')
+ header.setAttribute('brand-link', '/home')
+ header.authState = 'logged-out'
+ header.helpMenuList = [{ label: 'Help', action: 'open-help' }]
+ header.innerHTML = ''
+
+ document.body.appendChild(header)
+ await header.updateComplete
+
+ const shadow = header.shadowRoot
+ expect(shadow).not.toBeNull()
+
+ const brandImg = shadow?.getElementById('brandImg') as HTMLImageElement
+ const helpIcon = shadow?.getElementById('helpIcon') as HTMLImageElement
+ const brandLink = shadow?.getElementById('brandLink') as HTMLAnchorElement
+
+ expect(brandImg?.src).toContain('https://example.com/logo.png')
+ expect(helpIcon?.src).toContain('https://example.com/help.png')
+ expect(brandLink?.href).toContain('/home')
+
+ expect(shadow?.querySelector('solid-ui-login-button')).not.toBeNull()
+ expect(shadow?.querySelector('solid-ui-signup-button')).not.toBeNull()
+
+ const helpMenuSlot = shadow?.querySelector('slot[name="help-menu"]')
+ expect(helpMenuSlot).not.toBeNull()
+ expect(header.querySelector('#helpBtn')).not.toBeNull()
+ })
+
+ it('renders login and sign up actions when logged out', async () => {
+ const header = new Header()
+ const authActionSelected = jest.fn()
+
+ header.authState = 'logged-out'
+ header.loginAction = { label: 'Log in', action: 'login' }
+ header.signUpAction = { label: 'Sign Up', url: '/signup' }
+
+ header.addEventListener('auth-action-select', (event: Event) => {
+ authActionSelected((event as CustomEvent).detail)
+ })
+
+ document.body.appendChild(header)
+ await header.updateComplete
+
+ const shadow = header.shadowRoot
+ const loginButton = shadow?.querySelector('solid-ui-login-button') as HTMLElement
+ const signUpLink = shadow?.querySelector('solid-ui-signup-button') as HTMLElement
+
+ expect(loginButton).not.toBeNull()
+ expect(signUpLink).not.toBeNull()
+ expect(loginButton.getAttribute('label')).toBe('Log in')
+ expect(signUpLink.getAttribute('label')).toBe('Sign Up')
+ expect(signUpLink.getAttribute('signup-url')).toBe('/signup')
+
+ loginButton.dispatchEvent(new CustomEvent('login-success', { bubbles: true, composed: true }))
+
+ expect(authActionSelected).toHaveBeenCalledWith({
+ role: 'login'
+ })
+ })
+
+ it('renders an accounts dropdown with avatar when logged in', async () => {
+ const header = new Header()
+ const accountMenuSelected = jest.fn()
+
+ header.authState = 'logged-in'
+ header.accountLabel = 'Accounts'
+ header.accountAvatar = 'https://example.com/avatar.png'
+ header.accountMenu = [
+ { label: 'Personal Pod', webid: 'https://pod.example/profile/card#me', action: 'switch-personal' },
+ { label: 'Work Pod', webid: 'https://work.example/profile/card#me', url: '/work' }
+ ]
+
+ header.addEventListener('account-menu-select', (event: Event) => {
+ accountMenuSelected((event as CustomEvent).detail)
+ })
+
+ document.body.appendChild(header)
+ await header.updateComplete
+
+ const shadow = header.shadowRoot
+ const trigger = shadow?.getElementById('accountMenuTrigger') as HTMLButtonElement
+
+ expect(trigger).not.toBeNull()
+ expect(trigger.textContent).toContain('Accounts')
+ expect((shadow?.querySelector('.account-avatar img') as HTMLImageElement)?.src).toContain('https://example.com/avatar.png')
+
+ trigger.click()
+ await header.updateComplete
+
+ const dropdown = shadow?.getElementById('accountMenu') as HTMLElement
+ const accountButtons = shadow?.querySelectorAll('.account-menu-item-button') as NodeListOf
+ const firstItem = accountButtons[0]
+ const lastItem = accountButtons[accountButtons.length - 1]
+
+ expect(dropdown.hidden).toBe(false)
+ expect(firstItem.textContent).toContain('Personal Pod')
+ expect(lastItem.textContent).toContain('Log Out')
+
+ firstItem.click()
+
+ expect(accountMenuSelected).toHaveBeenCalledWith({
+ label: 'Personal Pod',
+ webid: 'https://pod.example/profile/card#me',
+ action: 'switch-personal'
+ })
+
+ expect(lastItem.textContent?.trim()).toBe('Log Out')
+ })
+
+ it('supports theme and layout attributes', async () => {
+ const header = new Header()
+ header.setAttribute('theme', 'dark')
+ header.setAttribute('layout', 'mobile')
+ document.body.appendChild(header)
+ await header.updateComplete
+
+ expect(header.getAttribute('theme')).toBe('dark')
+ expect(header.getAttribute('layout')).toBe('mobile')
+
+ const shadow = header.shadowRoot
+ expect(shadow?.querySelector('.headerInner')).not.toBeNull()
+ expect(shadow?.getElementById('brandLink')?.classList.contains('brand-not-displayed')).toBe(true)
+ expect(header.getAttribute('theme')).toBe('dark')
+ expect(header.getAttribute('layout')).toBe('mobile')
+ })
+
+ it('toggles the brand link visibility class by layout', async () => {
+ const header = new Header()
+ header.setAttribute('brand-link', '/home')
+
+ document.body.appendChild(header)
+ await header.updateComplete
+
+ expect(header.layout).toBe('desktop')
+ expect(header.shadowRoot?.getElementById('brandLink')).not.toBeNull()
+ expect(header.shadowRoot?.getElementById('brandLink')?.classList.contains('brand-not-displayed')).toBe(false)
+
+ header.layout = 'mobile'
+ await header.updateComplete
+
+ expect(header.layout).toBe('mobile')
+ expect(header.shadowRoot?.getElementById('brandLink')).not.toBeNull()
+ expect(header.shadowRoot?.getElementById('brandLink')?.classList.contains('brand-not-displayed')).toBe(true)
+
+ header.layout = 'desktop'
+ await header.updateComplete
+
+ expect(header.layout).toBe('desktop')
+ expect(header.shadowRoot?.getElementById('brandLink')).not.toBeNull()
+ expect(header.shadowRoot?.getElementById('brandLink')?.classList.contains('brand-not-displayed')).toBe(false)
+ })
+
+ it('renders helpMenuList inside the help dropdown and dispatches events', async () => {
+ const header = new Header()
+
+ const helpMenuClicked = jest.fn()
+
+ header.authState = 'logged-in'
+ header.helpMenuList = [{ label: 'Docs', url: 'https://example.com/docs', target: '_blank' }]
+
+ header.addEventListener('help-menu-select', (event: Event) => {
+ helpMenuClicked((event as CustomEvent).detail)
+ })
+
+ document.body.appendChild(header)
+ await header.updateComplete
+
+ const shadow = header.shadowRoot
+ const helpTrigger = shadow?.getElementById('helpMenuTrigger') as HTMLButtonElement
+
+ expect(helpTrigger?.disabled).toBe(false)
+
+ helpTrigger?.click()
+ await header.updateComplete
+
+ const helpMenu = shadow?.getElementById('helpMenu') as HTMLElement
+ const helpLink = shadow?.querySelector('a[part="help-menu-item"]') as HTMLAnchorElement
+
+ expect(helpMenu?.hidden).toBe(false)
+ expect(helpLink?.textContent?.trim()).toBe('Docs')
+
+ const originalWindowOpen = window.open
+ window.open = jest.fn()
+
+ expect(helpLink?.getAttribute('rel')).toBe('noopener noreferrer')
+
+ helpLink?.click()
+
+ expect(helpMenuClicked).toHaveBeenCalledWith({ label: 'Docs', url: 'https://example.com/docs', target: '_blank' })
+ expect(window.open).toHaveBeenCalledWith('https://example.com/docs', '_blank', 'noopener,noreferrer')
+
+ window.open = originalWindowOpen
+ })
+})
diff --git a/src/v2/components/header/index.ts b/src/v2/components/header/index.ts
new file mode 100644
index 000000000..be138e144
--- /dev/null
+++ b/src/v2/components/header/index.ts
@@ -0,0 +1,14 @@
+import { Header } from './Header'
+
+export { Header }
+export type {
+ HeaderAccountMenuItem,
+ HeaderAuthState,
+ HeaderMenuItem
+} from './Header'
+
+const HEADER_TAG_NAME = 'solid-ui-header'
+
+if (!customElements.get(HEADER_TAG_NAME)) {
+ customElements.define(HEADER_TAG_NAME, Header)
+}
diff --git a/src/v2/components/loginButton/LoginButton.test.ts b/src/v2/components/loginButton/LoginButton.test.ts
new file mode 100644
index 000000000..9dd3e5fa1
--- /dev/null
+++ b/src/v2/components/loginButton/LoginButton.test.ts
@@ -0,0 +1,46 @@
+import { LoginButton } from './LoginButton'
+import './index'
+
+jest.mock('solid-logic', () => ({
+ authSession: { login: jest.fn() },
+ authn: { saveUser: jest.fn() },
+ getSuggestedIssuers: jest.fn(() => []),
+ offlineTestID: jest.fn(() => false),
+ solidLogicSingleton: { store: { updater: { flagAuthorizationMetadata: jest.fn() } } }
+}))
+
+describe('SolidUILoginButton', () => {
+ beforeEach(() => {
+ document.body.innerHTML = ''
+ Object.defineProperty(window, 'open', {
+ configurable: true,
+ writable: true,
+ value: jest.fn()
+ })
+ localStorage.clear()
+ })
+
+ it('is defined as a custom element', () => {
+ expect(customElements.get('solid-ui-login-button')).toBe(LoginButton)
+ })
+
+ it('renders the login button and opens a popup with an associated label and input', async () => {
+ const loginButton = new LoginButton()
+ document.body.appendChild(loginButton)
+ await loginButton.updateComplete
+
+ const button = loginButton.shadowRoot?.querySelector('button.login-button') as HTMLButtonElement
+ expect(button).not.toBeNull()
+ expect(button.textContent?.trim()).toBe('Log In')
+
+ button.click()
+ await loginButton.updateComplete
+
+ const label = loginButton.shadowRoot?.querySelector('label.issuer-text-label') as HTMLLabelElement
+ const input = loginButton.shadowRoot?.querySelector('input.issuer-text-input') as HTMLInputElement
+ expect(label).not.toBeNull()
+ expect(input).not.toBeNull()
+ expect(label?.getAttribute('for')).toBe(input?.id)
+ expect(input?.id).toBeTruthy()
+ })
+})
diff --git a/src/v2/components/loginButton/LoginButton.ts b/src/v2/components/loginButton/LoginButton.ts
new file mode 100644
index 000000000..65ab307eb
--- /dev/null
+++ b/src/v2/components/loginButton/LoginButton.ts
@@ -0,0 +1,375 @@
+import { LitElement, html, css } from 'lit'
+import { authSession, authn, getSuggestedIssuers, offlineTestID, solidLogicSingleton } from 'solid-logic'
+
+export class LoginButton extends LitElement {
+ static properties = {
+ label: { type: String, reflect: true },
+ theme: { type: String, reflect: true },
+ issuerUrl: { type: String, attribute: 'issuer-url', reflect: true },
+ _popupOpen: { state: true },
+ _issuerInputValue: { state: true }
+ }
+
+ static styles = css`
+ :host { // default theme
+ display: inline-block;
+ --login-button-background: var(--lavender-900, #7c4cff);
+ --login-button-text: var(--color-header-text, #ffffff);
+ --popup-background: var(--color-background, #F8F9FB);
+ --popup-text: var(--color-text, #1A1A1A);
+ --popup-border: var(--color-border, #E5E7EB);
+ --popup-shadow: var(--box-shadow-sm, 0 1px 4px rgba(124,77,255,0.12));
+ --popup-overlay-background: rgba(0, 0, 0, 0.2);
+ --issuer-input-background: var(--color-background, #F8F9FB);
+ --issuer-input-text: var(--color-text, #1A1A1A);
+ --issuer-input-border: var(--color-border, #E5E7EB);
+ --issuer-button-background: var(--color-background, #F8F9FB);
+ --issuer-button-text: var(--color-text, #1A1A1A);
+ --issuer-button-border: var(--color-border, #E5E7EB);
+ --issuer-button-hover-background: var(--lavender-900, #7c4cff);
+ --issuer-label-color: var(--grey-purple-700, #5e546d);
+ --issuer-placeholder-color: var(--grey-purple-700, #5e546d);;
+ --error-text-color: var(--color-error, #B00020);
+ }
+
+ :host([theme='dark']) {
+ display: inline-block;
+ --login-button-background: var(--lavender-900, #7c4cff);
+ --login-button-text: var(--color-header-text, #ffffff);
+ --popup-background: var(--color-background, #F8F9FB);
+ --popup-text: var(--color-text, #1A1A1A);
+ --popup-border: var(--color-border, #E5E7EB);
+ --popup-shadow: var(--box-shadow-sm, 0 1px 4px rgba(124,77,255,0.12));
+ --popup-overlay-background: rgba(0, 0, 0, 0.2);
+ --issuer-input-background: var(--color-background, #F8F9FB);
+ --issuer-input-text: var(--color-text, #1A1A1A);
+ --issuer-input-border: var(--color-border, #E5E7EB);
+ --issuer-button-background: var(--color-background, #F8F9FB);
+ --issuer-button-text: var(--color-text, #1A1A1A);
+ --issuer-button-border: var(--color-border, #E5E7EB);
+ --issuer-button-hover-background: var(--lavender-900, #7c4cff);
+ --issuer-label-color: var(--grey-purple-700, #5e546d);
+ --issuer-placeholder-color: var(--grey-purple-700, #5e546d);;
+ --error-text-color: var(--color-error, #B00020);
+ }
+
+ .login-button {
+ display: flex;
+ height: 35px;
+ padding: var(--spacing-xxs, 0.3125rem) var(--spacing-xs, 0.75rem);
+ align-items: center;
+ gap: var(--spacing-xxs, 0.3125rem);
+ border-radius: var(--border-radius-base, 0.3125rem);
+ background: var(--login-button-background);
+ border: none;
+ color: var(--login-button-text);
+ cursor: pointer;
+ font: inherit;
+ line-height: 1;
+ white-space: nowrap;
+ text-decoration: none;
+ box-sizing: border-box;
+ transition: transform 0.2s ease;
+ }
+
+ .login-button:active {
+ transform: translateY(1px);
+ }
+
+ .popup-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+ background: var(--popup-overlay-background);
+ }
+
+ .popup-box {
+ background: var(--popup-background);
+ color: var(--popup-text);
+ box-shadow: var(--popup-shadow);
+ border: 1px solid var(--popup-border);
+ border-radius: var(--border-radius-sm, 0.2rem);
+ min-width: 400px;
+ padding: 10px;
+ z-index: 1001;
+ }
+
+ .popup-top-menu {
+ border-bottom: 1px solid #DDD;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ padding-bottom: 8px;
+ margin-bottom: 8px;
+ }
+
+ .popup-title {
+ font-weight: 800;
+ }
+
+ .popup-close {
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ font-size: 1.25rem;
+ line-height: 1;
+ padding: 0 0.25rem;
+ }
+
+ .issuer-text-section {
+ border-bottom: 1px solid #DDD;
+ display: flex;
+ flex-direction: column;
+ padding-top: 10px;
+ padding-bottom: 10px;
+ }
+
+ .issuer-text-label {
+ color: var(--issuer-label-color);
+ margin-bottom: 6px;
+ }
+
+ .issuer-text-row {
+ display: flex;
+ flex-direction: row;
+ gap: 6px;
+ }
+
+ .issuer-text-input {
+ flex: 1;
+ padding: 0.375rem 0.5rem;
+ border: 1px solid var(--issuer-input-border);
+ border-radius: var(--border-radius-sm, 0.2rem);
+ background: var(--issuer-input-background);
+ color: var(--issuer-input-text);
+ font: inherit;
+ }
+
+ .issuer-text-input::placeholder {
+ color: var(--issuer-placeholder-color);
+ }
+
+ .issuer-go-button {
+ padding: 0.375rem 0.75rem;
+ border: 1px solid var(--issuer-button-border);
+ border-radius: var(--border-radius-sm, 0.2rem);
+ background: var(--issuer-button-background);
+ color: var(--issuer-button-text);
+ cursor: pointer;
+ font: inherit;
+ }
+
+ .issuer-go-button:hover {
+ background: var(--issuer-button-hover-background);
+ }
+
+ .issuer-button-section {
+ display: flex;
+ flex-direction: column;
+ padding-top: 10px;
+ gap: 8px;
+ }
+
+ .issuer-button-label {
+ color: var(--issuer-label-color);
+ }
+
+ .issuer-button {
+ height: 38px;
+ border: 1px solid var(--issuer-button-border);
+ border-radius: var(--border-radius-sm, 0.2rem);
+ background: var(--issuer-button-background);
+ color: var(--issuer-button-text);
+ cursor: pointer;
+ font: inherit;
+ }
+
+ .issuer-button:hover {
+ background: var(--issuer-button-hover-background);
+ }
+
+ .error-msg {
+ color: var(--error-text-color);
+ font-size: 0.875rem;
+ margin-top: 8px;
+ }
+ `
+
+ declare label: string
+ declare theme: 'light' | 'dark'
+ declare issuerUrl: string
+ declare _popupOpen: boolean
+ declare _issuerInputValue: string
+
+ private _issuerInputId = `issuer-url-input-${Math.random().toString(36).slice(2, 10)}`
+ private _errorMsg = ''
+
+ constructor () {
+ super()
+ this.label = 'Log In'
+ this.theme = 'light'
+ this.issuerUrl = ''
+ this._popupOpen = false
+ this._issuerInputValue = ''
+ }
+
+ connectedCallback () {
+ super.connectedCallback()
+ document.addEventListener('click', this._handleDocumentClick)
+ document.addEventListener('keydown', this._handleDocumentKeydown)
+ }
+
+ disconnectedCallback () {
+ document.removeEventListener('click', this._handleDocumentClick)
+ document.removeEventListener('keydown', this._handleDocumentKeydown)
+ super.disconnectedCallback()
+ }
+
+ private readonly _handleDocumentClick = (event: MouseEvent) => {
+ if (this._popupOpen && !event.composedPath().includes(this)) {
+ this._popupOpen = false
+ }
+ }
+
+ private readonly _handleDocumentKeydown = (event: KeyboardEvent) => {
+ if (this._popupOpen && event.key === 'Escape') {
+ this._closePopup()
+ }
+ }
+
+ private _openPopup () {
+ const offline = offlineTestID()
+ if (offline) {
+ this._loginComplete(offline.uri)
+ return
+ }
+ this._issuerInputValue = (typeof localStorage !== 'undefined' && localStorage.getItem('loginIssuer')) || this.issuerUrl || ''
+ this._errorMsg = ''
+ this._popupOpen = true
+ }
+
+ private _closePopup () {
+ this._popupOpen = false
+ }
+
+ private async _loginToIssuer (issuerUri: string) {
+ if (!issuerUri) return
+ try {
+ // clear authorization metadata from store
+ ;(solidLogicSingleton.store.updater as any).flagAuthorizationMetadata()
+
+ const preLoginRedirectHash = new URL(window.location.href).hash
+ if (preLoginRedirectHash) {
+ window.localStorage.setItem('preLoginRedirectHash', preLoginRedirectHash)
+ }
+ window.localStorage.setItem('loginIssuer', issuerUri)
+
+ const locationUrl = new URL(window.location.href)
+ locationUrl.hash = ''
+ await authSession.login({
+ redirectUrl: locationUrl.href,
+ oidcIssuer: issuerUri
+ })
+ } catch (err: any) {
+ this._errorMsg = err.message || String(err)
+ this.requestUpdate()
+ }
+ }
+
+ private _loginComplete (webIdUri: string) {
+ authn.saveUser(webIdUri)
+ this.dispatchEvent(new CustomEvent('login-success', {
+ detail: { webId: webIdUri },
+ bubbles: true,
+ composed: true
+ }))
+ }
+
+ private _handleGoClick () {
+ this._loginToIssuer(this._issuerInputValue)
+ }
+
+ private _handleInputChange (e: Event) {
+ this._issuerInputValue = (e.target as HTMLInputElement).value
+ }
+
+ private _handleInputKeydown (e: KeyboardEvent) {
+ if (e.key === 'Enter') {
+ this._loginToIssuer(this._issuerInputValue)
+ }
+ if (e.key === 'Escape') {
+ this._closePopup()
+ }
+ }
+
+ private _renderPopup () {
+ const suggestedIssuers = getSuggestedIssuers()
+ return html`
+
+ `
+ }
+
+ render () {
+ return html`
+
+
+ ${this._popupOpen ? this._renderPopup() : ''}
+ `
+ }
+}
diff --git a/src/v2/components/loginButton/README.md b/src/v2/components/loginButton/README.md
new file mode 100644
index 000000000..42a402de4
--- /dev/null
+++ b/src/v2/components/loginButton/README.md
@@ -0,0 +1,104 @@
+# solid-ui-login-button component
+
+A Lit-based custom element that encapsulates the full Solid OIDC login flow. It renders a styled button that opens an identity provider (IDP) selection popup, handles the OIDC redirect, and emits a `login-success` event when the user is authenticated.
+
+Used automatically by `` when `auth-state="logged-out"` — see the [Header README](../header/README.md).
+
+## Installation
+
+```bash
+npm install solid-ui
+```
+
+## Usage in a bundled project (webpack, Vite, Rollup, etc.)
+
+```javascript
+import { LoginButton } from 'solid-ui/components/login-button'
+```
+
+```html
+
+
+
+```
+
+## Usage in a plain HTML page (CDN / script tag)
+
+```html
+
+
+
+```
+
+## TypeScript
+
+```typescript
+import { LoginButton } from 'solid-ui/components/login-button'
+
+const btn = document.querySelector('solid-ui-login-button') as LoginButton
+btn.label = 'Sign in to Solid'
+btn.addEventListener('login-success', (e: CustomEvent) => {
+ const { webId } = e.detail
+})
+```
+
+## API
+
+### Properties / attributes
+
+| Property | Attribute | Type | Default | Description |
+|-------------|---------------|--------------------|----------|-------------|
+| `label` | `label` | `string` | `Log In` | Button text. Overridable via the default slot. |
+| `issuerUrl` | `issuer-url` | `string` | `''` | Pre-fills the IDP URL input in the popup. If `localStorage.loginIssuer` is set it takes precedence. |
+| `theme` | `theme` | `'light' \| 'dark'` | `'light'` | Sets the colour theme. Use `'dark'` when placing the button on a dark background. |
+
+### Events
+
+| Event | Detail | Description |
+|-----------------|-------------------------|-------------|
+| `login-success` | `{ webId: string }` | Fired after a successful OIDC login. `webId` is the authenticated user's WebID URI. |
+
+### Slots
+
+| Slot | Description |
+|-----------|-------------|
+| (default) | Replaces the button label text. |
+
+### CSS custom properties
+
+The component inherits Header CSS variables automatically when used inside ``. When used standalone, these can be set on a parent or on `:root`:
+
+| Variable | Fallback | Description |
+|-------------------------------|-------------------------------------|-------------|
+| `--primary-royal-lavender` | `#7C4DFF` | Button background colour |
+| `--login-button-text` | `--header-button-text` / `#0f172a` | Button text colour (light theme) |
+
+### Theming
+
+Set `theme="dark"` for dark backgrounds. The button background (`--primary-royal-lavender`) stays the same; the text colour switches to white.
+
+```html
+
+```
+
+When used inside ``, the `theme` attribute is forwarded automatically.
+
+## Popup behaviour
+
+- Opens an IDP URL text input pre-filled from `localStorage.loginIssuer` or the `issuer-url` attribute.
+- Lists suggested identity providers from `solid-logic`'s `getSuggestedIssuers()`.
+- Closes on **Escape**, clicking the ✕ button, or clicking the backdrop.
+- Saves the chosen issuer to `localStorage.loginIssuer` for future visits.
+- Uses `offlineTestID()` from `solid-logic` for offline test environments — the popup is bypassed and `login-success` fires immediately.
+
+## Build
+
+```bash
+npm run build
+```
+
+Webpack emits bundles to `dist/components/loginButton/index.*`.
diff --git a/src/v2/components/loginButton/index.ts b/src/v2/components/loginButton/index.ts
new file mode 100644
index 000000000..0dff76088
--- /dev/null
+++ b/src/v2/components/loginButton/index.ts
@@ -0,0 +1,9 @@
+import { LoginButton } from './LoginButton'
+
+export { LoginButton }
+
+const LOGIN_BUTTON_TAG_NAME = 'solid-ui-login-button'
+
+if (!customElements.get(LOGIN_BUTTON_TAG_NAME)) {
+ customElements.define(LOGIN_BUTTON_TAG_NAME, LoginButton)
+}
diff --git a/src/v2/components/signupButton/README.md b/src/v2/components/signupButton/README.md
new file mode 100644
index 000000000..4403e6f65
--- /dev/null
+++ b/src/v2/components/signupButton/README.md
@@ -0,0 +1,88 @@
+# solid-ui-signup-button component
+
+A Lit-based custom element that renders a styled button which opens a Solid Pod signup page in a new browser tab.
+
+## Installation
+
+```bash
+npm install solid-ui
+```
+
+## Usage in a bundled project (webpack, Vite, Rollup, etc.)
+
+```javascript
+import { SignupButton } from 'solid-ui/components/signup-button'
+```
+
+```html
+
+```
+
+## Usage in a plain HTML page (CDN / script tag)
+
+```html
+
+
+
+```
+
+## TypeScript
+
+```typescript
+import { SignupButton } from 'solid-ui/components/signup-button'
+
+const btn = document.querySelector('solid-ui-signup-button') as SignupButton
+btn.label = 'Create a Pod'
+btn.signupUrl = 'https://solidproject.org/get_a_pod'
+```
+
+## API
+
+### Properties / attributes
+
+| Property | Attribute | Type | Default | Description |
+|-------------|--------------|---------------------|--------------------------------------|-------------|
+| `label` | `label` | `string` | `Sign Up` | Button text. Overridable via the default slot. |
+| `signupUrl` | `signup-url` | `string` | `https://solidproject.org/get_a_pod` | URL opened in a new tab when the button is clicked. |
+| `theme` | `theme` | `'light' \| 'dark'` | `'light'` | Sets the colour theme. Use `'dark'` when placing the button on a dark background. |
+
+### Slots
+
+| Slot | Description |
+|-----------|-------------|
+| (default) | Replaces the button label text. |
+
+### CSS shadow parts
+
+| Part | Description |
+|-----------------|-------------|
+| `signup-button` | The inner `