docs/agents/AGENTS.components.md
Generated from codebase reconnaissance on 2026-01-10 Scope:
webui/components/- Self-contained Alpine.js component architecture
| Aspect | Value |
|---|---|
| Tech Stack | Alpine.js, ES Modules, CSS Variables |
| Component Tag | <x-component path="..."> |
| State Management | createStore(name, model) from /js/AlpineStore.js |
| Modals | openModal(path) / closeModal() from /js/modals.js |
| API Layer | callJsonApi() / fetchApi() from /js/api.js |
| File | Purpose |
|---|---|
/js/components.js | Component loader - hydrates <x-component> tags |
/js/AlpineStore.js | Store factory with Alpine proxy |
/js/modals.js | Modal stack management |
/js/initFw.js | Bootstrap: loads Alpine, registers custom directives |
/js/api.js | CSRF-protected API client (callJsonApi, fetchApi) |
<x-component path="sidebar/left-sidebar.html">
↓
Resolves to: components/sidebar/left-sidebar.html
↓
Loader: importComponent() fetches, parses, injects
components/ if not presentComponent HTML
↓
imports Store module
↓
createStore() registers with Alpine
↓
Template binds via $store.name
↓
User actions → store methods → state updates → reactive UI
<html>
<head>
<!-- Module imports MUST be in <head> -->
<script type="module">
import { store } from "/components/feature/feature-store.js";
</script>
</head>
<body>
<!-- Store gate: prevents render until store registered -->
<div x-data>
<template x-if="$store.featureStore">
<!-- Single root element inside template (mandatory) -->
<div class="feature-container">
<p x-text="$store.featureStore.value"></p>
<button @click="$store.featureStore.action()">Do Thing</button>
</div>
</template>
</div>
<!-- Inline styles scoped to component -->
<style>
.feature-container {
color: var(--color-text);
}
</style>
</body>
</html>
| Rule | Rationale |
|---|---|
Scripts in <head>, content in <body> | Loader extracts separately |
Use type="module" for scripts | Enables ES imports, caching |
Wrap with x-data + x-if="$store.X" | Prevents render before store ready |
<template> has ONE root element | Alpine limitation |
| Styles inline in component | Self-contained, no global CSS files |
<div class="parent-container">
<x-component path="child/child-component.html"></x-component>
</div>
Components can nest other components. Loader recursively processes x-component tags.
// /components/feature/feature-store.js
import { createStore } from "/js/AlpineStore.js";
const model = {
// State
items: [],
loading: false,
_initialized: false,
// Lifecycle (called once by Alpine when store registers)
init() {
if (this._initialized) return;
this._initialized = true;
this.load();
},
// Actions
async load() {
this.loading = true;
// ... fetch data
this.loading = false;
},
// Computed-like getters (Alpine reactivity works)
get itemCount() {
return this.items.length;
}
};
export const store = createStore("featureStore", model);
createStore() returns a proxy that:
model objectAlpine.store(name)This enables safe module-level initialization before Alpine loads.
| Context | Syntax |
|---|---|
| Template (Alpine) | $store.featureStore.prop |
| Module import | import { store } from "./feature-store.js"; store.prop |
| Global (avoid) | Alpine.store("featureStore").prop |
Prefer module imports over global lookups.
import { saveState, loadState } from "/js/AlpineStore.js";
// Save to localStorage (exclude functions automatically)
const snapshot = saveState(store, [], ["transientField"]);
localStorage.setItem("myStore", JSON.stringify(snapshot));
// Restore
const saved = JSON.parse(localStorage.getItem("myStore"));
loadState(store, saved);
Registered in /js/initFw.js:
| Directive | When Fires | Use Case |
|---|---|---|
x-create | Once on mount | Initialize, subscribe to events |
x-destroy | On unmount/cleanup | Unsubscribe, clear timers |
x-every-second | Every 1s while mounted | Polling, countdowns |
x-every-minute | Every 60s while mounted | Low-frequency updates |
x-every-hour | Every 3600s while mounted | Rare periodic tasks |
<div
x-create="$store.myStore.onOpen()"
x-destroy="$store.myStore.cleanup()"
>
<!-- content -->
</div>
const model = {
_initialized: false,
resizeHandler: null,
init() {
// Guard: runs only once per app lifetime
if (this._initialized) return;
this._initialized = true;
// Global setup: event listeners, intervals
this.resizeHandler = () => this.handleResize();
window.addEventListener("resize", this.resizeHandler);
},
// Called via x-create when component mounts (can run multiple times)
onOpen() {
this.loadData();
},
// Called via x-destroy when component unmounts
cleanup() {
// Clear component-specific state, not global listeners
},
// For full teardown (rarely needed)
destroy() {
if (this.resizeHandler) {
window.removeEventListener("resize", this.resizeHandler);
this.resizeHandler = null;
}
this._initialized = false;
}
};
Key distinction:
init() → once per app load (store registration)onOpen() → each time component mounts (modal opens, etc.)cleanup()/destroy() → teardown resourcesimport { callJsonApi, fetchApi } from "/js/api.js";
// JSON POST with CSRF
const result = await callJsonApi("/endpoint", { key: "value" });
// Raw fetch with CSRF
const response = await fetchApi("/endpoint", {
method: "GET",
headers: { "Accept": "application/json" }
});
callJsonApi: JSON-in, JSON-out, throws on non-2xxfetchApi: Adds CSRF header, handles 403 retry, redirects to /loginimport { openModal, closeModal } from "/js/modals.js";
// Open (returns Promise that resolves when modal closes)
await openModal("feature/feature-modal.html");
// Close topmost modal
closeModal();
// Close specific modal by path
closeModal("feature/feature-modal.html");
Modal component receives title from <title> tag:
<head>
<title>My Modal Title</title>
</head>
Modal footer (outside scroll area):
<div data-modal-footer>
<button @click="closeModal()">Close</button>
</div>
Parent x-component attributes accessible via globalThis.xAttrs(element):
<!-- Parent -->
<x-component path="child.html" mydata='{"id": 123}'></x-component>
<!-- Child can access -->
<script type="module">
const attrs = globalThis.xAttrs(document.currentScript);
console.log(attrs.mydata.id); // 123
</script>
| Pattern | Syntax |
|---|---|
| Reactive text | x-text="$store.s.value" |
| Conditional render | x-if="$store.s.condition" |
| Visibility toggle | x-show="$store.s.visible" |
| Class binding | :class="{'active': $store.s.isActive}" |
| Event handler | @click="$store.s.action()" |
| Two-way bind | x-model="$store.s.inputValue" |
| List iteration | <template x-for="item in $store.s.items"> |
| Init expression | x-init="$store.s.load()" |
<div x-data>
<template x-if="$store.myStore">
<!-- Renders only when store exists -->
</template>
</div>
Always gate components that depend on stores. Prevents errors during initial load race.
| Pattern | Example |
|---|---|
| Self-contained components | All HTML/CSS/JS in one component folder |
| Module imports with absolute paths | import { store } from "/components/..." |
| CSS variables for theming | color: var(--color-text) |
Guard init() with _initialized | Prevents duplicate setup |
Use display: contents for flex chains | Wrapper doesn't break parent flex |
| Inline component styles | <style> in component <body> |
Import stores in <head> | Ensures registration before render |
| Name stores uniquely | createStore("featureStore", ...) |
.component {
background: var(--color-panel);
color: var(--color-text);
border: 1px solid var(--color-border);
padding: var(--spacing-md);
transition: all var(--transition-speed) ease-in-out;
font-size: var(--font-size-normal);
}
When x-component wrapper would break flex layout:
#parent-container > x-component,
#parent-container > x-component > div[x-data] {
display: contents;
}
| Anti-Pattern | Why | Fix |
|---|---|---|
| Global CSS files | Breaks encapsulation | Inline styles per component |
window.Alpine.store() lookups | Timing issues, coupling | Import store module directly |
Call init() from x-init | Runs multiple times | Use guard, or use x-create for per-mount |
Multiple roots in <template> | Alpine breaks | Wrap in single <div> |
.catch(() => null) for errors | Hides bugs | Let errors surface, use notifications |
Scripts outside <head> | May not load before template | Move to <head> with type="module" |
| Hardcoded colors | Breaks theming | Use CSS variables |
Relative imports ./file.js | Path resolution issues | Use absolute /components/... |
Race condition: store not ready
<!-- ❌ BAD: No gate -->
<div x-data>
<p x-text="$store.myStore.value"></p>
</div>
<!-- ✅ GOOD: Store gate -->
<div x-data>
<template x-if="$store.myStore">
<p x-text="$store.myStore.value"></p>
</template>
</div>
Duplicate initialization
// ❌ BAD: Runs every time store accessed
init() {
window.addEventListener("resize", this.handler);
}
// ✅ GOOD: Guard pattern
init() {
if (this._initialized) return;
this._initialized = true;
window.addEventListener("resize", this.handler);
}
Leaking listeners
// ❌ BAD: No cleanup
init() {
this.interval = setInterval(() => this.tick(), 1000);
}
// ✅ GOOD: With cleanup
init() {
this.interval = setInterval(() => this.tick(), 1000);
},
destroy() {
clearInterval(this.interval);
}
Files to copy:
/js/components.js # Component loader
/js/AlpineStore.js # Store factory
/js/modals.js # Modal system (optional)
/js/initFw.js # Alpine bootstrap + directives
Dependencies:
Bootstrap sequence:
// initFw.js pattern:
await import("path/to/alpine.min.js");
// Register custom directives
Alpine.directive("destroy", ...);
Alpine.directive("create", ...);
// etc.
HTML entry point:
<script type="module" src="/js/initFw.js"></script>
<x-component path="app/root.html"></x-component>
--color-*, --spacing-*, etc.)/components/ path (or adjust loader)api.js)| Framework | Consideration |
|---|---|
| Vanilla/Static | Works directly, include initFw.js |
| Electron | Works, may need CSP adjustments for Blob URLs |
| React/Vue | Mount Alpine in specific container, avoid conflicts |
| SPA Routers | MutationObserver handles dynamic inserts |
webui/components/
├── _examples/ # Reference implementations
│ ├── _example-component.html
│ └── _example-store.js
├── chat/
│ ├── input/
│ │ ├── chat-bar.html
│ │ └── input-store.js
│ └── ...
├── sidebar/
│ ├── sidebar-store.js
│ ├── left-sidebar.html
│ └── chats/
│ └── chats-list.html
├── modals/
│ └── file-browser/
│ ├── file-browser.html
│ └── file-browser-store.js
├── notifications/
│ ├── notification-store.js
│ └── notification-toast-stack.html
└── settings/
└── ...
Naming conventions:
feature-name.htmlfeature-store.js or feature-name-store.jsmodals/ or feature folder/js/components.jsexport async function importComponent(path, targetElement)
export async function loadComponents(roots)
export function getParentAttributes(el)
// Global: globalThis.xAttrs
/js/AlpineStore.jsexport function createStore(name, initialState)
export function getStore(name)
export function saveState(store, include, exclude)
export function loadState(store, state, include, exclude)
/js/modals.jsexport function openModal(modalPath)
export function closeModal(modalPath?)
export function scrollModal(id)
// Globals: globalThis.openModal, closeModal, scrollModal
/js/api.jsexport async function callJsonApi(endpoint, data)
export async function fetchApi(url, request)
Use x-transition for enter/leave animations:
<div x-show="visible"
x-transition:enter="fade-enter"
x-transition:leave="fade-leave">
$confirmClick)Magic helper for destructive actions:
<button @click="$confirmClick($event, () => $store.myStore.delete(item.id))">
<span class="material-symbols-outlined">delete</span>
</button>
First click arms (icon changes to checkmark), second click confirms. Auto-resets after 2s.
Body receives device-touch or device-mouse class via /js/initializer.js. Use for input-type-specific styling:
.device-touch .hover-only { display: none; }
Defined in /webui/index.css:
| Variable | Purpose |
|---|---|
--color-background | Page background |
--color-text | Primary text |
--color-primary | Headings, emphasis |
--color-panel | Card/sidebar backgrounds |
--color-border | Borders, dividers |
--color-accent | Highlights, actions |
--color-input | Form field backgrounds |
--spacing-xs/sm/md/lg | Consistent spacing scale |
--font-size-small/normal/large | Typography scale |
--transition-speed | Animation duration (0.3s) |
Theme switching via .light-mode class on root element.
End of Component System Documentation