diff --git a/.github/skills/pr/SKILL.md b/.github/skills/pr/SKILL.md index c9c55ee3..1d6c1875 100644 --- a/.github/skills/pr/SKILL.md +++ b/.github/skills/pr/SKILL.md @@ -39,3 +39,6 @@ Closes #43 ``` > **Note:** Issue-linking keywords only work when the PR targets the repository's default branch. See [GitHub docs: linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword) for the full reference. + +## PR description +Remove the Co-author-by line from the PR description. If you want to credit a co-author, add them as a reviewer instead. And check all the changes from its merge-base to get a detailed summary for the commit. \ No newline at end of file diff --git a/.github/skills/quality-gate/SKILL.md b/.github/skills/quality-gate/SKILL.md index d03d8adc..a3538fc2 100644 --- a/.github/skills/quality-gate/SKILL.md +++ b/.github/skills/quality-gate/SKILL.md @@ -1,6 +1,6 @@ --- name: quality-gate -description: Required verification workflow for formatting, linting, tests, dependency audits, and builds. +description: Run before every commit or push: formatting, linting, tests, dependency audits, and builds. --- # Quality Gate Workflow diff --git a/Cargo.lock b/Cargo.lock index ffefc831..83263395 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1960,6 +1960,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ssr-performance-showdown" +version = "0.1.0" +dependencies = [ + "actix-web", + "anyhow", + "serde_json", + "webui-handler", + "webui-protocol", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2536,6 +2547,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "webui-rust-example" +version = "0.1.0" +dependencies = [ + "anyhow", + "serde_json", + "webui-handler", + "webui-protocol", +] + [[package]] name = "webui-state" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index fedc4c3c..e914e0ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,9 @@ [workspace] members = [ - "crates/webui-cli", - "crates/webui-expressions", - "crates/webui-ffi", - "crates/webui-handler", - "crates/webui-node", - "crates/webui-parser", - "crates/webui-protocol", - "crates/webui-state", - "crates/webui-wasm", + "crates/*", "xtask", + "examples/integration/rust", + "examples/integration/ssr-performance-showdown", ] resolver = "2" diff --git a/crates/webui-parser/src/condition_parser.rs b/crates/webui-parser/src/condition_parser.rs index 05c0c216..37a98c54 100644 --- a/crates/webui-parser/src/condition_parser.rs +++ b/crates/webui-parser/src/condition_parser.rs @@ -137,16 +137,6 @@ impl ConditionParser { _ => unreachable!(), }; - // Clean up the right side (if it's a string literal) - let right = if (right.starts_with('"') && right.ends_with('"')) - || (right.starts_with('\'') && right.ends_with('\'')) - { - // Remove quotes - &right[1..right.len() - 1] - } else { - right - }; - let predicate = ConditionExpr::predicate(left, operator, right); return Ok(predicate); @@ -318,7 +308,7 @@ mod tests { assert!(matches!(&result.expr, Some(Expr::Predicate(pred)) if pred.left == "name" && ComparisonOperator::try_from(pred.operator) == Ok(ComparisonOperator::Equal) && - pred.right == "John" + pred.right == "\"John\"" )); } @@ -424,7 +414,7 @@ mod tests { matches!(compound.left.as_ref().and_then(|l| l.expr.as_ref()), Some(Expr::Predicate(pred)) if pred.left == "appearance" && ComparisonOperator::try_from(pred.operator) == Ok(ComparisonOperator::Equal) && - pred.right == "hub" + pred.right == "\"hub\"" ) && LogicalOperator::try_from(compound.op) == Ok(LogicalOperator::And) && matches!(compound.right.as_ref().and_then(|r| r.expr.as_ref()), Some(Expr::Identifier(id)) if id.value == "actions.trailing") diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index c2231c7d..8a831d6e 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -63,6 +63,7 @@ export default { { text: 'Overview', link: '/guide/concepts/handlers/' }, { text: 'Rust', link: '/guide/concepts/handlers/rust' }, { text: 'Node.js', link: '/guide/concepts/handlers/node' }, + { text: 'Electron', link: '/guide/concepts/handlers/electron' }, { text: 'WebAssembly', link: '/guide/concepts/handlers/wasm' }, { text: 'FFI (C API)', link: '/guide/concepts/handlers/ffi' } ] diff --git a/docs/guide/concepts/handlers/electron.md b/docs/guide/concepts/handlers/electron.md new file mode 100644 index 00000000..8aed60f3 --- /dev/null +++ b/docs/guide/concepts/handlers/electron.md @@ -0,0 +1,60 @@ +# WebUI Electron Handler + +WebUI apps can run as native desktop applications using Electron. The Electron integration uses the `webui-node` native addon to render pre-built protocols at startup, then serves the rendered HTML and static assets through a custom `webui://` protocol scheme — no HTTP server needed. + +## How it Works + +1. **Build phase** — `webui build --plugin=fast` compiles templates into `protocol.bin`. esbuild bundles the client JS. +2. **Startup** — Electron's main process loads the native addon (`webui-node`), reads `protocol.bin` + `state.json`, and calls `addon.render()` to produce the full SSR HTML. +3. **Custom protocol** — A `webui://` protocol scheme is registered. When Electron loads `webui://app/`, it serves the rendered HTML. CSS and JS assets are served from the app's `dist/` directory. +4. **Hydration** — The client JS bundle hydrates the SSR output, attaching event listeners and enabling interactivity — same as in a browser. + +## Usage + +```bash +# 1. Build the native addon +cargo build -p webui-node --release + +# 2. Build a WebUI app (e.g., contact-book-manager) +cd examples/app/contact-book-manager +npm run build + +# 3. Run it in Electron +cd examples/integration/electron +npm run build +npx electron dist/main.js ../../app/contact-book-manager/dist ../../app/contact-book-manager/data/state.json --plugin=fast +``` + +## CLI Arguments + +| Argument | Description | Default | +|----------|-------------|---------| +| `dist-dir` | Path to app's `dist/` directory with `protocol.bin` and assets | `../../app/hello-world/dist` | +| `state.json` | Path to state JSON file | `../../app/hello-world/data/state.json` | +| `--plugin=fast` | Enable FAST hydration plugin | None | + +## Custom Titlebar + +The integration uses `titleBarStyle: 'hidden'` with `titleBarOverlay` for a frameless native look. The app's header component uses `-webkit-app-region: drag` to act as the drag handle. Interactive elements within the header use `-webkit-app-region: no-drag` to remain clickable. + +## The `webui://` Protocol + +Electron's custom protocol handler maps routes to content: + +- `webui://app/` → SSR-rendered HTML +- `webui://app/*.css` → CSS files from the dist directory +- `webui://app/*.js` → JS bundles from the dist directory + +This avoids the need for a local HTTP server and provides a clean, secure origin for the app. + +## Example + +A complete working example is available at [`examples/integration/electron/`](https://github.com/user/webui/tree/main/examples/integration/electron). + +The [Contact Book Manager](https://github.com/user/webui/tree/main/examples/app/contact-book-manager) app demonstrates a full-featured WebUI application that works both in the browser (via `webui start`) and as an Electron desktop app. + +## Performance Notes + +- The native addon renders the entire page synchronously at startup — no per-request overhead. +- Protocol data is loaded once and rendered once. The custom protocol handler serves pre-rendered HTML from memory. +- Client-side hydration runs identically to the browser — same JS bundle, same FAST-HTML components. diff --git a/docs/guide/concepts/handlers/index.md b/docs/guide/concepts/handlers/index.md index 29052490..3b1bf9f9 100644 --- a/docs/guide/concepts/handlers/index.md +++ b/docs/guide/concepts/handlers/index.md @@ -8,6 +8,7 @@ WebUI provides official handlers for several popular programming languages (othe - [**Rust**](./rust) - High-performance native rendering with the Rust programming language - [**Node.js**](./node) - Streaming SSR via a native addon built with napi-rs +- [**Electron**](./electron) - Desktop apps via Electron with custom `webui://` protocol - [**WebAssembly**](./wasm) - In-browser rendering for playgrounds and client-side use - [**FFI (C API)**](./ffi) - Shared library for Go, C#, Python, and any language with C interop diff --git a/docs/guide/concepts/handlers/rust.md b/docs/guide/concepts/handlers/rust.md index 97d3bc18..590d9374 100644 --- a/docs/guide/concepts/handlers/rust.md +++ b/docs/guide/concepts/handlers/rust.md @@ -186,3 +186,9 @@ pub enum HandlerError { ``` You can handle these specific error cases to provide better error messages for different failure scenarios. + +## Examples + +Working integration examples are available in the repository: + +- [`examples/integration/rust/`](https://github.com/user/webui/tree/main/examples/integration/rust) — Rust HTTP server integrations (hyper, tiny_http) diff --git a/examples/app/contact-book-manager/README.md b/examples/app/contact-book-manager/README.md new file mode 100644 index 00000000..1bf24098 --- /dev/null +++ b/examples/app/contact-book-manager/README.md @@ -0,0 +1,192 @@ +# Contact Book Manager + +A full-featured contact book manager built with **WebUI SSR** and **FAST-Element** client hydration. Demonstrates Atomic Design component architecture, IndexedDB offline storage, client-side routing, and responsive layout — all rendered server-side with the `--plugin=fast` pipeline. + +## Views + +The app implements 7 views, routed via the `page` attribute on the root `` element: + +| View | Description | +|------|-------------| +| **Dashboard** | Stats row (total contacts, favorites, groups) + 5 most recent contacts | +| **All Contacts** | Searchable list of all contacts | +| **Favorites** | Filtered view of starred contacts | +| **Group View** | Contacts filtered by group (Work, Family, Friends, Other) | +| **Contact Detail** | Full contact profile with edit, favorite, and delete actions | +| **Add Contact** | Empty form to create a new contact | +| **Edit Contact** | Pre-filled form to modify an existing contact | + +The app ships with **15 sample contacts** across **4 groups** (Work, Family, Friends, Other). + +## Architecture + +### Atomic Design + +Components follow [Atomic Design](https://bradfrost.com/blog/post/atomic-web-design/) principles: + +``` +src/ +├── cb-app/ # Root shell — state, routing, event delegation +├── atoms/ # 6 stateless presentational primitives +├── molecules/ # 4 composite atom groupings +└── organisms/ # 6 full feature sections +``` + +### SSR + Hydration + +1. **Server** — The Rust `webui-cli` pre-renders HTML using `--plugin=fast`. Templates use `{{mustache}}` interpolation, ``, and `` directives, evaluated against `data/state.json`. +2. **Client** — `index.ts` registers all 17 components with `templateOptions: 'defer-and-hydrate'`. Templates are NOT bundled into JS — they already exist in the DOM from SSR. +3. **Hydration** — Components with a `prepare()` method read initial state from server-rendered DOM (e.g., contacts from hidden `data-*` spans), then FAST's observation system takes over for reactivity. + +### State Management + +All state lives in the root `` component: + +- **`@attr`** fields for HTML-reflected attributes (`page`, `searchQuery`, `activeGroup`, counts) +- **`@observable`** fields for internal arrays (`contacts`, `filteredContacts`, `favoriteContacts`, `selectedContact`, `groups`) +- **IndexedDB** (`ContactBookDB`) persists contacts client-side. On init, `prepare()` seeds IndexedDB from server data if needed; every mutation calls `saveToDB()` immediately. + +### Event Handling + +Child components communicate upward via **bubbling `CustomEvent`s** (`bubbles: true, composed: true`). The root `` listens on its `shadowRoot` in `connectedCallback()` — a delegation pattern. + +``` +cb-header → 'search', 'add-contact' +cb-sidebar → 'navigate' +cb-contact-card → 'select-contact' +cb-contact-detail → 'edit-contact', 'toggle-favorite', 'delete-contact', 'back' +cb-contact-form → 'form-save', 'form-cancel' +``` + +## Prerequisites + +- **Rust toolchain** — see `rust-toolchain.toml` at the repo root +- **Node.js** (≥18) + **pnpm** + +## Quick Start + +```bash +# From the repository root: + +# Install dependencies +pnpm install + +# Build the SSR protocol binary +cargo run -p webui-cli -- build ./examples/app/contact-book-manager/src \ + --out ./examples/app/contact-book-manager/dist \ + --css external \ + --plugin=fast + +# Bundle client JS +npx esbuild examples/app/contact-book-manager/src/index.ts \ + --bundle --outfile=examples/app/contact-book-manager/dist/index.js \ + --format=esm --sourcemap + +# Start dev server +cargo run -p webui-cli -- start ./examples/app/contact-book-manager/src \ + --state ./examples/app/contact-book-manager/data/state.json \ + --plugin=fast \ + --servedir ./examples/app/contact-book-manager/dist \ + --port 3001 +``` + +Or use the xtask shortcut (runs server + client watcher concurrently): + +```bash +cargo xtask dev contact-book-manager +``` + +Then open [http://localhost:3001](http://localhost:3001). + +## Project Structure + +``` +contact-book-manager/ +├── package.json +├── tsconfig.json +├── data/ +│ └── state.json # Pre-seeded state (15 contacts, 4 groups) +├── dist/ # Build output +│ ├── protocol.bin # SSR binary +│ ├── index.js # Bundled client JS +│ └── cb-*.css # Per-component stylesheets +└── src/ + ├── index.html # Root HTML shell + ├── index.ts # Entry — registers all 17 components + ├── cb-app/ # Root app component + │ ├── cb-app.ts + │ ├── cb-app.html + │ └── cb-app.css + ├── atoms/ + │ ├── cb-avatar/ # Circular initials avatar + │ ├── cb-badge/ # Colored group pill + │ ├── cb-button/ # Multi-variant button + │ ├── cb-empty-state/ # Placeholder for empty lists + │ ├── cb-icon-button/ # Square icon-only button + │ └── cb-input/ # Styled text input + ├── molecules/ + │ ├── cb-form-field/ # Label + input + error message + │ ├── cb-nav-item/ # Sidebar navigation row + │ ├── cb-search-bar/ # Search input with clear button + │ └── cb-stat-card/ # Dashboard KPI card + ├── organisms/ + │ ├── cb-contact-card/ # Compact contact row + │ ├── cb-contact-detail/ # Full contact profile view + │ ├── cb-contact-form/ # Add/edit contact form + │ ├── cb-contact-list/ # Scrollable contact list + │ ├── cb-header/ # Sticky top bar + │ └── cb-sidebar/ # Left navigation panel +``` + +## Component Catalog + +| Layer | Tag | Purpose | +|-------|-----|---------| +| **Root** | `` | Application shell — state, routing, event delegation, IndexedDB | +| **Atom** | `` | Circular avatar with initials and colored background (sm/md/lg) | +| **Atom** | `` | Pill label with group color variants (work/family/friends/other) | +| **Atom** | `` | Button with variant (primary/secondary/danger/ghost) and size | +| **Atom** | `` | Centered icon + message for empty content areas | +| **Atom** | `` | Square icon-only button with optional danger hover | +| **Atom** | `` | Styled text input with placeholder, type, and name | +| **Molecule** | `` | Label + `` + optional error message | +| **Molecule** | `` | Sidebar row with icon, label, count badge, and active state | +| **Molecule** | `` | Search input with icon and conditional clear button | +| **Molecule** | `` | Dashboard card with emoji icon, numeric value, and label | +| **Organism** | `` | Compact contact row: avatar, name, star, email, phone, badge | +| **Organism** | `` | Full-page contact view with edit/favorite/delete actions | +| **Organism** | `` | Two-column add/edit form with group selector and notes | +| **Organism** | `` | Renders contact cards via `` loop with empty state fallback | +| **Organism** | `` | Sticky top bar with title, search, and "Add Contact" button | +| **Organism** | `` | Fixed nav panel with static items + dynamic group list | + +## Key Design Decisions + +### Use `connectedCallback` listeners, not template `@event` bindings + +FAST-HTML hydration processes templates declaratively. Event bindings like `@click` are not supported in the SSR template syntax. Instead, components attach listeners manually in `connectedCallback()` and use a `listenersAttached` guard to prevent duplicates on reconnection. + +### Use `dispatchEvent` instead of `$emit` + +FAST-Element's `$emit` helper is not available during the hydration stage. Components use `this.dispatchEvent(new CustomEvent(...))` directly, with `bubbles: true` and `composed: true` to cross shadow DOM boundaries. + +### Use `field!: type` for fields set in `prepare()` + +Fields initialized by the `prepare()` lifecycle hook use TypeScript's definite assignment assertion (`!`) rather than default values. This avoids overwriting server-hydrated state with empty defaults. + +### No nested custom elements in templates + +Server-rendered templates avoid nesting custom elements inside other custom element templates to prevent hydration mismatches between the SSR output and the client's shadow DOM expectations. + +### Use CSS `::before` for emoji, not inline emoji + +Emoji characters in SSR templates can cause double-encoding issues during the server render pass. Components use CSS `::before` pseudo-elements with `content` properties for display emoji instead. + +### `data-action` delegation for multi-button components + +Components with multiple action buttons (e.g., ``) use a single `click` listener and identify the action via `data-action` attributes, dispatching the appropriate event in a switch/case block. + +## Responsive Layout + +- **Desktop (≥768px):** Fixed sidebar (260px) + scrollable main content area +- **Mobile (<768px):** Sidebar hidden, single-column layout, "Add Contact" button label hidden (icon only) diff --git a/examples/app/contact-book-manager/data/state.json b/examples/app/contact-book-manager/data/state.json new file mode 100644 index 00000000..e63dfe9f --- /dev/null +++ b/examples/app/contact-book-manager/data/state.json @@ -0,0 +1,578 @@ +{ + "page": "dashboard", + "searchQuery": "", + "activeGroup": "all", + "groups": ["Family", "Work", "Friends", "Other"], + "totalContacts": 15, + "totalFavorites": 5, + "totalGroups": 4, + "contacts": [ + { + "id": "1", + "firstName": "Sarah", + "lastName": "Chen", + "email": "sarah.chen@example.com", + "phone": "+1 (555) 123-4567", + "company": "Contoso Ltd", + "group": "Work", + "favorite": true, + "initials": "SC", + "avatarColor": "#4A90D9", + "notes": "Met at the tech conference in Seattle", + "address": "123 Innovation Dr, Seattle, WA 98101" + }, + { + "id": "2", + "firstName": "Marcus", + "lastName": "Johnson", + "email": "marcus.johnson@example.com", + "phone": "+1 (555) 234-5678", + "company": "", + "group": "Family", + "favorite": false, + "initials": "MJ", + "avatarColor": "#E67E22", + "notes": "", + "address": "456 Oak Avenue, Portland, OR 97201" + }, + { + "id": "3", + "firstName": "Yuki", + "lastName": "Tanaka", + "email": "yuki.tanaka@example.com", + "phone": "+1 (555) 345-6789", + "company": "Fabrikam Inc", + "group": "Work", + "favorite": true, + "initials": "YT", + "avatarColor": "#2ECC71", + "notes": "Collaborating on the API redesign project", + "address": "789 Market St, San Francisco, CA 94103" + }, + { + "id": "4", + "firstName": "Priya", + "lastName": "Sharma", + "email": "priya.sharma@example.com", + "phone": "+1 (555) 456-7890", + "company": "", + "group": "Friends", + "favorite": false, + "initials": "PS", + "avatarColor": "#9B59B6", + "notes": "Birthday: March 15", + "address": "321 Elm Street, Austin, TX 78701" + }, + { + "id": "5", + "firstName": "James", + "lastName": "O'Brien", + "email": "james.obrien@example.com", + "phone": "+1 (555) 567-8901", + "company": "Northwind Traders", + "group": "Work", + "favorite": false, + "initials": "JO", + "avatarColor": "#3498DB", + "notes": "", + "address": "654 Pine Road, Boston, MA 02108" + }, + { + "id": "6", + "firstName": "Amara", + "lastName": "Okafor", + "email": "amara.okafor@example.com", + "phone": "+1 (555) 678-9012", + "company": "", + "group": "Family", + "favorite": true, + "initials": "AO", + "avatarColor": "#E74C3C", + "notes": "Visiting in December for the holidays", + "address": "987 Maple Lane, Atlanta, GA 30301" + }, + { + "id": "7", + "firstName": "Luis", + "lastName": "Ramirez", + "email": "luis.ramirez@example.com", + "phone": "+1 (555) 789-0123", + "company": "Ramirez Photography", + "group": "Friends", + "favorite": false, + "initials": "LR", + "avatarColor": "#1ABC9C", + "notes": "", + "address": "147 Sunset Blvd, Miami, FL 33101" + }, + { + "id": "8", + "firstName": "Emma", + "lastName": "Lindström", + "email": "emma.lindstrom@example.com", + "phone": "+1 (555) 890-1234", + "company": "Adventure Works", + "group": "Work", + "favorite": false, + "initials": "EL", + "avatarColor": "#F39C12", + "notes": "Handles procurement and vendor relations", + "address": "258 Broadway, New York, NY 10007" + }, + { + "id": "9", + "firstName": "David", + "lastName": "Kim", + "email": "david.kim@example.com", + "phone": "+1 (555) 901-2345", + "company": "", + "group": "Family", + "favorite": false, + "initials": "DK", + "avatarColor": "#8E44AD", + "notes": "", + "address": "369 Cherry Blossom Way, Chicago, IL 60601" + }, + { + "id": "10", + "firstName": "Fatima", + "lastName": "Al-Hassan", + "email": "fatima.alhassan@example.com", + "phone": "+1 (555) 012-3456", + "company": "Contoso Ltd", + "group": "Work", + "favorite": true, + "initials": "FA", + "avatarColor": "#16A085", + "notes": "Leading the new design system initiative", + "address": "480 University Ave, Palo Alto, CA 94301" + }, + { + "id": "11", + "firstName": "Carlos", + "lastName": "Mendoza", + "email": "carlos.mendoza@example.com", + "phone": "+1 (555) 111-2233", + "company": "", + "group": "Friends", + "favorite": false, + "initials": "CM", + "avatarColor": "#D35400", + "notes": "Runs the weekend hiking group", + "address": "512 Mountain View Rd, Denver, CO 80201" + }, + { + "id": "12", + "firstName": "Aisha", + "lastName": "Patel", + "email": "aisha.patel@example.com", + "phone": "+1 (555) 222-3344", + "company": "Patel & Associates", + "group": "Other", + "favorite": false, + "initials": "AP", + "avatarColor": "#2980B9", + "notes": "Financial advisor — annual review in January", + "address": "630 Commerce St, Dallas, TX 75201" + }, + { + "id": "13", + "firstName": "Ryan", + "lastName": "Mitchell", + "email": "ryan.mitchell@example.com", + "phone": "+1 (555) 333-4455", + "company": "", + "group": "Family", + "favorite": false, + "initials": "RM", + "avatarColor": "#27AE60", + "notes": "", + "address": "741 Lakeshore Dr, Minneapolis, MN 55401" + }, + { + "id": "14", + "firstName": "Sofia", + "lastName": "Andersson", + "email": "sofia.andersson@example.com", + "phone": "+1 (555) 444-5566", + "company": "", + "group": "Friends", + "favorite": true, + "initials": "SA", + "avatarColor": "#C0392B", + "notes": "Planning a trip to Stockholm together next summer", + "address": "852 Birch Street, Nashville, TN 37201" + }, + { + "id": "15", + "firstName": "Kenji", + "lastName": "Watanabe", + "email": "kenji.watanabe@example.com", + "phone": "+1 (555) 555-6677", + "company": "Watanabe Martial Arts", + "group": "Other", + "favorite": false, + "initials": "KW", + "avatarColor": "#7F8C8D", + "notes": "Teaches Saturday morning classes", + "address": "963 Dojo Lane, San Diego, CA 92101" + } + ], + "filteredContacts": [ + { + "id": "1", + "firstName": "Sarah", + "lastName": "Chen", + "email": "sarah.chen@example.com", + "phone": "+1 (555) 123-4567", + "company": "Contoso Ltd", + "group": "Work", + "favorite": true, + "initials": "SC", + "avatarColor": "#4A90D9", + "notes": "Met at the tech conference in Seattle", + "address": "123 Innovation Dr, Seattle, WA 98101" + }, + { + "id": "2", + "firstName": "Marcus", + "lastName": "Johnson", + "email": "marcus.johnson@example.com", + "phone": "+1 (555) 234-5678", + "company": "", + "group": "Family", + "favorite": false, + "initials": "MJ", + "avatarColor": "#E67E22", + "notes": "", + "address": "456 Oak Avenue, Portland, OR 97201" + }, + { + "id": "3", + "firstName": "Yuki", + "lastName": "Tanaka", + "email": "yuki.tanaka@example.com", + "phone": "+1 (555) 345-6789", + "company": "Fabrikam Inc", + "group": "Work", + "favorite": true, + "initials": "YT", + "avatarColor": "#2ECC71", + "notes": "Collaborating on the API redesign project", + "address": "789 Market St, San Francisco, CA 94103" + }, + { + "id": "4", + "firstName": "Priya", + "lastName": "Sharma", + "email": "priya.sharma@example.com", + "phone": "+1 (555) 456-7890", + "company": "", + "group": "Friends", + "favorite": false, + "initials": "PS", + "avatarColor": "#9B59B6", + "notes": "Birthday: March 15", + "address": "321 Elm Street, Austin, TX 78701" + }, + { + "id": "5", + "firstName": "James", + "lastName": "O'Brien", + "email": "james.obrien@example.com", + "phone": "+1 (555) 567-8901", + "company": "Northwind Traders", + "group": "Work", + "favorite": false, + "initials": "JO", + "avatarColor": "#3498DB", + "notes": "", + "address": "654 Pine Road, Boston, MA 02108" + }, + { + "id": "6", + "firstName": "Amara", + "lastName": "Okafor", + "email": "amara.okafor@example.com", + "phone": "+1 (555) 678-9012", + "company": "", + "group": "Family", + "favorite": true, + "initials": "AO", + "avatarColor": "#E74C3C", + "notes": "Visiting in December for the holidays", + "address": "987 Maple Lane, Atlanta, GA 30301" + }, + { + "id": "7", + "firstName": "Luis", + "lastName": "Ramirez", + "email": "luis.ramirez@example.com", + "phone": "+1 (555) 789-0123", + "company": "Ramirez Photography", + "group": "Friends", + "favorite": false, + "initials": "LR", + "avatarColor": "#1ABC9C", + "notes": "", + "address": "147 Sunset Blvd, Miami, FL 33101" + }, + { + "id": "8", + "firstName": "Emma", + "lastName": "Lindström", + "email": "emma.lindstrom@example.com", + "phone": "+1 (555) 890-1234", + "company": "Adventure Works", + "group": "Work", + "favorite": false, + "initials": "EL", + "avatarColor": "#F39C12", + "notes": "Handles procurement and vendor relations", + "address": "258 Broadway, New York, NY 10007" + }, + { + "id": "9", + "firstName": "David", + "lastName": "Kim", + "email": "david.kim@example.com", + "phone": "+1 (555) 901-2345", + "company": "", + "group": "Family", + "favorite": false, + "initials": "DK", + "avatarColor": "#8E44AD", + "notes": "", + "address": "369 Cherry Blossom Way, Chicago, IL 60601" + }, + { + "id": "10", + "firstName": "Fatima", + "lastName": "Al-Hassan", + "email": "fatima.alhassan@example.com", + "phone": "+1 (555) 012-3456", + "company": "Contoso Ltd", + "group": "Work", + "favorite": true, + "initials": "FA", + "avatarColor": "#16A085", + "notes": "Leading the new design system initiative", + "address": "480 University Ave, Palo Alto, CA 94301" + }, + { + "id": "11", + "firstName": "Carlos", + "lastName": "Mendoza", + "email": "carlos.mendoza@example.com", + "phone": "+1 (555) 111-2233", + "company": "", + "group": "Friends", + "favorite": false, + "initials": "CM", + "avatarColor": "#D35400", + "notes": "Runs the weekend hiking group", + "address": "512 Mountain View Rd, Denver, CO 80201" + }, + { + "id": "12", + "firstName": "Aisha", + "lastName": "Patel", + "email": "aisha.patel@example.com", + "phone": "+1 (555) 222-3344", + "company": "Patel & Associates", + "group": "Other", + "favorite": false, + "initials": "AP", + "avatarColor": "#2980B9", + "notes": "Financial advisor — annual review in January", + "address": "630 Commerce St, Dallas, TX 75201" + }, + { + "id": "13", + "firstName": "Ryan", + "lastName": "Mitchell", + "email": "ryan.mitchell@example.com", + "phone": "+1 (555) 333-4455", + "company": "", + "group": "Family", + "favorite": false, + "initials": "RM", + "avatarColor": "#27AE60", + "notes": "", + "address": "741 Lakeshore Dr, Minneapolis, MN 55401" + }, + { + "id": "14", + "firstName": "Sofia", + "lastName": "Andersson", + "email": "sofia.andersson@example.com", + "phone": "+1 (555) 444-5566", + "company": "", + "group": "Friends", + "favorite": true, + "initials": "SA", + "avatarColor": "#C0392B", + "notes": "Planning a trip to Stockholm together next summer", + "address": "852 Birch Street, Nashville, TN 37201" + }, + { + "id": "15", + "firstName": "Kenji", + "lastName": "Watanabe", + "email": "kenji.watanabe@example.com", + "phone": "+1 (555) 555-6677", + "company": "Watanabe Martial Arts", + "group": "Other", + "favorite": false, + "initials": "KW", + "avatarColor": "#7F8C8D", + "notes": "Teaches Saturday morning classes", + "address": "963 Dojo Lane, San Diego, CA 92101" + } + ], + "recentContacts": [ + { + "id": "11", + "firstName": "Carlos", + "lastName": "Mendoza", + "email": "carlos.mendoza@example.com", + "phone": "+1 (555) 111-2233", + "company": "", + "group": "Friends", + "favorite": false, + "initials": "CM", + "avatarColor": "#D35400", + "notes": "Runs the weekend hiking group", + "address": "512 Mountain View Rd, Denver, CO 80201" + }, + { + "id": "12", + "firstName": "Aisha", + "lastName": "Patel", + "email": "aisha.patel@example.com", + "phone": "+1 (555) 222-3344", + "company": "Patel & Associates", + "group": "Other", + "favorite": false, + "initials": "AP", + "avatarColor": "#2980B9", + "notes": "Financial advisor — annual review in January", + "address": "630 Commerce St, Dallas, TX 75201" + }, + { + "id": "13", + "firstName": "Ryan", + "lastName": "Mitchell", + "email": "ryan.mitchell@example.com", + "phone": "+1 (555) 333-4455", + "company": "", + "group": "Family", + "favorite": false, + "initials": "RM", + "avatarColor": "#27AE60", + "notes": "", + "address": "741 Lakeshore Dr, Minneapolis, MN 55401" + }, + { + "id": "14", + "firstName": "Sofia", + "lastName": "Andersson", + "email": "sofia.andersson@example.com", + "phone": "+1 (555) 444-5566", + "company": "", + "group": "Friends", + "favorite": true, + "initials": "SA", + "avatarColor": "#C0392B", + "notes": "Planning a trip to Stockholm together next summer", + "address": "852 Birch Street, Nashville, TN 37201" + }, + { + "id": "15", + "firstName": "Kenji", + "lastName": "Watanabe", + "email": "kenji.watanabe@example.com", + "phone": "+1 (555) 555-6677", + "company": "Watanabe Martial Arts", + "group": "Other", + "favorite": false, + "initials": "KW", + "avatarColor": "#7F8C8D", + "notes": "Teaches Saturday morning classes", + "address": "963 Dojo Lane, San Diego, CA 92101" + } + ], + "favoriteContacts": [ + { + "id": "1", + "firstName": "Sarah", + "lastName": "Chen", + "email": "sarah.chen@example.com", + "phone": "+1 (555) 123-4567", + "company": "Contoso Ltd", + "group": "Work", + "favorite": true, + "initials": "SC", + "avatarColor": "#4A90D9", + "notes": "Met at the tech conference in Seattle", + "address": "123 Innovation Dr, Seattle, WA 98101" + }, + { + "id": "3", + "firstName": "Yuki", + "lastName": "Tanaka", + "email": "yuki.tanaka@example.com", + "phone": "+1 (555) 345-6789", + "company": "Fabrikam Inc", + "group": "Work", + "favorite": true, + "initials": "YT", + "avatarColor": "#2ECC71", + "notes": "Collaborating on the API redesign project", + "address": "789 Market St, San Francisco, CA 94103" + }, + { + "id": "6", + "firstName": "Amara", + "lastName": "Okafor", + "email": "amara.okafor@example.com", + "phone": "+1 (555) 678-9012", + "company": "", + "group": "Family", + "favorite": true, + "initials": "AO", + "avatarColor": "#E74C3C", + "notes": "Visiting in December for the holidays", + "address": "987 Maple Lane, Atlanta, GA 30301" + }, + { + "id": "10", + "firstName": "Fatima", + "lastName": "Al-Hassan", + "email": "fatima.alhassan@example.com", + "phone": "+1 (555) 012-3456", + "company": "Contoso Ltd", + "group": "Work", + "favorite": true, + "initials": "FA", + "avatarColor": "#16A085", + "notes": "Leading the new design system initiative", + "address": "480 University Ave, Palo Alto, CA 94301" + }, + { + "id": "14", + "firstName": "Sofia", + "lastName": "Andersson", + "email": "sofia.andersson@example.com", + "phone": "+1 (555) 444-5566", + "company": "", + "group": "Friends", + "favorite": true, + "initials": "SA", + "avatarColor": "#C0392B", + "notes": "Planning a trip to Stockholm together next summer", + "address": "852 Birch Street, Nashville, TN 37201" + } + ], + "selectedContact": null +} diff --git a/examples/app/contact-book-manager/package.json b/examples/app/contact-book-manager/package.json new file mode 100644 index 00000000..32dcaee2 --- /dev/null +++ b/examples/app/contact-book-manager/package.json @@ -0,0 +1,21 @@ +{ + "name": "contact-book-manager", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "build:protocol": "cargo run -p webui-cli -- build ./src --out ./dist --css external --plugin=fast", + "build:client": "esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --sourcemap", + "build": "npm run build:protocol && npm run build:client", + "start:client": "esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --sourcemap --watch", + "start:server": "cargo run -p webui-cli -- start ./src --state ./data/state.json --plugin=fast --servedir ./dist --port 3001", + "start": "cargo xtask dev contact-book-manager" + }, + "devDependencies": { + "@microsoft/fast-element": "catalog:", + "@microsoft/fast-html": "catalog:", + "esbuild": "catalog:", + "tslib": "catalog:", + "typescript": "catalog:" + } +} diff --git a/examples/app/contact-book-manager/src/atoms/cb-avatar/cb-avatar.css b/examples/app/contact-book-manager/src/atoms/cb-avatar/cb-avatar.css new file mode 100644 index 00000000..23136a6b --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-avatar/cb-avatar.css @@ -0,0 +1,33 @@ +:host { + display: inline-flex; +} + +.avatar { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + color: #fff; + font-weight: 600; + font-size: 16px; + user-select: none; +} + +.initials { + line-height: 1; + text-transform: uppercase; +} + +:host([size="sm"]) .avatar { + width: 32px; + height: 32px; + font-size: 13px; +} + +:host([size="lg"]) .avatar { + width: 64px; + height: 64px; + font-size: 24px; +} diff --git a/examples/app/contact-book-manager/src/atoms/cb-avatar/cb-avatar.html b/examples/app/contact-book-manager/src/atoms/cb-avatar/cb-avatar.html new file mode 100644 index 00000000..14946997 --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-avatar/cb-avatar.html @@ -0,0 +1,5 @@ + diff --git a/examples/app/contact-book-manager/src/atoms/cb-avatar/cb-avatar.ts b/examples/app/contact-book-manager/src/atoms/cb-avatar/cb-avatar.ts new file mode 100644 index 00000000..b105cc04 --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-avatar/cb-avatar.ts @@ -0,0 +1,13 @@ +import { FASTElement, attr } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +export class CbAvatar extends RenderableFASTElement(FASTElement) { + @attr initials = ''; + @attr color = '#6B7280'; + @attr size = 'md'; +} + +CbAvatar.defineAsync({ + name: 'cb-avatar', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/src/atoms/cb-badge/cb-badge.css b/examples/app/contact-book-manager/src/atoms/cb-badge/cb-badge.css new file mode 100644 index 00000000..cf9da1b6 --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-badge/cb-badge.css @@ -0,0 +1,34 @@ +:host { + display: inline-flex; +} + +.badge { + display: inline-block; + padding: 2px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + line-height: 1.5; + background-color: #e5e7eb; + color: #374151; +} + +:host([variant="work"]) .badge { + background-color: #dbeafe; + color: #1d4ed8; +} + +:host([variant="family"]) .badge { + background-color: #dcfce7; + color: #15803d; +} + +:host([variant="friends"]) .badge { + background-color: #f3e8ff; + color: #7e22ce; +} + +:host([variant="other"]) .badge { + background-color: #e5e7eb; + color: #374151; +} diff --git a/examples/app/contact-book-manager/src/atoms/cb-badge/cb-badge.html b/examples/app/contact-book-manager/src/atoms/cb-badge/cb-badge.html new file mode 100644 index 00000000..f21dfad3 --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-badge/cb-badge.html @@ -0,0 +1,3 @@ + diff --git a/examples/app/contact-book-manager/src/atoms/cb-badge/cb-badge.ts b/examples/app/contact-book-manager/src/atoms/cb-badge/cb-badge.ts new file mode 100644 index 00000000..7e7e10ce --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-badge/cb-badge.ts @@ -0,0 +1,12 @@ +import { FASTElement, attr } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +export class CbBadge extends RenderableFASTElement(FASTElement) { + @attr label = ''; + @attr variant = 'default'; +} + +CbBadge.defineAsync({ + name: 'cb-badge', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/src/atoms/cb-button/cb-button.css b/examples/app/contact-book-manager/src/atoms/cb-button/cb-button.css new file mode 100644 index 00000000..49c2b4ed --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-button/cb-button.css @@ -0,0 +1,73 @@ +:host { + display: inline-flex; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 16px; + border: 1px solid transparent; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: background-color 0.15s, border-color 0.15s, box-shadow 0.15s; + line-height: 1.4; + background-color: #0078d4; + color: #fff; +} + +.btn:hover { + background-color: #106ebe; +} + +.btn:active { + background-color: #005a9e; +} + +.btn:focus-visible { + outline: 2px solid #0078d4; + outline-offset: 2px; +} + +:host([variant="secondary"]) .btn { + background-color: #fff; + color: #374151; + border-color: #d1d5db; +} + +:host([variant="secondary"]) .btn:hover { + background-color: #f9fafb; + border-color: #9ca3af; +} + +:host([variant="danger"]) .btn { + background-color: #dc2626; + color: #fff; +} + +:host([variant="danger"]) .btn:hover { + background-color: #b91c1c; +} + +:host([variant="ghost"]) .btn { + background-color: transparent; + color: #374151; +} + +:host([variant="ghost"]) .btn:hover { + background-color: #f3f4f6; +} + +:host([size="sm"]) .btn { + padding: 4px 10px; + font-size: 12px; +} + +:host([size="lg"]) .btn { + padding: 12px 24px; + font-size: 16px; +} diff --git a/examples/app/contact-book-manager/src/atoms/cb-button/cb-button.html b/examples/app/contact-book-manager/src/atoms/cb-button/cb-button.html new file mode 100644 index 00000000..85b93bc1 --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-button/cb-button.html @@ -0,0 +1,5 @@ + diff --git a/examples/app/contact-book-manager/src/atoms/cb-button/cb-button.ts b/examples/app/contact-book-manager/src/atoms/cb-button/cb-button.ts new file mode 100644 index 00000000..b50039ad --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-button/cb-button.ts @@ -0,0 +1,13 @@ +import { FASTElement, attr } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +export class CbButton extends RenderableFASTElement(FASTElement) { + @attr label = ''; + @attr variant = 'primary'; + @attr size = 'md'; +} + +CbButton.defineAsync({ + name: 'cb-button', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/src/atoms/cb-empty-state/cb-empty-state.css b/examples/app/contact-book-manager/src/atoms/cb-empty-state/cb-empty-state.css new file mode 100644 index 00000000..e0fa141d --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-empty-state/cb-empty-state.css @@ -0,0 +1,32 @@ +:host { + display: block; +} + +.empty { + display: flex; + flex-direction: column; + align-items: center; + padding: 48px; + text-align: center; +} + +.empty-icon { + font-size: 48px; + margin-bottom: 16px; + line-height: 1; +} + +.empty-title { + font-size: 18px; + font-weight: 600; + color: #374151; + margin: 0 0 8px; +} + +.empty-message { + font-size: 14px; + color: #6b7280; + margin: 0; +} + +.empty-icon::before { content: '\1F4ED'; } diff --git a/examples/app/contact-book-manager/src/atoms/cb-empty-state/cb-empty-state.html b/examples/app/contact-book-manager/src/atoms/cb-empty-state/cb-empty-state.html new file mode 100644 index 00000000..ed685003 --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-empty-state/cb-empty-state.html @@ -0,0 +1,7 @@ + diff --git a/examples/app/contact-book-manager/src/atoms/cb-empty-state/cb-empty-state.ts b/examples/app/contact-book-manager/src/atoms/cb-empty-state/cb-empty-state.ts new file mode 100644 index 00000000..ed095ce2 --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-empty-state/cb-empty-state.ts @@ -0,0 +1,13 @@ +import { FASTElement, attr } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +export class CbEmptyState extends RenderableFASTElement(FASTElement) { + @attr icon = '📭'; + @attr title = ''; + @attr message = ''; +} + +CbEmptyState.defineAsync({ + name: 'cb-empty-state', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/src/atoms/cb-icon-button/cb-icon-button.css b/examples/app/contact-book-manager/src/atoms/cb-icon-button/cb-icon-button.css new file mode 100644 index 00000000..6fc9acd4 --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-icon-button/cb-icon-button.css @@ -0,0 +1,33 @@ +:host { + display: inline-flex; +} + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + border-radius: 8px; + background-color: transparent; + cursor: pointer; + font-size: 18px; + padding: 0; + color: #374151; + transition: background-color 0.15s; +} + +.icon-btn:hover { + background-color: #f3f4f6; +} + +.icon-btn:focus-visible { + outline: 2px solid #0078d4; + outline-offset: 2px; +} + +:host([variant="danger"]) .icon-btn:hover { + background-color: #fee2e2; + color: #dc2626; +} diff --git a/examples/app/contact-book-manager/src/atoms/cb-icon-button/cb-icon-button.html b/examples/app/contact-book-manager/src/atoms/cb-icon-button/cb-icon-button.html new file mode 100644 index 00000000..a2e58cce --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-icon-button/cb-icon-button.html @@ -0,0 +1,5 @@ + diff --git a/examples/app/contact-book-manager/src/atoms/cb-icon-button/cb-icon-button.ts b/examples/app/contact-book-manager/src/atoms/cb-icon-button/cb-icon-button.ts new file mode 100644 index 00000000..03c7771c --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-icon-button/cb-icon-button.ts @@ -0,0 +1,13 @@ +import { FASTElement, attr } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +export class CbIconButton extends RenderableFASTElement(FASTElement) { + @attr icon = ''; + @attr title = ''; + @attr variant = 'default'; +} + +CbIconButton.defineAsync({ + name: 'cb-icon-button', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/src/atoms/cb-input/cb-input.css b/examples/app/contact-book-manager/src/atoms/cb-input/cb-input.css new file mode 100644 index 00000000..7f5594ba --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-input/cb-input.css @@ -0,0 +1,27 @@ +:host { + display: block; +} + +.input { + width: 100%; + padding: 10px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 14px; + font-family: inherit; + line-height: 1.4; + color: #111827; + background-color: #fff; + box-sizing: border-box; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.input::placeholder { + color: #9ca3af; +} + +.input:focus { + outline: none; + border-color: #0078d4; + box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.15); +} diff --git a/examples/app/contact-book-manager/src/atoms/cb-input/cb-input.html b/examples/app/contact-book-manager/src/atoms/cb-input/cb-input.html new file mode 100644 index 00000000..48f80ed1 --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-input/cb-input.html @@ -0,0 +1,3 @@ + diff --git a/examples/app/contact-book-manager/src/atoms/cb-input/cb-input.ts b/examples/app/contact-book-manager/src/atoms/cb-input/cb-input.ts new file mode 100644 index 00000000..16614d00 --- /dev/null +++ b/examples/app/contact-book-manager/src/atoms/cb-input/cb-input.ts @@ -0,0 +1,14 @@ +import { FASTElement, attr } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +export class CbInput extends RenderableFASTElement(FASTElement) { + @attr placeholder = ''; + @attr value = ''; + @attr type = 'text'; + @attr name = ''; +} + +CbInput.defineAsync({ + name: 'cb-input', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/src/cb-app/cb-app.css b/examples/app/contact-book-manager/src/cb-app/cb-app.css new file mode 100644 index 00000000..5a012b36 --- /dev/null +++ b/examples/app/contact-book-manager/src/cb-app/cb-app.css @@ -0,0 +1,93 @@ +:host { + display: block; + min-height: 100vh; + background: #f0f2f5; +} + +.layout { + display: flex; + min-height: calc(100vh - 65px); +} + +.content { + flex: 1; + padding: 24px 32px; + overflow-y: auto; +} + +.page-title { + font-size: 24px; + font-weight: 700; + color: #111827; + margin: 0 0 20px; +} + +.section-title { + font-size: 16px; + font-weight: 600; + color: #374151; + margin: 28px 0 14px; +} + +.stats-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; +} + +.contact-list-container { + background: #fff; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + overflow: hidden; +} + +/* Inlined stat-card styles */ +.stat-card { + display: flex; + align-items: center; + gap: 14px; + background: white; + border-radius: 12px; + padding: 20px 24px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.stat-icon { + font-size: 28px; +} + +.stat-content { + display: flex; + flex-direction: column; +} + +.stat-value { + font-size: 28px; + font-weight: 700; + color: #111827; +} + +.stat-label { + font-size: 13px; + color: #6b7280; + margin-top: 2px; +} + +/* Responsive */ +@media (max-width: 768px) { + .layout { + flex-direction: column; + } + .content { + padding: 16px; + } + .stats-row { + grid-template-columns: 1fr; + } +} + +/* Icon classes using CSS content to avoid emoji encoding issues */ +.stat-icon-contacts::before { content: '\1F465'; } +.stat-icon-favorites::before { content: '\2B50'; } +.stat-icon-groups::before { content: '\1F4C1'; } diff --git a/examples/app/contact-book-manager/src/cb-app/cb-app.html b/examples/app/contact-book-manager/src/cb-app/cb-app.html new file mode 100644 index 00000000..62cac25c --- /dev/null +++ b/examples/app/contact-book-manager/src/cb-app/cb-app.html @@ -0,0 +1,193 @@ + diff --git a/examples/app/contact-book-manager/src/cb-app/cb-app.ts b/examples/app/contact-book-manager/src/cb-app/cb-app.ts new file mode 100644 index 00000000..661c71bb --- /dev/null +++ b/examples/app/contact-book-manager/src/cb-app/cb-app.ts @@ -0,0 +1,350 @@ +import { FASTElement, attr, observable } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +interface Contact { + id: string; + firstName: string; + lastName: string; + email: string; + phone: string; + company: string; + group: string; + favorite: boolean; + initials: string; + avatarColor: string; + notes: string; + address: string; +} + +const AVATAR_COLORS = [ + '#4A90D9', '#E74C3C', '#2ECC71', '#F39C12', '#9B59B6', + '#1ABC9C', '#E67E22', '#3498DB', '#E91E63', '#00BCD4', +]; + +const groupsStore = new WeakMap(); + +export class CbApp extends RenderableFASTElement(FASTElement) { + @attr page = 'dashboard'; + @attr({ attribute: 'search-query' }) searchQuery = ''; + @attr({ attribute: 'active-group' }) activeGroup = 'all'; + @attr({ attribute: 'total-contacts' }) totalContacts = '0'; + @attr({ attribute: 'total-favorites' }) totalFavorites = '0'; + @attr({ attribute: 'total-groups' }) totalGroups = '0'; + + @observable contacts!: Contact[]; + @observable filteredContacts!: Contact[]; + @observable favoriteContacts!: Contact[]; + @observable recentContacts!: Contact[]; + @observable selectedContact: Contact | null = null; + + private nextId = 100; + private db: IDBDatabase | null = null; + private listenersAttached!: boolean; + + connectedCallback(): void { + super.connectedCallback(); + if (this.listenersAttached) return; + this.listenersAttached = true; + const root = this.shadowRoot; + if (!root) return; + root.addEventListener('navigate', (e: Event) => { + e.stopPropagation(); + this.onNavigate(e as CustomEvent); + }); + root.addEventListener('search', (e: Event) => { + e.stopPropagation(); + this.onSearch(e as CustomEvent); + }); + root.addEventListener('add-contact', (e: Event) => { + e.stopPropagation(); + this.onAddContact(); + }); + root.addEventListener('select-contact', (e: Event) => { + e.stopPropagation(); + this.onSelectContact(e as CustomEvent); + }); + root.addEventListener('edit-contact', (e: Event) => { + e.stopPropagation(); + this.onEditContact(e as CustomEvent); + }); + root.addEventListener('delete-contact', (e: Event) => { + e.stopPropagation(); + this.onDeleteContact(e as CustomEvent); + }); + root.addEventListener('toggle-favorite', (e: Event) => { + e.stopPropagation(); + this.onToggleFavorite(e as CustomEvent); + }); + root.addEventListener('back', (e: Event) => { + e.stopPropagation(); + this.onBack(); + }); + root.addEventListener('form-save', (e: Event) => { + e.stopPropagation(); + this.onFormSave(e as CustomEvent); + }); + root.addEventListener('form-cancel', (e: Event) => { + e.stopPropagation(); + this.onFormCancel(); + }); + } + + async prepare(): Promise { + if (!this.shadowRoot) return; + + // Read ALL contacts from the hidden data store + const dataEls = this.shadowRoot.querySelectorAll('.contact-data'); + const contacts: Contact[] = []; + const seen = new Set(); + for (const el of dataEls) { + const id = (el as HTMLElement).dataset.id || ''; + if (!id || seen.has(id)) continue; + seen.add(id); + const ds = (el as HTMLElement).dataset; + contacts.push({ + id, + firstName: ds.fn || '', + lastName: ds.ln || '', + email: ds.email || '', + phone: ds.phone || '', + company: ds.company || '', + group: ds.group || '', + favorite: ds.favorite === 'true', + initials: ds.initials || '', + avatarColor: ds.color || '#6B7280', + notes: ds.notes || '', + address: ds.address || '', + }); + } + this.contacts = contacts; + + // Read groups from sidebar nav items + const sidebar = this.shadowRoot.querySelector('cb-sidebar'); + const navItems = sidebar?.shadowRoot?.querySelectorAll('.nav-item') || []; + const groups: string[] = []; + for (const el of navItems) { + const label = (el as HTMLElement).getAttribute('data-nav') || ''; + if (!['Dashboard', 'All Contacts', 'Favorites'].includes(label) && label) { + groups.push(label); + } + } + groupsStore.set(this, groups); + + if (this.contacts.length > 0) { + this.nextId = Math.max(...this.contacts.map(c => Number(c.id) || 0)) + 1; + } + + this.updateDerivedState(); + await this.initDB(); + } + + // --- IndexedDB --- + private async initDB(): Promise { + return new Promise((resolve) => { + const request = indexedDB.open('ContactBookDB', 1); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains('contacts')) { + db.createObjectStore('contacts', { keyPath: 'id' }); + } + }; + request.onsuccess = () => { + this.db = request.result; + this.loadFromDB().then(resolve); + }; + request.onerror = () => resolve(); + }); + } + + private async loadFromDB(): Promise { + if (!this.db) return; + return new Promise((resolve) => { + const tx = this.db!.transaction('contacts', 'readonly'); + const store = tx.objectStore('contacts'); + const req = store.getAll(); + req.onsuccess = () => { + const stored = req.result as Contact[]; + if (stored.length >= this.contacts.length && stored.length > 0) { + this.contacts = stored; + this.updateDerivedState(); + } else { + this.saveToDB(); + } + resolve(); + }; + req.onerror = () => resolve(); + }); + } + + private saveToDB(): void { + if (!this.db) return; + const tx = this.db.transaction('contacts', 'readwrite'); + const store = tx.objectStore('contacts'); + store.clear(); + for (const c of this.contacts) { + store.put(c); + } + } + + // --- Derived state --- + private updateDerivedState(): void { + this.totalContacts = String(this.contacts.length); + this.favoriteContacts = this.contacts.filter(c => c.favorite); + this.totalFavorites = String(this.favoriteContacts.length); + this.recentContacts = this.contacts.slice(-5).reverse(); + + const uniqueGroups = [...new Set(this.contacts.map(c => c.group).filter(Boolean))]; + const groups = groupsStore.get(this) || []; + if (groups.length === 0) { + groupsStore.set(this, uniqueGroups); + } + this.totalGroups = String((groupsStore.get(this) || uniqueGroups).length); + + this.applyFilter(); + } + + private applyFilter(): void { + let filtered = this.contacts; + if (this.page === 'favorites') { + filtered = this.favoriteContacts; + } else if (this.page === 'group' && this.activeGroup !== 'all') { + filtered = this.contacts.filter(c => c.group === this.activeGroup); + } else if (this.page === 'dashboard') { + filtered = this.recentContacts; + } + + if (this.searchQuery) { + const q = this.searchQuery.toLowerCase(); + filtered = filtered.filter(c => + c.firstName.toLowerCase().includes(q) || + c.lastName.toLowerCase().includes(q) || + c.email.toLowerCase().includes(q) || + c.company.toLowerCase().includes(q) + ); + } + this.filteredContacts = filtered; + + // Push filtered contacts to the visible cb-contact-list + const contactList = this.shadowRoot!.querySelector( + `.content > [class$="-page"]:not([hidden]) cb-contact-list, .dashboard cb-contact-list` + ) as any; + if (contactList && contactList.contacts !== undefined) { + contactList.contacts = this.filteredContacts; + } + } + + // --- Event handlers --- + onNavigate(e: CustomEvent<{ page: string; group?: string }>): void { + this.page = e.detail.page; + if (e.detail.group) this.activeGroup = e.detail.group; + this.selectedContact = null; + this.updateDerivedState(); + } + + onSearch(e: CustomEvent<{ value: string }>): void { + this.searchQuery = e.detail.value; + this.applyFilter(); + } + + onAddContact(): void { + this.page = 'add'; + this.selectedContact = null; + } + + onSelectContact(e: CustomEvent<{ id: string }>): void { + this.selectedContact = this.contacts.find(c => c.id === e.detail.id) || null; + if (this.selectedContact) this.page = 'detail'; + } + + onEditContact(e: CustomEvent<{ id: string }>): void { + this.selectedContact = this.contacts.find(c => c.id === e.detail.id) || null; + if (this.selectedContact) this.page = 'edit'; + } + + onDeleteContact(e: CustomEvent<{ id: string }>): void { + this.contacts = this.contacts.filter(c => c.id !== e.detail.id); + this.selectedContact = null; + this.page = 'contacts'; + this.updateDerivedState(); + this.saveToDB(); + } + + onToggleFavorite(e: CustomEvent<{ id: string }>): void { + const contact = this.contacts.find(c => c.id === e.detail.id); + if (contact) { + contact.favorite = !contact.favorite; + this.contacts = [...this.contacts]; + if (this.selectedContact?.id === e.detail.id) { + this.selectedContact = { ...contact }; + } + this.updateDerivedState(); + this.saveToDB(); + } + } + + onBack(): void { + this.page = 'contacts'; + this.selectedContact = null; + this.updateDerivedState(); + } + + onFormSave(e: CustomEvent>): void { + const data = e.detail; + if (data.id) { + // Edit existing + const idx = this.contacts.findIndex(c => c.id === data.id); + if (idx >= 0) { + const existing = this.contacts[idx]; + const updated: Contact = { + ...existing, + firstName: data.firstName || existing.firstName, + lastName: data.lastName || existing.lastName, + email: data.email || existing.email, + phone: data.phone || existing.phone, + company: data.company || '', + address: data.address || '', + group: data.group || existing.group, + notes: data.notes || '', + initials: (data.firstName?.[0] || existing.firstName[0] || '').toUpperCase() + + (data.lastName?.[0] || existing.lastName[0] || '').toUpperCase(), + }; + this.contacts = [...this.contacts.slice(0, idx), updated, ...this.contacts.slice(idx + 1)]; + this.selectedContact = updated; + } + } else { + // Add new + const initials = ((data.firstName?.[0] || '') + (data.lastName?.[0] || '')).toUpperCase(); + const newContact: Contact = { + id: String(this.nextId++), + firstName: data.firstName || '', + lastName: data.lastName || '', + email: data.email || '', + phone: data.phone || '', + company: data.company || '', + address: data.address || '', + group: data.group || 'Other', + notes: data.notes || '', + favorite: false, + initials, + avatarColor: AVATAR_COLORS[this.contacts.length % AVATAR_COLORS.length], + }; + this.contacts = [...this.contacts, newContact]; + } + this.page = 'contacts'; + this.updateDerivedState(); + this.saveToDB(); + } + + onFormCancel(): void { + if (this.selectedContact) { + this.page = 'detail'; + } else { + this.page = 'contacts'; + } + } +} + +CbApp.defineAsync({ + name: 'cb-app', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/src/index.html b/examples/app/contact-book-manager/src/index.html new file mode 100644 index 00000000..329db2a1 --- /dev/null +++ b/examples/app/contact-book-manager/src/index.html @@ -0,0 +1,39 @@ + + + + + + + Contact Book + + + + + + + + + + diff --git a/examples/app/contact-book-manager/src/index.ts b/examples/app/contact-book-manager/src/index.ts new file mode 100644 index 00000000..d087823b --- /dev/null +++ b/examples/app/contact-book-manager/src/index.ts @@ -0,0 +1,68 @@ +/** + * Contact-book-manager entry point — bootstraps FAST-HTML hydration. + * + * The server pre-renders HTML with hydration markers via `webui build --plugin=fast`. + * This script: + * 1. Registers custom elements (17 components) via defineAsync + * 2. Configures FAST-HTML observation maps for reactive attribute tracking + * 3. Defines , triggering hydration of pre-rendered shadow DOM + */ + +performance.mark('contact-book-hydration-started'); + +import { TemplateElement } from '@microsoft/fast-html'; + +// Side-effect imports — register custom elements via defineAsync + +// Root +import './cb-app/cb-app.js'; + +// Atoms +import './atoms/cb-avatar/cb-avatar.js'; +import './atoms/cb-badge/cb-badge.js'; +import './atoms/cb-button/cb-button.js'; +import './atoms/cb-input/cb-input.js'; +import './atoms/cb-icon-button/cb-icon-button.js'; +import './atoms/cb-empty-state/cb-empty-state.js'; + +// Molecules +import './molecules/cb-search-bar/cb-search-bar.js'; +import './molecules/cb-form-field/cb-form-field.js'; +import './molecules/cb-stat-card/cb-stat-card.js'; +import './molecules/cb-nav-item/cb-nav-item.js'; + +// Organisms +import './organisms/cb-header/cb-header.js'; +import './organisms/cb-sidebar/cb-sidebar.js'; +import './organisms/cb-contact-card/cb-contact-card.js'; +import './organisms/cb-contact-list/cb-contact-list.js'; +import './organisms/cb-contact-detail/cb-contact-detail.js'; +import './organisms/cb-contact-form/cb-contact-form.js'; + +// Configure and start hydration +TemplateElement.options({ + 'cb-app': { observerMap: 'all' }, + 'cb-avatar': { observerMap: 'all' }, + 'cb-badge': { observerMap: 'all' }, + 'cb-button': { observerMap: 'all' }, + 'cb-input': { observerMap: 'all' }, + 'cb-icon-button': { observerMap: 'all' }, + 'cb-empty-state': { observerMap: 'all' }, + 'cb-search-bar': { observerMap: 'all' }, + 'cb-form-field': { observerMap: 'all' }, + 'cb-stat-card': { observerMap: 'all' }, + 'cb-nav-item': { observerMap: 'all' }, + 'cb-header': { observerMap: 'all' }, + 'cb-sidebar': {}, + 'cb-contact-card': { observerMap: 'all' }, + 'cb-contact-list': { observerMap: 'all' }, + 'cb-contact-detail': { observerMap: 'all' }, + 'cb-contact-form': { observerMap: 'all' }, +}).config({ + hydrationComplete() { + performance.measure('contact-book-hydration-completed', 'contact-book-hydration-started'); + console.log('Hydration complete!'); + }, +}).define({ + name: 'f-template', +}); diff --git a/examples/app/contact-book-manager/src/molecules/cb-form-field/cb-form-field.css b/examples/app/contact-book-manager/src/molecules/cb-form-field/cb-form-field.css new file mode 100644 index 00000000..5ccd93d5 --- /dev/null +++ b/examples/app/contact-book-manager/src/molecules/cb-form-field/cb-form-field.css @@ -0,0 +1,37 @@ +:host { + display: block; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 16px; +} + +.label { + font-size: 13px; + font-weight: 600; + color: #374151; +} + +.input { + width: 100%; + padding: 10px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 14px; + font-family: inherit; + box-sizing: border-box; +} + +.input:focus { + outline: none; + border-color: #0078d4; + box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.15); +} + +.error { + font-size: 12px; + color: #dc2626; +} diff --git a/examples/app/contact-book-manager/src/molecules/cb-form-field/cb-form-field.html b/examples/app/contact-book-manager/src/molecules/cb-form-field/cb-form-field.html new file mode 100644 index 00000000..96d78a15 --- /dev/null +++ b/examples/app/contact-book-manager/src/molecules/cb-form-field/cb-form-field.html @@ -0,0 +1,9 @@ + diff --git a/examples/app/contact-book-manager/src/molecules/cb-form-field/cb-form-field.ts b/examples/app/contact-book-manager/src/molecules/cb-form-field/cb-form-field.ts new file mode 100644 index 00000000..8ab66964 --- /dev/null +++ b/examples/app/contact-book-manager/src/molecules/cb-form-field/cb-form-field.ts @@ -0,0 +1,16 @@ +import { FASTElement, attr } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +export class CbFormField extends RenderableFASTElement(FASTElement) { + @attr label = ''; + @attr name = ''; + @attr value = ''; + @attr placeholder = ''; + @attr type = 'text'; + @attr error = ''; +} + +CbFormField.defineAsync({ + name: 'cb-form-field', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/src/molecules/cb-nav-item/cb-nav-item.css b/examples/app/contact-book-manager/src/molecules/cb-nav-item/cb-nav-item.css new file mode 100644 index 00000000..0bc45a90 --- /dev/null +++ b/examples/app/contact-book-manager/src/molecules/cb-nav-item/cb-nav-item.css @@ -0,0 +1,49 @@ +:host { + display: block; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 8px; + cursor: pointer; + transition: background 0.15s; +} + +.nav-item:hover { + background: #f3f4f6; +} + +:host([active]) .nav-item { + background: #e0f2fe; + color: #0078d4; +} + +.nav-icon { + font-size: 18px; + width: 24px; + text-align: center; +} + +.nav-label { + flex: 1; + font-size: 14px; + font-weight: 500; +} + +.nav-count { + font-size: 12px; + background: #e5e7eb; + color: #6b7280; + border-radius: 10px; + padding: 1px 8px; + min-width: 22px; + text-align: center; +} + +:host([active]) .nav-count { + background: #bfdbfe; + color: #0078d4; +} diff --git a/examples/app/contact-book-manager/src/molecules/cb-nav-item/cb-nav-item.html b/examples/app/contact-book-manager/src/molecules/cb-nav-item/cb-nav-item.html new file mode 100644 index 00000000..2c3bdf5c --- /dev/null +++ b/examples/app/contact-book-manager/src/molecules/cb-nav-item/cb-nav-item.html @@ -0,0 +1,9 @@ + diff --git a/examples/app/contact-book-manager/src/molecules/cb-nav-item/cb-nav-item.ts b/examples/app/contact-book-manager/src/molecules/cb-nav-item/cb-nav-item.ts new file mode 100644 index 00000000..52fb0da3 --- /dev/null +++ b/examples/app/contact-book-manager/src/molecules/cb-nav-item/cb-nav-item.ts @@ -0,0 +1,26 @@ +import { FASTElement, attr } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +export class CbNavItem extends RenderableFASTElement(FASTElement) { + @attr icon = ''; + @attr label = ''; + @attr count = ''; + @attr({ mode: 'boolean' }) active = false; + private listenersAttached!: boolean; + + connectedCallback(): void { + super.connectedCallback(); + if (this.listenersAttached) return; + this.listenersAttached = true; + this.addEventListener('click', () => this.onClick()); + } + + onClick(): void { + this.dispatchEvent(new CustomEvent('nav-select', { bubbles: true, composed: true, detail: { label: this.label } })); + } +} + +CbNavItem.defineAsync({ + name: 'cb-nav-item', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/src/molecules/cb-search-bar/cb-search-bar.css b/examples/app/contact-book-manager/src/molecules/cb-search-bar/cb-search-bar.css new file mode 100644 index 00000000..1ed173a6 --- /dev/null +++ b/examples/app/contact-book-manager/src/molecules/cb-search-bar/cb-search-bar.css @@ -0,0 +1,54 @@ +:host { + display: block; +} + +.search-container { + display: flex; + align-items: center; + background: white; + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 0 12px; + gap: 8px; +} + +.search-container:focus-within { + border-color: #0078d4; + box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.15); +} + +.search-icon { + font-size: 16px; + color: #9ca3af; +} + +.search-input { + flex: 1; + border: none; + outline: none; + padding: 10px 0; + font-size: 14px; + font-family: inherit; + background: transparent; +} + +.clear-btn { + width: 28px; + height: 28px; + border: none; + background: transparent; + cursor: pointer; + font-size: 18px; + color: #9ca3af; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; +} + +.clear-btn:hover { + color: #374151; + background: #f3f4f6; +} + +.search-icon::before { content: '\1F50D'; } diff --git a/examples/app/contact-book-manager/src/molecules/cb-search-bar/cb-search-bar.html b/examples/app/contact-book-manager/src/molecules/cb-search-bar/cb-search-bar.html new file mode 100644 index 00000000..704b42ee --- /dev/null +++ b/examples/app/contact-book-manager/src/molecules/cb-search-bar/cb-search-bar.html @@ -0,0 +1,9 @@ + diff --git a/examples/app/contact-book-manager/src/molecules/cb-search-bar/cb-search-bar.ts b/examples/app/contact-book-manager/src/molecules/cb-search-bar/cb-search-bar.ts new file mode 100644 index 00000000..a02366a2 --- /dev/null +++ b/examples/app/contact-book-manager/src/molecules/cb-search-bar/cb-search-bar.ts @@ -0,0 +1,43 @@ +import { FASTElement, attr } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +export class CbSearchBar extends RenderableFASTElement(FASTElement) { + @attr placeholder = 'Search contacts...'; + @attr value = ''; + private listenersAttached!: boolean; + + connectedCallback(): void { + super.connectedCallback(); + if (this.listenersAttached) return; + this.listenersAttached = true; + this.addEventListener('input', (e: Event) => this.onInput(e)); + this.addEventListener('click', (e: Event) => this.onClick(e as MouseEvent)); + } + + private emit(type: string, detail?: unknown): void { + this.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true, detail })); + } + + onInput(e: Event): void { + const input = e.composedPath().find(el => (el as HTMLElement).tagName === 'INPUT') as HTMLInputElement; + if (input) { + this.value = input.value; + this.emit('search', { value: this.value }); + } + } + + onClick(e: MouseEvent): void { + const target = (e.composedPath()[0] as HTMLElement); + if (target.closest('[data-action="clear"]')) { + this.value = ''; + const input = this.shadowRoot?.querySelector('input'); + if (input) input.value = ''; + this.emit('search', { value: '' }); + } + } +} + +CbSearchBar.defineAsync({ + name: 'cb-search-bar', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/src/molecules/cb-stat-card/cb-stat-card.css b/examples/app/contact-book-manager/src/molecules/cb-stat-card/cb-stat-card.css new file mode 100644 index 00000000..3af42ef8 --- /dev/null +++ b/examples/app/contact-book-manager/src/molecules/cb-stat-card/cb-stat-card.css @@ -0,0 +1,34 @@ +:host { + display: block; +} + +.stat-card { + display: flex; + align-items: center; + gap: 14px; + background: white; + border-radius: 12px; + padding: 20px 24px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.stat-icon { + font-size: 28px; +} + +.stat-content { + display: flex; + flex-direction: column; +} + +.stat-value { + font-size: 28px; + font-weight: 700; + color: #111827; +} + +.stat-label { + font-size: 13px; + color: #6b7280; + margin-top: 2px; +} diff --git a/examples/app/contact-book-manager/src/molecules/cb-stat-card/cb-stat-card.html b/examples/app/contact-book-manager/src/molecules/cb-stat-card/cb-stat-card.html new file mode 100644 index 00000000..39b5a762 --- /dev/null +++ b/examples/app/contact-book-manager/src/molecules/cb-stat-card/cb-stat-card.html @@ -0,0 +1,9 @@ + diff --git a/examples/app/contact-book-manager/src/molecules/cb-stat-card/cb-stat-card.ts b/examples/app/contact-book-manager/src/molecules/cb-stat-card/cb-stat-card.ts new file mode 100644 index 00000000..1e8380a5 --- /dev/null +++ b/examples/app/contact-book-manager/src/molecules/cb-stat-card/cb-stat-card.ts @@ -0,0 +1,13 @@ +import { FASTElement, attr } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +export class CbStatCard extends RenderableFASTElement(FASTElement) { + @attr icon = ''; + @attr value = ''; + @attr label = ''; +} + +CbStatCard.defineAsync({ + name: 'cb-stat-card', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/src/organisms/cb-contact-card/cb-contact-card.css b/examples/app/contact-book-manager/src/organisms/cb-contact-card/cb-contact-card.css new file mode 100644 index 00000000..489c8d73 --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-contact-card/cb-contact-card.css @@ -0,0 +1,88 @@ +:host { + display: block; +} + +.card { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 18px; + border-bottom: 1px solid #f3f4f6; + cursor: pointer; + transition: background 0.15s; +} + +.card:hover { + background: #f9fafb; +} + +.card-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.card-top { + display: flex; + align-items: center; + gap: 6px; +} + +.name { + font-size: 15px; + font-weight: 600; + color: #111827; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.fav-star { + font-size: 12px; +} + +.fav-star::before { + content: '\2B50'; +} + +.email { + font-size: 13px; + color: #6b7280; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.phone { + font-size: 13px; + color: #9ca3af; +} + +.avatar { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.avatar-initials { + color: #fff; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.5px; +} + +.badge { + font-size: 12px; + padding: 2px 10px; + border-radius: 12px; + background: #e5e7eb; + color: #374151; + white-space: nowrap; + flex-shrink: 0; +} diff --git a/examples/app/contact-book-manager/src/organisms/cb-contact-card/cb-contact-card.html b/examples/app/contact-book-manager/src/organisms/cb-contact-card/cb-contact-card.html new file mode 100644 index 00000000..7dbdc75f --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-contact-card/cb-contact-card.html @@ -0,0 +1,18 @@ + diff --git a/examples/app/contact-book-manager/src/organisms/cb-contact-card/cb-contact-card.ts b/examples/app/contact-book-manager/src/organisms/cb-contact-card/cb-contact-card.ts new file mode 100644 index 00000000..3dfa553a --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-contact-card/cb-contact-card.ts @@ -0,0 +1,37 @@ +import { FASTElement, attr } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +export class CbContactCard extends RenderableFASTElement(FASTElement) { + @attr id = ''; + @attr({ attribute: 'first-name' }) firstName = ''; + @attr({ attribute: 'last-name' }) lastName = ''; + @attr email = ''; + @attr phone = ''; + @attr company = ''; + @attr group = ''; + @attr favorite = 'false'; + @attr initials = ''; + @attr({ attribute: 'avatar-color' }) avatarColor = ''; + @attr notes = ''; + @attr address = ''; + + private listenersAttached!: boolean; + + connectedCallback(): void { + super.connectedCallback(); + if (this.listenersAttached) return; + this.listenersAttached = true; + this.addEventListener('click', () => { + this.onClick(); + }); + } + + onClick(): void { + this.dispatchEvent(new CustomEvent('select-contact', { bubbles: true, composed: true, detail: { id: this.id } })); + } +} + +CbContactCard.defineAsync({ + name: 'cb-contact-card', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/src/organisms/cb-contact-detail/cb-contact-detail.css b/examples/app/contact-book-manager/src/organisms/cb-contact-detail/cb-contact-detail.css new file mode 100644 index 00000000..47369afc --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-contact-detail/cb-contact-detail.css @@ -0,0 +1,173 @@ +:host { + display: block; +} + +.detail { + background: #fff; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + overflow: hidden; +} + +.detail-header { + display: flex; + align-items: center; + gap: 20px; + padding: 24px; + border-bottom: 1px solid #f3f4f6; +} + +.detail-name h2 { + font-size: 22px; + font-weight: 700; + margin: 0; +} + +.company { + font-size: 14px; + color: #6b7280; +} + +.detail-actions { + display: flex; + gap: 4px; + margin-left: auto; +} + +.detail-body { + padding: 24px; +} + +.detail-section { + margin-bottom: 24px; +} + +.detail-section h3 { + font-size: 14px; + font-weight: 600; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 12px; + margin-top: 0; +} + +.detail-field { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 8px 0; +} + +.field-label { + font-size: 13px; + color: #6b7280; + min-width: 100px; + flex-shrink: 0; +} + +.field-value { + font-size: 14px; + color: #111827; +} + +.notes { + white-space: pre-wrap; +} + +.detail-footer { + padding: 16px 24px; + border-top: 1px solid #f3f4f6; +} + +.back-btn { + background: none; + border: none; + cursor: pointer; + font-size: 14px; + color: #0078d4; + padding: 8px 0; + font-family: inherit; +} + +.back-btn:hover { + text-decoration: underline; +} + +/* Inlined avatar styles */ +.avatar { + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + color: #fff; + font-weight: 600; + user-select: none; +} + +.avatar-lg { + width: 64px; + height: 64px; + font-size: 24px; +} + +.avatar-initials { + line-height: 1; + text-transform: uppercase; + color: #fff; + font-size: 24px; + font-weight: 700; +} + +/* Inlined badge styles */ +.badge { + display: inline-block; + padding: 2px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + line-height: 1.5; + background-color: #e5e7eb; + color: #374151; +} + +/* Inlined icon-button styles */ +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + border-radius: 8px; + background-color: transparent; + cursor: pointer; + font-size: 18px; + padding: 0; + color: #374151; + transition: background-color 0.15s; +} + +.icon-btn:hover { + background-color: #f3f4f6; +} + +.icon-btn:focus-visible { + outline: 2px solid #0078d4; + outline-offset: 2px; +} + +.icon-btn-danger:hover { + background-color: #fee2e2; + color: #dc2626; +} + +/* Icon classes using CSS content to avoid emoji encoding issues */ +.icon-edit::before { content: '\270F\FE0F'; } +.icon-star::before { content: '\2B50'; } +.icon-trash::before { content: '\1F5D1\FE0F'; } +.label-email::before { content: '\1F4E7 '; } +.label-phone::before { content: '\1F4F1 '; } +.label-address::before { content: '\1F4CD '; } +.label-group::before { content: '\1F465 '; } +.label-notes::before { content: '\1F4DD '; } diff --git a/examples/app/contact-book-manager/src/organisms/cb-contact-detail/cb-contact-detail.html b/examples/app/contact-book-manager/src/organisms/cb-contact-detail/cb-contact-detail.html new file mode 100644 index 00000000..ff0dcce6 --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-contact-detail/cb-contact-detail.html @@ -0,0 +1,53 @@ + diff --git a/examples/app/contact-book-manager/src/organisms/cb-contact-detail/cb-contact-detail.ts b/examples/app/contact-book-manager/src/organisms/cb-contact-detail/cb-contact-detail.ts new file mode 100644 index 00000000..7d914428 --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-contact-detail/cb-contact-detail.ts @@ -0,0 +1,60 @@ +import { FASTElement, attr } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +export class CbContactDetail extends RenderableFASTElement(FASTElement) { + @attr id = ''; + @attr({ attribute: 'first-name' }) firstName = ''; + @attr({ attribute: 'last-name' }) lastName = ''; + @attr email = ''; + @attr phone = ''; + @attr company = ''; + @attr group = ''; + @attr favorite = ''; + @attr initials = ''; + @attr({ attribute: 'avatar-color' }) avatarColor = ''; + @attr notes = ''; + @attr address = ''; + + private listenersAttached!: boolean; + + connectedCallback(): void { + super.connectedCallback(); + if (this.listenersAttached) return; + this.listenersAttached = true; + this.addEventListener('click', (e: Event) => { + this.onClick(e as MouseEvent); + }); + } + + private emit(type: string, detail?: unknown): void { + this.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true, detail })); + } + + onClick(e: MouseEvent): void { + const target = e.composedPath()[0] as HTMLElement; + const actionEl = target.closest('[data-action]'); + if (!actionEl) return; + + const action = actionEl.getAttribute('data-action'); + + switch (action) { + case 'edit': + this.emit('edit-contact', { id: this.id }); + break; + case 'toggle-favorite': + this.emit('toggle-favorite', { id: this.id }); + break; + case 'delete': + this.emit('delete-contact', { id: this.id }); + break; + case 'back': + this.emit('back'); + break; + } + } +} + +CbContactDetail.defineAsync({ + name: 'cb-contact-detail', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/src/organisms/cb-contact-form/cb-contact-form.css b/examples/app/contact-book-manager/src/organisms/cb-contact-form/cb-contact-form.css new file mode 100644 index 00000000..140cbebc --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-contact-form/cb-contact-form.css @@ -0,0 +1,149 @@ +:host { + display: block; +} + +.form-container { + background: #fff; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + padding: 32px; +} + +.form-title { + font-size: 22px; + font-weight: 700; + margin: 0 0 24px; +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 20px; +} + +@media (max-width: 640px) { + .form-grid { + grid-template-columns: 1fr; + } +} + +/* Inlined form-field styles */ +.form-field { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 16px; +} + +.field-input { + width: 100%; + padding: 10px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 14px; + font-family: inherit; + box-sizing: border-box; +} + +.field-input:focus { + outline: none; + border-color: #0078d4; + box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.15); +} + +.form-field-full { + margin-bottom: 16px; +} + +.field-label { + font-size: 13px; + font-weight: 600; + color: #374151; + display: block; + margin-bottom: 4px; +} + +.group-selector { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 4px; +} + +.group-option { + padding: 8px 16px; + border: 1px solid #d1d5db; + border-radius: 20px; + background: #fff; + cursor: pointer; + font-size: 13px; + font-family: inherit; + transition: all 0.15s; +} + +.group-option:hover { + border-color: #0078d4; + color: #0078d4; +} + +.group-option.active { + background: #0078d4; + color: #fff; + border-color: #0078d4; +} + +.notes-input { + width: 100%; + padding: 10px 14px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 14px; + font-family: inherit; + resize: vertical; + box-sizing: border-box; +} + +.notes-input:focus { + border-color: #0078d4; + outline: none; + box-shadow: 0 0 0 2px rgba(0, 120, 212, 0.2); +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 24px; + padding-top: 20px; + border-top: 1px solid #f3f4f6; +} + +.cancel-btn { + padding: 10px 24px; + border: 1px solid #d1d5db; + border-radius: 8px; + background: #fff; + cursor: pointer; + font-size: 14px; + font-family: inherit; +} + +.cancel-btn:hover { + background: #f9fafb; +} + +.save-btn { + padding: 10px 24px; + border: none; + border-radius: 8px; + background: #0078d4; + color: #fff; + cursor: pointer; + font-size: 14px; + font-weight: 600; + font-family: inherit; +} + +.save-btn:hover { + background: #106ebe; +} diff --git a/examples/app/contact-book-manager/src/organisms/cb-contact-form/cb-contact-form.html b/examples/app/contact-book-manager/src/organisms/cb-contact-form/cb-contact-form.html new file mode 100644 index 00000000..67e122b7 --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-contact-form/cb-contact-form.html @@ -0,0 +1,47 @@ + diff --git a/examples/app/contact-book-manager/src/organisms/cb-contact-form/cb-contact-form.ts b/examples/app/contact-book-manager/src/organisms/cb-contact-form/cb-contact-form.ts new file mode 100644 index 00000000..7b5d7346 --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-contact-form/cb-contact-form.ts @@ -0,0 +1,127 @@ +import { FASTElement, attr, observable } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +const formGroupsStore = new WeakMap(); + +export class CbContactForm extends RenderableFASTElement(FASTElement) { + @attr({ attribute: 'form-title' }) formTitle = 'Add Contact'; + @attr({ attribute: 'edit-id' }) editId = ''; + @attr({ attribute: 'first-name' }) firstName = ''; + @attr({ attribute: 'last-name' }) lastName = ''; + @attr email = ''; + @attr phone = ''; + @attr company = ''; + @attr address = ''; + @attr group = ''; + @attr notes = ''; + @observable selectedGroup = ''; + + private listenersAttached!: boolean; + + connectedCallback(): void { + super.connectedCallback(); + if (this.listenersAttached) return; + this.listenersAttached = true; + this.addEventListener('click', (e: Event) => { + this.onClick(e as MouseEvent); + }); + + // When the form is dynamically created (not SSR'd), the loop + // for groups produces no buttons. Ensure they exist after first render. + requestAnimationFrame(() => this.ensureGroupButtons()); + } + + async prepare(): Promise { + const groups: string[] = []; + for (const el of this.shadowRoot!.querySelectorAll('.group-option')) { + const g = el.getAttribute('data-group') || el.textContent || ''; + if (g) groups.push(g); + } + formGroupsStore.set(this, groups); + this.selectedGroup = this.group || (groups.length > 0 ? groups[0] : ''); + } + + private ensureGroupButtons(): void { + const sr = this.shadowRoot; + if (!sr) return; + + const existing = sr.querySelectorAll('.group-option'); + if (existing.length > 0) return; + + // Read groups from the sidebar (always SSR'd with group nav items) + const hostRoot = this.getRootNode() as ShadowRoot; + const app = hostRoot?.host; + const sidebar = app?.shadowRoot?.querySelector('cb-sidebar'); + const navItems = sidebar?.shadowRoot?.querySelectorAll('.nav-item[data-nav]') || []; + const groups: string[] = []; + for (const el of navItems) { + const label = (el as HTMLElement).getAttribute('data-nav') || ''; + if (!['Dashboard', 'All Contacts', 'Favorites'].includes(label) && label) { + groups.push(label); + } + } + + if (groups.length === 0) return; + + const selector = sr.querySelector('.group-selector'); + if (!selector) return; + + for (const g of groups) { + const btn = document.createElement('button'); + btn.className = 'group-option'; + btn.setAttribute('data-group', g); + btn.textContent = g; + selector.appendChild(btn); + } + + formGroupsStore.set(this, groups); + this.selectedGroup = this.group || groups[0] || ''; + + for (const btn of selector.querySelectorAll('.group-option')) { + btn.classList.toggle('active', btn.getAttribute('data-group') === this.selectedGroup); + } + } + + onClick(e: MouseEvent): void { + const target = e.composedPath()[0] as HTMLElement; + const action = target.closest('[data-action]')?.getAttribute('data-action'); + const groupBtn = target.closest('[data-group]'); + + if (groupBtn) { + this.selectedGroup = groupBtn.getAttribute('data-group') || ''; + // Update visual active state + for (const btn of this.shadowRoot!.querySelectorAll('.group-option')) { + btn.classList.toggle('active', btn.getAttribute('data-group') === this.selectedGroup); + } + return; + } + + if (action === 'cancel') { + this.dispatchEvent(new CustomEvent('form-cancel', { bubbles: true, composed: true })); + } else if (action === 'save') { + const formData = this.collectFormData(); + if (formData) { + this.dispatchEvent(new CustomEvent('form-save', { bubbles: true, composed: true, detail: formData })); + } + } + } + + private collectFormData(): Record | null { + const inputs = this.shadowRoot!.querySelectorAll('input.field-input'); + const data: Record = {}; + for (const input of inputs) { + const name = (input as HTMLInputElement).name || ''; + data[name] = (input as HTMLInputElement).value || ''; + } + data.group = this.selectedGroup; + const textarea = this.shadowRoot!.querySelector('.notes-input') as HTMLTextAreaElement; + data.notes = textarea?.value || ''; + if (this.editId) data.id = this.editId; + return data; + } +} + +CbContactForm.defineAsync({ + name: 'cb-contact-form', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/src/organisms/cb-contact-list/cb-contact-list.css b/examples/app/contact-book-manager/src/organisms/cb-contact-list/cb-contact-list.css new file mode 100644 index 00000000..0fa47d64 --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-contact-list/cb-contact-list.css @@ -0,0 +1,14 @@ +:host { + display: block; +} + +.list-container { + min-height: 200px; +} + +.list { + background: #fff; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + overflow: hidden; +} diff --git a/examples/app/contact-book-manager/src/organisms/cb-contact-list/cb-contact-list.html b/examples/app/contact-book-manager/src/organisms/cb-contact-list/cb-contact-list.html new file mode 100644 index 00000000..d457bc25 --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-contact-list/cb-contact-list.html @@ -0,0 +1,31 @@ + diff --git a/examples/app/contact-book-manager/src/organisms/cb-contact-list/cb-contact-list.ts b/examples/app/contact-book-manager/src/organisms/cb-contact-list/cb-contact-list.ts new file mode 100644 index 00000000..8da6526b --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-contact-list/cb-contact-list.ts @@ -0,0 +1,53 @@ +import { FASTElement, observable } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +interface Contact { + id: string; + firstName: string; + lastName: string; + email: string; + phone: string; + company: string; + group: string; + favorite: boolean; + initials: string; + avatarColor: string; + notes: string; + address: string; +} + +export class CbContactList extends RenderableFASTElement(FASTElement) { + @observable contacts!: Contact[]; + @observable hasContacts = true; + + async prepare(): Promise { + const contacts: Contact[] = []; + for (const el of this.shadowRoot!.querySelectorAll('cb-contact-card')) { + contacts.push({ + id: el.getAttribute('id') || '', + firstName: el.getAttribute('first-name') || '', + lastName: el.getAttribute('last-name') || '', + email: el.getAttribute('email') || '', + phone: el.getAttribute('phone') || '', + company: el.getAttribute('company') || '', + group: el.getAttribute('group') || '', + favorite: el.getAttribute('favorite') === 'true', + initials: el.getAttribute('initials') || '', + avatarColor: el.getAttribute('avatar-color') || '#6B7280', + notes: el.getAttribute('notes') || '', + address: el.getAttribute('address') || '', + }); + } + this.contacts = contacts; + this.hasContacts = contacts.length > 0; + } + + contactsChanged(): void { + this.hasContacts = this.contacts && this.contacts.length > 0; + } +} + +CbContactList.defineAsync({ + name: 'cb-contact-list', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/src/organisms/cb-header/cb-header.css b/examples/app/contact-book-manager/src/organisms/cb-header/cb-header.css new file mode 100644 index 00000000..48f1178f --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-header/cb-header.css @@ -0,0 +1,105 @@ +:host { + display: block; +} + +.header { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 24px; + background: #fff; + border-bottom: 1px solid #e5e7eb; + position: sticky; + top: 0; + z-index: 10; + -webkit-app-region: drag; +} + +.header-left { + flex-shrink: 0; +} + +.title { + font-size: 20px; + font-weight: 700; + color: #111827; + margin: 0; +} + +.header-center { + flex: 1; + max-width: 480px; +} + +.search-container { + display: flex; + align-items: center; + background: #fff; + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 0 12px; + gap: 8px; + -webkit-app-region: no-drag; +} + +.search-container:focus-within { + border-color: #0078d4; + box-shadow: 0 0 0 2px rgba(0, 120, 212, 0.15); +} + +.search-icon { + font-size: 16px; + color: #9ca3af; +} + +.search-input { + flex: 1; + border: none; + outline: none; + padding: 10px 0; + font-size: 14px; + font-family: inherit; + background: transparent; +} + +.header-right { + flex-shrink: 0; +} + +.add-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + background: #0078d4; + color: #fff; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + font-family: inherit; + -webkit-app-region: no-drag; +} + +.add-btn:hover { + background: #106ebe; +} + +.add-icon { + font-size: 18px; + font-weight: bold; +} + +@media (max-width: 768px) { + .add-label { + display: none; + } + + .header-center { + max-width: none; + } +} + +.title::before { content: '\1F4C7 '; } +.search-icon::before { content: '\1F50D'; } diff --git a/examples/app/contact-book-manager/src/organisms/cb-header/cb-header.html b/examples/app/contact-book-manager/src/organisms/cb-header/cb-header.html new file mode 100644 index 00000000..e9588a63 --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-header/cb-header.html @@ -0,0 +1,19 @@ + diff --git a/examples/app/contact-book-manager/src/organisms/cb-header/cb-header.ts b/examples/app/contact-book-manager/src/organisms/cb-header/cb-header.ts new file mode 100644 index 00000000..cb79d187 --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-header/cb-header.ts @@ -0,0 +1,45 @@ +import { FASTElement, attr } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +export class CbHeader extends RenderableFASTElement(FASTElement) { + @attr({ attribute: 'search-query' }) searchQuery = ''; + + private listenersAttached!: boolean; + + connectedCallback(): void { + super.connectedCallback(); + if (this.listenersAttached) return; + this.listenersAttached = true; + this.addEventListener('click', (e: Event) => { + this.onClick(e as MouseEvent); + }); + this.addEventListener('input', (e: Event) => { + this.onInput(e); + }); + } + + private emit(type: string, detail?: unknown): void { + this.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true, detail })); + } + + onInput(e: Event): void { + const input = e.composedPath().find(el => (el as HTMLElement).tagName === 'INPUT') as HTMLInputElement; + if (input) { + this.searchQuery = input.value; + this.emit('search', { value: input.value }); + } + } + + onClick(e: MouseEvent): void { + const target = e.composedPath()[0] as HTMLElement; + const action = target.closest('[data-action]')?.getAttribute('data-action'); + if (action === 'add-contact') { + this.emit('add-contact'); + } + } +} + +CbHeader.defineAsync({ + name: 'cb-header', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/src/organisms/cb-sidebar/cb-sidebar.css b/examples/app/contact-book-manager/src/organisms/cb-sidebar/cb-sidebar.css new file mode 100644 index 00000000..35f80bb9 --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-sidebar/cb-sidebar.css @@ -0,0 +1,92 @@ +:host { + display: block; +} + +.sidebar { + width: 260px; + min-height: calc(100vh - 65px); + background: #fff; + border-right: 1px solid #e5e7eb; + padding: 16px 12px; + overflow-y: auto; +} + +.nav-section { + display: flex; + flex-direction: column; + gap: 2px; +} + +.nav-divider { + height: 1px; + background: #e5e7eb; + margin: 12px 0; +} + +.nav-heading { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #9ca3af; + padding: 8px 14px 4px; + font-weight: 600; + margin: 0; +} + +/* Inlined nav-item styles */ +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 8px; + cursor: pointer; + transition: background 0.15s; +} + +.nav-item:hover { + background: #f3f4f6; +} + +.nav-item.active { + background: #e0f2fe; + color: #0078d4; +} + +.nav-icon { + font-size: 18px; + width: 24px; + text-align: center; +} + +.nav-label { + flex: 1; + font-size: 14px; + font-weight: 500; +} + +.nav-count { + font-size: 12px; + background: #e5e7eb; + color: #6b7280; + border-radius: 10px; + padding: 1px 8px; + min-width: 22px; + text-align: center; +} + +.nav-item.active .nav-count { + background: #bfdbfe; + color: #0078d4; +} + +.nav-icon-dashboard::before { content: '\1F4CA'; } +.nav-icon-contacts::before { content: '\1F465'; } +.nav-icon-favorites::before { content: '\2B50'; } +.nav-icon-folder::before { content: '\1F4C1'; } + +@media (max-width: 768px) { + :host { + display: none; + } +} diff --git a/examples/app/contact-book-manager/src/organisms/cb-sidebar/cb-sidebar.html b/examples/app/contact-book-manager/src/organisms/cb-sidebar/cb-sidebar.html new file mode 100644 index 00000000..923f42bb --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-sidebar/cb-sidebar.html @@ -0,0 +1,30 @@ + diff --git a/examples/app/contact-book-manager/src/organisms/cb-sidebar/cb-sidebar.ts b/examples/app/contact-book-manager/src/organisms/cb-sidebar/cb-sidebar.ts new file mode 100644 index 00000000..5026c593 --- /dev/null +++ b/examples/app/contact-book-manager/src/organisms/cb-sidebar/cb-sidebar.ts @@ -0,0 +1,77 @@ +import { FASTElement, attr, observable } from '@microsoft/fast-element'; +import { RenderableFASTElement } from '@microsoft/fast-html'; + +export class CbSidebar extends RenderableFASTElement(FASTElement) { + @attr page = 'dashboard'; + @attr({ attribute: 'active-group' }) activeGroup = 'all'; + @attr({ attribute: 'total-contacts' }) totalContacts = '0'; + @attr({ attribute: 'total-favorites' }) totalFavorites = '0'; + groups!: string[]; + @observable dashboardActive!: boolean; + @observable contactsActive!: boolean; + @observable favoritesActive!: boolean; + + private listenersAttached!: boolean; + + connectedCallback(): void { + super.connectedCallback(); + if (this.listenersAttached) return; + this.listenersAttached = true; + this.addEventListener('click', (e: Event) => { + this.onNavClick(e as MouseEvent); + }); + } + + async prepare(): Promise { + if (!this.shadowRoot) return; + + const groups: string[] = []; + for (const el of this.shadowRoot.querySelectorAll('.nav-item')) { + const label = el.getAttribute('data-nav') || ''; + if (['Dashboard', 'All Contacts', 'Favorites'].includes(label)) continue; + if (label) groups.push(label); + } + this.groups = groups; + this.updateActiveState(); + } + + pageChanged(): void { + this.updateActiveState(); + } + + private updateActiveState(): void { + this.dashboardActive = this.page === 'dashboard'; + this.contactsActive = this.page === 'contacts'; + this.favoritesActive = this.page === 'favorites'; + + // Update active class on nav items + for (const el of this.shadowRoot?.querySelectorAll('.nav-item') || []) { + const nav = el.getAttribute('data-nav') || ''; + let isActive = false; + if (nav === 'Dashboard') isActive = this.dashboardActive; + else if (nav === 'All Contacts') isActive = this.contactsActive; + else if (nav === 'Favorites') isActive = this.favoritesActive; + else if (this.page === 'group') isActive = nav === this.activeGroup; + el.classList.toggle('active', isActive); + } + } + + private emit(type: string, detail?: unknown): void { + this.dispatchEvent(new CustomEvent(type, { bubbles: true, composed: true, detail })); + } + + onNavClick(e: MouseEvent): void { + const target = (e.composedPath()[0] as HTMLElement).closest('.nav-item') as HTMLElement | null; + if (!target) return; + const label = target.getAttribute('data-nav') || ''; + if (label === 'Dashboard') this.emit('navigate', { page: 'dashboard' }); + else if (label === 'All Contacts') this.emit('navigate', { page: 'contacts' }); + else if (label === 'Favorites') this.emit('navigate', { page: 'favorites' }); + else this.emit('navigate', { page: 'group', group: label }); + } +} + +CbSidebar.defineAsync({ + name: 'cb-sidebar', + templateOptions: 'defer-and-hydrate', +}); diff --git a/examples/app/contact-book-manager/tsconfig.json b/examples/app/contact-book-manager/tsconfig.json new file mode 100644 index 00000000..efdf4bb9 --- /dev/null +++ b/examples/app/contact-book-manager/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "experimentalDecorators": true, + "useDefineForClassFields": false, + "declaration": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/integration/electron/README.md b/examples/integration/electron/README.md new file mode 100644 index 00000000..ac1762e5 --- /dev/null +++ b/examples/integration/electron/README.md @@ -0,0 +1,36 @@ +# WebUI Electron Integration + +Wraps any pre-built WebUI app in a frameless Electron desktop window using the `webui-node` native addon. + +## Prerequisites + +1. Build the native addon: + +```bash +cargo build -p webui-node --release +``` + +2. Build a WebUI app (e.g. hello-world): + +```bash +cargo run -p webui-cli -- build ../../app/hello-world/templates --out ../../app/hello-world/dist --css external --plugin=fast +esbuild ../../app/hello-world/src/index.ts --bundle --outfile=../../app/hello-world/dist/index.js --format=esm +``` + +## Usage + +```bash +# hello-world +pnpm start ../../app/hello-world/dist ../../app/hello-world/data/state.json --plugin=fast + +# contact-book-manager +pnpm start ../../app/contact-book-manager/dist ../../app/contact-book-manager/data/state.json --plugin=fast +``` + +## CLI Arguments + +| Argument | Description | +|---|---| +| `dist-dir` | **(required)** Path to the app's `dist/` directory containing `protocol.bin` and CSS/JS assets | +| `state.json` | **(required)** Path to the state JSON file | +| `--plugin=fast` | Enable FAST hydration plugin _(optional)_ | diff --git a/examples/integration/electron/main.ts b/examples/integration/electron/main.ts new file mode 100644 index 00000000..ee266a04 --- /dev/null +++ b/examples/integration/electron/main.ts @@ -0,0 +1,144 @@ +// Electron integration for WebUI — renders any pre-built WebUI app in a +// frameless desktop window using the webui-node native addon. +// +// Prerequisites: +// 1. Build the native addon: cargo build -p webui-node --release +// 2. Build a WebUI app: +// cargo run -p webui-cli -- build /src --out /dist --css external --plugin=fast +// esbuild /src/index.ts --bundle --outfile=/dist/index.js --format=esm +// +// Usage: +// electron dist/main.js [dist-dir] [state.json] [--plugin=fast] + +import { app, BrowserWindow, Menu, net, protocol } from 'electron'; +import { existsSync, readFileSync } from 'fs'; +import { platform } from 'os'; +import { resolve, join, basename } from 'path'; + +// --------------------------------------------------------------------------- +// CLI arguments +// --------------------------------------------------------------------------- + +// Filter out Electron's own args (everything before --) +const args = process.argv.slice(2).filter(a => !a.startsWith('--inspect')); + +const positional = args.filter(a => !a.startsWith('--')); +const flags = args.filter(a => a.startsWith('--')); + +if (positional.length < 2) { + console.error('Usage: electron dist/main.js [--plugin=fast]'); + console.error(' dist-dir Path to the app dist/ directory containing protocol.bin'); + console.error(' state.json Path to the JSON state file'); + process.exit(1); +} + +const distDir = resolve(positional[0]); +const statePath = resolve(positional[1]); + +const pluginArg = flags.find(a => a.startsWith('--plugin=')); +const pluginName = pluginArg ? pluginArg.split('=')[1] : undefined; + +// --------------------------------------------------------------------------- +// Native addon +// --------------------------------------------------------------------------- + +function loadAddon() { + const root = resolve(import.meta.dirname, '..', '..', '..', '..'); + const ext = platform() === 'darwin' ? 'dylib' : platform() === 'win32' ? 'dll' : 'so'; + const prefix = platform() === 'win32' ? '' : 'lib'; + const filename = `${prefix}webui_node.${ext}`; + + const mod = { exports: {} } as { exports: Record }; + for (const profile of ['release', 'debug']) { + try { + process.dlopen(mod, join(root, 'target', profile, filename)); + return mod.exports as { render(protocol: Buffer, state: string, onChunk: (chunk: string) => void, plugin?: string): void }; + } catch { + // try next profile + } + } + throw new Error( + `Could not find ${filename} in target/release or target/debug.\nRun: cargo build -p webui-node --release`, + ); +} + +const addon = loadAddon(); + +// --------------------------------------------------------------------------- +// Custom protocol +// --------------------------------------------------------------------------- + +protocol.registerSchemesAsPrivileged([ + { + scheme: 'webui', + privileges: { standard: true, secure: true, supportFetchAPI: true }, + }, +]); + +// --------------------------------------------------------------------------- +// App lifecycle +// --------------------------------------------------------------------------- + +app.whenReady().then(() => { + Menu.setApplicationMenu(null); + + if (!existsSync(join(distDir, 'protocol.bin'))) { + console.error(`protocol.bin not found in ${distDir}`); + console.error('Build the app first: cargo run -p webui-cli -- build /src --out /dist'); + app.quit(); + return; + } + + const protocolBin = readFileSync(join(distDir, 'protocol.bin')); + const stateJson = existsSync(statePath) ? readFileSync(statePath, 'utf-8') : '{}'; + + // Render SSR HTML + const chunks: string[] = []; + addon.render(protocolBin, stateJson, (chunk: string) => { + chunks.push(chunk); + }, pluginName); + const html = chunks.join(''); + + // Protocol handler — serves rendered HTML + static assets + protocol.handle('webui', (request) => { + const url = new URL(request.url); + + if (url.pathname === '/' || url.pathname === '') { + return new Response(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } + + // Serve static assets (CSS, JS, maps) from dist dir + const filePath = join(distDir, basename(url.pathname)); + if (existsSync(filePath)) { + return net.fetch(`file://${filePath}`); + } + + return new Response('Not Found', { status: 404 }); + }); + + // Main window + const win = new BrowserWindow({ + width: 1200, + height: 800, + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#ffffff', + symbolColor: '#374151', + height: 40, + }, + webPreferences: { + preload: join(import.meta.dirname, 'preload.js'), + contextIsolation: true, + }, + }); + + win.loadURL('webui://app/'); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); diff --git a/examples/integration/electron/package.json b/examples/integration/electron/package.json new file mode 100644 index 00000000..905ed244 --- /dev/null +++ b/examples/integration/electron/package.json @@ -0,0 +1,15 @@ +{ + "name": "webui-electron-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "esbuild main.ts preload.ts --outdir=dist --platform=node --format=esm", + "start": "npm run build && electron dist/main.js --" + }, + "devDependencies": { + "electron": "catalog:", + "esbuild": "catalog:", + "typescript": "catalog:" + } +} diff --git a/examples/integration/electron/preload.ts b/examples/integration/electron/preload.ts new file mode 100644 index 00000000..3c01bbf0 --- /dev/null +++ b/examples/integration/electron/preload.ts @@ -0,0 +1,7 @@ +import { contextBridge } from 'electron'; + +contextBridge.exposeInMainWorld('versions', { + electron: process.versions.electron, + node: process.versions.node, + chrome: process.versions.chrome, +}); diff --git a/examples/integration/electron/tsconfig.json b/examples/integration/electron/tsconfig.json new file mode 100644 index 00000000..56caed33 --- /dev/null +++ b/examples/integration/electron/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "outDir": "dist" + }, + "include": ["*.ts"] +} diff --git a/examples/integration/node/index.js b/examples/integration/node/index.js index d938df3c..fd3c8717 100644 --- a/examples/integration/node/index.js +++ b/examples/integration/node/index.js @@ -40,9 +40,15 @@ function loadAddon() { ); } -const protocolPath = - process.argv[2] || "../../app/hello-world/dist/protocol.bin"; -const statePath = process.argv[3] || "../../app/hello-world/data/state.json"; +if (!process.argv[2] || !process.argv[3]) { + console.error("Usage: node index.js [--plugin=fast]"); + console.error(" protocol.bin Path to the compiled protocol binary"); + console.error(" state.json Path to the JSON state file"); + process.exit(1); +} + +const protocolPath = process.argv[2]; +const statePath = process.argv[3]; // Check for --plugin=fast flag const pluginArg = process.argv.find((a) => a.startsWith("--plugin=")); diff --git a/examples/integration/rust/Cargo.toml b/examples/integration/rust/Cargo.toml index 440d7c7c..662a3f1a 100644 --- a/examples/integration/rust/Cargo.toml +++ b/examples/integration/rust/Cargo.toml @@ -9,9 +9,7 @@ name = "webui-rust-example" path = "src/main.rs" [dependencies] -serde_json = "1.0" +serde_json = { workspace = true } webui-protocol = { path = "../../../crates/webui-protocol" } webui-handler = { path = "../../../crates/webui-handler" } -anyhow = "1.0" - -[workspace] +anyhow = { workspace = true } diff --git a/examples/integration/ssr-performance-showdown/Cargo.lock b/examples/integration/ssr-performance-showdown/Cargo.lock index 03a22c5e..3d6cc985 100644 --- a/examples/integration/ssr-performance-showdown/Cargo.lock +++ b/examples/integration/ssr-performance-showdown/Cargo.lock @@ -1279,6 +1279,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ssr-performance-showdown" +version = "0.1.0" +dependencies = [ + "actix-web", + "anyhow", + "serde_json", + "webui-handler", + "webui-protocol", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1590,17 +1601,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "webui-ssr-test" -version = "0.1.0" -dependencies = [ - "actix-web", - "anyhow", - "serde_json", - "webui-handler", - "webui-protocol", -] - [[package]] name = "webui-state" version = "0.1.0" diff --git a/examples/integration/ssr-performance-showdown/Cargo.toml b/examples/integration/ssr-performance-showdown/Cargo.toml index 9f1e7fbf..7d9afd37 100644 --- a/examples/integration/ssr-performance-showdown/Cargo.toml +++ b/examples/integration/ssr-performance-showdown/Cargo.toml @@ -14,5 +14,3 @@ webui-protocol = { path = "../../../crates/webui-protocol" } webui-handler = { path = "../../../crates/webui-handler" } actix-web = { workspace = true } anyhow = { workspace = true } - -[workspace] diff --git a/examples/integration/ssr-performance-showdown/README.md b/examples/integration/ssr-performance-showdown/README.md index 1426daaa..0f2d7640 100644 --- a/examples/integration/ssr-performance-showdown/README.md +++ b/examples/integration/ssr-performance-showdown/README.md @@ -11,8 +11,8 @@ as state to `WebUIHandler`, which streams the final HTML. ## Usage ```bash -# From the repository root — build the protocol first -cargo run -p webui-cli --release -- build examples/integration/ssr-performance-showdown/app --out examples/integration/ssr-performance-showdown/dist +# From this folder +cargo run -p webui-cli --release -- build app --out dist # Run the server frrm this location cargo run -p ssr-performance-showdown --release # listen on :3000 diff --git a/examples/integration/ssr-performance-showdown/src/main.rs b/examples/integration/ssr-performance-showdown/src/main.rs index 28e5043a..580541ed 100644 --- a/examples/integration/ssr-performance-showdown/src/main.rs +++ b/examples/integration/ssr-performance-showdown/src/main.rs @@ -1,140 +1,149 @@ -//! SSR Performance Test — WebUI Framework -//! -//! Rust server that loads a pre-built protocol.bin, computes a spiral -//! pattern of tiles per-request, and renders them through the WebUI -//! handler — comparable to the `fastify-html` entry in the -//! ssr-performance-showdown benchmark. -//! -//! Prerequisites: -//! cargo run -p webui-cli -- build app --out dist -//! -//! Usage: -//! cargo run # listen on :3000 -//! cargo run -- --port 3001 # custom port - -use actix_web::{web, App, HttpResponse, HttpServer}; -use anyhow::{Context, Result}; -use std::path::Path; -use webui_handler::{ResponseWriter, WebUIHandler}; -use webui_protocol::WebUIProtocol; - -// ── Spiral parameters (match ssr-performance-showdown) ────────────────── - -const WRAPPER_WIDTH: f64 = 960.0; -const WRAPPER_HEIGHT: f64 = 720.0; -const CELL_SIZE: f64 = 10.0; -const ANGLE_STEP: f64 = 0.2; -const RADIUS_STEP: f64 = CELL_SIZE * 0.015; - -// ── In-memory response writer ─────────────────────────────────────────── - -struct MemoryWriter { - buf: String, -} - -impl MemoryWriter { - fn with_capacity(cap: usize) -> Self { - Self { - buf: String::with_capacity(cap), - } - } -} - -impl ResponseWriter for MemoryWriter { - fn write(&mut self, content: &str) -> webui_handler::Result<()> { - self.buf.push_str(content); - Ok(()) - } - - fn end(&mut self) -> webui_handler::Result<()> { - Ok(()) - } -} - -// ── Spiral tile computation (runs per-request) ────────────────────────── - -fn compute_tiles() -> Vec { - let center_x = WRAPPER_WIDTH / 2.0; - let center_y = WRAPPER_HEIGHT / 2.0; - let max_radius = WRAPPER_WIDTH.min(WRAPPER_HEIGHT) / 2.0; - - let mut angle: f64 = 0.0; - let mut radius: f64 = 0.0; - let mut tiles = Vec::with_capacity(2400); - - while radius < max_radius { - let x = center_x + angle.cos() * radius; - let y = center_y + angle.sin() * radius; - - if x >= 0.0 - && x <= WRAPPER_WIDTH - CELL_SIZE - && y >= 0.0 - && y <= WRAPPER_HEIGHT - CELL_SIZE - { - tiles.push(serde_json::json!({ - "left": format!("{:.2}px", x), - "top": format!("{:.2}px", y), - })); - } - - angle += ANGLE_STEP; - radius += RADIUS_STEP; - } - - tiles -} - -// ── Route handler ─────────────────────────────────────────────────────── - -async fn handle_index(protocol: web::Data) -> HttpResponse { - let state = serde_json::json!({ "tiles": compute_tiles() }); - - let mut writer = MemoryWriter::with_capacity(256 * 1024); - let mut handler = WebUIHandler::new(); - - if let Err(e) = handler.handle(&protocol, &state, &mut writer) { - return HttpResponse::InternalServerError().body(format!("Render error: {e}")); - } - - HttpResponse::Ok() - .content_type("text/html; charset=utf-8") - .body(writer.buf) -} - -// ── Startup ───────────────────────────────────────────────────────────── - -fn main() -> Result<()> { - let port: u16 = std::env::args() - .position(|a| a == "--port") - .and_then(|i| std::env::args().nth(i + 1)) - .and_then(|v| v.parse().ok()) - .unwrap_or(3000); - - let protocol_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("dist/protocol.bin"); - let protocol = WebUIProtocol::from_protobuf_file(&protocol_path) - .with_context(|| format!("Failed to load {}", protocol_path.display()))?; - let protocol_data = web::Data::new(protocol); - - println!("Listening on http://localhost:{port}"); - - actix_web::rt::System::new() - .block_on(async move { - HttpServer::new(move || { - App::new() - .app_data(protocol_data.clone()) - .route("/", web::get().to(handle_index)) - .route("/index.html", web::get().to(handle_index)) - .default_service(web::route().to(|| async { - HttpResponse::NotFound().body("Not Found") - })) - }) - .bind(format!("0.0.0.0:{port}")) - .with_context(|| format!("Failed to bind to port {port}"))? - .run() - .await - .map_err(|e| anyhow::anyhow!("{e}")) - }) - .context("Server error")?; - - Ok(()) -} +//! SSR Performance Test — WebUI Framework +//! +//! Rust server that loads a pre-built protocol.bin, computes a spiral +//! pattern of tiles per-request, and renders them through the WebUI +//! handler — comparable to the `fastify-html` entry in the +//! ssr-performance-showdown benchmark. +//! +//! Prerequisites: +//! cargo run -p webui-cli -- build app --out dist +//! +//! Usage: +//! cargo run # listen on :3000 +//! cargo run -- --port 3001 # custom port + +use actix_web::{web, App, HttpResponse, HttpServer}; +use anyhow::{Context, Result}; +use std::path::Path; +use webui_handler::{ResponseWriter, WebUIHandler}; +use webui_protocol::WebUIProtocol; + +// ── Spiral parameters (match ssr-performance-showdown) ────────────────── + +const WRAPPER_WIDTH: f64 = 960.0; +const WRAPPER_HEIGHT: f64 = 720.0; +const CELL_SIZE: f64 = 10.0; +const ANGLE_STEP: f64 = 0.2; +const RADIUS_STEP: f64 = CELL_SIZE * 0.015; + +// ── In-memory response writer ─────────────────────────────────────────── + +struct MemoryWriter { + buf: String, +} + +impl MemoryWriter { + fn with_capacity(cap: usize) -> Self { + Self { + buf: String::with_capacity(cap), + } + } +} + +impl ResponseWriter for MemoryWriter { + fn write(&mut self, content: &str) -> webui_handler::Result<()> { + self.buf.push_str(content); + Ok(()) + } + + fn end(&mut self) -> webui_handler::Result<()> { + Ok(()) + } +} + +// ── Spiral tile computation (runs per-request) ────────────────────────── + +fn compute_tiles() -> Vec { + let center_x = WRAPPER_WIDTH / 2.0; + let center_y = WRAPPER_HEIGHT / 2.0; + let max_radius = WRAPPER_WIDTH.min(WRAPPER_HEIGHT) / 2.0; + + let mut angle: f64 = 0.0; + let mut radius: f64 = 0.0; + let mut tiles = Vec::with_capacity(2400); + + while radius < max_radius { + let x = center_x + angle.cos() * radius; + let y = center_y + angle.sin() * radius; + + if (0.0..=WRAPPER_WIDTH - CELL_SIZE).contains(&x) + && (0.0..=WRAPPER_HEIGHT - CELL_SIZE).contains(&y) + { + tiles.push(serde_json::Value::Object({ + let mut m = serde_json::Map::with_capacity(2); + m.insert( + "left".to_owned(), + serde_json::Value::String(format!("{x:.2}px")), + ); + m.insert( + "top".to_owned(), + serde_json::Value::String(format!("{y:.2}px")), + ); + m + })); + } + + angle += ANGLE_STEP; + radius += RADIUS_STEP; + } + + tiles +} + +// ── Route handler ─────────────────────────────────────────────────────── + +async fn handle_index(protocol: web::Data) -> HttpResponse { + let tiles_value = serde_json::Value::Array(compute_tiles()); + let mut state_map = serde_json::Map::with_capacity(1); + state_map.insert("tiles".to_owned(), tiles_value); + let state = serde_json::Value::Object(state_map); + + let mut writer = MemoryWriter::with_capacity(256 * 1024); + let mut handler = WebUIHandler::new(); + + if let Err(e) = handler.handle(&protocol, &state, &mut writer) { + return HttpResponse::InternalServerError().body(format!("Render error: {e}")); + } + + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(writer.buf) +} + +// ── Startup ───────────────────────────────────────────────────────────── + +fn main() -> Result<()> { + let port: u16 = std::env::args() + .position(|a| a == "--port") + .and_then(|i| std::env::args().nth(i + 1)) + .and_then(|v| v.parse().ok()) + .unwrap_or(3000); + + let protocol_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("dist/protocol.bin"); + let protocol = WebUIProtocol::from_protobuf_file(&protocol_path) + .with_context(|| format!("Failed to load {}", protocol_path.display()))?; + let protocol_data = web::Data::new(protocol); + + println!("Listening on http://localhost:{port}"); + + actix_web::rt::System::new() + .block_on(async move { + HttpServer::new(move || { + App::new() + .app_data(protocol_data.clone()) + .route("/", web::get().to(handle_index)) + .route("/index.html", web::get().to(handle_index)) + .default_service( + web::route().to(|| async { HttpResponse::NotFound().body("Not Found") }), + ) + }) + .bind(format!("0.0.0.0:{port}")) + .with_context(|| format!("Failed to bind to port {port}"))? + .run() + .await + .map_err(|e| anyhow::anyhow!("{e}")) + }) + .context("Server error")?; + + Ok(()) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb0541e2..31b3830f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ catalogs: '@microsoft/fast-html': specifier: 1.0.0-alpha.38 version: 1.0.0-alpha.38 + electron: + specifier: ^36.3.1 + version: 36.9.5 esbuild: specifier: ^0.25.0 version: 0.25.12 @@ -84,11 +87,29 @@ importers: version: 6.39.15 vitepress: specifier: 'catalog:' - version: 1.6.4(@algolia/client-search@5.21.0)(postcss@8.5.6)(search-insights@2.17.3)(typescript@5.9.3) + version: 1.6.4(@algolia/client-search@5.21.0)(@types/node@22.19.13)(postcss@8.5.6)(search-insights@2.17.3)(typescript@5.9.3) vue: specifier: 'catalog:' version: 3.5.29(typescript@5.9.3) + examples/app/contact-book-manager: + devDependencies: + '@microsoft/fast-element': + specifier: 'catalog:' + version: 2.10.1 + '@microsoft/fast-html': + specifier: 'catalog:' + version: 1.0.0-alpha.38(@microsoft/fast-element@2.10.1) + esbuild: + specifier: 'catalog:' + version: 0.25.12 + tslib: + specifier: 'catalog:' + version: 2.8.1 + typescript: + specifier: 'catalog:' + version: 5.9.3 + examples/app/hello-world: {} examples/app/todo-fast: @@ -109,6 +130,18 @@ importers: specifier: 'catalog:' version: 5.9.3 + examples/integration/electron: + devDependencies: + electron: + specifier: 'catalog:' + version: 36.9.5 + esbuild: + specifier: 'catalog:' + version: 0.25.12 + typescript: + specifier: 'catalog:' + version: 5.9.3 + examples/integration/node: {} packages: @@ -258,6 +291,10 @@ packages: search-insights: optional: true + '@electron/get@2.0.3': + resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} + engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -722,12 +759,29 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} @@ -740,12 +794,21 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@22.19.13': + resolution: {integrity: sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==} + + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -851,6 +914,21 @@ packages: birpc@0.2.19: resolution: {integrity: sha512-5WeXXAvTmitV1RqJFppT5QtUiz2p1mRSYU000Jkft5ZUCLJIk4uQriYNO50HknxKwM6jd8utNc66K1qGIwwWBQ==} + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -860,6 +938,9 @@ packages: character-entities-legacy@3.0.0: resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -873,20 +954,71 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + electron@36.9.5: + resolution: {integrity: sha512-1UCss2IqxqujSzg/2jkRjuiT3G+EEXgd6UKB5kUekwQW1LJ6d4QCr8YItfC3Rr9VIGRDJ29eOERmnRNO1Eh+NA==} + engines: {node: '>= 12.20.55'} + hasBin: true + emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -897,17 +1029,59 @@ packages: engines: {node: '>=18'} hasBin: true + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + focus-trap@7.6.4: resolution: {integrity: sha512-xx560wGBk7seZ6y933idtjJQc1l+ck+pI3sKvhKozdBV1dRZoKhkW5xoCaFv9tQiX5RH1xfSxjuNu6g+lmN/gw==} + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} @@ -920,16 +1094,43 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + is-what@4.1.16: resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} engines: {node: '>=12.13'} + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} mark.js@8.11.1: resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + mdast-util-to-hast@13.2.0: resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} @@ -948,20 +1149,49 @@ packages: micromark-util-types@2.0.2: resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minisearch@7.1.2: resolution: {integrity: sha512-R1Pd9eF+MD5JYDDSPAp/q1ougKglm14uEkPMvQ/05RGmx6G9wvmLTrTI/Q5iPNJLYqNdsDQ7qTGIcNWR+FrHmA==} mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + oniguruma-to-es@3.1.1: resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -975,9 +1205,20 @@ packages: preact@10.26.4: resolution: {integrity: sha512-KJhO7LBFTjP71d83trW+Ilnjbo+ySsaAgCfXOXUlmGzJ4ygYPWmysm77yg4emwfmoz3b22yvH5IsVFHbhUaH5w==} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + property-information@7.0.0: resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -987,9 +1228,19 @@ packages: regex@6.0.1: resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==} + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + rollup@4.36.0: resolution: {integrity: sha512-zwATAXNQxUcd40zgtQG0ZafcRK4g004WtEl7kbuhTWPvf07PsfohXl39jVUvPF7jvNAIkKPQ2XrsDlWuxBd++Q==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -998,6 +1249,22 @@ packages: search-insights@2.17.3: resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + shiki@2.5.0: resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} @@ -1012,12 +1279,19 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + superjson@2.2.2: resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==} engines: {node: '>=16'} @@ -1031,11 +1305,18 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unist-util-is@6.0.0: resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} @@ -1051,6 +1332,10 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + vfile-message@4.0.2: resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} @@ -1111,6 +1396,12 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -1340,6 +1631,20 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' + '@electron/get@2.0.3': + dependencies: + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -1634,12 +1939,31 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/is@4.6.0': {} + + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 22.19.13 + '@types/responselike': 1.0.3 + '@types/estree@1.0.6': {} '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 + '@types/http-cache-semantics@4.2.0': {} + + '@types/keyv@3.1.4': + dependencies: + '@types/node': 22.19.13 + '@types/linkify-it@5.0.0': {} '@types/markdown-it@14.1.2': @@ -1653,15 +1977,28 @@ snapshots: '@types/mdurl@2.0.0': {} + '@types/node@22.19.13': + dependencies: + undici-types: 6.21.0 + + '@types/responselike@1.0.3': + dependencies: + '@types/node': 22.19.13 + '@types/unist@3.0.3': {} '@types/web-bluetooth@0.0.21': {} + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 22.19.13 + optional: true + '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-vue@5.2.3(vite@5.4.14)(vue@3.5.29(typescript@5.9.3))': + '@vitejs/plugin-vue@5.2.3(vite@5.4.14(@types/node@22.19.13))(vue@3.5.29(typescript@5.9.3))': dependencies: - vite: 5.4.14 + vite: 5.4.14(@types/node@22.19.13) vue: 3.5.29(typescript@5.9.3) '@vue/compiler-core@3.5.29': @@ -1781,12 +2118,33 @@ snapshots: birpc@0.2.19: {} + boolean@3.2.0: + optional: true + + buffer-crc32@0.2.13: {} + + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + ccount@2.0.1: {} character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + comma-separated-tokens@2.0.3: {} copy-anything@3.0.5: @@ -1797,16 +2155,66 @@ snapshots: csstype@3.2.3: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + optional: true + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + optional: true + dequal@2.0.3: {} + detect-node@2.1.0: + optional: true + devlop@1.1.0: dependencies: dequal: 2.0.3 + electron@36.9.5: + dependencies: + '@electron/get': 2.0.3 + '@types/node': 22.19.13 + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + emoji-regex-xs@1.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + entities@7.0.1: {} + env-paths@2.2.1: {} + + es-define-property@1.0.1: + optional: true + + es-errors@1.3.0: + optional: true + + es6-error@4.1.1: + optional: true + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -1862,15 +2270,82 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 + escape-string-regexp@4.0.0: + optional: true + estree-walker@2.0.2: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + focus-trap@7.6.4: dependencies: tabbable: 6.2.0 + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + fsevents@2.3.3: optional: true + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.7.4 + serialize-error: 7.0.1 + optional: true + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + optional: true + + gopd@1.2.0: + optional: true + + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + + graceful-fs@4.2.11: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + optional: true + hast-util-to-html@9.0.5: dependencies: '@types/hast': 3.0.4 @@ -1893,14 +2368,41 @@ snapshots: html-void-elements@3.0.0: {} + http-cache-semantics@4.2.0: {} + + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + is-what@4.1.16: {} + json-buffer@3.0.1: {} + + json-stringify-safe@5.0.1: + optional: true + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + lowercase-keys@2.0.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 mark.js@8.11.1: {} + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + mdast-util-to-hast@13.2.0: dependencies: '@types/hast': 3.0.4 @@ -1930,18 +2432,37 @@ snapshots: micromark-util-types@2.0.2: {} + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + minisearch@7.1.2: {} mitt@3.0.1: {} + ms@2.1.3: {} + nanoid@3.3.11: {} + normalize-url@6.1.0: {} + + object-keys@1.1.1: + optional: true + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + oniguruma-to-es@3.1.1: dependencies: emoji-regex-xs: 1.0.0 regex: 6.0.1 regex-recursion: 6.0.2 + p-cancelable@2.1.1: {} + + pend@1.2.0: {} + perfect-debounce@1.0.0: {} picocolors@1.1.1: {} @@ -1954,8 +2475,17 @@ snapshots: preact@10.26.4: {} + progress@2.0.3: {} + property-information@7.0.0: {} + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + quick-lru@5.1.1: {} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -1966,8 +2496,24 @@ snapshots: dependencies: regex-utilities: 2.3.0 + resolve-alpn@1.2.1: {} + + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + rfdc@1.4.1: {} + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + rollup@4.36.0: dependencies: '@types/estree': 1.0.6 @@ -1995,6 +2541,19 @@ snapshots: search-insights@2.17.3: {} + semver-compare@1.0.0: + optional: true + + semver@6.3.1: {} + + semver@7.7.4: + optional: true + + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + shiki@2.5.0: dependencies: '@shikijs/core': 2.5.0 @@ -2012,6 +2571,9 @@ snapshots: speakingurl@14.0.1: {} + sprintf-js@1.1.3: + optional: true + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -2019,6 +2581,12 @@ snapshots: style-mod@4.1.3: {} + sumchecker@3.0.1: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + superjson@2.2.2: dependencies: copy-anything: 3.0.5 @@ -2029,8 +2597,13 @@ snapshots: tslib@2.8.1: {} + type-fest@0.13.1: + optional: true + typescript@5.9.3: {} + undici-types@6.21.0: {} + unist-util-is@6.0.0: dependencies: '@types/unist': 3.0.3 @@ -2054,6 +2627,8 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 + universalify@0.1.2: {} + vfile-message@4.0.2: dependencies: '@types/unist': 3.0.3 @@ -2064,15 +2639,16 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite@5.4.14: + vite@5.4.14(@types/node@22.19.13): dependencies: esbuild: 0.21.5 postcss: 8.5.6 rollup: 4.36.0 optionalDependencies: + '@types/node': 22.19.13 fsevents: 2.3.3 - vitepress@1.6.4(@algolia/client-search@5.21.0)(postcss@8.5.6)(search-insights@2.17.3)(typescript@5.9.3): + vitepress@1.6.4(@algolia/client-search@5.21.0)(@types/node@22.19.13)(postcss@8.5.6)(search-insights@2.17.3)(typescript@5.9.3): dependencies: '@docsearch/css': 3.8.2 '@docsearch/js': 3.8.2(@algolia/client-search@5.21.0)(search-insights@2.17.3) @@ -2081,7 +2657,7 @@ snapshots: '@shikijs/transformers': 2.5.0 '@shikijs/types': 2.5.0 '@types/markdown-it': 14.1.2 - '@vitejs/plugin-vue': 5.2.3(vite@5.4.14)(vue@3.5.29(typescript@5.9.3)) + '@vitejs/plugin-vue': 5.2.3(vite@5.4.14(@types/node@22.19.13))(vue@3.5.29(typescript@5.9.3)) '@vue/devtools-api': 7.7.2 '@vue/shared': 3.5.29 '@vueuse/core': 12.8.2(typescript@5.9.3) @@ -2090,7 +2666,7 @@ snapshots: mark.js: 8.11.1 minisearch: 7.1.2 shiki: 2.5.0 - vite: 5.4.14 + vite: 5.4.14(@types/node@22.19.13) vue: 3.5.29(typescript@5.9.3) optionalDependencies: postcss: 8.5.6 @@ -2133,4 +2709,11 @@ snapshots: w3c-keyname@2.2.8: {} + wrappy@1.0.2: {} + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4bed85cb..457ac3bc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,8 +2,7 @@ packages: - docs - examples/** - '!**/test/**' -onlyBuiltDependencies: - - esbuild + catalog: '@codemirror/commands': ^6.10.2 '@codemirror/lang-css': ^6.3.1 @@ -15,8 +14,13 @@ catalog: '@codemirror/view': ^6.39.15 '@microsoft/fast-element': ^2.0.1 '@microsoft/fast-html': 1.0.0-alpha.38 + electron: ^36.3.1 esbuild: ^0.25.0 tslib: ^2.8.0 typescript: ^5.9.3 vitepress: ^1.6.4 vue: ^3.5.29 + +onlyBuiltDependencies: + - electron + - esbuild diff --git a/xtask/src/build_examples.rs b/xtask/src/build_examples.rs index b90a0e88..7c0207d5 100644 --- a/xtask/src/build_examples.rs +++ b/xtask/src/build_examples.rs @@ -28,6 +28,15 @@ pub const INTEGRATION_BUILDS: &[IntegrationBuild] = &[ }], run_commands: &[], }, + IntegrationBuild { + name: "electron", + commands: &[BuildCommand { + cmd: "pnpm", + args: &["run", "build"], + cwd: Some("examples/integration/electron"), + }], + run_commands: &[], + }, IntegrationBuild { name: "rust", commands: &[BuildCommand { @@ -41,6 +50,19 @@ pub const INTEGRATION_BUILDS: &[IntegrationBuild] = &[ }], run_commands: &[], }, + IntegrationBuild { + name: "ssr-performance-showdown", + commands: &[BuildCommand { + cmd: "cargo", + args: &[ + "check", + "--manifest-path", + "examples/integration/ssr-performance-showdown/Cargo.toml", + ], + cwd: None, + }], + run_commands: &[], + }, ]; pub fn run_integration_builds() -> Result<(), String> {