Recipes you can copy.
These are the demo pages in the plainkit repo, captured from make preview. Each page is one HTML document. Hover a screenshot to see its script. The card opens that file on GitHub.

login.js
View on GitHub
1import { DataProvider } from '../../src/provider.js';2import { setFieldError } from '../../src/bind.js';34const account = new DataProvider({5 name: 'account',6 data: { email: '', status: 'signed-out' },7});89const form = document.getElementById('login-form');10const bannerErr = document.getElementById('banner-err');11const bannerOk = document.getElementById('banner-ok');12const bannerInfo = document.getElementById('banner-info');1314form.addEventListener('submit', (evt) => {15 evt.preventDefault();16 bannerErr.hidden = true;17 bannerOk.hidden = true;18 bannerInfo.hidden = true;1920 const email = form.email.value.trim();21 const password = form.password.value;22 let bad = false;Sign in
Cover page. Email, password, and a second button that does not call an identity provider.
demo/recipes/login.js

dashboard.js
View on GitHub
66const dash = new DataProvider({67 name: 'dashboard',68 load(_provider, query) {69 const rows = matchRows(query);70 const seats = rows.reduce((sum, it) => sum + (Number(it.seats) || 0), 0);71 const active = rows.filter((it) => it.state === 'active').length;72 const standard = rows.filter((it) => it.plan === 'standard').length;73 const trialCount = rows.filter((it) => it.plan === 'trial').length;74 const ranks = rows.slice().sort((a, b) => b.seats - a.seats);75 const total = rows.length;76 const meters = total77 ? [78 { label: 'Standard', value: standard, max: total },79 { label: 'Trial', value: trialCount, max: total },80 { label: 'Seats', value: seats, max: SEATS_CAP },81 ]82 : [];83 return {84 q: (query && query.q) || '',85 trial: !query || query.trial !== false,86 draft: !query || query.draft !== false,87 accounts: total,88 seats,89 active,90 seatsCap: SEATS_CAP,91 accountsTrend: '+2 this week',92 accountsDir: 'up',93 seatsTrend: '+4 this week',94 seatsDir: 'up',95 activeTrend: '-1 this week',96 activeDir: 'down',97 ranks,98 rows,99 meters,100 };Dashboard
App frame with a filter, metric cards, tabs, and a ranked list. Copy this when the page is a summary.
demo/recipes/dashboard.js

items.js
View on GitHub
1import { DataProvider } from '../../src/provider.js';2import { bindList, bindHidden, setFieldError } from '../../src/bind.js';3import { openDialog, closeDialog } from '../../src/dialog.js';45const items = new DataProvider({6 name: 'items',7 data: [],8});910bindHidden('#empty', items, (e) => ((e.data && e.data.length) || 0) > 0);11bindHidden('#list-card', items, (e) => !((e.data && e.data.length) || 0));12bindList('#item-list', items, {13 template: '#tpl-item',14 items: (e) => e.data || [],15 apply(frag, item) {16 frag.querySelector('.pk-list-title').textContent = item.title;17 frag.querySelector('.pk-list-meta').textContent = item.meta;18 frag.querySelector('[data-remove]').setAttribute('data-remove', item.id);19 },20});2122function openCreate() {23 setFieldError('#field-title', '');24 openDialog('#create-dialog');25 const input = document.querySelector('#create-form [name="title"]');26 if (input) input.focus();27}2829document.getElementById('open-create').addEventListener('click', openCreate);30document.getElementById('open-create-empty').addEventListener('click', openCreate);31document.getElementById('cancel-create').addEventListener('click', () => {32 closeDialog('#create-dialog');33});3435document.getElementById('create-form').addEventListener('submit', (evt) => {36 evt.preventDefault();Items
An empty state, then a list. Create and remove use dialogs on this page.
demo/recipes/items.js

table.js
View on GitHub
69const items = new DataProvider({70 name: 'items',71 load(_provider, query) {72 const term = String((query && query.q) || '').trim().toLowerCase();73 const requested = Math.max(1, Number(query && query.page) || 1);74 const matched = CATALOG.filter((it) => {75 if (!term) return true;76 return (77 it.name.toLowerCase().includes(term) ||78 it.plan.toLowerCase().includes(term) ||79 it.state.toLowerCase().includes(term)80 );81 });82 const pages = Math.max(1, Math.ceil(matched.length / PAGE_SIZE) || 1);83 const current = Math.min(requested, pages);84 const start = (current - 1) * PAGE_SIZE;85 const rows = matched.slice(start, start + PAGE_SIZE);86 return {87 q: (query && query.q) || '',88 page: current,89 pages: matched.length ? pages : 0,90 total: matched.length,91 items: rows,92 };93 },94});Table
A bound tbody. Search, pager, select, and dialogs stay on this URL and call load again.
demo/recipes/table.js

settings.js
View on GitHub
5const account = new DataProvider({6 name: 'account',7 data: {8 name: 'Ada',9 'given-name': 'Ada',10 'family-name': 'Lovelace',11 email: 'ada@example.test',12 tel: '+1 555 0100',13 'street-address': '1 Example Way',14 'address-level2': 'Springfield',15 'address-level1': 'IL',16 'postal-code': '62701',17 country: 'US',18 notices: true,19 plan: 'standard',20 seats: '3',21 status: 'active',22 },23});2425const form = document.getElementById('settings-form');26const bannerOk = document.getElementById('banner-ok');2728bindField(form.elements['name'], account, 'name');29bindField(form.elements['given-name'], account, 'given-name');30bindField(form.elements['family-name'], account, 'family-name');31bindField(form.email, account, 'email');32bindField(form.tel, account, 'tel');33bindField(form.elements['street-address'], account, 'street-address');34bindField(form.elements['address-level2'], account, 'address-level2');35bindField(form.elements['address-level1'], account, 'address-level1');36bindField(form.elements['postal-code'], account, 'postal-code');37bindField(form.country, account, 'country');38bindField(form.notices, account, 'notices');39bindText('#plan-name', account, (e) => (e.data && e.data.plan) || '');40bindText('#plan-seats', account, (e) => (e.data && e.data.seats) || '');41bindText('#plan-status', account, (e) => (e.data && e.data.status) || '');42Settings
A form, autofill tokens, and a read-only plan. English stays in the markup.
demo/recipes/settings.js

activity.js
View on GitHub
31const item = new DataProvider({32 name: 'item',33 data: {34 title: 'Widget',35 status,36 events: empty ? [] : EVENTS,37 },38});3940function eventsOf(e) {41 return (e.data && e.data.events) || [];42}4344function setZone(next, note) {45 zone = timeZone(next);46 const localBtn = document.getElementById('zone-local');47 const utcBtn = document.getElementById('zone-utc');48 if (localBtn) localBtn.setAttribute('aria-pressed', zone === 'local' ? 'true' : 'false');49 if (utcBtn) utcBtn.setAttribute('aria-pressed', zone === 'utc' ? 'true' : 'false');50 paintTimes(document.getElementById('activity'), zone);51 if (note) noteZone('#zone-note', zone);52}5354bindText('#item-title', item, (e) => (e.data && e.data.title) || 'Item');55bindHidden('#empty', item, (e) => eventsOf(e).length > 0);56bindHidden('#log-card', item, (e) => eventsOf(e).length === 0);57bindList('#activity', item, {58 template: '#tpl-event',59 items: eventsOf,60 apply(frag, event) {61 const time = frag.querySelector('time');62 const at = event.at || '';63 time.setAttribute('datetime', at);64 time.textContent = formatTime(at, zone) || at;65 frag.querySelector('.pk-timeline-title').textContent = event.title || '';66 frag.querySelector('.pk-timeline-meta').textContent = event.meta || '';67 },68});Activity
Facts, a status stepper, and a bound log. Local and UTC are painted from time elements.
demo/recipes/activity.js

room.js
View on GitHub
83const live = new LiveProvider({84 name: 'room',85 url: 'wss://example.test/rooms/1',86 ws: DemoSocket,87 tick: 'seconds',88});8990bindText('#live-status', live, (e) => e.status);91bindText('#live-meta', live, (e) => (e.meta && e.meta.source) || 'none');92bindText('#room-title', live, (e) => (e.data && e.data.title) || 'Room');93bindText('#room-pill', live, (e) => (e.data && e.data.status) || e.status);94bindClass('#room-pill', live, 'pk-pill-ok', (e) => e.status === 'open');95bindClass('#room-pill', live, 'pk-pill-warn', (e) => e.status !== 'open');96bindText('#room-timer', live, (e) => formatSeconds(e.data && e.data.seconds));97bindHidden('#dropped', live, (e) => !e.meta || e.meta.source !== 'send-dropped');9899bindList('#option-grid', live, {100 template: '#tpl-option',101 items: (e) => (e.data && e.data.options) || [],102 apply(frag, item, evt) {103 const btn = frag.querySelector('.pk-selectable');104 btn.dataset.id = item.id;105 btn.setAttribute(106 'aria-pressed',107 evt.data && evt.data.selected === item.id ? 'true' : 'false'108 );109 frag.querySelector('.pk-selectable-cat').textContent = item.category;110 frag.querySelector('.pk-selectable-title').textContent = item.title;111 frag.querySelector('.pk-selectable-cost').textContent = 'cost ' + item.cost;112 },113});Room
A live page. LiveProvider owns the socket. The page is still one document, not a router.
demo/recipes/room.js