diff --git a/src/shared/generated/entity_schemas.json b/src/shared/generated/entity_schemas.json index 3ef7d8b32..585466382 100644 --- a/src/shared/generated/entity_schemas.json +++ b/src/shared/generated/entity_schemas.json @@ -1,7 +1,7 @@ { "$schemaVersion": 1, - "$generatedAt": "2026-04-16T16:01:24.629Z", - "$sha256": "8cf44380640f9ba2f2e56548259b69d71c31b22c4a9553a74e92d23a82033f20", + "$generatedAt": "2026-05-13T17:01:40.910Z", + "$sha256": "27d02233ae3839f7fad6affbd9b4e308a7a08c3bb72329aafa2cb39fcbcd3217", "entities": { "users": { "collection": "users", @@ -147,6 +147,13 @@ "nullable": true, "references": "genomes.id" } + }, + { + "fieldName": "hasOnboarded", + "fieldType": "boolean", + "options": { + "nullable": true + } } ], "compositeIndexes": [], diff --git a/src/system/data/entities/UserEntity.ts b/src/system/data/entities/UserEntity.ts index 670260918..589f7b4e7 100644 --- a/src/system/data/entities/UserEntity.ts +++ b/src/system/data/entities/UserEntity.ts @@ -96,6 +96,7 @@ import { EnumField, JsonField, ForeignKeyField, + BooleanField, TEXT_LENGTH } from '../decorators/FieldDecorators'; import { BaseEntity } from './BaseEntity'; @@ -174,6 +175,12 @@ export class UserEntity extends BaseEntity { @ForeignKeyField({ references: 'genomes.id', nullable: true }) genomeId?: UUID; + // First-run onboarding state. Per-user, cross-device — the welcome + // modal is shown when this is falsy and set to true when the user + // completes (or dismisses) the introduction. Tracked under #1101. + @BooleanField({ nullable: true }) + hasOnboarded?: boolean; + // ✨ DECORATOR-DRIVEN AUTO-JOIN: Profile always included (future: @JoinField decorator) // For now, manually joined - decorator system will automate this profile?: UserProfileEntity; diff --git a/src/widgets/chat/chat-widget/ChatWidget.ts b/src/widgets/chat/chat-widget/ChatWidget.ts index 58c591d46..4b011cf2e 100644 --- a/src/widgets/chat/chat-widget/ChatWidget.ts +++ b/src/widgets/chat/chat-widget/ChatWidget.ts @@ -29,6 +29,7 @@ import { ImageMessageAdapter } from '../adapters/ImageMessageAdapter'; import { URLCardAdapter } from '../adapters/URLCardAdapter'; import { ToolOutputAdapter } from '../adapters/ToolOutputAdapter'; import { TextMessageAdapter } from '../adapters/TextMessageAdapter'; +import '../../shared/EmptyStateWidget'; import { MessageInputEnhancer } from '../message-input/MessageInputEnhancer'; import { MentionAutocomplete } from '../message-input/MentionAutocomplete'; import { AIStatusIndicator } from './AIStatusIndicator'; @@ -971,6 +972,17 @@ export class ChatWidget extends EntityScrollerWidget { + + +
${this.renderFooter()} @@ -989,6 +1001,23 @@ export class ChatWidget extends EntityScrollerWidget { `; } + /** + * Toggle the empty-state panel on top of the standard count-badge + * update. The base implementation only updates the .list-count text; + * we also reveal the "Send your first message" panel when the room + * has zero messages so a new user isn't staring at a blank surface. + * Called after the initial scroller load and after every CRUD event + * — the messages-container is hidden via CSS sibling rules during + * the empty state to avoid a stacked-empty-box look. + */ + protected override updateEntityCount(): void { + super.updateEntityCount(); + const emptyState = this.shadowRoot?.getElementById('chatEmptyState') as HTMLElement | null; + if (!emptyState) return; + const isEmpty = this.getEntityCount() === 0; + emptyState.toggleAttribute('hidden', !isEmpty); + } + /** * Render thumbnail chips for pendingAttachments above the textarea. * Image attachments get a thumbnail; non-image attachments get a filename chip. diff --git a/src/widgets/chat/room-list/RoomListWidget.ts b/src/widgets/chat/room-list/RoomListWidget.ts index f5dfb0368..ffe9a1d25 100644 --- a/src/widgets/chat/room-list/RoomListWidget.ts +++ b/src/widgets/chat/room-list/RoomListWidget.ts @@ -16,6 +16,7 @@ import { type TemplateResult, type CSSResultGroup } from '../../shared/ReactiveListWidget'; +import '../../shared/EmptyStateWidget'; import { RoomEntity } from '../../../system/data/entities/RoomEntity'; import { UserEntity } from '../../../system/data/entities/UserEntity'; import { CONTENT_TYPE_CONFIGS, type ContentType } from '../../../shared/generated/ContentTypes'; @@ -116,6 +117,24 @@ export class RoomListWidget extends ReactiveListWidget { this.scroller?.load(); } + // === EMPTY STATE === (#1101) + protected override renderEmptyState(): TemplateResult { + // Copy depends on which filter is active so the message matches what + // the user is looking at. The "create your first room" CTA is left + // unwired for now — emits an event the parent can listen for once + // room-creation UX lands. + const isDmFilter = this.activeFilter === 'dms'; + return html` + + `; + } + // === ITEM === renderItem(room: RoomEntity): TemplateResult { if (this.isDM(room)) { diff --git a/src/widgets/chat/user-list/UserListWidget.ts b/src/widgets/chat/user-list/UserListWidget.ts index e943c42f5..95734e938 100644 --- a/src/widgets/chat/user-list/UserListWidget.ts +++ b/src/widgets/chat/user-list/UserListWidget.ts @@ -16,6 +16,7 @@ import { type TemplateResult, type CSSResultGroup } from '../../shared/ReactiveListWidget'; +import '../../shared/EmptyStateWidget'; import { render } from 'lit'; import type { RenderFn, RenderContext } from '../../shared/EntityScroller'; import { UserEntity } from '../../../system/data/entities/UserEntity'; @@ -177,12 +178,27 @@ export class UserListWidget extends ReactiveListWidget { return html`
${this.renderHeader()} -
+
+ ${this.isEmpty ? this.renderEmptyState() : nothing} ${this.renderFooter()}
`; } + // === EMPTY STATE === (#1101) + protected override renderEmptyState(): TemplateResult { + const filterActive = this.activeFilters.size > 0 && !this.activeFilters.has('all'); + return html` + + `; + } + // === ITEM RENDERING === renderItem(user: UserEntity): TemplateResult { const displayName = user.displayName ?? 'Unknown User'; diff --git a/src/widgets/shared/EmptyStateWidget.ts b/src/widgets/shared/EmptyStateWidget.ts new file mode 100644 index 000000000..ecea71a9b --- /dev/null +++ b/src/widgets/shared/EmptyStateWidget.ts @@ -0,0 +1,128 @@ +/** + * EmptyStateWidget — generic "no items yet" panel. + * + * Drop into any list or content area that can be empty (no messages, + * no rooms, no personas). The user sees an icon, a title, an optional + * subtitle, and an optional action button instead of an unexplained + * blank surface. + * + * Properties: + * - icon: string — emoji or single character (decorative, aria-hidden) + * - emptyTitle: string — heading text + * - subtitle: string — explanatory text under the heading (optional) + * - actionLabel: string — text on the call-to-action button. If empty, + * no button is rendered. + * + * Events: + * - empty-state-action: fired when the action button is clicked + * + * Slots: + * - default: extra content rendered below the subtitle + * + * Introduced under #1101 (first-run UX) as part of PR-A. + */ + +import { LitElement, html, css, type TemplateResult } from 'lit'; + +export class EmptyStateWidget extends LitElement { + static override properties = { + icon: { type: String }, + emptyTitle: { type: String, attribute: 'empty-title' }, + subtitle: { type: String }, + actionLabel: { type: String, attribute: 'action-label' }, + } as const; + + icon = ''; + emptyTitle = ''; + subtitle = ''; + actionLabel = ''; + + static override styles = css` + :host { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 32px 24px; + text-align: center; + color: var(--text-muted, rgba(255, 255, 255, 0.55)); + min-height: 200px; + } + + .empty-icon { + font-size: 2.5em; + line-height: 1; + opacity: 0.7; + } + + .empty-title { + font-size: 1.1em; + font-weight: 600; + margin: 0; + color: var(--text-primary, #e0e0e0); + } + + .empty-subtitle { + font-size: 0.92em; + max-width: 42ch; + margin: 0; + line-height: 1.45; + } + + .empty-action { + margin-top: 8px; + padding: 8px 16px; + background: var(--accent-color, #4a9eff); + color: var(--button-text, #fff); + border: 0; + border-radius: 6px; + cursor: pointer; + font-size: 0.95em; + font-weight: 500; + } + + .empty-action:hover { + filter: brightness(1.08); + } + + .empty-action:focus-visible { + outline: 2px solid var(--accent-color, #4a9eff); + outline-offset: 2px; + } + `; + + private onActionClick(): void { + this.dispatchEvent(new CustomEvent('empty-state-action', { bubbles: true, composed: true })); + } + + override render(): TemplateResult { + return html` + ${this.icon + ? html`` + : null} + ${this.emptyTitle + ? html`

${this.emptyTitle}

` + : null} + ${this.subtitle + ? html`

${this.subtitle}

` + : null} + + ${this.actionLabel + ? html`` + : null} + `; + } +} + +customElements.define('empty-state', EmptyStateWidget); + +declare global { + interface HTMLElementTagNameMap { + 'empty-state': EmptyStateWidget; + } +} diff --git a/src/widgets/shared/ModalWidget.ts b/src/widgets/shared/ModalWidget.ts new file mode 100644 index 000000000..cd3292d6b --- /dev/null +++ b/src/widgets/shared/ModalWidget.ts @@ -0,0 +1,271 @@ +/** + * ModalWidget — generic Lit modal dialog. + * + * Reactive `open` property. When opened, traps focus inside, restores + * focus on close, listens for Escape and backdrop clicks. Accessible + * by default: role="dialog", aria-modal="true", aria-labelledby on the + * title. + * + * Slots: + * - default: modal body content + * - footer: action buttons (optional) + * + * Properties: + * - open: boolean — whether the modal is visible + * - modalTitle: string — title text (drives aria-labelledby) + * - closable: boolean — whether the user can dismiss via X / Escape / + * backdrop. Set false for required flows. Defaults true. + * + * Events: + * - modal-close: fired when the user dismisses the modal + * + * Introduced under #1101 (first-run UX) as part of PR-A. Designed to + * be reusable for any future modal need — settings dialogs, confirms, + * onboarding flows. + */ + +import { LitElement, html, css, type TemplateResult } from 'lit'; + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'textarea:not([disabled])', + 'select:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +export class ModalWidget extends LitElement { + static override properties = { + open: { type: Boolean, reflect: true }, + modalTitle: { type: String, attribute: 'modal-title' }, + closable: { type: Boolean }, + } as const; + + open = false; + modalTitle = ''; + closable = true; + + private _previouslyFocused: HTMLElement | null = null; + private _onKeyDown = (e: KeyboardEvent) => this.handleKeyDown(e); + + static override styles = css` + :host { + display: contents; + } + + .modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + animation: fade-in 120ms ease-out; + } + + .modal-dialog { + background: var(--surface-primary, #1e1e1e); + color: var(--text-primary, #e0e0e0); + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1)); + border-radius: 10px; + min-width: 320px; + max-width: min(560px, 90vw); + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.45); + animation: zoom-in 150ms cubic-bezier(0.2, 0.9, 0.2, 1.1); + } + + .modal-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1)); + } + + .modal-title { + flex: 1; + font-size: 1.1em; + font-weight: 600; + margin: 0; + } + + .modal-close { + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + font-size: 1.2em; + padding: 4px 8px; + border-radius: 4px; + line-height: 1; + } + + .modal-close:hover { + background: rgba(255, 255, 255, 0.08); + } + + .modal-body { + padding: 16px; + overflow-y: auto; + flex: 1; + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1)); + } + + .modal-footer:empty { + display: none; + } + + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes zoom-in { + from { transform: scale(0.96); opacity: 0; } + to { transform: scale(1); opacity: 1; } + } + `; + + override connectedCallback(): void { + super.connectedCallback(); + document.addEventListener('keydown', this._onKeyDown); + } + + override disconnectedCallback(): void { + document.removeEventListener('keydown', this._onKeyDown); + super.disconnectedCallback(); + } + + override updated(changed: Map): void { + if (changed.has('open')) { + if (this.open) { + this._previouslyFocused = (this.getRootNode() as Document).activeElement as HTMLElement | null; + // Defer focusing to next paint so the dialog is in the DOM. + requestAnimationFrame(() => this.focusFirstElement()); + } else if (this._previouslyFocused) { + this._previouslyFocused.focus?.(); + this._previouslyFocused = null; + } + } + } + + private handleKeyDown(e: KeyboardEvent): void { + if (!this.open) return; + if (e.key === 'Escape' && this.closable) { + e.stopPropagation(); + this.requestClose(); + return; + } + if (e.key === 'Tab') { + this.trapFocus(e); + } + } + + private trapFocus(e: KeyboardEvent): void { + const focusable = this.getFocusableElements(); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = this.shadowRoot?.activeElement as HTMLElement | null; + if (e.shiftKey && active === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + } + + private getFocusableElements(): HTMLElement[] { + const dialog = this.shadowRoot?.querySelector('.modal-dialog'); + if (!dialog) return []; + return Array.from(dialog.querySelectorAll(FOCUSABLE_SELECTOR)); + } + + private focusFirstElement(): void { + const focusable = this.getFocusableElements(); + if (focusable.length > 0) { + focusable[0].focus(); + } else { + // Fallback: focus the dialog itself so Escape still works + (this.shadowRoot?.querySelector('.modal-dialog') as HTMLElement | null)?.focus(); + } + } + + /** + * Programmatic close — also fires the modal-close event so parents + * can react (e.g., persist `hasOnboarded=true`). + */ + requestClose(): void { + if (!this.closable) return; + this.open = false; + this.dispatchEvent(new CustomEvent('modal-close', { bubbles: true, composed: true })); + } + + private onBackdropClick(e: MouseEvent): void { + if (e.target === e.currentTarget) { + this.requestClose(); + } + } + + override render(): TemplateResult | null { + if (!this.open) return null; + const titleId = `modal-title-${this.uniqueId}`; + return html` + + `; + } + + // Stable id per instance — used for aria-labelledby. Random suffix + // so two modals on the same page don't collide. + private readonly uniqueId = Math.random().toString(36).slice(2, 10); +} + +customElements.define('modal-widget', ModalWidget); + +declare global { + interface HTMLElementTagNameMap { + 'modal-widget': ModalWidget; + } +} diff --git a/src/widgets/shared/ReactiveEntityScrollerWidget.ts b/src/widgets/shared/ReactiveEntityScrollerWidget.ts index 9671e255e..8a940d53f 100644 --- a/src/widgets/shared/ReactiveEntityScrollerWidget.ts +++ b/src/widgets/shared/ReactiveEntityScrollerWidget.ts @@ -187,6 +187,16 @@ export abstract class ReactiveEntityScrollerWidget extends // === Convenience methods === /** Get current entity count (reactive — triggers re-render when changed) */ + /** + * True when the scroller has finished its first load AND has zero + * entities. Subclasses use this to decide whether to render an + * empty-state UI. Distinct from `entityCount === 0` alone, which + * is also true during the brief pre-load window. + */ + protected get isEmpty(): boolean { + return this._scrollerInitialized && this._entityCount === 0; + } + protected get entityCount(): number { return this._entityCount; } diff --git a/src/widgets/shared/ReactiveListWidget.ts b/src/widgets/shared/ReactiveListWidget.ts index 75d47677d..efa38cc7a 100644 --- a/src/widgets/shared/ReactiveListWidget.ts +++ b/src/widgets/shared/ReactiveListWidget.ts @@ -108,15 +108,27 @@ export abstract class ReactiveListWidget extends ReactiveE return nothing; } + /** + * Render the empty-state shown when the scroller has loaded zero + * items. Empty by default — `nothing` means "do not render an empty + * state, leave the container blank." Subclasses override to surface + * a guided empty state (icon + title + subtitle + optional action). + * Introduced under #1101 — see `widgets/shared/EmptyStateWidget.ts`. + */ + protected renderEmptyState(): TemplateResult | typeof nothing { + return nothing; + } + // === MAIN RENDER - Composes header/body/footer === override render(): TemplateResult { return html`
${this.renderHeader()} -
+
+ ${this.isEmpty ? this.renderEmptyState() : nothing} ${this.renderFooter()}
`;