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..6d2e8d53c 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 `
+
+
+
+
+```
+
+## TypeScript
+
+Types are included. Import the exported element class:
+
+```typescript
+import { Footer } from 'solid-ui/components/footer'
+
+const footer = document.querySelector('solid-ui-footer') as Footer
+footer.position = 'fixed'
+footer.bottom = '1rem'
+```
+
+## API
+
+Properties / attributes:
+
+- `position`: `static`, `absolute`, `relative`, `fixed`, or `sticky`.
+- `top`: CSS offset for the top edge when `position` is not `static`.
+- `right`: CSS offset for the right edge when `position` is not `static`.
+- `bottom`: CSS offset for the bottom edge when `position` is not `static`.
+- `left`: CSS offset for the left edge when `position` is not `static`.
+- `store`: an `rdflib` store instance used to resolve the logged-in user name from the current Solid session.
+
+## Display behavior
+
+- When no user is logged in, the footer displays a public-view message.
+- When a user is logged in, the footer displays a logged-in message and links the current profile name to the user profile URI.
+
+## Styling
+
+Customize the footer using CSS variables:
+
+- `--footer-bg` — background color (default: `#e6e6e6`).
+- `--footer-text` — text color (default: `#4f4f4f`).
+- `--footer-border-radius` — corner radius (default: `1rem`).
+- `--footer-box-shadow` — box shadow.
+- `--footer-link` — link color.
+
+## Example
+
+```html
+
+```
+
+```typescript
+import { Footer } from 'solid-ui/components/footer'
+import type { LiveStore } from 'rdflib'
+
+const footer = document.querySelector('solid-ui-footer') as Footer
+footer.position = 'fixed'
+footer.bottom = '1rem'
+footer.left = '1rem'
+footer.right = '1rem'
+footer.store = myRdflibStore as LiveStore
+```
+
+## Testing
+
+The component is covered by unit tests under `src/v2/components/footer/Footer.test.ts`.
diff --git a/src/v2/components/footer/index.ts b/src/v2/components/footer/index.ts
new file mode 100644
index 000000000..7df3d81d0
--- /dev/null
+++ b/src/v2/components/footer/index.ts
@@ -0,0 +1,9 @@
+import { Footer } from './Footer'
+
+export { Footer }
+
+const FOOTER_TAG_NAME = 'solid-ui-footer'
+
+if (!customElements.get(FOOTER_TAG_NAME)) {
+ customElements.define(FOOTER_TAG_NAME, Footer)
+}
diff --git a/src/v2/components/header/Header.ts b/src/v2/components/header/Header.ts
new file mode 100644
index 000000000..8760c4622
--- /dev/null
+++ b/src/v2/components/header/Header.ts
@@ -0,0 +1,899 @@
+import { LitElement, html, css } from 'lit'
+import { icons } from '../../../iconBase'
+import { authSession } from 'solid-logic'
+import '../loginButton/index'
+import '../signupButton/index'
+import { ifDefined } from 'lit/directives/if-defined.js'
+
+const DEFAULT_HELP_MENU_ICON = ''
+const DEFAULT_SOLID_ICON_URL = 'https://solidproject.org/assets/img/solid-emblem.svg'
+const DEFAULT_SIGNUP_URL = 'https://solidproject.org/get_a_pod'
+const DEFAULT_LOGGEDIN_MENU_BUTTON_AVATAR = icons.iconBase + 'emptyProfileAvatar.png'
+
+export type HeaderAuthState = 'logged-out' | 'logged-in'
+
+export type HeaderMenuItem = {
+ label: string
+ url?: string
+ target?: string
+ action?: string
+ icon?: string
+}
+
+export type HeaderAccountMenuItem = HeaderMenuItem & {
+ avatar?: string
+ webid?: string
+}
+
+export class Header extends LitElement {
+ static properties = {
+ logo: { type: String, reflect: true },
+ helpIcon: { type: String, attribute: 'help-icon', reflect: true },
+ layout: { type: String, reflect: true },
+ theme: { type: String, reflect: true },
+ brandLink: { type: String, attribute: 'brand-link', reflect: true },
+ authState: { type: String, attribute: 'auth-state', reflect: true },
+ loginAction: { type: Object, attribute: false },
+ signUpAction: { type: Object, attribute: false },
+ accountMenu: { type: Array, attribute: false },
+ logoutLabel: { type: String, attribute: 'logout-label', reflect: true },
+ logoutIcon: { type: String, attribute: 'logout-icon', reflect: true },
+ accountLabel: { type: String, attribute: 'account-label', reflect: true },
+ accountAvatar: { type: String, attribute: 'account-avatar', reflect: true },
+ accountAvatarFallback: { type: String, attribute: 'account-avatar-fallback', reflect: true },
+ loginIcon: { type: String, attribute: 'login-icon', reflect: true },
+ signUpIcon: { type: String, attribute: 'sign-up-icon', reflect: true },
+ helpMenuList: { type: Array },
+ accountMenuOpen: { state: true },
+ helpMenuOpen: { state: true },
+ hasSlottedAccountMenu: { state: true },
+ hasSlottedHelpMenu: { state: true }
+ }
+
+ static styles = css`
+ :host { // default theme
+ display: block;
+ --header-bg: var(--color-header-row-bg, #332746);
+ --header-text: var(--color-header-text, #ffffff);
+ --header-border: var(--color-border, #efecf3);
+ --header-line: var(--color-header-menu-separator-line, #5e546d);
+ --header-link: var(--color-text-heading, #000000);
+ --header-menu-item-hover: var(--color-header-menu-item-hover, #e6dcff);
+ --header-menu-item-selected: var(--color-header-menu-item-selected, #cbb9ff);
+ --header-menu-bg: var(--color-menu-bg, #f6f5f9);
+ --header-menu-loggedin-bg: var(--color-header-menu-loggedin-bg, #5e546d);
+ --header-menu-text: var(--color-menu-item-text, #654d6c);
+ --header-border-radius: var(--border-radius-sm, 0.2rem);
+ --header-button-bg: var(--color-menu-bg, #ffffff);
+ --header-button-text: var(--color-header-button-text, #0F172B);
+ --header-button-detail-text: var(--color-header-button-detail-text, #99A1AF);
+ --header-shadow: var(--color-header-shadow, 2px 6px 10px 0 rgba(0, 0, 0, 0.4), 2px 8px 24px 0 rgba(0, 0, 0, 0.19));
+ font-family: var(--font-family-base, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif);
+ }
+
+ // for now light and dark are the same
+ :host([theme='dark']) {
+ display: block;
+ --header-bg: var(--color-header-row-bg, #332746);
+ --header-text: var(--color-header-text, #ffffff);
+ --header-border: var(--color-border, #efecf3);
+ --header-line: var(--color-header-menu-separator-line, #5e546d);
+ --header-link: var(--color-text-heading, #000000);
+ --header-menu-item-hover: var(--color-header-menu-item-hover, #e6dcff);
+ --header-menu-item-selected: var(--color-header-menu-item-selected, #cbb9ff);
+ --header-menu-bg: var(--color-menu-bg, #f6f5f9);
+ --header-menu-loggedin-bg: var(--color-header-menu-loggedin-bg, #5e546d);
+ --header-menu-text: var(--color-menu-item-text, #654d6c);
+ --header-border-radius: var(--border-radius-sm, 0.2rem);
+ --header-button-bg: var(--color-menu-bg, #ffffff);
+ --header-button-text: var(--color-header-button-text, #0f172a);
+ --header-button-detail-text: var(--color-header-button-detail-text, #878192);
+ --header-icon-filter: invert(1) brightness(1.3); /* special way to invert SVG color of icons, from white to black */
+ --header-shadow: var(--color-header-shadow, 2px 6px 10px 0 rgba(0, 0, 0, 0.4), 2px 8px 24px 0 rgba(0, 0, 0, 0.19));
+ font-family: var(--font-family-base, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif);
+ }
+
+ :host([layout='mobile']) .headerInner {
+ flex-wrap: wrap;
+ text-align: center;
+ gap: 0.5rem;
+ }
+
+ .headerInner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background: var(--header-bg);
+ color: var(--header-text);
+ padding: 0 1.5rem;
+ height: 3.75rem;
+ }
+
+ ::slotted([slot='navigation-toggle']) {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-right: 0.75rem;
+ }
+
+ :host([layout='desktop']) ::slotted([slot='navigation-toggle']) {
+ display: none !important;
+ }
+
+ .brand {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ text-decoration: none;
+ color: inherit;
+ }
+
+ .brand-not-displayed {
+ display: none;
+ }
+
+ .brand img {
+ height: 50px;
+ width: 55px;
+ object-fit: contain;
+ }
+
+ .menu {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ margin-left: auto;
+ }
+
+ .auth-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ }
+
+ .auth-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ min-height: 2.25rem;
+ padding: 0.5rem 0.875rem;
+ border: 1px solid var(--header-border);
+ border-radius: 999px;
+ background: var(--header-menu-bg);
+ color: var(--header-button-text);
+ cursor: pointer;
+ font: inherit;
+ line-height: 1;
+ text-decoration: none;
+ box-sizing: border-box;
+ transition: border-color 0.2s ease, transform 0.2s ease;
+ }
+
+ .auth-button:hover {
+ border-color: var(--header-menu-item-hover);
+ }
+
+ .auth-button:active {
+ transform: translateY(1px);
+ }
+
+ .auth-button-sign-up {
+ background: color-mix(in srgb, var(--header-menu-bg) 78%, var(--header-menu-item-selected) 22%);
+ }
+
+ .header-menu-separator {
+ background: var(--header-line);
+ width: 1px;
+ height: 2.3rem;
+ }
+
+ .account-menu-container {
+ position: relative;
+ display: flex;
+ align-items: center;
+ }
+
+ .account-menu-trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.625rem;
+ min-height: 2.5rem;
+ border: 1px solid var(--header-menu-loggedin-bg);
+ border-radius: 999px;
+ background: var(--header-menu-loggedin-bg);
+ color: var(--header-button-text);
+ cursor: pointer;
+ font: inherit;
+ line-height: 1;
+ }
+
+ :host([layout='mobile']) .account-menu-trigger {
+ gap: 0;
+ min-height: auto;
+ padding: 0;
+ border: 1.5px solid var(--header-border);
+ background: var(--header-menu-loggedin-bg);
+ }
+
+ :host([layout='mobile']) .account-menu-trigger-label {
+ display: none;
+ }
+
+ .account-menu-trigger:disabled {
+ cursor: default;
+ opacity: 0.7;
+ }
+
+ .account-menu-trigger-label {
+ white-space: nowrap;
+ }
+
+ .account-avatar,
+ .account-menu-avatar {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ overflow: hidden;
+ background: color-mix(in srgb, var(--header-bg) 18%, #ded8e7 82%);
+ color: var(--header-button-text);
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ }
+
+ .account-avatar {
+ width: 1.75rem;
+ height: 1.75rem;
+ border-radius: 999px;
+ border: 1.5px solid var(--header-border);
+ }
+
+ .account-menu-avatar {
+ width: 2rem;
+ height: 2rem;
+ border-radius: 0.5rem;
+ }
+
+ .account-avatar img,
+ .account-menu-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ .account-avatar-img {
+ width: 1.75rem;
+ height: 1.75rem;
+ border-radius: 999px;
+ object-fit: cover;
+ background-color: var(--header-border);
+ }
+
+ .account-dropdown {
+ position: absolute;
+ top: calc(100% + 0.9rem);
+ right: 0;
+ min-width: 15rem;
+ padding: 0.5rem;
+ background: var(--header-button-bg);
+ border: 1px solid var(--header-border);
+ border-radius: var(--header-border-radius);
+ box-shadow: var(--header-shadow);
+ z-index: 10;
+ }
+
+ .account-dropdown[hidden] {
+ display: none;
+ }
+
+ .account-menu-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ }
+
+ .account-menu-item-link,
+ .account-menu-item-button,
+ ::slotted([slot='account-menu']) {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ width: 100%;
+ box-sizing: border-box;
+ color: var(--header-link);
+ text-decoration: none;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 10px;
+ padding: 0.5rem;
+ cursor: pointer;
+ font: inherit;
+ text-align: left;
+ }
+
+ .account-menu-item-link:hover,
+ .account-menu-item-button:hover {
+ color: var(--header-link);
+ background: var(--header-menu-item-hover);
+ border-color: var(--header-menu-item-hover);
+ }
+
+ .account-menu-item-link:active,
+ .account-menu-item-button:active {
+ color: var(--header-link);
+ background: var(--header-menu-item-selected);
+ border-color: var(--header-menu-item-selected);
+ transform: translateY(1px);
+ }
+
+ .account-switch {
+ display: block;
+ width: 100%;
+ color: var(--header-menu-text);
+ text-align: left;
+ text-transform: uppercase;
+ font-size: 80%;
+ }
+
+ .dropdown-menu-separator {
+ display: block;
+ width: calc(100% + 1rem);
+ margin: 0.5rem -0.5rem;
+ border: 0;
+ border-top: 1px solid var(--header-border);
+ }
+
+ .account-menu-copy {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ }
+
+ .account-menu-label {
+ color: var(--header-button-text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .account-menu-webid {
+ color: var(--header-button-detail-text);
+ font-size: 0.5rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .help-menu-container {
+ position: relative;
+ display: flex;
+ align-items: center;
+ background: transparent;
+ }
+
+ .help-menu-trigger {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ }
+
+ .help-menu-trigger:disabled {
+ cursor: default;
+ }
+
+ .help-dropdown {
+ position: absolute;
+ top: calc(100% + 0.9rem);
+ right: 0;
+ min-width: 12rem;
+ padding: 0.5rem;
+ background: var(--header-button-bg);
+ border: 1px solid var(--header-border);
+ border-radius: var(--header-border-radius);
+ box-shadow: var(--header-shadow);
+ z-index: 10;
+ }
+
+ .help-dropdown[hidden] {
+ display: none;
+ }
+
+ .help-dropdown-content {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ }
+
+ .help-menu-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ }
+
+ .help-menu-list a,
+ .help-menu-list button,
+ ::slotted([slot='help-menu']) {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ color: var(--header-link);
+ text-decoration: none;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ padding: 0.375rem 0.5rem;
+ cursor: pointer;
+ font: inherit;
+ text-align: left;
+ }
+
+ .help-menu-list a:hover,
+ .help-menu-list button:hover {
+ color: var(--header-link);
+ background: var(--header-menu-item-hover);
+ border-color: var(--header-menu-item-hover);
+ }
+
+ .help-menu-list a:active,
+ .help-menu-list button:active {
+ color: var(--header-link);
+ background: var(--header-menu-item-selected);
+ border-color: var(--header-menu-item-selected);
+ transform: translateY(1px);
+ }
+
+ ::slotted(a), ::slotted(button) {
+ color: var(--header-link);
+ text-decoration: none;
+ background: var(--header-button-bg);
+ border: 1px solid var(--header-border);
+ border-radius: 4px;
+ padding: 0.25rem 0.5rem;
+ cursor: pointer;
+ font: inherit;
+ }
+
+ .help-icon {
+ width: 35px;
+ height: 35px;
+ cursor: pointer;
+ }
+
+ .help-text {
+ color: var(--header-text, #ffffff);
+ font: inherit;
+ }
+
+ .logout-action-icon {
+ width: 16px;
+ height: 16px;
+ display: inline-block;
+ object-fit: contain;
+ margin-right: 0.5rem;
+ }
+ `
+
+ declare logo: string
+ declare helpIcon: string
+ declare layout: 'desktop' | 'mobile'
+ declare theme: 'light' | 'dark'
+ declare brandLink: string
+ declare authState: HeaderAuthState
+ declare loginAction: HeaderMenuItem
+ declare signUpAction: HeaderMenuItem
+ declare accountMenu: HeaderAccountMenuItem[]
+ declare logoutLabel: string | null
+ declare logoutIcon: string
+ declare accountLabel: string
+ declare accountAvatar: string
+ declare accountAvatarFallback: string
+ declare loginIcon: string
+ declare signUpIcon: string
+ declare helpMenuList: HeaderMenuItem[]
+ declare accountMenuOpen: boolean
+ declare helpMenuOpen: boolean
+ declare hasSlottedAccountMenu: boolean
+ declare hasSlottedHelpMenu: boolean
+
+ constructor () {
+ super()
+ this.logo = DEFAULT_SOLID_ICON_URL
+ this.helpIcon = DEFAULT_HELP_MENU_ICON
+ this.layout = 'desktop'
+ this.theme = 'light'
+ this.brandLink = '#'
+ this.authState = 'logged-out'
+ this.loginAction = { label: 'Log In', action: 'login' }
+ this.signUpAction = { label: 'Sign Up', action: 'sign-up', url: DEFAULT_SIGNUP_URL }
+ this.accountMenu = []
+ this.logoutLabel = 'Log Out'
+ this.logoutIcon = ''
+ this.accountLabel = 'Accounts'
+ this.accountAvatar = ''
+ this.accountAvatarFallback = ''
+ this.loginIcon = ''
+ this.signUpIcon = ''
+ this.helpMenuList = []
+ this.accountMenuOpen = false
+ this.helpMenuOpen = false
+ this.hasSlottedAccountMenu = false
+ this.hasSlottedHelpMenu = false
+ }
+
+ connectedCallback () {
+ super.connectedCallback()
+ document.addEventListener('click', this.handleDocumentClick)
+ window.addEventListener('keydown', this.handleWindowKeydown)
+ }
+
+ disconnectedCallback () {
+ document.removeEventListener('click', this.handleDocumentClick)
+ window.removeEventListener('keydown', this.handleWindowKeydown)
+ super.disconnectedCallback()
+ }
+
+ private handleHelpMenuClick (item: HeaderMenuItem, event: MouseEvent) {
+ event.preventDefault()
+ this.helpMenuOpen = false
+ this.dispatchEvent(new CustomEvent('help-menu-select', {
+ detail: item,
+ bubbles: true,
+ composed: true
+ }))
+ if (item.url) {
+ const target = item.target || '_blank'
+ const features = target === '_blank' ? 'noopener,noreferrer' : undefined
+ window.open(item.url, target, features)
+ }
+ }
+
+ private handleAccountMenuClick (item: HeaderAccountMenuItem) {
+ this.accountMenuOpen = false
+ this.dispatchEvent(new CustomEvent('account-menu-select', {
+ detail: item,
+ bubbles: true,
+ composed: true
+ }))
+ }
+
+ private readonly handleDocumentClick = (event: MouseEvent) => {
+ if (!event.composedPath().includes(this)) {
+ this.accountMenuOpen = false
+ this.helpMenuOpen = false
+ }
+ }
+
+ private readonly handleWindowKeydown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape' && (this.accountMenuOpen || this.helpMenuOpen)) {
+ this.accountMenuOpen = false
+ this.helpMenuOpen = false
+ }
+ }
+
+ private handleAccountSlotChange (event: Event) {
+ const slot = event.target as HTMLSlotElement
+ this.hasSlottedAccountMenu = slot.assignedElements({ flatten: true }).length > 0
+ }
+
+ private handleHelpSlotChange (event: Event) {
+ const slot = event.target as HTMLSlotElement
+ this.hasSlottedHelpMenu = slot.assignedElements({ flatten: true }).length > 0
+ }
+
+ private toggleAccountMenu (event: MouseEvent) {
+ event.preventDefault()
+ if (!this.hasAccountMenuItems()) return
+ this.helpMenuOpen = false
+ this.accountMenuOpen = !this.accountMenuOpen
+ }
+
+ private toggleHelpMenu (event: MouseEvent) {
+ event.preventDefault()
+ if (!this.hasHelpMenuItems()) return
+ this.accountMenuOpen = false
+ this.helpMenuOpen = !this.helpMenuOpen
+ }
+
+ private hasAccountMenuItems () {
+ return Boolean(this.accountMenu?.length || this.hasSlottedAccountMenu || this.logoutLabel)
+ }
+
+ private hasHelpMenuItems () {
+ return Boolean(this.helpMenuList?.length || this.hasSlottedHelpMenu)
+ }
+
+ private shouldRenderHelpMenu () {
+ return this.layout !== 'mobile' && this.hasHelpMenuItems()
+ }
+
+ private renderLoggedInAvatar (avatar?: string, wrapperClass = 'account-avatar') {
+ const hasAvatar = Boolean(avatar)
+ const imageSrc = hasAvatar ? avatar : this.accountAvatarFallback || DEFAULT_LOGGEDIN_MENU_BUTTON_AVATAR
+ const imageAlt = hasAvatar ? 'Profile Avatar' : 'Default Avatar'
+
+ if (this.layout === 'mobile' && wrapperClass === 'account-avatar') {
+ return html`
`
+ }
+
+ return html`
+
+
+
+ `
+ }
+
+ private renderLoggedOutActions () {
+ return html`
+
+
+ this.handleLoginSuccess()}"
+ >
+
+
+
+
+
+ `
+ }
+
+ private handleLoginSuccess () {
+ this.authState = 'logged-in'
+ this.dispatchEvent(new CustomEvent('auth-action-select', {
+ detail: { role: 'login' },
+ bubbles: true,
+ composed: true
+ }))
+ }
+
+ private async handleLogout () {
+ this.accountMenuOpen = false
+ try {
+ await authSession.logout()
+ } catch (_err) {
+ // logout errors are non-fatal — proceed to clear state
+ }
+ this.authState = 'logged-out'
+ this.dispatchEvent(new CustomEvent('logout-select', {
+ detail: { role: 'logout' },
+ bubbles: true,
+ composed: true
+ }))
+ }
+
+ private renderAccountMenuItem (item: HeaderAccountMenuItem) {
+ const content = html`
+ ${this.renderLoggedInAvatar(item.avatar, 'account-menu-avatar')}
+
+ `
+
+ if (item.url) {
+ return html`
+
+ `
+ }
+
+ return html`
+
+ `
+ }
+
+ private renderLoggedInActions () {
+ return html`
+
+ `
+ }
+
+ private renderUserArea () {
+ if (this.authState === 'logged-out') {
+ return this.renderLoggedOutActions()
+ }
+
+ return this.renderLoggedInActions()
+ }
+
+ protected firstUpdated () {
+ const brandLink = this.shadowRoot?.getElementById('brandLink')
+ if (brandLink) {
+ brandLink.classList.toggle('brand-not-displayed', this.layout === 'mobile')
+ }
+ }
+
+ protected updated (changedProperties: Map) {
+ if (changedProperties.has('layout')) {
+ const brandLink = this.shadowRoot?.getElementById('brandLink')
+ if (brandLink) {
+ brandLink.classList.toggle('brand-not-displayed', this.layout === 'mobile')
+ }
+ }
+ }
+
+ render () {
+ return html`
+
+ `
+ }
+}
diff --git a/src/v2/components/header/README.md b/src/v2/components/header/README.md
new file mode 100644
index 000000000..a7ae3900f
--- /dev/null
+++ b/src/v2/components/header/README.md
@@ -0,0 +1,208 @@
+# solid-ui-header component
+
+A Lit-based custom element that renders the Solid application header, including branding, auth actions, account management, and a help menu.
+
+When `layout="mobile"`, the header hides the help menu entirely, even if `helpMenuList` items or `help-menu` slotted content are provided. In this mode the header also omits icons from the built-in login/sign-up buttons, hides the account webid text in the account dropdown, and hides the logout icon.
+
+When `auth-state="logged-out"`, the header renders a `` as the login action. The login button opens a Solid IDP selection popup and handles the full OIDC login flow via `solid-logic`. On success it emits a `login-success` event and the header transitions to `logged-in` state automatically.
+
+## Installation
+
+```bash
+npm install solid-ui
+```
+
+## Usage in a bundled project (webpack, Vite, Rollup, etc.)
+
+Import once to register the custom element and get access to the types:
+
+```javascript
+import { Header } from 'solid-ui/components/header'
+```
+
+The header automatically imports and registers `` — no separate import is needed.
+
+Then use the element in HTML or in your framework templates:
+
+```html
+
+ Help
+
+```
+
+## Usage in a plain HTML page (CDN / script tag)
+
+The UMD/standalone bundles externalize `rdflib` and `solid-logic`, and `` renders `` when `auth-state="logged-out"`. Load those dependencies first so the login button works correctly.
+
+```html
+
+
+
+
+
+```
+
+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. If `help-icon` is empty or not provided, the help trigger renders the text `Help` instead.
+- `brandLink`: URL string for the brand link (default: `#`).
+- `layout`: `desktop` or `mobile`. Mobile layout hides the brand logo link, does not render the help menu, omits icons from the built-in login and sign-up buttons, hides the account webid text in the dropdown, and hides the logout icon.
+- `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. The optional `icon` field supplies a left-aligned icon URL for the rendered login button, but icons are hidden on mobile layout.
+- `signUpAction`: object for the logged-out Sign Up action. The `label` field sets the button text, the `url` field (default: `https://solidproject.org/get_a_pod`) is the destination opened in a new tab when the button is clicked, and the optional `icon` field supplies a left-aligned icon URL for the rendered signup button, but icons are hidden on mobile layout.
+- `accountLabel`: label for the logged-in dropdown trigger (default: `Accounts`).
+- `accountAvatar`: avatar URL used as the logged-in dropdown icon.
+- `accountAvatarFallback`: avatar URL used when `accountAvatar` is not provided. This replaces the internal default profile placeholder image.
+- `loginIcon`: optional URL string for a left-aligned icon on the login action button, taking precedence over `loginAction.icon`. Icons are still hidden on mobile layout.
+- `signUpIcon`: optional URL string for a left-aligned icon on the sign-up action button, taking precedence over `signUpAction.icon`. Icons are still hidden on mobile layout.
+- `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.
+- `logoutIcon`: URL string for a left-aligned icon displayed in the logout menu item. Hidden on mobile layout.
+
+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..653da907f
--- /dev/null
+++ b/src/v2/components/header/header.test.ts
@@ -0,0 +1,307 @@
+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', icon: 'https://example.com/login-icon.svg' }
+ header.signUpAction = { label: 'Sign Up', url: '/signup', icon: 'https://example.com/signup-icon.svg' }
+ header.loginIcon = 'https://example.com/login-icon-top.svg'
+ header.signUpIcon = 'https://example.com/signup-icon-top.svg'
+
+ 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(loginButton.getAttribute('icon')).toBe('https://example.com/login-icon-top.svg')
+ expect(signUpLink.getAttribute('label')).toBe('Sign Up')
+ expect(signUpLink.getAttribute('signup-url')).toBe('/signup')
+ expect(signUpLink.getAttribute('icon')).toBe('https://example.com/signup-icon-top.svg')
+
+ loginButton.dispatchEvent(new CustomEvent('login-success', { bubbles: true, composed: true }))
+
+ expect(authActionSelected).toHaveBeenCalledWith({
+ role: 'login'
+ })
+ })
+
+ it('does not show login or signup icons on mobile layout', async () => {
+ const header = new Header()
+ header.authState = 'logged-out'
+ header.layout = 'mobile'
+ header.loginAction = { label: 'Log in', action: 'login', icon: 'https://example.com/login-icon.svg' }
+ header.signUpAction = { label: 'Sign Up', url: '/signup', icon: 'https://example.com/signup-icon.svg' }
+ header.loginIcon = 'https://example.com/login-icon-top.svg'
+ header.signUpIcon = 'https://example.com/signup-icon-top.svg'
+
+ document.body.appendChild(header)
+ await header.updateComplete
+
+ const shadow = header.shadowRoot
+ const loginButton = shadow?.querySelector('solid-ui-login-button') as HTMLElement
+ const signUpButton = shadow?.querySelector('solid-ui-signup-button') as HTMLElement
+
+ expect(loginButton?.shadowRoot?.querySelector('.login-button-icon')).toBeNull()
+ expect(signUpButton?.shadowRoot?.querySelector('.signup-button-icon')).toBeNull()
+ })
+
+ it('uses a custom fallback avatar when no accountAvatar is configured', async () => {
+ const header = new Header()
+
+ header.authState = 'logged-in'
+ header.accountAvatar = ''
+ header.accountAvatarFallback = 'https://example.com/fallback-avatar.png'
+
+ document.body.appendChild(header)
+ await header.updateComplete
+
+ const shadow = header.shadowRoot
+ const avatarImg = shadow?.querySelector('.account-avatar img') as HTMLImageElement
+
+ expect(avatarImg).not.toBeNull()
+ expect(avatarImg.src).toContain('https://example.com/fallback-avatar.png')
+ })
+
+ 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.logoutIcon = 'https://example.com/logout-icon.svg'
+ 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')
+ expect((lastItem.querySelector('img.logout-action-icon') as HTMLImageElement)?.src).toContain('https://example.com/logout-icon.svg')
+
+ 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('does not render the logout icon on mobile layout', async () => {
+ const header = new Header()
+ header.layout = 'mobile'
+ header.authState = 'logged-in'
+ header.logoutIcon = 'https://example.com/logout-icon.svg'
+ header.logoutLabel = 'Log Out'
+
+ document.body.appendChild(header)
+ await header.updateComplete
+
+ const shadow = header.shadowRoot
+ const trigger = shadow?.getElementById('accountMenuTrigger') as HTMLButtonElement
+ expect(trigger).not.toBeNull()
+
+ trigger.click()
+ await header.updateComplete
+
+ const lastItem = shadow?.querySelectorAll('.account-menu-item-button')[0] as HTMLButtonElement
+ expect(lastItem).not.toBeNull()
+ expect(lastItem.querySelector('img.logout-action-icon')).toBeNull()
+ expect(lastItem.textContent?.trim()).toBe('Log Out')
+ })
+
+ it('does not render account webid on mobile layout', async () => {
+ const header = new Header()
+ header.layout = 'mobile'
+ header.authState = 'logged-in'
+ header.accountMenu = [
+ { label: 'Personal Pod', webid: 'https://pod.example/profile/card#me', action: 'switch-personal' }
+ ]
+
+ document.body.appendChild(header)
+ await header.updateComplete
+
+ const shadow = header.shadowRoot
+ const trigger = shadow?.getElementById('accountMenuTrigger') as HTMLButtonElement
+ expect(trigger).not.toBeNull()
+
+ trigger.click()
+ await header.updateComplete
+
+ const firstItem = shadow?.querySelector('.account-menu-item-button') as HTMLButtonElement
+ expect(firstItem).not.toBeNull()
+ expect(firstItem.querySelector('.account-menu-webid')).toBeNull()
+ expect(firstItem.textContent?.trim()).toBe('Personal Pod')
+ })
+
+ 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.helpIcon = ''
+ 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)
+ expect(helpTrigger?.textContent?.trim()).toBe('Help')
+
+ 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..0fb0d4e3d
--- /dev/null
+++ b/src/v2/components/loginButton/LoginButton.test.ts
@@ -0,0 +1,57 @@
+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()
+ })
+
+ it('renders an icon when the icon property is set', async () => {
+ const loginButton = new LoginButton()
+ loginButton.icon = 'https://example.com/login-icon.svg'
+ document.body.appendChild(loginButton)
+ await loginButton.updateComplete
+
+ const icon = loginButton.shadowRoot?.querySelector('img.login-button-icon') as HTMLImageElement
+ expect(icon).not.toBeNull()
+ expect(icon.src).toContain('https://example.com/login-icon.svg')
+ })
+})
diff --git a/src/v2/components/loginButton/LoginButton.ts b/src/v2/components/loginButton/LoginButton.ts
new file mode 100644
index 000000000..ebe5f33b8
--- /dev/null
+++ b/src/v2/components/loginButton/LoginButton.ts
@@ -0,0 +1,511 @@
+import { LitElement, html, css } from 'lit'
+import { authSession, authn, getSuggestedIssuers, offlineTestID, solidLogicSingleton } from 'solid-logic'
+import { phoneIcon as downArrowIcon } from './downArrow'
+
+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 },
+ icon: { type: String, reflect: true },
+ _popupOpen: { state: true },
+ _issuerInputValue: { state: true },
+ _dropdownOpen: { state: true }
+ }
+
+ static styles = css`
+ :host { // default theme
+ display: inline-block;
+ position: relative;
+ z-index: 400;
+ --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.6);
+ --issuer-input-background: var(--color-background, #F8F9FB);
+ --issuer-input-text: var(--color-text, #1A1A1A);
+ --issuer-input-border: var(--color-text, #1A1A1A);
+ --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, #1A1A1A);
+ --issuer-placeholder-color: var(--grey-purple-700, #5e546d);;
+ --error-text-color: var(--color-error, #B00020);
+ }
+
+ :host([theme='dark']) {
+ display: inline-block;
+ position: relative;
+ z-index: 900;
+ --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.6);
+ --issuer-input-background: var(--color-background, #F8F9FB);
+ --issuer-input-text: var(--color-text, #1A1A1A);
+ --issuer-input-border: var(--color-text, #1A1A1A);
+ --issuer-button-background: var(--color-background, #F8F9FB);
+ --issuer-button-text: var(--color-text, #1A1A1A);
+ --issuer-button-border: var(--color-text, #1A1A1A);
+ --issuer-button-hover-background: var(--lavender-900, #7c4cff);
+ --issuer-label-color: var(--grey-purple-700, #1A1A1A);
+ --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-icon {
+ width: 16px;
+ height: 16px;
+ display: inline-block;
+ object-fit: contain;
+ }
+
+ .login-button:active {
+ transform: translateY(1px);
+ }
+
+ .popup-dialog {
+ border: none;
+ padding: 0;
+ background: transparent;
+ outline: none;
+ overflow: visible;
+ max-height: none;
+ max-width: none;
+ }
+
+ .popup-dialog::backdrop {
+ background: var(--popup-overlay-background, rgba(0, 0, 0, 0.6));
+ }
+
+ .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-md, 0.5rem);
+ min-width: 480px;
+ 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;
+ padding: 1rem;
+ background: var(--gray-200, #E5E7EB);
+ }
+
+ .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 {
+ display: flex;
+ flex-direction: column;
+ padding: 1rem 1rem 1.75rem;
+ }
+
+ .issuer-text-label {
+ color: var(--issuer-label-color);
+ margin-bottom: 6px;
+ }
+
+ .issuer-text-row {
+ display: flex;
+ flex-direction: row;
+ gap: 6px;
+ align-items: flex-start;
+ }
+
+ .issuer-input-wrapper {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ position: relative;
+ }
+
+ .issuer-input-field-row {
+ display: flex;
+ flex-direction: row;
+ position: relative;
+ }
+
+ .issuer-text-input {
+ flex: 1;
+ padding: 0.375rem 2.75rem 0.375rem 0.5rem;
+ border: 1px solid var(--issuer-input-border);
+ border-radius: var(--border-radius-base, 0.3125rem);
+ background: var(--issuer-input-background);
+ color: var(--issuer-input-text);
+ font: inherit;
+ min-width: 0;
+ }
+
+ .issuer-text-input::placeholder {
+ color: var(--issuer-placeholder-color);
+ }
+
+ .issuer-dropdown-toggle {
+ position: absolute;
+ right: 6px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 26px;
+ height: 26px;
+ padding: 0;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ border-radius: var(--border-radius-base, 0.3125rem);
+ }
+
+ .issuer-dropdown-toggle:hover {
+ background: var(--color-header-menu-item-hover, #e6dcff);
+ }
+
+ .issuer-dropdown-toggle svg {
+ width: 14px;
+ height: 14px;
+ display: block;
+ }
+
+ .issuer-dropdown-list {
+ position: absolute;
+ top: calc(100% + 6px);
+ left: 0;
+ right: 0;
+ border: 1px solid var(--color-border, #E5E7EB);
+ border-top: none;
+ border-radius: 0 0 var(--border-radius-base, 0.3125rem) var(--border-radius-base, 0.3125rem);
+ background: var(--issuer-input-background);
+ overflow: visible;
+ z-index: 10;
+ box-shadow: 0 4px 12px rgba(124, 77, 255, 0.12);
+ }
+
+ .issuer-dropdown-item {
+ display: block;
+ width: 100%;
+ padding: 0.625rem 0.75rem;
+ border: none;
+ border-bottom: 1px solid var(--color-border, #E5E7EB);
+ background: transparent;
+ color: var(--issuer-button-text);
+ cursor: pointer;
+ font: inherit;
+ text-align: left;
+ box-sizing: border-box;
+ }
+
+ .issuer-dropdown-item:last-child {
+ border-bottom: none;
+ }
+
+ .issuer-dropdown-item:hover {
+ background: var(--color-header-menu-item-hover, #e6dcff);
+ border-radius: var(--border-radius-base-md, 0.5rem);
+ }
+
+ .popup-footer {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ gap: 8px;
+ padding: 0.75rem 1rem 1rem;
+ }
+
+ .popup-footer-hr {
+ margin: 0;
+ border: none;
+ border-top: 1px solid var(--popup-border, #E5E7EB);
+ }
+
+ .popup-cancel-button {
+ padding: 0.5rem 1.25rem;
+ border: 1px solid #C0BFC7;
+ border-radius: var(--border-radius-base, 0.3125rem);
+ background: var(--popup-background);
+ color: #314158;
+ cursor: pointer;
+ font: inherit;
+ }
+
+ .popup-cancel-button:hover {
+ background: #D1D5DB;
+ }
+
+ .popup-login-button {
+ padding: 0.5rem 1.25rem;
+ border: none;
+ border-radius: var(--border-radius-base, 0.3125rem);
+ background: var(--lavender-900, #7c4cff);
+ color: #ffffff;
+ cursor: pointer;
+ font: inherit;
+ }
+
+ .popup-login-button:hover {
+ background: #6a3de8;
+ }
+
+ .popup-login-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ .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 icon: string
+ declare _popupOpen: boolean
+ declare _issuerInputValue: string
+ declare _dropdownOpen: boolean
+
+ 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.icon = ''
+ this._popupOpen = false
+ this._issuerInputValue = ''
+ this._dropdownOpen = false
+ }
+
+ connectedCallback () {
+ super.connectedCallback()
+ }
+
+ disconnectedCallback () {
+ super.disconnectedCallback()
+ }
+
+ 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
+ }
+
+ updated () {
+ const dialog = this.shadowRoot?.querySelector('dialog') as HTMLDialogElement | null
+ if (!dialog) return
+ if (this._popupOpen && !dialog.open) {
+ dialog.showModal()
+ } else if (!this._popupOpen && dialog.open) {
+ dialog.close()
+ }
+ }
+
+ 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._dropdownOpen = false
+ this._loginToIssuer(this._issuerInputValue)
+ }
+
+ private _toggleDropdown () {
+ this._dropdownOpen = !this._dropdownOpen
+ }
+
+ private _selectIssuerFromDropdown (uri: string) {
+ this._issuerInputValue = uri
+ this._dropdownOpen = false
+ }
+
+ 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`
+
+
+
+ `
+ }
+}
diff --git a/src/v2/components/loginButton/README.md b/src/v2/components/loginButton/README.md
new file mode 100644
index 000000000..82b58fffe
--- /dev/null
+++ b/src/v2/components/loginButton/README.md
@@ -0,0 +1,120 @@
+# 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. |
+| `icon` | `icon` | `string` | `''` | URL of a decorative icon displayed on the left side of the button text. When used inside ``, the header suppresses the icon. |
+| `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 |
+|-----------------------------------|-------------------------------------|-------------|
+| `--login-button-background` | `--lavender-900` / `#7c4cff` | Login button background colour |
+| `--login-button-text` | `--color-header-text` / `#ffffff` | Login button text colour |
+| `--popup-background` | `--color-background` / `#F8F9FB` | Popup background colour |
+| `--popup-text` | `--color-text` / `#1A1A1A` | Popup text colour |
+| `--popup-border` | `--color-border` / `#E5E7EB` | Popup border colour |
+| `--popup-shadow` | `--box-shadow-sm` / `0 1px 4px …` | Popup box shadow |
+| `--popup-overlay-background` | `rgba(0, 0, 0, 0.6)` | Modal backdrop colour |
+| `--issuer-input-background` | `--color-background` / `#F8F9FB` | IDP input background |
+| `--issuer-input-text` | `--color-text` / `#1A1A1A` | IDP input text colour |
+| `--issuer-input-border` | `--color-text` / `#1A1A1A` | IDP input border colour |
+| `--issuer-button-hover-background`| `--lavender-900` / `#7c4cff` | Dropdown item hover background |
+| `--issuer-label-color` | `--grey-purple-700` / `#1A1A1A` | IDP label text colour |
+| `--issuer-placeholder-color` | `--grey-purple-700` / `#5e546d` | IDP input placeholder colour |
+| `--error-text-color` | `--color-error` / `#B00020` | Validation error text colour |
+
+### 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. When the header is in `mobile` layout, its built-in login button suppresses the `icon`.
+
+## Popup behaviour
+
+- Opens a native `