From 648412dd6dd8465dfb58cd13a01f35c070301854 Mon Sep 17 00:00:00 2001 From: Brian Kirkpatrick Date: Wed, 4 Mar 2026 13:02:28 -0800 Subject: [PATCH 1/3] added missing del --- README.md | 2 +- store/index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7f53eba..062e549 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Lit + Zustand Architecture -A modern, no-build web application architecture using Lit web components with Zustand state management and IndexedDB persistence. +A simple (but modern & scalable) no-build web application architecture using Lit web components with Zustand state management and IndexedDB persistence. ## For TSX/React Developers diff --git a/store/index.js b/store/index.js index 5fdf883..86bedc5 100644 --- a/store/index.js +++ b/store/index.js @@ -1,6 +1,6 @@ import { createStore } from 'zustand/vanilla' import { persist, createJSONStorage } from 'zustand/middleware' -import { get, set } from 'idb-keyval' +import { get, set, del } from 'idb-keyval' const storage = createJSONStorage(() => ({ getItem: async (name) => { From 3736d7278bd4d385cb7ebcaf0ecd0574a879208c Mon Sep 17 00:00:00 2001 From: Brian Kirkpatrick Date: Wed, 4 Mar 2026 14:18:44 -0800 Subject: [PATCH 2/3] finished pruning for lit-only demo --- README.md | 266 +++++++++++++++++--------------- components/app-root.js | 60 ++++--- components/nav-bar.js | 17 +- components/page-home.js | 14 +- components/page-items.js | 29 ++-- controllers/store.js | 23 --- index.html | 5 +- store/idb.js | 33 ---- store/index.js | 42 ----- store/middleware/persistence.js | 9 -- 10 files changed, 217 insertions(+), 281 deletions(-) delete mode 100644 controllers/store.js delete mode 100644 store/idb.js delete mode 100644 store/index.js delete mode 100644 store/middleware/persistence.js diff --git a/README.md b/README.md index 062e549..fb3507d 100644 --- a/README.md +++ b/README.md @@ -1,168 +1,113 @@ -# Lit + Zustand Architecture +# Lit-Only Architecture (No Build) -A simple (but modern & scalable) no-build web application architecture using Lit web components with Zustand state management and IndexedDB persistence. +A minimal, no-build web application using only Lit web components with local state management. + +> **Note:** This is the `lit-only` branch. For the full-featured version with Zustand state management and IndexedDB persistence, see the `main` branch. ## For TSX/React Developers -If you're coming from React/TSX, here's how this architecture maps to familiar concepts: +If you're coming from React/TSX, here's how this minimal architecture maps to familiar concepts: | React/TSX Pattern | This Architecture | |-------------------|-------------------| -| `useState` / `useReducer` | Zustand vanilla store | -| `useEffect` + state subscription | `StoreController` (ReactiveController) | -| Context API | Global Zustand store | -| `localStorage` / `sessionStorage` | IndexedDB via persist middleware | +| `useState` | Lit's `static properties` with `state: true` | +| Props drilling | Property passing via `.prop=${value}` | +| Custom events | `CustomEvent` with `bubbles: true` | | JSX | Lit's `html` tagged template literals | | CSS-in-JS / CSS Modules | Lit's `css` tagged template literals | -| React Router | Simple route state in store | +| React Router | Simple route state in root component | | Build step (Vite/Webpack) | Import maps (no build!) | ### Key Differences 1. **No Virtual DOM**: Lit uses native Web Components with efficient DOM updates 2. **No Build Step**: Import maps let you use npm packages directly from CDN -3. **Reactive Controllers**: Replace hooks - attach to component lifecycle -4. **Tagged Templates**: Instead of JSX, use `html\`
...
\`` +3. **Local State Only**: Each component manages its own state, parent manages shared state +4. **Event-Based Communication**: Child components emit events, parent handles them +5. **Tagged Templates**: Instead of JSX, use `html\`
...
\`` ## Architecture Overview * **Lit-based web components** - Standards-based, framework-agnostic UI * **No-build tooling** - Import maps only, no bundler required -* **Zustand state management** - Lightweight, vanilla JS store -* **IndexedDB persistence** - Automatic state persistence with idb-keyval +* **Local state management** - Component properties and state +* **Event-driven** - CustomEvents for child-to-parent communication - -## Layout +## Project Structure ``` -src/ - store/ - index.js ← zustand store definition - middleware/ - persistence.js ← idb-keyval persist adapter - sync.js ← optional cross-tab broadcast - components/ - app-root.js - feature-a/ - feature-a.js ← Lit component - feature-a.css ← adopted stylesheet or constructable - controllers/ - fetch.js ← ReactiveController for API calls - 3d.js ← optional Three/WebGPU controller - lib/ - idb.js ← thin idb-keyval wrapper/schema -index.html -importmap.json ← extracted importmap (referenced via @@ -206,17 +149,88 @@ customElements.define('my-counter', Counter) ``` +## Component Communication Patterns + +### Pattern 1: Shared State in Root + +```javascript +class AppRoot extends LitElement { + static properties = { + items: { type: Array, state: true } + } + + constructor() { + super() + this.items = [] + } + + addItem(item) { + this.items = [...this.items, item] + } + + render() { + return html` + this.addItem(e.detail.item)} + > + ` + } +} +``` + +### Pattern 2: Local State in Component + +```javascript +class MyComponent extends LitElement { + static properties = { + _draft: { type: String, state: true } + } + + constructor() { + super() + this._draft = '' + } + + render() { + return html` + this._draft = e.target.value} + /> + ` + } +} +``` + ## Benefits Over React/TSX ✅ **No build step** - Edit and refresh, instant feedback +✅ **Minimal dependencies** - Just Lit, nothing else ✅ **Smaller bundle** - No framework runtime, just standards ✅ **Better encapsulation** - Shadow DOM, scoped styles ✅ **Framework agnostic** - Works anywhere, even in React apps ✅ **Future-proof** - Built on web standards +✅ **Easy to learn** - Simple mental model, no complex state management + +## When to Use This vs. Full Stack + +### Use Lit-Only When: +- Building small to medium apps +- Don't need persistence +- State is simple and hierarchical +- Want maximum simplicity + +### Use Full Stack (main branch) When: +- Need IndexedDB persistence +- Complex state shared across many components +- Need cross-tab synchronization +- Want time-travel debugging +- Building larger applications ## See Also - [tasks.md](./tasks.md) - Current issues and improvements - [Lit Documentation](https://lit.dev) -- [Zustand Documentation](https://zustand.docs.pmnd.rs) - [Web Components](https://developer.mozilla.org/en-US/docs/Web/Web_Components) +- [Import Maps](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) diff --git a/components/app-root.js b/components/app-root.js index 6fe53e9..88f59a6 100644 --- a/components/app-root.js +++ b/components/app-root.js @@ -1,44 +1,64 @@ import { LitElement, html, css } from "lit"; -import { store} from "../store/index.js"; -import { StoreController } from "../controllers/store.js"; import "./nav-bar.js"; import "./page-home.js"; import "./page-items.js"; class AppRoot extends LitElement { - #hydrated = new StoreController(this, store, s => s._hydrated); - #user = new StoreController(this, store, s => s.user); - #route = new StoreController(this, store, s => s.route); + static properties = { + user: { type: Object, state: true }, + route: { type: String, state: true }, + items: { type: Array, state: true } + }; + + constructor() { + super(); + this.user = null; + this.route = 'home'; + this.items = []; + } static styles = css` :host { display: block; min-height: 100vh; } - - .loading { - display: grid; - place-items: center; - height: 100vh; - opacity: 0.4; - } `; + #navigate(route) { + this.route = route; + } + + #addItem(item) { + this.items = [...this.items, item]; + } + + #removeItem(id) { + this.items = this.items.filter(i => i.id !== id); + } + render() { - if (!this.#hydrated.value) { - return html`
loading...
`; - } return html` - + this.#navigate(e.detail.route)} + >
${this.#renderRoute()}
`; } #renderRoute() { - switch (this.#route.value) { - case "home": return html``; - case "items": return html``; - default: return html``; + switch (this.route) { + case "home": + return html``; + case "items": + return html` this.#addItem(e.detail.item)} + @remove-item=${(e) => this.#removeItem(e.detail.id)} + >`; + default: + return html``; } } } diff --git a/components/nav-bar.js b/components/nav-bar.js index 1b6e241..6f192a1 100644 --- a/components/nav-bar.js +++ b/components/nav-bar.js @@ -1,13 +1,10 @@ // components/nav-bar.js import { LitElement, html, css } from 'lit' -import { store } from '../store/index.js' -import { StoreController } from '../controllers/store.js' class NavBar extends LitElement { - #route = new StoreController(this, store, s => s.route) - static properties = { - user: { type: Object } + user: { type: Object }, + route: { type: String } } static styles = css` @@ -35,14 +32,18 @@ class NavBar extends LitElement { #navigate(route, e) { e.preventDefault() - store.getState().navigate(route) + this.dispatchEvent(new CustomEvent('navigate', { + detail: { route }, + bubbles: true, + composed: true + })) } #link(route, label) { return html` this.#navigate(route, e)} >${label} ` @@ -61,4 +62,4 @@ class NavBar extends LitElement { } } -customElements.define('nav-bar', NavBar) \ No newline at end of file +customElements.define('nav-bar', NavBar) diff --git a/components/page-home.js b/components/page-home.js index 5d71b8a..3d5c616 100644 --- a/components/page-home.js +++ b/components/page-home.js @@ -1,11 +1,11 @@ // components/page-home.js import { LitElement, html, css } from 'lit' -import { store } from '../store/index.js' -import { StoreController } from '../controllers/store.js' class PageHome extends LitElement { - #user = new StoreController(this, store, s => s.user) - #items = new StoreController(this, store, s => s.items) + static properties = { + user: { type: Object }, + items: { type: Array } + } static styles = css` :host { display: block; padding: 2rem 1.5rem; } @@ -25,8 +25,8 @@ class PageHome extends LitElement { ` render() { - const name = this.#user.value?.name ?? 'there' - const count = this.#items.value.length + const name = this.user?.name ?? 'there' + const count = this.items?.length ?? 0 return html`

Hey, ${name}

@@ -41,4 +41,4 @@ class PageHome extends LitElement { } } -customElements.define('page-home', PageHome) \ No newline at end of file +customElements.define('page-home', PageHome) diff --git a/components/page-items.js b/components/page-items.js index efdcf96..f788396 100644 --- a/components/page-items.js +++ b/components/page-items.js @@ -1,16 +1,17 @@ // components/page-items.js import { LitElement, html, css } from 'lit' -import { store } from '../store/index.js' -import { StoreController } from '../controllers/store.js' class PageItems extends LitElement { - #items = new StoreController(this, store, s => s.items) - static properties = { + items: { type: Array }, _draft: { type: String, state: true } } - _draft = '' + constructor() { + super(); + this.items = []; + this._draft = ''; + } static styles = css` :host { display: block; padding: 2rem 1.5rem; } @@ -63,12 +64,22 @@ class PageItems extends LitElement { #add() { const name = this._draft.trim() if (!name) return - store.getState().addItem({ id: crypto.randomUUID(), name }) + + this.dispatchEvent(new CustomEvent('add-item', { + detail: { item: { id: crypto.randomUUID(), name } }, + bubbles: true, + composed: true + })) + this._draft = '' } #remove(id) { - store.getState().removeItem(id) + this.dispatchEvent(new CustomEvent('remove-item', { + detail: { id }, + bubbles: true, + composed: true + })) } #onKeydown(e) { @@ -76,7 +87,7 @@ class PageItems extends LitElement { } render() { - const items = this.#items.value + const items = this.items ?? [] return html`

