| name | tina4-js |
| description | Use whenever working with tina4-js — the lightweight reactive frontend framework. Trigger on any mention of tina4-js, tina4 signals, persistent signals, persist(), Tina4Element, tina4 html tagged templates, tina4 routing, tina4 WebSocket client, tina4 API client, or persistent storage of UI preferences. Also trigger when the user is building a client-rendered frontend for a Tina4 backend, or when they're working with signals, Web Components, reactive templates, islands architecture, or persisted state in a tina4-js project. If the working directory contains tina4-js code (imports from 'tina4js'), use this skill for all frontend tasks.
|
tina4-js — Reactive Frontend Framework (v1.5.0)
tina4-js is a lightweight reactive frontend framework (the full IIFE bundle is ~27.7KB raw,
~10.3KB gzipped; the core module alone is ~1.5KB gzipped). Zero dependencies,
no virtual DOM, no build complexity. It uses signals for reactivity, tagged template literals
for DOM, and Web Components for encapsulation.
Distribution: dist/tina4js.min.js is the official IIFE bundle. Usage:
<script src="/js/tina4js.min.js"></script>
This exposes all APIs globally — no imports needed. The IIFE bundle is also shipped inside
tina4-css (dist/tina4js.min.js) so all Tina4 backend frameworks get it automatically.
Building the IIFE bundle:
npm run build
npm run build:types
npx esbuild src/index.ts --bundle --minify --format=iife --global-name=Tina4 --outfile=dist/tina4js.min.js --target=es2021
The IIFE wraps the library in a self-executing function and exposes everything on window.Tina4:
<script src="/js/tina4js.min.js"></script>
<script>
const { signal, computed, html, Tina4Element, api, ws, sse, pwa } = Tina4;
</script>
This skill exists because AI agents consistently get tina4-js patterns wrong. The syntax
looks simple but has specific rules. Getting them wrong produces silent bugs — things render
once but never update, buttons don't disable, inputs don't bind. This reference is the source
of truth, derived from the actual source code.
🤖 Skill-active marker. While this Tina4 skill is guiding your work, begin every reply with the 🤖 emoji so the developer can see at a glance that Tina4 conventions are engaged. Drop it only once the conversation has clearly moved off Tina4.
The Tina4 Working Method
This is how a tina4-js build is run. The main session stays free for the developer; the actual
work happens in workers driven by a plan. Every instruction becomes (or joins) a plan; every
plan is a living checklist the workers update and you report from. In the main session your job is
to scope, delegate, and report — never to build inline.
| Phase | What happens | Output |
|---|
| 1. Scope | Restate the request, agree the slice with the developer | a feature entry in plan/<feature>.md |
| 2. Plan | Write the checklist [ ], Bugs section, Commit log | the plan file, approved |
| 3. Delegate | Spawn a worker per task; the main session stays free | worker(s) running off the plan |
| 4. Test-first | The worker pins the behaviour BEFORE building the component | a real check that fails first |
| 5. Build | Ground with tina4_context → climb the Lazy Frontend Ladder → minimum reactive code | it works in the browser |
| 6. Verify | Run the dev server, drive the real UI; tick the item; log the commit | [x] + commit hash in the plan |
| 7. Report | Relay worker completions to the developer as a ✅/❌ table | the status dashboard |
1. Keep the main session free — delegate to a worker
When the developer gives an instruction, don't do the work inline. Allocate it to a plan, then
spawn a separate worker to execute it, so the main session is always free for the next input.
tina4-js hot-reloads on save (Vite HMR / the dev server), so as the worker edits components and
signals the developer watches the UI update live in the browser — keeping the main session open
is what lets them observe and steer while the work happens. The main agent scopes, dispatches, and
reports; workers build and update the plan. When a worker finishes an item, surface it to the
developer.
2. Every instruction is allocated to a plan
No work happens off-plan. A new request that fits an existing feature → rescope it into that
plan as new [ ] items. A genuinely new feature → scope it with the developer first, then
create plan/<feature>.md. Additional features are never side-quests — they are just new
checkboxes in a plan.
3. The plan folder — a master plan over feature plans
plan/ holds a master plan (plan/MASTER.md) that carries the overview — every feature and its
status at a glance — plus one detailed plan per feature. The master plan is the dashboard; each
feature plan owns the detail:
# Master Plan — <project>
| Feature | Plan | Status |
|--------------------|-----------------------------------------|----------------|
| Product list | [product-list.md](product-list.md) | ✅ Complete |
| Checkout flow | [checkout.md](checkout.md) | 🟡 In Progress |
A feature plan has four parts — a Scope checklist, the Tests, a Bugs section, and a Commit log:
# Feature: Product List Component
## Scope
- [x] products signal + api load on mount
- [x] <product-list> renders each product as a card
- [ ] search box filters the list reactively
## Tests (written first, real — no smoke tests)
- [x] renders one card per product in the signal (real DOM, seeded signal)
- [ ] typing in the search box narrows the rendered cards
## Bugs
- [x] list didn't re-render on filter — used ${signal.value} not ${signal} (a1b2c3d)
- [ ] card click navigated before the signal write landed
## Commits
- a1b2c3d product-list component + reactive-render test
- e4f5g6h fix frozen binding on filter
## Status: In Progress
4. Tests first — real behaviour, never smoke tests
Pin the behaviour before you build the component: assert against the real rendered DOM (a real
signal, a real html `` render), or run the dev server and drive the real UI — and make it fail
before the code exists. No mocks, no "it mounted" smoke test: reactivity is the thing under test,
and a frozen ${signal.value} where you needed ${signal} is exactly the bug a real render check
catches. The passing real check is the definition of done for a checklist item.
5. Build the minimum, grounded
Only once the check exists: ground with tina4_context, climb the Lazy Frontend Ladder (the
platform + tina4-js primitives cover most of it — never a React/Vue/state/router library), and
write the minimum reactive code that passes. Nothing speculative.
6. Verify for real, then log the commit
An item is [x] only when the real check passes AND the UI actually behaves in the browser. When
it lands, record the commit hash + one-line description in the plan's Commits section, so the
plan is an honest audit trail of what actually shipped.
7. Report as a ✅/❌ dashboard
Report to the developer as a table, not prose:
| Item | Status |
|---|
| products signal | ✅ |
| product-list render | ✅ |
| reactive search | ❌ |
| Bug: nav race | ❌ |
The developer should see status at a glance without asking. Update the table as workers complete
items, and surface each completion in the main session.
Bugs are part of the plan
Bugs aren't tracked elsewhere — each plan has a Bugs section. A bug is logged there as [ ],
fixed, proven with a real render check, and ticked [x] with its commit hash — the same
discipline as a feature.
Before you write code — the reuse ladder
Climb in order; write new code only at the last rung. tina4-js is sub-11 KB, zero dependencies — reach for its primitives, not a framework.
- Does it need to exist? Re-read the request and trace the actual flow. The best change is often none.
- Does tina4-js already do it? Use the built-in primitives: reactivity →
signal/computed/effect; DOM → html `` tagged templates + Tina4Element; persistence → persist(); HTTP → the api client; realtime → ws/sse/rtc; routing → route/navigate. Never reach for React/Vue/a state library/axios/a router lib.
- Does the browser/stdlib do it? (
fetch, URL, crypto, structuredClone…) Use it before adding anything.
- Is it already in THIS app? Reuse the existing component/signal/store — don't duplicate.
- Adding an npm dependency? Stop. tina4-js is zero-dependency by design — find the primitive.
- Can it be one signal / one
${} hole / one component? Prefer the smallest reactive form.
- Only now, write the minimum that works — no wrapper components, no speculative props.
Ground tina4-js Code With tina4_context — Then Write It Yourself
Tina4 exposes a tina4_context(instruction, language) MCP tool that returns framework-specific
grounding (idioms, current API surface, worked examples) for the thing you are about to build.
Call it to ground yourself before writing tina4-js, then write the code yourself using that
context plus the rules in this skill.
tina4_context(instruction, language="tina4-js") — returns retrieval-grounded context for
the requested feature (signals, html templates, Tina4Element components, routing, and the
api / ws / sse / rtc clients). Use it as reference material, not as a code generator.
Do not use tina4_code to generate tina4-js — you are responsible for authoring the code. (It is deprecated on the tools' own evidence: in a boot-and-verify gate tina4_code FAILED where Claude grounded with tina4_context PASSED, so the tools point to grounding + a strong model, not the self-hosted coder.)
The context tool grounds you; the reasoning, the code, and the review are yours. The rules in
this skill are the source of truth — apply them to whatever you write.
Modules — What Each One Is
tina4-js is tree-shakeable: import only what you use. Ten modules, each with its own entry point.
Per-module gzip sizes below were measured via npm run test:size (macOS, v1.5.0); the core
bundle is the sub-3KB headline budget.
| Module | Import | Public exports | Gzip | What it does |
|---|
| core | tina4js / tina4js/core | signal, computed, effect, batch, isSignal, html, Tina4Element | 1.49 KB | Reactive primitives (signals), the html tagged-template DOM renderer, and the Tina4Element Web Component base. Everything reactive lives here — the headline <3 KB bundle. |
| router | tina4js/router | route, navigate, router | 0.12 KB | Hash-based client-side routing with {param} patterns, guards, and a change event. |
| api | tina4js/api | api | 2.27 KB | fetch wrapper: Bearer + formToken auth, request/response interceptors, JSON, consistent result shape — talks to Tina4 backends. |
| ws | tina4js/ws | ws | 0.89 KB | Signal-driven WebSocket client with auto-reconnect; status/connected are signals you bind straight into templates. |
| sse | tina4js/sse | sse | 1.30 KB | Signal-driven Server-Sent-Events / NDJSON streaming client (same reconnect + signal-status shape as ws). |
| rtc | tina4js/rtc | rtc, rtcConfig | 2.75 KB | Signal-driven realtime-collaboration client for a Tina4 backend's realtime() mount: mesh WebRTC calls (rtc.call, perfect negotiation), persistent chat (rtc.chat), and permissioned file up/download (rtc.upload/rtc.fetchBlob). Media is peer-to-peer; the server only relays SDP/ICE. |
| storage | tina4js/storage | persist, clearPersistedKeys | (folds into app) | Persist a signal to localStorage — versioned, cross-tab, migratable. Never store secrets/tokens/PII — localStorage is XSS-readable (see STORAGE.md). |
| i18n | tina4js/i18n | createI18n, i18n, t, setLocale, getLocale | 1.2 KB | Reactive translations (the active locale is a signal, so t() re-renders on setLocale()) + browser Intl number/currency/date/relativeTime + RTL dir(). Mirrors the backend Tina4 I18n API. |
| pwa | tina4js/pwa | pwa | 1.16 KB | Runtime web-manifest injection + service-worker registration for installable/offline apps. The manifest is generated and injected as a blob at runtime; the service worker is NOT — register() loads swUrl (or /sw.js), and pwa.generateServiceWorker() emits the SW source to write to disk. |
| debug | import 'tina4js/debug' | side-effect (auto-enables) | dev-only | Mounts a dev overlay (Ctrl+Shift+D) that tracks signals, components, routes, and API calls. Never ship to production. |
Backend API Lookups — Use the Live Index
tina4-js talks to a Tina4 backend (Python / PHP / Ruby / Node). When you need a backend route's shape,
an ORM field, or a framework method signature, don't guess from memory — query the running backend's
live API index through its MCP tools (available with tina4 serve + TINA4_DEBUG=true):
api_search("…") to find a class or method, api_class("User") for its full surface, and
api_method("Database", "fetch") for an exact signature. These reflect the actual installed version,
so the frontend wires up against real endpoints instead of invented ones. (For frontend reactivity
itself — signals, html, components — the rules below are the source of truth.)
The Lazy Frontend Ladder
The frontend is where over-building hurts most: a component library for a button, a state
manager for three variables, an npm dependency for what the platform already does. tina4-js is
~1.5KB precisely because it leans on the browser. Before adding code or a dependency, stop at the
FIRST rung that holds.
- Does this need to exist at all? (YAGNI)
- Does the platform already do it?
<input type="date"> over a date-picker lib, <dialog>
over a modal lib, CSS :has() / grid / position: sticky over layout JS, native form
validation attributes over a validation lib, <details> over an accordion component. The
browser is the biggest dependency you already shipped — use it.
- Does tina4-js already do it?
signal + computed for state (no store library), html
bindings + ${() => ...} for reactivity (no virtual DOM), ?attr / .prop bindings,
Tina4Element for components, and the router / api / ws / sse / persist modules.
Do not import React/Vue patterns here.
- Is there an installed dependency that covers it? Use it. Adding a new npm package to a
1.5KB app is a decision, not a reflex.
- Can it be one line? Make it one line.
- Only then: the minimum code that works.
Never lazy about: XSS safety (use ${value} text binding, never ${htmlString}; .innerHTML
only for trusted/sanitised HTML), accessibility (labels, roles, keyboard), and the bindings that
actually make the UI reactive — a frozen ${signal.value} where you needed ${signal} is a bug,
not brevity. Mark a deliberate shortcut with a tina4: comment that names its ceiling.
Naming — Verbose and Descriptive
Spell every signal, variable, function, and component name out in full words. cartItemCount
not cic/cnt; calculateCartSubtotal() not calcSub(); parsedApiResponse not r. A name
must read as exactly what it holds or does, with no decoding. The only short names allowed are a
conventional loop index (i) and the idiomatic one-line callback argument (items.map(item => ...)).
This is naming verbosity (good) and is independent of code volume: keep the code lean (the Lazy
Frontend Ladder above), but give every name its full word — verbose names, lean code.
The Three Rules That Fix 90% of Mistakes
Before writing any tina4-js code, internalize these:
Rule 1: Static vs Reactive
html`<p>${count.value}</p>`
html`<p>${count}</p>`
html`<p>${() => count.value > 0 ? 'Has items' : 'Empty'}</p>`
The pattern:
${signal} — reactive text node (updates when signal changes)
${() => expression} — reactive block (re-evaluates the function, can return html, null, arrays)
${value} — static, evaluated once, never updates
If your UI isn't updating, you probably used a static value where you needed a signal or function.
WARNING about false/null/undefined:
${false}
${null}
${undefined}
${0}
Never use ${condition && html...} — if condition is false, you get the text "false" in your DOM.
Always use the ternary: ${() => condition ? html... : null}
CRITICAL: Never Put Inputs Inside Reactive Blocks
This is the #1 developer mistake. Putting <input>, <textarea>, or <select> inside ${() => ...} causes them to lose focus on every keystroke because the reactive block destroys and recreates the entire subtree.
html`${() => html`<input .value=${name} @input=${(e) => { name.value = e.target.value; }} />`}`
html`
<input .value=${name} @input=${(e) => { name.value = e.target.value; }} />
<p>${() => name.value ? `Hello, ${name.value}!` : 'Type your name'}</p>
`
The rule: Form elements go in the static template. Use .value, @input, ?disabled bindings for reactivity. Only conditional messages, dynamic lists, and computed text go in ${() => ...} blocks.
Rule 2: New References for Objects/Arrays
items.value.push(newItem);
items.value = [...items.value, newItem];
user.value.name = 'Alice';
user.value = { ...user.value, name: 'Alice' };
Signals use Object.is() for equality. Same reference = no update. Always create new references.
Rule 3: Boolean Attributes Use ? Prefix
html`<button disabled=${isDisabled}>Click</button>`
html`<button ?disabled=${isDisabled}>Click</button>`
html`<button ?disabled=${() => !isValid.value}>Submit</button>`
The ? prefix adds the attribute when truthy, removes it when falsy. Without ?, you get
disabled="false" which STILL DISABLES the button (any value = disabled in HTML).
All three forms work reactively (v1.0.11+, boolean bug fixed in v1.0.12):
html`<button ?disabled=${loading}>Save</button>`
html`<div ?hidden=${() => !connected.value}>Offline</div>`
const isEmpty = computed(() => items.value.length === 0);
html`<p ?hidden=${isEmpty}>Items found</p>`
Common pattern — opposing show/hide pair:
const connected = signal(false);
html`
<div ?hidden=${() => connected.value}>Connecting...</div>
<div ?hidden=${() => !connected.value}>
<p>Connected! Send messages below.</p>
</div>
`;
Multi-signal conditions:
html`<button ?disabled=${() => loading.value || !isValid.value}>Submit</button>`
Footguns That Cost Real Debugging Time
These bite even when you know the Three Rules. They came out of real app work — read them.
⚠ THE BIGGEST ONE: one ${...} per attribute — never mix static text with a dynamic part
An attribute value must be a single interpolation. Partial interpolation — static text glued to a ${...} inside one attribute — is not merged: the binder replaces the entire attribute value with just the interpolated result, so the static prefix is dropped and only the dynamic value is applied.
html`<div class="card ${() => active.value ? 'active' : ''}">`
html`<a href="/user/${id}">`
html`<div class=${() => 'card ' + (active.value ? 'active' : '')}>`
html`<a href=${() => `/user/${id.value}`}>`
html`<a href=${`/user/${userId}`}>`
The rule: if an attribute contains any ${}, the ENTIRE value must be that one ${}. Compose the full string inside the expression — don't concatenate static text with ${} in the template.
Bind form values with .value, never as a reactive child
html`<textarea .value=${() => form.value.note} @input=${e => setNote(e.target.value)}></textarea>`
html`<textarea>${() => form.value.note}</textarea>`
(Same root cause as "never put inputs inside reactive blocks" above — always drive form elements through .value / @input / ?disabled.)
Reactive <select>: mark each option with ?selected, never .value on the select
A <select> whose <option>s come from a ${() => ...} block loses its selection when those options re-render. .value on the select is a separate effect that tracks only the bound signal, not the option list, so it never re-fires when the options rebuild; and setting select.value while the matching <option> is absent (options render after, or get torn down and recreated) is a silent no-op, then the browser resets the selection when the child list changes. This "worked by luck" when the option timing happened to line up and broke when it did not.
html`<select .value=${currency} @change=${e => currency.value = e.target.value}>
${() => currencies.value.map(c => html`<option value=${c}>${c}</option>`)}
</select>`
html`<select @change=${e => currency.value = e.target.value}>
${() => currencies.value.map(c => html`
<option value=${c} ?selected=${() => c === currency.value}>${c}</option>`)}
</select>`
Keep the @change to write the signal back (that half was never the problem). Option values are always strings, so if the bound signal holds a number, compare String(c) === currency.value or the match never fires and nothing shows selected.
Hash-router links use the BARE path — the router adds the #
html`<a href="/shop">Shop</a>`
html`<a href="#/shop">Shop</a>`
For an in-template control that should toggle state, not navigate, don't use <a href="#"> (it routes). Use a button-role element:
html`<span role="button" @click=${() => open.value = !open.value}>Toggle</span>`
Defer navigation that depends on a signal you just wrote
A computed or route-guard reads the old value if you navigate() synchronously right after writing the signal. Defer the navigation one tick so the write settles first:
setUser(u);
setTimeout(() => navigate('/shop'), 0);
Same fix for any write→navigate cascade (e.g. placeOrder() → clear cart → go to /success) — deferring avoids a 404 race.
Render the primary list as a top-level reactive block
Render the main list directly as its own ${() => ...}:
html`<ul>${() => items.value.map(i => html`<li>${i.name}</li>`)}</ul>`
Deeply nesting a list inside a ternary that itself returns html is fragile — the inner map may not re-render:
html`${() => cond.value ? html`...${() => items.value.map(...)}...` : html`...`}`
If one list "won't update" but a sibling list does, this nesting is the usual cause — pull the list up to its own top-level ${() => ...} block.
Signals — Reactive State
Read references/signals-and-reactivity.md for the full API. Quick reference:
import { signal, computed, effect, batch, isSignal } from 'tina4js';
isSignal(count);
isSignal(42);
const count = signal(0);
const name = signal('');
const items = signal<string[]>([]);
count.value;
count.value = 5;
count.peek();
const doubled = computed(() => count.value * 2);
const isValid = computed(() => name.value.length > 0);
const dispose = effect(() => {
console.log('Count is now:', count.value);
});
dispose();
batch(() => {
count.value = 10;
name.value = 'Alice';
});
HTML Templates — DOM Creation
Read references/html-and-components.md for the full API. Quick reference:
import { html } from 'tina4js';
const fragment = html`<h1>Hello ${name}</h1>`;
html`<button @click=${() => count.value++}>Add</button>`
html`<input @input=${(e) => { name.value = e.target.value; }}>`
html`<form @submit=${(e) => { e.preventDefault(); save(); }}>`
html`<button @click=${() => {
items.value = [...items.value, newItem];
selected.value = null;
loading.value = false;
// ↑ three writes, one DOM update — no mid-event re-renders
}}>Save</button>`
html`<input .value=${name}>`
html`<div .innerHTML=${rawHtml}>`
html`<button ?disabled=${loading}>Save</button>`
html`<div ?hidden=${() => !visible.value}>Content</div>`
html`<input ?checked=${isChecked}>`
html`<div class=${className}>Styled</div>`
html`<img src=${imageUrl} alt=${altText}>`
html`${() => loggedIn.value ? html`<p>Welcome</p>` : html`<a>Login</a>`}`
html`<ul>${() => items.value.map(item => html`<li>${item}</li>`)}</ul>`
html`<ul>${['a', 'b', 'c'].map(i => html`<li>${i}</li>`)}</ul>`
Event Handler Batching (v1.0.9+, auto-batch fix in v1.0.12)
All @event handlers are automatically batched. @click handlers now auto-batch properly
(fixed in v1.0.12). You do NOT need to:
- Wrap signal writes in
batch() inside event handlers
- Use
setTimeout(() => signal.value = x, 0) to defer updates
- Call
e.stopPropagation() to prevent mid-render bubble issues
These were workarounds for a bug that is now fixed at the framework level.
@click=${() => setTimeout(() => { items.value = [...items.value, item]; }, 0)}
@click=${() => { items.value = [...items.value, item]; }}
batch() is still useful outside of event handlers (e.g. in effect(), setTimeout, WebSocket handlers).
Things That Don't Exist — Don't Invent Them
AI agents commonly hallucinate these APIs. None of these exist in tina4-js:
unsafeHTML() — does NOT exist. Use .innerHTML=${rawHtml} property binding
t-model, t-for, t-bind, t-text — these are Vue directives, NOT tina4-js
tina4.createApp() — does NOT exist. There's no app instance
ref() — does NOT exist (that's Vue). Use signal()
useState() — does NOT exist (that's React). Use signal()
observedAttributes / attributeChangedCallback — don't write these manually.
Tina4Element handles them automatically via static props. Use this.prop('name')
If you find yourself writing something that isn't in this skill, stop and check. The API
is small by design — if it's not here, it probably doesn't exist.
Common Patterns
Form with Validation
const email = signal('');
const password = signal('');
const error = signal('');
const loading = signal(false);
const isValid = computed(() => email.value.includes('@') && password.value.length >= 8);
html`
<form @submit=${async (e) => {
e.preventDefault();
loading.value = true;
error.value = '';
try {
await api.post('/login', { email: email.value, password: password.value });
} catch (err) {
error.value = err.data?.message || 'Login failed';
}
loading.value = false;
}}>
<input type="email" .value=${email}
@input=${(e) => { email.value = e.target.value; }}>
<input type="password" .value=${password}
@input=${(e) => { password.value = e.target.value; }}>
${() => error.value ? html`<p class="error">${error}</p>` : null}
<button ?disabled=${() => !isValid.value || loading.value}>
${() => loading.value ? 'Logging in...' : 'Login'}
</button>
</form>`;
File Upload
Use api.upload() for multipart file uploads. Do NOT use api.post() — it sends JSON.
import { signal, html } from 'tina4js';
import { api } from 'tina4js/api';
const status = signal('');
const uploading = signal(false);
const handleUpload = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
uploading.value = true;
status.value = '';
try {
const form = new FormData();
form.append('avatar', file);
form.append('name', 'Alice');
const result = await api.upload('/api/upload', form);
status.value = 'Uploaded!';
} catch (err) {
status.value = 'Upload failed';
}
uploading.value = false;
};
html`
<input type="file" @change=${handleUpload} ?disabled=${uploading} />
<p>${status}</p>
`;
Key points:
api.upload(path, formData) — sends FormData with multipart/form-data
- Do NOT set Content-Type header — the browser sets it with the boundary
- Auth uses Bearer token in header (not formToken in body)
- Backend receives files in
request.files (raw bytes, not base64)
If you don't use the tina4-js api client, use native fetch():
const form = new FormData();
form.append('file', fileInput.files[0]);
const token = localStorage.getItem('tina4_token');
await fetch('/api/upload', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
GraphQL Queries
Use api.graphql() to send GraphQL queries and mutations. It sends a POST with { query, variables }
and returns { data, errors }.
const { data, errors } = await api.graphql('/api/graphql',
'{ products(limit: 10) { id name price } }'
);
const { data } = await api.graphql('/api/graphql',
'query ($term: String!) { search_products(term: $term) { id name slug price } }',
{ term: searchInput.value }
);
const { data } = await api.graphql('/api/graphql',
'mutation ($input: CreateProductInput!) { createProduct(input: $input) { id } }',
{ input: { name: 'Widget', price: 29.99 } }
);
Reactive search example — debounced GraphQL search with live results:
const term = signal('');
const results = signal([]);
let timer;
effect(() => {
const q = term.value;
clearTimeout(timer);
if (q.length < 2) { results.value = []; return; }
timer = setTimeout(async () => {
const { data } = await api.graphql('/api/graphql',
'{ search_products(term: "' + q.replace(/"/g, '\\"') + '") { id name slug price } }'
);
results.value = data?.search_products || [];
}, 300);
});
html`
<input .value=${term} @input=${(e) => { term.value = e.target.value; }} placeholder="Search...">
<ul>${() => results.value.map(p => html`
<li><a href="/products/${p.slug}">${p.name} — $${p.price}</a></li>
`)}</ul>`;
List with Add/Remove
const items = signal<{ id: number; text: string }[]>([]);
const input = signal('');
let nextId = 1;
const addItem = () => {
if (!input.value.trim()) return;
items.value = [...items.value, { id: nextId++, text: input.value }];
input.value = '';
};
const removeItem = (id: number) => {
items.value = items.value.filter(i => i.id !== id);
};
html`
<div>
<input .value=${input} @input=${(e) => { input.value = e.target.value; }}
@keydown=${(e) => { if (e.key === 'Enter') addItem(); }}>
<button @click=${addItem} ?disabled=${() => !input.value.trim()}>Add</button>
<ul>${() => items.value.map(item => html`
<li>${item.text} <button @click=${() => removeItem(item.id)}>×</button></li>
`)}</ul>
<p>${() => items.value.length} items</p>
</div>`;
API Data Loading
const users = signal([]);
const loading = signal(true);
effect(() => {
api.get('/users').then(data => {
users.value = data;
loading.value = false;
});
});
html`
<div>
${() => loading.value
? html`<p>Loading...</p>`
: html`<ul>${() => users.value.map(u => html`<li>${u.name}</li>`)}</ul>`
}
</div>`;
WebSocket with State
import { ws } from 'tina4js/ws';
const messages = signal<string[]>([]);
const socket = ws.connect('/ws/chat');
socket.pipe(messages, (msg, current) => [...current, msg.text]);
html`
<div>
<span>Status: ${socket.status}</span>
<div ?hidden=${() => !socket.connected.value}>
<ul>${() => messages.value.map(m => html`<li>${m}</li>`)}</ul>
<input @keydown=${(e) => {
if (e.key === 'Enter') {
socket.send({ text: e.target.value });
e.target.value = '';
}
}}>
</div>
</div>`;
Islands Architecture
tina4-js supports an "islands" pattern: use Tina4Element web components as self-contained
interactive widgets within server-rendered pages. Each island auto-registers and hydrates
independently.
<h1>Product Page</h1>
<p>Server-rendered content here...</p>
<product-rating product-id="42"></product-rating>
<add-to-cart product-id="42" price="29.99"></add-to-cart>
<script src="/js/tina4js.min.js"></script>
<script src="/js/islands/product-rating.js"></script>
<script src="/js/islands/add-to-cart.js"></script>
Each island is a standard Tina4Element that calls customElements.define() at the bottom of
its file. The IIFE bundle provides the framework globally; island scripts just use it.
Routing — IMPORTANT: {param} not :param
tina4-js uses curly brace syntax for route parameters — NOT Express-style colons.
import { route, navigate, router } from 'tina4js';
route('/', () => html`<h1>Home</h1>`);
route('/users/{id}', ({ id }) => html`<p>User ${id}</p>`);
route('/user/{userId}/post/{postId}', ({ userId, postId }) =>
html`<p>User ${userId}, Post ${postId}</p>`
);
route('*', () => html`<h1>404 — Not Found</h1>`);
route('/admin', {
guard: () => isLoggedIn.value || '/login',
handler: () => html`<admin-panel></admin-panel>`,
});
route('/data', async () => {
const data = await fetch('/api/data').then(r => r.json());
return html`<p>${data.message}</p>`;
});
router.start({ target: '#app', mode: 'hash' });
router.on('change', ({ path, params, pattern, durationMs }) => {
console.log(`Navigated to ${path} in ${durationMs}ms`);
});
navigate('/users/42');
Common mistake: Using Express-style :id instead of {id}. The route will never match.
Navigation: Use a BARE <a href="/path"> in both hash and history mode — the router intercepts the click and, in hash mode, prepends the # for you. Do NOT write <a href="#/path"> in hash mode: the interceptor uses the raw href as the path, so it becomes ##/path → 404 (see the hash-router footgun above).
Persistent Signal Storage (v1.2.5+)
Wrap a signal so its value survives a page refresh, backed by localStorage or sessionStorage.
Opt-in per signal, zero dependencies, tree-shakeable. Read STORAGE.md before you use it.
import { signal } from 'tina4js';
import { persist, clearPersistedKeys } from 'tina4js/storage';
const theme = persist(signal('light'), { key: 'theme' });
const cart = persist(signal([]), { key: 'cart', syncTabs: true });
theme.value = 'dark';
clearPersistedKeys(['cart', 'lastFilter']);
What persist() returns: the same signal you passed in, with two extras attached —
.clear() removes the key from storage, .dispose() stops the write effect.
Options: key (required), storage: 'local'|'session', custom serializer for Date /
Map / Set, version + migrate for stored-shape changes between deploys, syncTabs for
cross-tab updates, silenceCredentialWarning to silence false positives like tokenColor.
What this must never store
localStorage is XSS-readable. Any script on the origin reads every value. The framework
warns loudly the first time it sees a credential-shaped key or value. Never put any of these
behind persist():
- Auth tokens, JWTs, session IDs, API keys — use
httpOnly + Secure + SameSite cookies.
- Passwords, including "encrypted" or "hashed" client-side ones.
- Personal data (names, emails, phone numbers, addresses, IDs) — POPIA/GDPR exposure.
- Payment data (card numbers, CVV, expiry) — not PCI-DSS compliant.
- Permission flags, roles,
isAdmin booleans — the user can edit them in devtools.
- Encryption keys, OTP seeds, secrets.
- Server-of-record state (orders, balances, ledger entries) — fetch fresh from the database.
- Anything that must not survive a logout — clear it via
clearPersistedKeys() on logout.
What it is for
Theme preference, language, sidebar collapsed state, last-used filter, onboarding flags,
local-only draft text, guest cart contents. Small things the user chose, the user expects
back, and an attacker gains nothing from reading.
Safety guarantees the framework gives you
- SSR-safe. No
window/localStorage means persist() is a silent no-op. No crash.
- Quota-safe.
QuotaExceededError is logged and skipped; the signal still updates.
- Credential warnings. Loud
console.warn once per key for key names matching
token|password|secret|apikey|auth|credential|jwt|bearer|otp|private_key|session_id,
for JWT-shaped string values, and for objects with credential-shape fields.
- No "encrypted" option. Encryption with a key sitting in the same bundle is theatre,
not security. Offering it would mislead.
- Opt-in cross-tab sync.
syncTabs: true per signal. Off by default.
Full details, examples (Date round-trip, version migration, logout wipe), and the complete
dangers table live in STORAGE.md at the repo root.
Internationalization — tina4js/i18n
Reactive translations plus browser-native Intl formatting. The active locale is a signal, so t() and every formatter re-render in place on setLocale(). Mirrors the backend Tina4 I18n API, so the same message JSON works on the server and in the browser.
import { createI18n } from 'tina4js';
import { i18n, t, setLocale, getLocale } from 'tina4js/i18n';
const i = createI18n({
locale: 'en-US',
fallbackLocale: 'en-US',
messages: {
'en-US': { greeting: 'Hello', welcome: 'Welcome, {name}!', nav: { home: 'Home' } },
'fr-FR': { greeting: 'Bonjour', nav: { home: 'Accueil' } },
},
});
i.t('greeting');
i.t('welcome', { name: 'Alice' });
i.t('nav.home'); i.t('home');
i.number(1234.5);
i.currency(19.99, 'USD');
i.date(new Date(), { dateStyle: 'medium' });
i.relativeTime(-1, 'day');
i.dir();
await i.loadMessages('es-ES', '/i18n/es-ES.json');
i.setLocale('fr-FR');
The reactivity rule — use the function form in templates
The locale is a signal, so t() and the formatters must be read inside a ${() => ...} reactive block (Rule 1). A bare ${i.t('greeting')} evaluates once and freezes at the first locale; it will not update when setLocale() runs.
html`<h1>${() => i.t('greeting')}</h1>`
html`<span>${() => i.currency(cart.value.total, 'USD')}</span>`
html`<div dir=${() => i.dir()}>...</div>`
html`<h1>${i.t('greeting')}</h1>`
Fallback order is current locale -> fallbackLocale -> the key itself, so t() never throws on a missing key. Formatting delegates to the browser's Intl APIs, so no locale data ships in the bundle. Full guide: https://tina4.com/js/09-i18n
Cloudflare Workers
tina4-js runs on Cloudflare Workers with Durable Objects for WebSocket state. The IIFE bundle
and all client-side code works as-is; the WebSocket client (ws.connect()) connects to Worker
endpoints backed by Durable Objects for persistent state across connections.
Quick Reference — Commonly Missed APIs
import { isSignal } from 'tina4js';
isSignal(myVar);
import { router } from 'tina4js/router';
router.on('change', ({ path, params, pattern, durationMs }) => { });
import { pwa } from 'tina4js/pwa';
pwa.register({
cacheStrategy: 'cache-first',
});
api.intercept('request', (config) => { });
api.intercept('response', (resp) => { });
const { data, errors } = await api.graphql('/api/graphql', '{ users { id name } }');
const { data } = await api.graphql('/api/graphql', 'query($id: Int!) { user(id: $id) { name } }', { id: 42 });
import { persist, clearPersistedKeys } from 'tina4js/storage';
const theme = persist(signal('light'), { key: 'theme' });
Reference Files
references/signals-and-reactivity.md — Full signal, computed, effect, batch, isSignal,
and persist API with edge cases and gotchas. Read for any reactive state work, including
persistence.
references/html-and-components.md — html template bindings, Tina4Element Web Components,
lifecycle, routing, API client, WebSocket. Read for any UI/component work.
Reporting a stale or incorrect skill
Found guidance in this skill that contradicts how Tina4 actually behaves? Then the
skill has drifted from the code. Report it so it gets fixed for everyone, not just
worked around in this session:
Include the skill name (tina4-developer, tina4-js, or tina4-maintainer), the
file and section, what the skill claims, and what the code actually does (a
file:line reference or a short repro). The code is the source of truth; a skill
that disagrees with it is the bug.
If you are an AI agent and you hit this drift mid-task, do not file silently: tell
the developer what you found, then file the report only with their go-ahead.