vanilla-web-architecture
Skill0 starsChanged 28 days agoApache-2.0View on GitHub
Architecture for maintainable no-framework web apps (plain HTML, CSS, JS; Web Components, one-way store, multi-page or single-page). Use when building or extending such an app.
In this repository9 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- angular-expertSkill
- animejsSkill
- error-loggingSkill
- goalifySkill
- handoffSkill
- premortemSkill
- prompt-builderSkill
- writing-clearly-and-conciselySkill
The file
We split this skill into the rules it gives an agent
6 rules. About 0 are common, 3 are distinctive, and 3 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 19
If the user has not said which, default to a multi-page app.
- Very specific: line 28
Use native ES modules (<script type="module">).
- Very specific: line 90
Write values with textContent or DOM properties.
- Distinctive: line 210
Keep to the layers above. Add an abstraction once code has a second use, and a library or build tool only with the user's approval.
- Distinctive: line 213
When unsure, choose the simplest option that keeps the folder boundaries, and state the tradeoff in one sentence.
- Very specific: line 217
Every rule above holds for each changed file.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Plain HTML 5, CSS 3 and JavaScript only: no frameworks, no build step, no npm runtime
dependencies. ES modules do not load from file://, so develop and test through any
static server. If the user wants to open the app by double-clicking index.html, tell
them in one sentence that it needs a static server and give the command
(python -m http.server). Keep the modules: classic scripts would drop the import
structure this skill depends on.
Prerequisites: Node 20 or later for node --test (stable from v20.0.0), Python 3 for
python -m http.server, and the package.json script "test": "node --test".
Multi-page app (one HTML file per route): also read mpa.md. Single-page app (client-side routing): also read spa.md. If the user has not said which, default to a multi-page app. A single-screen app uses the folder layout below alone; add mpa.md or spa.md when a second page appears.
Core pattern
Component-Based Architecture with a small Flux/Redux-lite store. State flows one way: event -> action -> reducer -> store -> subscribed components re-render.
Rules
- Use native ES modules (
<script type="module">). One responsibility per file. - Each UI piece is a Web Component (custom element) owning its markup (
<name>.html), CSS and behavior in one folder. Code that two or more components repeat (template loading, shadow-root setup, stylesheet link, subscribe/unsubscribe, a DOM helper) lives incomponents/base/. - A component composes its own child components. It imports each child's module (a
missing import leaves an undefined element that fails silently) and, in
setup(), which the base class runs once after cloning the template, attaches its listeners and assigns itsstoreandservicesto the children in its template. Siblings communicate through the store, or throughCustomEvent/EventTargetwhen the message is not state. Components read state from the store, dispatch actions, and reach data only through services. store/exports acreateStore(reducer, initialState)factory returninggetState(),dispatch(action)andsubscribe(listener). The reducer updates state immutably. The entry module creates the store and the services once and assigns them asstoreandservicesproperties to each top-level component, and each component passes them to its children. The base class renders once the element is connected, bothservicesandstoreare set, in whatever order that happens, and its template and stylesheets have loaded.- Data access lives in
api/repositories (Repository pattern), use-case logic inservices/(Service Layer), so swapping the data source never touches components. Durable state is written back by apersistOnChange(store, repository)subscriber inservices/, which the entry module starts after rehydrating. It takes the repository as an argument so importing it never toucheslocalStorageand tests can pass a fake. The reducer stays pure. - Singletons (config, logger) are module-level exports.
- CSS: the base class adds a
<link rel="stylesheet">to each shadow root for the sharedshadow-reset.cssand for the component'sstatic stylesheet, its sibling.cssfile, with the href resolved bynew URL('./x.css', import.meta.url).hrefbecause a bare relative href resolves against the page. It inserts the template only after these stylesheets load (or fail), so markup never shows unstyled, and fetches the template in parallel with them. Design tokens are CSS custom properties in onetokens.css(they inherit into shadow roots). Styling lives in CSS files. Page-level CSS (tokens.css,base.css, pluslayouts.cssorutilities.cssonce a second use exists) goes in cascade layers, one<link>per file in the HTML and no@import. A<link>cannot assign a layer, so each file wraps its rules in@layer <name> { }blocks (base.cssholds bothresetandbase).base.cssis linked first and opens with@layer reset, tokens, base, layouts, utilities;. Itsbaselayer holds:not(:defined) { visibility: hidden; }, so page content inside a component stays hidden until its module defines it, instead of showing then vanishing. Page-level selectors never match inside shadow roots (only inherited properties such as custom properties,colorandfontcross), so component CSS stays unlayered.shadow-reset.cssincomponents/base/holds CSS that every component shares, including[hidden], :host([hidden]) { display: none !important; }so componentdisplayrules never override thehiddenattribute. - Markup lives in HTML files so it can be edited by hand. Static markup never lives in
JS strings or
createElementchains. A component's structure is its<name>.htmltemplate, which the base class fetches once per component, caches and clones into the shadow root. Page content (headings, text, sections) is written in the page HTML as the element's children and shown through a<slot>in the template. Repeated items clone a nested<template>from the component's template file withdocument.importNode(template.content, true), which upgrades already-defined child components immediately, so setting theirstoreandservicesbefore insertion reaches the base class setters. Relative URLs inside a template (src,href) resolve against the page, not the template file, so JS sets them withnew URL(path, import.meta.url)(in an MPA, from theroutesmap). Fixed states (loading, empty, not found) are elements already in the HTML that JS shows or hides withhidden. JS creates elements only for the base class's<template>and<link>elements and, in an SPA, the router's page elements. - Rendering: JS only writes dynamic values. The base class collects
[data-ref]nodes intothis.refs. Keep node references there and never name a component field after a built-in element property such astitle,hiddenorid, which it would silently shadow. Update only the nodes that changed, and leave a focused input in place. Write values withtextContentor DOM properties. A component that reads attributes lists them instatic observedAttributes, and the base class re-renders when they change. Parsing the component's own same-origin template file is the onlyinnerHTMLuse. Never useinnerHTMLwith user or remote data. - Services and reducers are pure or take their dependencies as arguments. Cover them
with plain
*.test.jsfiles run bynode --test, a dev-only tool.
Folder layout
index.html
package.json {"type":"module"} and the test script, no dependencies
src/
main.js entry module, wiring only
config.js singletons (config, logger) as module-level exports
components/base/ base-element.js, shadow-reset.css, other shared component code
components/<name>/<name>.js + <name>.html + <name>.css
store/ createStore, reducers, actions
services/ use-case logic
api/ fetch/localStorage repositories
styles/tokens.css base.css (layer order, page-level reset)Wiring example
Rules 4, 5, 7, 8 and 9 in code (reducer, service, repository and CSS omitted):
// components/base/base-element.js
const templates = new Map();
const resetHref = new URL('./shadow-reset.css', import.meta.url).href;
function loadTemplate(href) {
if (!templates.has(href)) {
templates.set(href, fetch(href).then(async (response) => {
if (!response.ok) throw new Error(`Template ${href}: ${response.status}`);
const template = document.createElement('template');
template.innerHTML = await response.text();
return template;
}));
}
return templates.get(href);
}
function stylesheetLink(href) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
return link;
}
export class BaseElement extends HTMLElement {
#store;
#services;
#unsubscribe;
#ready;
get store() { return this.#store; }
set store(value) { this.#store = value; this.#start(); }
get services() { return this.#services; }
set services(value) { this.#services = value; this.#start(); }
connectedCallback() { this.#start(); }
disconnectedCallback() { this.#unsubscribe?.(); this.#unsubscribe = null; }
attributeChangedCallback() { if (this.#unsubscribe) this.render(); }
async #prepare() {
this.root = this.attachShadow({ mode: 'open' });
const links = [resetHref, this.constructor.stylesheet].filter(Boolean).map(stylesheetLink);
const styled = links.map((link) => new Promise((resolve) => {
link.onload = link.onerror = resolve;
}));
this.root.append(...links);
// Insert the markup only once its CSS has loaded, so it never shows unstyled.
const [template] = await Promise.all([loadTemplate(this.constructor.template), ...styled]);
this.root.append(document.importNode(template.content, true));
const refs = this.root.querySelectorAll('[data-ref]');
this.refs = Object.fromEntries([...refs].map((node) => [node.dataset.ref, node]));
this.setup?.();
}
async #start() {
if (!this.isConnected || !this.#store || !this.#services || this.#unsubscribe) return;
this.#ready ??= this.#prepare();
await this.#ready;
// Re-check: the element may have been removed, or another call started it, during the fetch.
if (!this.isConnected || this.#unsubscribe) return;
this.render();
this.#unsubscribe = this.#store.subscribe(() => this.render());
}
}<!-- components/todo-count/todo-count.html -->
<p><span data-ref="count"></span> todos</p>// components/todo-count/todo-count.js
import { BaseElement } from '../base/base-element.js';
class TodoCount extends BaseElement {
static template = new URL('./todo-count.html', import.meta.url).href;
static stylesheet = new URL('./todo-count.css', import.meta.url).href;
render() {
this.refs.count.textContent = this.store.getState().todos.length;
}
}
customElements.define('todo-count', TodoCount);
// main.js
import { createStore } from './store/store.js';
import { reducer, initialState } from './store/reducer.js';
import { todoRepository } from './api/todo-repository.js';
import { createTodoService } from './services/todo-service.js';
import { persistOnChange } from './services/persist-on-change.js';
import './components/todo-count/todo-count.js';
const services = { todos: createTodoService(todoRepository) };
const store = createStore(reducer, { ...initialState, ...todoRepository.load() });
persistOnChange(store, todoRepository);
const app = document.querySelector('todo-count');
app.services = services;
app.store = store;Scope
Keep to the layers above. Add an abstraction once code has a second use, and a library or build tool only with the user's approval.
When unsure, choose the simplest option that keeps the folder boundaries, and state the tradeoff in one sentence.
Done when
Every rule above holds for each changed file. Check each rule against the changes and fix
every violation. Run node --test and fix any failures.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