Items

@@ -108,4 +119,4 @@ class PageItems extends LitElement { } } -customElements.define('page-items', PageItems) \ No newline at end of file +customElements.define('page-items', PageItems) diff --git a/controllers/store.js b/controllers/store.js deleted file mode 100644 index 998294d..0000000 --- a/controllers/store.js +++ /dev/null @@ -1,23 +0,0 @@ -export class StoreController { - constructor(host, store, selector) { - this.host = host - this.store = store - this.selector = selector - host.addController(this) - } - - hostConnected() { - this._unsub = this.store.subscribe( - (state) => { - const next = this.selector(state) - if (next !== this.value) { - this.value = next - this.host.requestUpdate() - } - } - ) - this.value = this.selector(this.store.getState()) - } - - hostDisconnected() { this._unsub?.() } - } \ No newline at end of file diff --git a/index.html b/index.html index 51c53cd..879175b 100644 --- a/index.html +++ b/index.html @@ -8,10 +8,7 @@ { "imports": { "lit": "https://esm.sh/lit@3", - "lit/decorators.js": "https://esm.sh/lit@3/decorators.js", - "zustand/vanilla": "https://esm.sh/zustand@5/vanilla", - "zustand/middleware": "https://esm.sh/zustand@5/middleware", - "idb-keyval": "https://esm.sh/idb-keyval@6" + "lit/decorators.js": "https://esm.sh/lit@3/decorators.js" } } diff --git a/store/idb.js b/store/idb.js deleted file mode 100644 index e276f00..0000000 --- a/store/idb.js +++ /dev/null @@ -1,33 +0,0 @@ -// lib/idb.js -import { createStore, get, set, del, entries, clear } from 'idb-keyval' - -// Named stores — each maps to a distinct IDBObjectStore -export const itemsStore = createStore('app-db', 'items') -export const userStore = createStore('app-db', 'user') -export const cacheStore = createStore('app-db', 'cache') - -// Typed wrappers — keeps raw idb-keyval calls out of the rest of the app -// and gives you one place to add validation, logging, or migration logic - -export const db = { - items: { - getAll: () => entries(itemsStore), - get: (id) => get(id, itemsStore), - set: (id, value) => set(id, value, itemsStore), - remove: (id) => del(id, itemsStore), - clear: () => clear(itemsStore), - }, - - user: { - get: () => get('user', userStore), - set: (value) => set('user', value, userStore), - clear: () => del('user', userStore), - }, - - cache: { - get: (key) => get(key, cacheStore), - set: (key, value) => set(key, value, cacheStore), - remove: (key) => del(key, cacheStore), - clear: () => clear(cacheStore), - } -} \ No newline at end of file diff --git a/store/index.js b/store/index.js deleted file mode 100644 index 86bedc5..0000000 --- a/store/index.js +++ /dev/null @@ -1,42 +0,0 @@ -import { createStore } from 'zustand/vanilla' -import { persist, createJSONStorage } from 'zustand/middleware' -import { get, set, del } from 'idb-keyval' - -const storage = createJSONStorage(() => ({ - getItem: async (name) => { - const value = await get(name) - return value ?? null - }, - setItem: async (name, value) => { - await set(name, value) - }, - removeItem: async (name) => { - await del(name) - }, -})) - -export const store = createStore( - persist( - (set, get) => ({ - _hydrated: false, - user: null, - items: [], - route: 'home', - setUser: (user) => set({ user }), - addItem: (item) => set(s => ({ items: [...s.items, item] })), - removeItem: (id) => set(s => ({ items: s.items.filter(i => i.id !== id) })), - navigate: (route) => set({ route }), - }), - { - name: 'app-store', - storage, - onRehydrateStorage: () => { - return (state, error) => { - if (!error) { - store.setState({ _hydrated: true }) - } - } - }, - } - ) -) diff --git a/store/middleware/persistence.js b/store/middleware/persistence.js deleted file mode 100644 index 75552f1..0000000 --- a/store/middleware/persistence.js +++ /dev/null @@ -1,9 +0,0 @@ -// store/middleware/persistence.js -import { db } from '../idb.js' - -export const makeIdbStorage = (storeName) => - createJSONStorage(() => ({ - getItem: (name) => db[storeName].get(name), - setItem: (name, value) => db[storeName].set(name, value), - removeItem: (name) => db[storeName].remove(name), - })) \ No newline at end of file From 3a819e6fe19e31b7df8f579f169cf521e55a0992 Mon Sep 17 00:00:00 2001 From: Brian Kirkpatrick Date: Wed, 4 Mar 2026 17:45:32 -0800 Subject: [PATCH 3/3] streamlining readme --- README.md | 214 ++++++++++-------------------------------------------- 1 file changed, 38 insertions(+), 176 deletions(-) diff --git a/README.md b/README.md index fb3507d..d2e4df2 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,7 @@ -# Lit-Only Architecture (No Build) +# zulip -A minimal, no-build web application using only Lit web components with local state management. +A minimal, no-build web app kit using only Lit-based web components via importmaps. -> **Note:** This is the `lit-only` branch. For the full-featured version with Zustand state management and IndexedDB persistence, see the `main` branch. - -## For TSX/React Developers - -If you're coming from React/TSX, here's how this minimal architecture maps to familiar concepts: - -| React/TSX Pattern | This Architecture | -|-------------------|-------------------| -| `useState` | Lit's `static properties` with `state: true` | -| Props drilling | Property passing via `.prop=${value}` | -| Custom events | `CustomEvent` with `bubbles: true` | -| JSX | Lit's `html` tagged template literals | -| CSS-in-JS / CSS Modules | Lit's `css` tagged template literals | -| React Router | Simple route state in root component | -| Build step (Vite/Webpack) | Import maps (no build!) | - -### Key Differences - -1. **No Virtual DOM**: Lit uses native Web Components with efficient DOM updates -2. **No Build Step**: Import maps let you use npm packages directly from CDN -3. **Local State Only**: Each component manages its own state, parent manages shared state -4. **Event-Based Communication**: Child components emit events, parent handles them -5. **Tagged Templates**: Instead of JSX, use `html\`
...
\`` - -## Architecture Overview - -* **Lit-based web components** - Standards-based, framework-agnostic UI -* **No-build tooling** - Import maps only, no bundler required -* **Local state management** - Component properties and state -* **Event-driven** - CustomEvents for child-to-parent communication - -## Project Structure - -``` -components/ - app-root.js ← Root component with shared state - nav-bar.js ← Navigation component - page-home.js ← Home page component - page-items.js ← Items page component -index.html ← Entry point with inline importmap -README.md -tasks.md -``` - -## Data Flow Pattern - -### Parent-to-Child (Props) - -```javascript -// Parent passes data down -html`` - -// Child receives via properties -static properties = { - items: { type: Array } -} -``` - -### Child-to-Parent (Events) - -```javascript -// Child emits event -this.dispatchEvent(new CustomEvent('add-item', { - detail: { item: newItem }, - bubbles: true, - composed: true -})) - -// Parent listens -html` this.handleAdd(e.detail.item)}>` -``` - -## State Management Pattern - -```mermaid -graph TD - A[app-root] -->|.user, .items, .route| B[nav-bar] - A -->|.user, .items| C[page-home] - A -->|.items| D[page-items] - B -->|@navigate event| A - D -->|@add-item event| A - D -->|@remove-item event| A - - style A fill:#f9f,stroke:#333,stroke-width:2px - style B fill:#bbf,stroke:#333,stroke-width:2px - style C fill:#bfb,stroke:#333,stroke-width:2px - style D fill:#bfb,stroke:#333,stroke-width:2px -``` ## Quick Start Example @@ -128,109 +40,59 @@ class Counter extends LitElement { customElements.define('my-counter', Counter) ``` -### 2. Use in HTML (no build step!) +### 2. Use in App root (or other composition node) -```html - - - - - - - - - - -``` +```js +import "./my-counter.js"; -## Component Communication Patterns - -### Pattern 1: Shared State in Root - -```javascript class AppRoot extends LitElement { - static properties = { - items: { type: Array, state: true } - } - - constructor() { - super() - this.items = [] - } - - addItem(item) { - this.items = [...this.items, item] - } - + ... render() { - return html` - this.addItem(e.detail.item)} - > - ` + return html``; } } ``` -### Pattern 2: Local State in Component +### 3. Define Data Flows + +#### Parent-to-Child (Props) ```javascript -class MyComponent extends LitElement { - static properties = { - _draft: { type: String, state: true } - } +// Parent passes data down +html`` - constructor() { - super() - this._draft = '' - } - - render() { - return html` - this._draft = e.target.value} - /> - ` - } +// Child receives via properties +static properties = { + items: { type: Array } } ``` -## Benefits Over React/TSX +#### Child-to-Parent (Events) -✅ **No build step** - Edit and refresh, instant feedback -✅ **Minimal dependencies** - Just Lit, nothing else -✅ **Smaller bundle** - No framework runtime, just standards -✅ **Better encapsulation** - Shadow DOM, scoped styles -✅ **Framework agnostic** - Works anywhere, even in React apps -✅ **Future-proof** - Built on web standards -✅ **Easy to learn** - Simple mental model, no complex state management +```javascript +// Child emits event +this.dispatchEvent(new CustomEvent('add-item', { + detail: { item: newItem }, + bubbles: true, + composed: true +})) -## When to Use This vs. Full Stack +// Parent listens +html` this.handleAdd(e.detail.item)}>` +``` -### Use Lit-Only When: -- Building small to medium apps -- Don't need persistence -- State is simple and hierarchical -- Want maximum simplicity +## Benefits Over TSX -### Use Full Stack (main branch) When: -- Need IndexedDB persistence -- Complex state shared across many components -- Need cross-tab synchronization -- Want time-travel debugging -- Building larger applications +1. **No build step** - Edit and refresh, instant feedback -## See Also +1. **Minimal dependencies** - Just Lit, nothing else -- [tasks.md](./tasks.md) - Current issues and improvements -- [Lit Documentation](https://lit.dev) -- [Web Components](https://developer.mozilla.org/en-US/docs/Web/Web_Components) -- [Import Maps](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) +1. **Smaller bundle** - No framework runtime, just standards + +1. **Better encapsulation** - Shadow DOM, scoped styles + +1. **Framework agnostic** - Works anywhere, even in React apps + +1. **Future-proof** - Built on web standards + +1. **Easy to learn** - Simple mental model for state, with natural evolution into Zustand/IndexedDB or similar extensions