From b37753ba42c540bb71d078bb6a871f459e85e1eb Mon Sep 17 00:00:00 2001 From: Mandar Deolalikar <11888634+dmandar@users.noreply.github.com> Date: Fri, 8 May 2026 14:02:41 -0700 Subject: [PATCH] feat: modernize A2UI local uploader dashboard and integrate workspace_settings sample --- samples/client/lit/shell/app.ts | 439 +++++++++++++++++- samples/client/lit/shell/configs/configs.ts | 1 + samples/client/lit/shell/configs/local.ts | 23 + .../shell/public/samples/contact_card.json | 269 +++++++++++ .../public/samples/workspace_settings.json | 165 +++++++ 5 files changed, 890 insertions(+), 7 deletions(-) create mode 100644 samples/client/lit/shell/configs/local.ts create mode 100644 samples/client/lit/shell/public/samples/contact_card.json create mode 100644 samples/client/lit/shell/public/samples/workspace_settings.json diff --git a/samples/client/lit/shell/app.ts b/samples/client/lit/shell/app.ts index ec05ba2a2..98f450514 100644 --- a/samples/client/lit/shell/app.ts +++ b/samples/client/lit/shell/app.ts @@ -24,7 +24,7 @@ import { SnackbarUUID, SnackType, } from '../custom-components-example/types/types.js'; -import {type Snackbar} from '../custom-components-example/ui/snackbar.js'; +import {Snackbar} from '../custom-components-example/ui/snackbar.js'; import {repeat} from 'lit/directives/repeat.js'; // A2UI @@ -34,17 +34,21 @@ import {renderMarkdown} from '@a2ui/markdown-it'; // Configurations import {A2UIClient} from './client.js'; -import {restaurantConfig, AppConfig} from './configs/configs.js'; +import {restaurantConfig, localConfig, AppConfig} from './configs/configs.js'; import {styleMap} from 'lit/directives/style-map.js'; - const configs: Record = { restaurant: restaurantConfig, + local: localConfig, }; +type MarkdownRendererFn = (value: string, options?: any) => Promise; + @customElement('a2ui-shell') export class A2UILayoutEditor extends SignalWatcher(LitElement) { @provide({context: Context.markdown}) - accessor markdownRenderer: any = renderMarkdown; + accessor markdownRenderer: MarkdownRendererFn = (value: string, options?: any) => { + return Promise.resolve(renderMarkdown(value, options)); + }; @state() accessor #requesting = false; @@ -59,6 +63,20 @@ export class A2UILayoutEditor extends SignalWatcher(LitElement) { accessor #loadingTextIndex = 0; #loadingInterval: number | undefined; + @state() + accessor #isLocalMode = false; + + @state() + accessor #localFileName = ''; + + @state() + accessor #toastMessage = ''; + + @state() + accessor #toastType = 'info'; + + #toastTimeout: number | undefined; + static styles = [ css` * { @@ -244,6 +262,237 @@ export class A2UILayoutEditor extends SignalWatcher(LitElement) { border-radius: 8px; } + .local-mode-header { + display: flex; + align-items: center; + justify-content: space-between; + background: light-dark(var(--p-95), var(--n-20)); + border: 1px solid light-dark(var(--p-80), var(--n-30)); + padding: 12px 20px; + border-radius: 16px; + margin-bottom: 24px; + animation: fadeIn 0.5s ease-out; + } + + .local-mode-header .file-info { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: light-dark(var(--p-35), var(--n-85)); + } + + .local-mode-header .clear-btn { + background: transparent; + border: none; + cursor: pointer; + color: light-dark(var(--p-30), var(--n-90)); + display: flex; + align-items: center; + padding: 4px; + border-radius: 50%; + transition: background 0.2s; + + &:hover { + background: light-dark(var(--p-90), var(--n-30)); + } + } + + .upload-btn { + background: transparent; + color: var(--p-40); + border: 1px solid var(--p-60); + border-radius: 50%; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + padding: 0; + + &:hover { + background: light-dark(var(--p-95), var(--n-20)); + transform: scale(1.05); + } + } + + .local-header-section { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin-top: 64px; + margin-bottom: 32px; + animation: fadeIn 0.8s cubic-bezier(0, 0, 0.3, 1); + } + + .local-header-section h1 { + margin: 0 0 16px 0; + font-size: 36px; + font-weight: 700; + color: light-dark(var(--p-30), var(--n-90)); + letter-spacing: -0.5px; + } + + .local-header-section p { + margin: 0 0 12px 0; + max-width: 560px; + font-size: 16px; + color: light-dark(var(--n-20), var(--n-90)); + line-height: 1.6; + } + + .local-header-section .support-info { + font-size: 13px; + color: light-dark(var(--n-40), var(--n-70)); + max-width: 560px; + line-height: 1.5; + margin: 0; + } + + .local-upload-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 48px; + background: light-dark(var(--n-98), var(--n-15)); + border: 2px dashed light-dark(var(--p-60), var(--n-35)); + border-radius: 24px; + width: 100%; + max-width: 560px; + margin: 0 auto 64px auto; + animation: fadeIn 0.8s cubic-bezier(0, 0, 0.3, 1) 0.2s backwards; + gap: 24px; + } + + .primary-upload-btn { + display: flex; + align-items: center; + gap: 8px; + background: var(--p-40); + color: var(--n-100); + border: none; + padding: 12px 24px; + border-radius: 32px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + margin-top: 0; + + &:hover { + background: var(--p-30); + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); + } + } + + .samples-section { + margin-top: 24px; + width: 100%; + border-top: 1px solid light-dark(var(--n-90), var(--n-30)); + padding-top: 20px; + } + + .samples-section h3 { + font-size: 13px; + font-weight: 500; + color: light-dark(var(--n-40), var(--n-70)); + margin: 0 0 12px 0; + } + + .samples-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 8px; + width: 100%; + } + + .sample-btn { + background: light-dark(var(--n-95), var(--n-25)); + color: light-dark(var(--p-30), var(--n-90)); + border: 1px solid light-dark(var(--n-85), var(--n-35)); + border-radius: 12px; + padding: 8px 12px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--p-40); + color: var(--n-100); + border-color: var(--p-40); + transform: translateY(-1px); + } + } + + .custom-toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: rgba(30, 32, 35, 0.92); + border: 1px solid rgba(255, 255, 255, 0.12); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + padding: 14px 28px; + border-radius: 16px; + display: flex; + align-items: center; + gap: 16px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + z-index: 2000; + animation: slideUp 0.4s cubic-bezier(0, 0, 0.3, 1); + max-width: 90vw; + pointer-events: auto; + } + + .custom-toast.error { + background: rgba(190, 40, 40, 0.92); + border-color: rgba(255, 255, 255, 0.2); + } + + .toast-text { + color: #ffffff; + font-size: 14px; + font-weight: 500; + } + + .toast-close { + background: transparent; + border: none; + cursor: pointer; + color: #ffffff; + opacity: 0.7; + display: flex; + align-items: center; + padding: 2px; + border-radius: 50%; + transition: + opacity 0.2s, + background-color 0.2s; + + &:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.15); + } + } + + @keyframes slideUp { + from { + transform: translate(-50%, 32px); + opacity: 0; + } + to { + transform: translate(-50%, 0); + opacity: 1; + } + } + @keyframes fadeIn { from { opacity: 0; @@ -266,7 +515,7 @@ export class A2UILayoutEditor extends SignalWatcher(LitElement) { `, ]; - // Create a Message Processor that uses the basic catalog. + // Create a Message Processor that uses the catalogs. #processor = new v0_9.MessageProcessor( [basicCatalog], async (action: v0_9.A2uiClientAction): Promise => { @@ -274,6 +523,11 @@ export class A2UILayoutEditor extends SignalWatcher(LitElement) { const context: Record = {...action.context}; + if (this.#isLocalMode) { + this.showToast(`⚡ Action dispatched: "${action.name}"`, 'info'); + return; + } + // Do we need to update this to a more strict v0.9 type? const message = { userAction: { @@ -290,7 +544,7 @@ export class A2UILayoutEditor extends SignalWatcher(LitElement) { ); #a2uiClient = new A2UIClient(); @query('ui-snackbar') - accessor #snackbar!: Snackbar; + private accessor snackbar!: Snackbar; #pendingSnackbarMessages: Array<{ message: SnackbarMessage; @@ -340,7 +594,7 @@ export class A2UILayoutEditor extends SignalWatcher(LitElement) { protected firstUpdated() { if (this.#pendingSnackbarMessages.length > 0) { for (const {message, replaceAll} of this.#pendingSnackbarMessages) { - this.#snackbar.show(message, replaceAll); + this.snackbar.show(message, replaceAll); } this.#pendingSnackbarMessages = []; } @@ -348,14 +602,31 @@ export class A2UILayoutEditor extends SignalWatcher(LitElement) { render() { return [ + this.#renderLocalModeHeader(), this.#renderThemeToggle(), this.#maybeRenderForm(), this.#maybeRenderData(), this.#maybeRenderError(), + this.#renderToast(), html``, ]; } + #renderLocalModeHeader() { + if (!this.#isLocalMode) return nothing; + + return html` +
+ + Loaded local mockup: ${this.#localFileName} + + +
+ `; + } + #renderThemeToggle() { return html`
+ + + +
+

Or quick-load a built-in sample:

+
+ + +
+
+
+ `; + } return html`
{ @@ -504,4 +824,109 @@ export class A2UILayoutEditor extends SignalWatcher(LitElement) { this.#processor.processMessages(messages); } + + #triggerFileUpload() { + const fileInput = this.shadowRoot?.getElementById('local-file-input') as HTMLInputElement; + if (fileInput) { + fileInput.click(); + } + } + + #onLocalFileChange(evt: Event) { + const fileInput = evt.target as HTMLInputElement; + const file = fileInput.files?.[0]; + if (!file) return; + + this.#localFileName = file.name; + const reader = new FileReader(); + reader.onload = e => { + try { + const content = e.target?.result as string; + const parsed = JSON.parse(content); + const messages = Array.isArray(parsed) ? parsed : [parsed]; + + this.#isLocalMode = true; + + // Clear all existing surfaces + for (const surfaceId of Array.from(this.#processor.model.surfacesMap.keys())) { + this.#processor.model.deleteSurface(surfaceId); + } + + this.#processor.processMessages(messages); + + this.showToast(`Successfully loaded mockup from ${file.name}`, 'info'); + } catch (err) { + console.error(err); + this.showToast( + `Failed to parse A2UI JSON: ${err instanceof Error ? err.message : String(err)}`, + 'error', + ); + } + }; + reader.readAsText(file); + fileInput.value = ''; + } + + #clearLocalFile() { + this.#isLocalMode = false; + this.#localFileName = ''; + for (const surfaceId of Array.from(this.#processor.model.surfacesMap.keys())) { + this.#processor.model.deleteSurface(surfaceId); + } + this.showToast(`Local mockup cleared.`, 'info'); + } + + async #loadBuiltinSample(filename: string) { + try { + this.#localFileName = filename; + const response = await fetch(`/samples/${filename}`); + if (!response.ok) { + throw new Error(`Failed to fetch sample file: ${response.statusText}`); + } + const parsed = await response.json(); + const messages = Array.isArray(parsed) ? parsed : [parsed]; + + this.#isLocalMode = true; + + // Clear all existing surfaces + for (const surfaceId of Array.from(this.#processor.model.surfacesMap.keys())) { + this.#processor.model.deleteSurface(surfaceId); + } + + this.#processor.processMessages(messages); + + this.showToast(`Successfully loaded sample: ${filename}`, 'info'); + } catch (err) { + console.error(err); + this.showToast( + `Failed to load sample JSON: ${err instanceof Error ? err.message : String(err)}`, + 'error', + ); + } + } + + #renderToast() { + if (!this.#toastMessage) return nothing; + + return html` +
+ ${this.#toastMessage} + +
+ `; + } + + showToast(msg: string, type = 'info') { + if (this.#toastTimeout) { + window.clearTimeout(this.#toastTimeout); + } + this.#toastMessage = msg; + this.#toastType = type; + this.#toastTimeout = window.setTimeout(() => { + this.#toastMessage = ''; + this.#toastTimeout = undefined; + }, 4000); + } } diff --git a/samples/client/lit/shell/configs/configs.ts b/samples/client/lit/shell/configs/configs.ts index eab592dfd..0663ea13e 100644 --- a/samples/client/lit/shell/configs/configs.ts +++ b/samples/client/lit/shell/configs/configs.ts @@ -16,3 +16,4 @@ export type {AppConfig} from './types.js'; export {restaurantConfig} from './restaurant.js'; +export {localConfig} from './local.js'; diff --git a/samples/client/lit/shell/configs/local.ts b/samples/client/lit/shell/configs/local.ts new file mode 100644 index 000000000..51e0143a2 --- /dev/null +++ b/samples/client/lit/shell/configs/local.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {AppConfig} from './types.js'; + +export const localConfig: AppConfig = { + key: 'local', + title: 'Local A2UI Previewer', + placeholder: 'Select a local A2UI JSON file to preview...', +}; diff --git a/samples/client/lit/shell/public/samples/contact_card.json b/samples/client/lit/shell/public/samples/contact_card.json new file mode 100644 index 000000000..b743262fd --- /dev/null +++ b/samples/client/lit/shell/public/samples/contact_card.json @@ -0,0 +1,269 @@ +[ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "contact-card", + "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "contact-card", + "components": [ + { + "id": "root", + "component": "Card", + "child": "main_column" + }, + { + "id": "main_column", + "component": "Column", + "children": ["description_column", "div", "info_rows_column", "action_buttons_row"], + "align": "stretch" + }, + { + "id": "profile_image", + "component": "Image", + "url": { + "path": "/imageUrl" + }, + "variant": "avatar", + "fit": "cover" + }, + { + "id": "description_column", + "component": "Column", + "children": ["profile_image", "user_heading", "description_text_1", "description_text_2"], + "align": "center" + }, + { + "id": "user_heading", + "component": "Text", + "weight": 1, + "text": { + "path": "/name" + }, + "variant": "h2" + }, + { + "id": "description_text_1", + "component": "Text", + "text": { + "path": "/title" + } + }, + { + "id": "description_text_2", + "component": "Text", + "text": { + "path": "/team" + } + }, + { + "id": "div", + "component": "Divider" + }, + { + "id": "info_rows_column", + "component": "Column", + "weight": 1, + "children": ["info_row_1", "info_row_2", "info_row_3", "info_row_4"], + "align": "stretch" + }, + { + "id": "info_row_1", + "component": "Row", + "children": ["calendar_icon", "calendar_text_column"], + "justify": "start", + "align": "start" + }, + { + "id": "calendar_icon", + "component": "Icon", + "name": "calendarToday" + }, + { + "id": "calendar_text_column", + "component": "Column", + "children": ["calendar_primary_text", "calendar_secondary_text"], + "justify": "start", + "align": "start" + }, + { + "id": "calendar_primary_text", + "component": "Text", + "variant": "h5", + "text": { + "path": "/calendar" + } + }, + { + "id": "calendar_secondary_text", + "component": "Text", + "text": "Calendar" + }, + { + "id": "info_row_2", + "component": "Row", + "children": ["location_icon", "location_text_column"], + "justify": "start", + "align": "start" + }, + { + "id": "location_icon", + "component": "Icon", + "name": "locationOn" + }, + { + "id": "location_text_column", + "component": "Column", + "children": ["location_primary_text", "location_secondary_text"], + "justify": "start", + "align": "start" + }, + { + "id": "location_primary_text", + "component": "Text", + "variant": "h5", + "text": { + "path": "/location" + } + }, + { + "id": "location_secondary_text", + "component": "Text", + "text": "Location" + }, + { + "id": "info_row_3", + "component": "Row", + "children": ["mail_icon", "mail_text_column"], + "justify": "start", + "align": "start" + }, + { + "id": "mail_icon", + "component": "Icon", + "name": "mail" + }, + { + "id": "mail_text_column", + "component": "Column", + "children": ["mail_primary_text", "mail_secondary_text"], + "justify": "start", + "align": "start" + }, + { + "id": "mail_primary_text", + "component": "Text", + "variant": "h5", + "text": { + "path": "/email" + } + }, + { + "id": "mail_secondary_text", + "component": "Text", + "text": "Email" + }, + { + "id": "info_row_4", + "component": "Row", + "children": ["call_icon", "call_text_column"], + "justify": "start", + "align": "start" + }, + { + "id": "call_icon", + "component": "Icon", + "name": "call" + }, + { + "id": "call_text_column", + "component": "Column", + "children": ["call_primary_text", "call_secondary_text"], + "justify": "start", + "align": "start" + }, + { + "id": "call_primary_text", + "component": "Text", + "variant": "h5", + "text": { + "path": "/mobile" + } + }, + { + "id": "call_secondary_text", + "component": "Text", + "text": "Mobile" + }, + { + "id": "action_buttons_row", + "component": "Row", + "children": ["message-button", "location-button"], + "justify": "center", + "align": "center" + }, + { + "id": "message-button", + "component": "Button", + "child": "message-button-text", + "action": { + "event": { + "name": "send_message", + "context": { + "contactName": { + "path": "/name" + } + } + } + } + }, + { + "id": "message-button-text", + "component": "Text", + "text": "Message" + }, + { + "id": "location-button", + "component": "Button", + "child": "location-button-text", + "action": { + "event": { + "name": "view_location", + "context": { + "contactId": { + "path": "/id" + } + } + } + } + }, + { + "id": "location-button-text", + "component": "Text", + "text": "Location" + } + ] + } + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "contact-card", + "path": "/", + "value": { + "name": "Jane Doe", + "title": "Senior UX Architect", + "team": "Google DeepMind", + "location": "London, UK", + "email": "jane.doe@example.com", + "mobile": "+44 20 7946 0192", + "calendar": "Available until 4:30 PM", + "imageUrl": "/samples/assets/profile4.png" + } + } + } +] diff --git a/samples/client/lit/shell/public/samples/workspace_settings.json b/samples/client/lit/shell/public/samples/workspace_settings.json new file mode 100644 index 000000000..232939903 --- /dev/null +++ b/samples/client/lit/shell/public/samples/workspace_settings.json @@ -0,0 +1,165 @@ +[ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "workspace-settings", + "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "workspace-settings", + "components": [ + { + "id": "root", + "component": "Card", + "child": "main_column" + }, + { + "id": "main_column", + "component": "Column", + "children": [ + "form_title", + "divider_1", + "field_username", + "field_theme", + "field_font_size", + "field_notifications", + "field_sync_date", + "divider_2", + "actions_row" + ], + "align": "stretch" + }, + { + "id": "form_title", + "component": "Text", + "variant": "h2", + "weight": 1, + "text": "Workspace Setup" + }, + { + "id": "divider_1", + "component": "Divider" + }, + { + "id": "field_username", + "component": "TextField", + "label": "Developer Handle", + "value": { + "path": "/username" + } + }, + { + "id": "field_theme", + "component": "ChoicePicker", + "label": "Preferred IDE Theme", + "variant": "singleSelection", + "displayStyle": "chips", + "options": [ + { + "label": "Sleek Dark", + "value": "dark" + }, + { + "label": "Harmonious Light", + "value": "light" + }, + { + "label": "Glassmorphic", + "value": "glass" + } + ], + "value": { + "path": "/theme" + } + }, + { + "id": "field_font_size", + "component": "Slider", + "label": "Editor Font Size (px)", + "min": 10, + "max": 24, + "value": { + "path": "/fontSize" + } + }, + { + "id": "field_notifications", + "component": "CheckBox", + "label": "Enable Real-Time Build Alerts", + "value": { + "path": "/alertsEnabled" + } + }, + { + "id": "field_sync_date", + "component": "DateTimeInput", + "label": "Weekly Pair Programming Sync", + "enableDate": true, + "enableTime": true, + "value": { + "path": "/syncTime" + } + }, + { + "id": "divider_2", + "component": "Divider" + }, + { + "id": "actions_row", + "component": "Row", + "children": ["save_button"], + "justify": "end" + }, + { + "id": "save_button", + "component": "Button", + "child": "save_button_text", + "action": { + "event": { + "name": "save_settings", + "context": { + "username": { + "path": "/username" + }, + "theme": { + "path": "/theme" + }, + "fontSize": { + "path": "/fontSize" + }, + "alertsEnabled": { + "path": "/alertsEnabled" + }, + "syncTime": { + "path": "/syncTime" + } + } + } + } + }, + { + "id": "save_button_text", + "component": "Text", + "text": "Save Workspace Preferences" + } + ] + } + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "workspace-settings", + "path": "/", + "value": { + "username": "antigravity_dev", + "theme": ["dark"], + "fontSize": 14, + "alertsEnabled": true, + "syncTime": "2026-05-08T14:00" + } + } + } +]