/** * openvibe-shared/kit.js — the OpenVibe Kit: the pieces an app page is made of, one look on every OpenVibe site (and on * anyone's: the package is MIT), phone first. Draw with /shared/kit.css. docs/kit.md describes every piece and how it * behaves on a phone, a tablet and a computer. * * The owner, 2026-10-28: "the pages are so dang long… use tabs or collapsible areas… fixed position elements… on a * phone it should act like an app, not a huge long web page… one app ecosystem across all our systems." So a page is: * a sticky head (back, title, one muted line, icon actions) with its tabs, content in lists of rows and cards that fold, * a dock pinned at the bottom for the one thing you type, and the navbar's app bar under it on phones. * * The same file runs on the server (require('openvibe-shared/kit'): builders return HTML strings, every text escaped, * so pages are complete without JavaScript) and in the browser (window.OpenVibeKit: the same builders for pages that * draw themselves). The behaviour (copy buttons, menus that close, Enter to send, sheets, confirms) is kit-web.js, a * small file of its own: a server-rendered site loads only that; a page that draws itself loads both and calls * OpenVibeKit.enhance(container) after adding markup. * * const kit = require('openvibe-shared/kit'); * kit.head({ title: 'Settings', sub: 'Saved to your account', back: { href: '/', label: 'Home' }, * actions: [kit.iconButton({ href: '/help', icon: 'help', label: 'Help' })], * tabs: kit.tabs({ base: '/settings', items: [{ key: 'look', label: 'Look' }, …], active }) }) * kit.tabOf(req.query, ['look', 'sound']) → the open tab (the first when none or unknown) * kit.list([kit.row({ href, mark: kit.mark({ name }), title, meta: kit.when(ts).text, sub, unread: 2 }), …]) * kit.section({ title: 'Messages look', body: kit.switchRow({ name, icon, title, line, checked }) + kit.rangeRow(…) + … }) * kit.segmented({ name, items: [{ value, label, icon }] }) three or four equal choices in one control * kit.fold({ id, title, meta, open, body }) a card that opens: setup, extras, danger * kit.find({ action, name, placeholder, icon: 'search' }) a search or "add by name" row * kit.dock({ action, name, placeholder, label }) the composer pinned at the bottom * kit.stats([[1234, 'messages this week'], …]) a strip of numbers (scrolls sideways on phones) * kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items }) * kit.button({ href | type, label, icon, kind }) · kit.share({ title, url, kind, id }) · kit.askAgent({ text }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.segmented(…) · kit.icon(name) * kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names) * kit.mediaW(url, width) a Media object at a resize width (64/96/160/320/480/640) * kit.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar, * body }) the left sidebar: vertical navigation, a drawer on phones * * The tool frame, one order for every tool page: kit.tool({ head: kit.toolHead({ icon, hue, crumb, title, task, facts }), * input: kit.toolInput({ name, label, placeholder, button, tries: { items } }) | kit.drop(…) | an editor, * answer: kit.answer({ title, badge, rows: [[label, value, note]], next }), family: kit.family({ title, more, items }), * folds: [kit.fold(…)], related: kit.list(…) }) * * Text arguments are escaped; arguments named body, html, before, after, actions, trail, badge, extra and mark take * trusted markup (usually another builder's output). URLs are kept only when they are http(s) or same-site paths. */ (function (root, factory) { const api = factory(root); if (typeof module === 'object' && module.exports) { module.exports = api; return; } root.OpenVibeKit = api; })(typeof globalThis !== 'undefined' ? globalThis : this, function (root) { 'use strict'; const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); /** A URL kept only when it is http(s), a same-site path, a query or a fragment; anything else becomes '#'. */ const url = (u) => { const s = String(u == null ? '' : u).trim(); return /^(https?:\/\/|\/(?!\/)|\?|#)/i.test(s) ? s : '#'; }; // A Media object (openvibe.media/o/med_…) at a width from Media's resize ladder (64, 96, 160, 320, 480, 640); any other // URL as url() gives it. Avatars and pictures are stored as uploaded: Report and Community drew a 3072 px, 3 MB // avatar at 34 px (4.87.0). const mediaW = (u, w) => { const s = url(u); return /^https:\/\/openvibe\.media\/o\/med_[0-9A-Za-z]+$/.test(s) ? `${s}?w=${Number(w) || 160}` : s; }; const attrs = (o) => Object.keys(o || {}).filter((k) => o[k] != null && o[k] !== false && /^[a-z][a-z0-9-]*$/i.test(k)).map((k) => (o[k] === true ? ` ${k}` : ` ${k}="${esc(o[k])}"`)).join(''); // ── Icons: 24-unit line drawings in the text's colour (stroke 2, round), one set for every site ── const P = { back: '', forward: '', chevron: '', up: '', down: '', close: '', plus: '', minus: '', check: '', send: '', search: '', more: '', menu: '', sidebar: '', filter: '', home: '', chat: '', mail: '', bell: '', user: '', members: '', invite: '', gear: '', pencil: '', trash: '', copy: '', open: '', link: '', download: '', upload: '', lock: '', key: '', ban: '', help: '', info: '', alert: '', clock: '', calendar: '', spark: '', star: '', heart: '', play: '', pause: '', stop: '', live: '', video: '', mic: '', image: '', file: '', folder: '', code: '', terminal: '', computer: '', phone: '', globe: '', tag: '', cart: '', coin: '', gift: '', trophy: '', flag: '', eye: '', 'eye-off': '', flame: '', map: '', book: '', game: '', leave: '', retry: '', task: '', steps: '', hand: '', shield: '', zap: '', cloud: '', server: '', sun: '', moon: '', grid: '', list: '', chart: '', swap: '', wave: '', shoe: '', palette: '', robot: '', }; // ── The glyphs the kit does not draw itself, generated from glyphs.js (scripts/build-kit-glyphs.js) ── // kit.icon has to draw every glyph a page can name: a page that asks for compass or binoculars gets the same // registry's drawing, not an empty . The block is generated and tested, never edited by hand. // const MORE = Object.freeze({ 'bell-ring': '', scissors: '', tree: '', pin: '', volume: '', mute: '', tent: '', food: '', train: '', water: '', dish: '', history: '', film: '', circle: '', sliders: '', leaf: '', clipboard: '', gauge: '', save: '', plug: '', puzzle: '', compress: '', expand: '', care: '', city: '', wrench: '', music: '', 'eye-off': '', plane: '', fire: '', nodes: '', bridge: '', 'check-circle': '', 'x-circle': '', 'alert-circle': '', news: '', camera: '', crown: '', wallet: '', chip: '', id: '', brain: '', snow: '', mountain: '', 'mic-off': '', database: '', layers: '', hash: '', text: '', enter: '', language: '', idea: '', dollar: '', target: '', 'arrow-right': '', 'arrow-left': '', 'arrow-up': '', 'arrow-down': '', shirt: '', route: '', reply: '', smile: '', archive: '', 'user-check': '', ticket: '', tack: '', store: '', keyboard: '', gavel: '', drop: '', wifi: '', branch: '', binoculars: '', telescope: '', microscope: '', gpu: '', cpu: '', memory: '', joystick: '', wand: '', 'layers-plus': '', restroom: '', shelter: '', 'hot-tub': '', umbrella: '', parking: '', skull: '', group: '', medical: '', shapes: '', headphones: '', drive: '', 'check-double': '', tv: '', toolbox: '', table: '', scale: '', road: '', money: '', 'heart-pulse': '', heading: '', fist: '', 'fast-forward': '', rewind: '', 'file-code': '', dice: '', contrast: '', briefcase: '', timeline: '', seedling: '', rocket: '', 'list-ordered': '', lifebuoy: '', infinity: '', inbox: '', door: '', card: '', storm: '', 'trend-up': '', 'trend-down': '', 'chevrons-left': '', 'chevrons-right': '', sort: '', 'sort-asc': '', share: '', 'arrows-h': '', move: '', focus: '', at: '', box: '', bug: '', megaphone: '', burst: '', 'camera-flip': '', 'camera-off': '', car: '', child: '', donate: '', record: '', 'plus-circle': '', 'minus-circle': '', clapper: '', 'clipboard-check': '', cc: '', 'cloud-off': '', commit: '', 'chat-off': '', compass: '', cookie: '', couch: '', cubes: '', ear: '', eraser: '', laugh: '', fish: '', finish: '', flask: '', 'folder-tree': '', skip: '', gem: '', ghost: '', grip: '', hammer: '', handshake: '', wizard: '', 'heart-broken': '', hourglass: '', lungs: '', 'link-off': '', mask: '', mug: '', paw: '', walk: '', 'phone-off': '', piggy: '', airplane: '', print: '', quote: '', radio: '', ranking: '', recycle: '', rss: '', scroll: '', signature: '', square: '', sword: '', columns: '', 'thumbs-up': '', undo: '', 'user-circle': '', 'user-lock': '', 'user-minus': '', 'user-pen': '', 'user-tag': '', 'user-off': '', weight: '', wind: '', window: '', 'window-restore': '', 'align-left': '', slash: '', 'book-open': '', busy: '', }); // const ALL = Object.freeze({ ...P, ...MORE }); const ICONS = Object.freeze(Object.keys(ALL)); /** Every icon's drawing (the inside of a 24-unit SVG), for openvibe-shared/glyphs and every kit builder. */ const PATHS = ALL; /** The drawings this file was built with, before the glyphs.js block: glyphs.js adds the rest. */ const KIT_PATHS = P; /** One icon as inline SVG (decorative: aria-hidden); '' for a name the set does not have. */ function icon(name, { size = null, cls = '' } = {}) { const d = PATHS[name]; if (!d) return ''; return ``; } // ── Time and marks ── const DAY = 86400000; const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; /** A time people read at a glance: 14:05 today, Yesterday, Mon this week, Oct 20 this year, else 2025-10-20 (UTC). */ function when(ts, now = Date.now()) { const s = ts instanceof Date ? ts.toISOString() : typeof ts === 'number' ? new Date(ts).toISOString() : String(ts || ''); const t = Date.parse(/[TZ]/.test(s) || !s.includes(' ') ? s : `${s.replace(' ', 'T')}Z`); if (!Number.isFinite(t)) return { iso: '', text: '' }; const d = new Date(t), n = new Date(now); const today = Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate()); let text; if (t >= today) text = d.toISOString().slice(11, 16); else if (t >= today - DAY) text = 'Yesterday'; else if (t >= today - 6 * DAY) text = WEEKDAYS[d.getUTCDay()]; else if (d.getUTCFullYear() === n.getUTCFullYear()) text = `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`; else text = d.toISOString().slice(0, 10); return { iso: d.toISOString(), text }; } /** A steady hue (0-359) for a name or id, so the same thing has the same colour everywhere. */ function hueOf(key) { let h = 0; for (const ch of String(key || '')) h = (h * 31 + ch.codePointAt(0)) % 360; return h; } /** A person's or a place's mark: their picture, or up to two letters on a colour from the name (square: a place). */ function mark({ name = '?', src = '', hue = null, square = false, size = null } = {}) { const cls = `ovk-mark${square ? ' ovk-mark--square' : ''}`; const st = size ? `--ovk-mark:${Number(size) || 40}px;` : ''; if (src && url(src) !== '#') return ``; const words = String(name || '?').replace(/[^\p{L}\p{N} ]/gu, ' ').trim().split(/\s+/).filter(Boolean); const letters = (words.length > 1 ? [...words[0]][0] + [...words[1]][0] : [...(words[0] || '?')].slice(0, 2).join('')).toUpperCase(); return ``; } // ── Controls ── /** A button or a link that looks like one. kind: 'primary' (default), 'quiet', 'danger', 'ghost'; small: a pill. */ // 4.204.0, buttons that show what they do and what is happening (docs/kit.md#buttons): kind 'hero' | 'soft' | 'live' // beside primary/quiet/ghost/danger; nudge (forward, back, add, open, down, refresh) moves the icon by meaning; // iconEnd puts it after the words; attention marks the one next step; badge a count; dot ok|warn|bad|live; hold // (ms) makes a destructive button hold-to-confirm; pressed makes it a toggle. function button({ href = null, type = 'submit', label = '', icon: ic = '', iconEnd = '', kind = 'primary', small = false, name = null, value = null, attrs: extra = null, nudge = null, attention = false, badge = null, dot = null, hold = null, pressed = null } = {}) { const cls = `ovk-btn ovk-btn--${esc(kind)}${small ? ' ovk-btn--small' : ''}`; const more = { ...(extra || {}) }; if (nudge) more['data-nudge'] = nudge; if (attention) more['data-attention'] = ''; if (badge != null && badge !== '' && badge !== 0) more['data-badge'] = Number(badge) > 99 ? '99+' : String(badge); if (dot) more['data-dot'] = dot; if (hold) more['data-hold'] = String(Math.max(400, Math.min(4000, Number(hold) || 1200))); if (pressed != null) more['aria-pressed'] = pressed ? 'true' : 'false'; extra = Object.keys(more).length ? more : null; const inner = `${ic ? icon(ic) : ''}${esc(label)}${iconEnd ? icon(iconEnd) : ''}`; if (href != null) return `${inner}`; return ``; } /** * The one Share button (4.114.0, docs/share.md): it opens the network's Share sheet (ov-share.js, which the navbar * loads on the first press). url is what is shared (else the page's address); kind and id add "Who can open it" for * the owner, where the service mounts openvibe-sdk/share's router. */ function share({ title = '', url: link = null, text = '', image = null, embed = null, kind = null, id = null, label = 'Share', small = false, quiet = true } = {}) { return button({ type: 'button', label, icon: 'upload', kind: quiet ? 'quiet' : 'primary', small, attrs: { 'data-ov-share-sheet': true, 'data-title': title || null, 'data-url': link ? url(link) : null, 'data-text': text || null, 'data-image': image ? url(image) : null, 'data-embed': embed ? url(embed) : null, 'data-kind': kind || null, 'data-id': id || null } }); } /** * "Ask your agent" (4.135.0, plan Z3): one chip any page can carry. A tap opens the person's Vibe in the Dock with the * question waiting in its box, this page's context included, for them to read and send; it never sends for them. * Signed out, the navbar sends them to sign in. text: the prefilled question ("About this stream: …"). */ function askAgent({ text = '', label = 'Ask your agent', small = true } = {}) { return button({ type: 'button', label, icon: 'spark', kind: 'quiet', small, attrs: { 'data-ov-ask': String(text || '').slice(0, 2000), class: null } }).replace('class="ovk-btn', 'class="ovk-ask ovk-btn'); } /** An icon-only control: a link (href), or a button (type) — it always carries its label for screen readers and a tooltip. */ function iconButton({ href = null, type = 'submit', icon: ic = 'more', label = '', name = null, value = null, kind = '', attrs: extra = null } = {}) { const cls = `ovk-icon-btn${kind ? ` ovk-icon-btn--${esc(kind)}` : ''}`; const a = ` aria-label="${esc(label)}" title="${esc(label)}"${attrs(extra)}`; if (href != null) return `${icon(ic)}`; return ``; } /** A one-button form (a POST action as an icon or a button), e.g. leave, delete, revoke. confirm: asked first (kit.enhance). */ function action({ action: to, method = 'post', hidden = {}, confirm = '', ...btn }) { const fields = Object.keys(hidden || {}).map((k) => ``).join(''); const control = btn.label && !btn.iconOnly ? button(btn) : iconButton(btn); return `
${fields}${control}
`; } /** A small badge beside a name: tone 'accent' | 'ok' | 'warn' | 'bad' | 'live' | '' (quiet). */ const badge = (text, tone = '') => `${esc(text)}`; /** An unread count: 1-9, then 9+. */ const dot = (n) => (Number(n) > 0 ? `${Number(n) > 9 ? '9+' : Number(n)}` : ''); // ── The page's head and its tabs ── /** * The sticky head every app page starts with: back (an icon), the title and one muted line, icon actions on the right, * and the page's tabs under it (kit.tabs). It stays under the navbar while the page scrolls. */ function head({ title = '', titleHtml = null, sub = '', subHtml = null, back = null, icon: ic = '', actions = [], tabs = '', level = 1, sticky = true } = {}) { const h = level === 2 ? 'h2' : 'h1'; return `
` + (back ? `${icon('back')}` : '') + (ic ? `${icon(ic)}` : '') + `
<${h}>${titleHtml != null ? titleHtml : esc(title)}${subHtml != null ? `

${subHtml}

` : sub ? `

${esc(sub)}

` : ''}
` + (actions && actions.length ? `
${actions.join('')}
` : '') + `
${tabs || ''}
`; } /** A page identity, with a named parent and a native, script-free mobile description disclosure. * actions accepts iconButton options or trusted control markup, like head(). */ function pageHead({ back = null, icon: ic = 'home', title = '', lede = '', actions = [] } = {}) { const tools = (actions || []).map(a => typeof a === 'string' ? a : iconButton(a)).join(''); return `
` + (back ? `${icon('back')}${esc(back.label || 'Back')}` : '') + `
${icon(ic)}

${esc(title)}

` + (tools ? `
${tools}
` : '') + `
` + (lede ? `

${esc(lede)}

` : '') // one copy, whole, on every screen + `
`; } // ── The left sidebar (L35) ── /** * An app's left sidebar, one for every OpenVibe product (the owner, 2026-10-31: "the navigation makes more sense * in a vertical way… part of the openvibe.design shared styling across everything"): the main action and search at * the top, the navigation as vertical rows (an icon and a name, the page you are on marked, a count or a dot), the * sections under it (conversations, recent things) and a foot. A column beside the page from 761 px; on a phone a * drawer that kit.sidebarButton opens and the scrim, its close button or Escape close (kit-web.js, boost-safe). * kit.sidebar({ id, label, action: { href, label, icon }, find: { action, name, placeholder, label }, * nav: [{ href, label, icon, iconHtml, here, count, dot }], * sections: [{ title, items: [{ href, label, meta, mark, here, dot }], empty }], foot }) * kit.sidebarButton({ id, label }) the button that opens it on a phone (shown only there) * kit.withSidebar({ sidebar, body }) the sidebar and the page side by side * dot: true, or 'busy' | 'needs' | 'bad'. mark and foot take trusted markup; iconHtml replaces a kit icon. */ function sidebar({ id = 'ovk-sidebar', label = 'Navigation', action: act = null, find: f = null, nav = [], sections = [], foot = '' } = {}) { const dotHtml = (d) => (d ? `` : ''); const link = (x) => `` + `${x.iconHtml != null ? x.iconHtml : icon(x.icon || 'chevron')}${esc(x.label)}` + `${Number(x.count) > 0 ? `${Number(x.count) > 99 ? '99+' : Number(x.count)}` : dotHtml(x.dot)}`; // A row's data-* attributes (a site finds its rows by them: a conversation's id), nothing else. const data = (a) => Object.entries(a || {}).filter(([k, v]) => /^data-[a-z0-9-]+$/.test(k) && v != null && v !== false).map(([k, v]) => ` ${k}="${esc(v)}"`).join(''); const item = (x) => `` + `${x.mark || ''}${esc(x.label)}${x.meta ? `${esc(x.meta)}` : ''}${dotHtml(x.dot)}` + `${x.sub ? `${esc(x.sub)}` : ''}`; const secs = (sections || []).map((sec) => { const items = sec.items || []; return `
${sec.title ? `

${esc(sec.title)}

` : ''}${items.map(item).join('')}` + `${!items.length && sec.empty ? `

${esc(sec.empty)}

` : ''}
`; }).join(''); return `'; } const sidebarButton = ({ id = 'ovk-sidebar', label = 'Menu' } = {}) => ``; const withSidebar = ({ sidebar: sb = '', body = '' } = {}) => `
${sb}
${body}
`; /** * Tabs as links (?tab=key, or ?=key): the server builds only the open one, so a long page becomes a few short * ones and nothing needs JavaScript. items: [{ key, label, count, icon, href }]. */ function tabs({ base = '', items = [], active = '', param = 'tab', label = 'Sections' } = {}) { if (!items || items.length < 2) return ''; const sep = String(base).includes('?') ? '&' : '?'; return ``; } /** The open tab from a query object: its value when it is one of keys, else the first key. */ function tabOf(query, keys, param = 'tab') { const v = query && query[param] != null ? String(query[param]) : ''; return keys.includes(v) ? v : keys[0]; } // ── Lists and rows ── /** A list of rows (kit.row), in one rounded panel; title: a small heading above it. */ function list(rows = [], { title = '', more = null, flush = false, empty = '' } = {}) { const headHtml = title ? `

${esc(title)}

${more ? `${esc(more.label || 'All')}` : ''}
` : ''; if (!rows.length) return empty ? `${headHtml}

${esc(empty)}

` : ''; return `${headHtml}
    ${rows.join('')}
`; } /** * One row: its mark, the title (with a badge) and the time on one line, one muted line under it, a trailing control. * href makes the whole row a link; without it the row is static and trail can hold its own forms. rel: the link's * rel ('nofollow ugc' for links people posted, 'noopener' off-site). */ function row({ href = null, rel = '', mark: markHtml = '', title = '', titleHtml = null, badge: badgeHtml = '', meta = '', metaIso = '', sub = '', subHtml = null, trail = '', unread = 0, attrs: extra = null } = {}) { const top = `${titleHtml != null ? titleHtml : esc(title)}${badgeHtml || ''}${meta ? (metaIso ? `` : `${esc(meta)}`) : ''}`; const subLine = subHtml != null || sub || unread ? `${subHtml != null ? subHtml : esc(sub)}${dot(unread)}` : ''; const main = `${markHtml || ''}${top}${subLine}`; const cls = `ovk-row${unread ? ' ovk-row--unread' : ''}`; if (href != null) return `
  • ${main}${trail ? `${trail}` : ''}
  • `; return `
  • ${main}${trail ? `${trail}` : ''}
  • `; } // ── Cards, sections, folds ── /** A card with a small heading (settings groups, a page's parts): body is markup; note: one muted line under the title. */ function section({ title = '', count = null, note = '', body = '', id = null, cls = '', icon: ic = '', actions = [] } = {}) { const hid = id ? `${id}-h` : null; const heading = title ? `${esc(title)}${count != null ? `${esc(count)}` : ''}` : ''; return `
    ` + (ic || actions.length ? `
    ${ic ? `${icon(ic)}` : ''}${heading}${actions.length ? `
    ${actions.join('')}
    ` : ''}
    ` : heading) + (note ? `

    ${esc(note)}

    ` : '') + body + '
    '; } /** A card that opens and closes (setup, extras, the danger zone): closed unless open; meta: a muted word after the title. */ function fold({ id = null, title = '', meta = '', open = false, body = '', icon: ic = 'chevron', tone = '' } = {}) { return `
    ${esc(title)}${meta ? ` · ${esc(meta)}` : ''}${icon(ic)}
    ${body}
    `; } /** A "more" menu: an icon that opens a small panel of links and one-button forms. items: [{ href | action, label, icon, danger, hidden }]. */ function more({ label = 'More', items = [], icon: ic = 'more' } = {}) { const item = (it) => { const inner = `${it.icon ? icon(it.icon) : ''}${esc(it.label)}`; const cls = `ovk-pop-item${it.danger ? ' ovk-pop-item--danger' : ''}`; if (it.action) return `
    ${Object.keys(it.hidden || {}).map((k) => ``).join('')}
    `; if (it.share) return `${inner}`; return `${inner}`; }; return `
    ${icon(ic)}
    `; } // ── Settings rows ── /** * A switch row: an icon tile, a title and one muted line on the left, an iOS-style switch on the right. The whole * row is the target (56 px), and the switch is the checkbox itself (role="switch"), so the form works without * JavaScript. `label`/`hint`/`on` are the older names, kept working. */ const switchRow = ({ name, icon: ic = null, title = null, line = null, label = null, hint = null, checked = null, on = null, value = '1' } = {}) => { const t = title != null ? title : (label != null ? label : ''); const l = line != null ? line : (hint || ''); const c = checked != null ? !!checked : !!on; return ``; }; /** A slider row (0-100 by 5 unless told otherwise); the value is spoken as a percent when unit is '%'. */ function rangeRow({ name, label, value = 0, min = 0, max = 100, step = 5, unit = '%' }) { const v = Number(value); return ``; } /** A choice row: the label on the left, a select on the right. options: [[value, label], …]. */ const selectRow = ({ name, label, hint = '', options = [], value = '' }) => ``; /** A segmented choice (2-4 options) as radios that look like one control. */ function seg({ name, options = [], value = '', label = '' }) { const id = `ovk-seg-${esc(name)}`; return `
    ${esc(label)}${options.map(([v, l]) => ``).join('')}
    `; } /** * A segmented choice (three or four) as radios that look like one control: items [{ value, label, icon }]. The * value still posts as the radio group's; the input is the browser's, only drawn differently, so it works without * JavaScript. Without a label the control stands alone (a form puts its own label above it) and `ariaLabel` names * the group; label draws a labelled row. */ function segmented({ name, items = [], value = '', label = '', ariaLabel = '' } = {}) { const id = `ovk-seg-${String(name).replace(/[^A-Za-z0-9_-]/g, '-')}`; const control = `${items.map((it) => ``).join('')}`; return label ? `
    ${esc(label)}${control}
    ` : control; } /** Chips: checkboxes (or radios: single) that look like small pills, for filters and "which of these". options: [[value, label, checked]]. */ function chips({ name, options = [], single = false, label = '' }) { const box = options.map(([v, l, on]) => ``).join(''); return label ? `
    ${esc(label)}${box}
    ` : `${box}`; } /** A form's last line that stays in reach (sticky above the dock/app bar on phones). */ const saveBar = ({ label = 'Save', name = null, value = null } = {}) => `

    ${button({ label, name, value })}

    `; // ── Search, add, compose ── /** * A one-line form in a pill: an icon, the input, optional extra controls (a select), and a round icon button. For * search (method get) and for "add by name" (post). hidden: { name: value } fields. */ function find({ action: to = '', method = 'get', name = 'q', placeholder = '', value = '', label = '', icon: ic = 'search', type = 'search', required = false, pattern = null, maxlength = null, extra = '', hidden = {}, button: btn = null, inset = false, id = null } = {}) { const fid = id || `ovk-find-${esc(name)}`; const b = btn || { icon: method === 'get' ? 'forward' : 'plus', label: method === 'get' ? 'Search' : 'Add' }; return ``; } /** * The composer pinned at the bottom of a conversation (above the app bar on phones): a one-line box that grows, Enter * sends (Shift+Enter: a new line; kit.enhance), a round send button. before: controls left of the box (attach). */ function dock({ action: to = '', name = 'message', placeholder = 'Write a message', label = 'Message', maxlength = 2000, id = 'ovk-dock', before = '', hidden = {}, send = 'Send', value = '' } = {}) { return `
    ${before || ''}
    ` + Object.keys(hidden || {}).map((k) => ``).join('') + `` + `
    `; } // ── Numbers, empty, notices, code ── /** A strip of numbers: [[value, label], …] (zero and unknown values left out); scrolls sideways on phones. */ function stats(items = [], { label = 'In numbers' } = {}) { const fmt = (v) => (typeof v === 'number' ? v.toLocaleString('en-US') : String(v)); const shown = items.filter((x) => x && x[0] != null && x[0] !== '' && x[0] !== 0); if (!shown.length) return ''; // tabindex: on a phone the row scrolls sideways, and a row with no link in it had no way in by keyboard // (axe scrollable-region-focusable, 2026-11-01 sweep). return `
    ${shown.map(([v, l, href]) => `
    ${href ? `${esc(l)}` : esc(l)}
    ${esc(fmt(v))}
    `).join('')}
    `; } /** * Content labels (L1, common.content-labels@1) as small chips beside a title: what a stream, recording, picture or * post is labelled as. Only the labels in force: an AI or viewer suggestion is the creator's to confirm, never shown * to viewers. The page also marks the labelled thing itself (data-ov-labels="…") so ov-labels.js applies each * viewer's own choice; these chips only say what it carries. * kit.labels(['language', '18+']) or kit.labels({ labels: [...], suggested: [...] }) */ const LABEL_NAMES = { nudity: 'Nudity', language: 'Strong language', graphic: 'Graphic', '18+': '18+', flashing: 'Flashing lights', spoilers: 'Spoilers' }; function labels(list, { label = 'Content labels' } = {}) { const arr = Array.isArray(list) ? list : (list && Array.isArray(list.labels) ? list.labels : []); const shown = [...new Set(arr)].filter((l) => Object.prototype.hasOwnProperty.call(LABEL_NAMES, l)); if (!shown.length) return ''; return `
      ${shown.map((l) => `
    • ${esc(LABEL_NAMES[l])}
    • `).join('')}
    `; } /** The value for data-ov-labels on the labelled element (empty when there is none). */ const labelsAttr = (list) => { const arr = Array.isArray(list) ? list : (list && Array.isArray(list.labels) ? list.labels : []); const shown = [...new Set(arr)].filter((l) => Object.prototype.hasOwnProperty.call(LABEL_NAMES, l)); return shown.length ? ` data-ov-labels="${esc(shown.join(','))}"` : ''; }; /** What an empty list says: a title, one line, and the one thing to do next. */ const empty = ({ title = '', text = '', action: act = null, icon: ic = '' } = {}) => `
    ${ic ? icon(ic) : ''}${title ? `

    ${esc(title)}

    ` : ''}${text ? `

    ${esc(text)}

    ` : ''}${act ? button({ href: act.href, label: act.label, kind: act.kind || 'primary', icon: act.icon || '' }) : ''}
    `; /** A notice in the page: kind 'ok' | 'error' | 'info' | 'warn'; errors are announced (role=alert). */ const notice = ({ kind = 'info', text = '', html = null } = {}) => `

    ${icon(kind === 'ok' ? 'check' : kind === 'error' || kind === 'warn' ? 'alert' : 'info')}${html != null ? html : esc(text)}

    `; /** A block of code people copy: the copy button in its corner (shown by kit.enhance), never taller than half a screen. */ function snippet({ code = '', id = null, label = 'Copy' } = {}) { const sid = id || `ovk-snip-${hueOf(code)}-${String(code).length}`; return `
    ${esc(code)}
    `; } /** A bottom sheet on phones, a dialog on computers: kit.sheetButton opens it (without JavaScript the sheet is a fold). */ const sheet = ({ id, title = '', body = '' }) => `

    ${esc(title)}

    ${body}
    `; const sheetButton = ({ sheet: sid, label = '', icon: ic = 'filter', iconOnly = true }) => (iconOnly ? `` : ``); // ── In the browser: the behaviour lives in kit-web.js (a server-rendered site loads only that); a page that draws // markup with these builders loads both and calls enhance() on what it added. A no-op on the server. const enhance = (rootEl) => (root.OpenVibeKitWeb ? root.OpenVibeKitWeb.enhance(rootEl) : undefined); // ── Cards and rails (plan 2026-10-28 §3.4): one card for every kind of thing, in rows that snap ── // The media's shape by what the card shows; `ratio` overrides it. const CARD_RATIO = { stream: '16/9', clip: '16/9', vod: '16/9', video: '16/9', run: '16/9', map: '16/9', cam: '16/9', tour: '16/9', trip: '16/9', event: '16/9', brief: '16/9', post: '16/9', meme: '1/1', shoe: '1/1', listing: '1/1', deal: '1/1', person: '1/1', toy: '4/3', game: '4/3', simulator: '4/3', report: '4/3', dashboard: '4/3', theme: '4/3', kit: '4/3', benchmark: '4/3', site: '4/3', domain: '2/1', }; // What a card without a picture draws in its media, by type. const CARD_ICON = { stream: 'live', clip: 'play', vod: 'play', video: 'play', run: 'wave', map: 'map', cam: 'video', tour: 'map', trip: 'map', event: 'calendar', brief: 'calendar', post: 'file', meme: 'image', shoe: 'shoe', listing: 'tag', deal: 'tag', person: 'user', toy: 'spark', game: 'game', simulator: 'game', report: 'chart', dashboard: 'chart', theme: 'palette', kit: 'palette', benchmark: 'chart', site: 'computer', domain: 'globe' }; const media = (u) => /^(https:\/\/|\/(?!\/))/.test(String(u || '').trim()); const fmtCount = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(n < 1e7 ? 1 : 0).replace(/\.0$/, '')}M` : n >= 1e4 ? `${Math.round(n / 1e3)}k` : n >= 1e3 ? `${(n / 1e3).toFixed(1).replace(/\.0$/, '')}k` : String(n)); /** * card({ href, title, type, ratio, image, alt, video, badge, live, corner, peek, who, avatar, count: [n, 'watching'], * when, level, save, remind, share, more: [items] }) — one thing, the same anatomy everywhere: * - media in the type's shape, a badge top-left (a word: NEW, −24 %, WR; live: true draws LIVE with a pulse) and a * corner bottom-right (a length or a price); peek: one line over the media on hover or focus; video: a muted * loop played while it is hovered (computers only, never under reduced motion, loaded only then) * - the title (two lines at most); sub: one plain muted line under it (a city, a kind, a language); then who (with * their picture or letters), one count and when; a count of 0 or none is left out, never shown as 0. whoHtml: the * name as markup the caller escaped (a name with its decorations); who stays the plain name for the letters * - preview: for a thing without a picture whose text is the point (a code paste, a note), its first lines drawn in * the media instead of the type's icon * - date: for a thing without a picture that happens on a day (a drop, an event, a deadline), the day itself drawn in * the media (weekday and month, the day large; UTC) instead of a calendar icon repeated on every card * - ⋯ with Save and Remind when given ({ href } or { action, hidden }), Share (always: the system share sheet, else * the link copied) and any `more` items (kit.more's) * The card is one link, the title's, stretched over it; ⋯ sits above it. level: the title's heading level (3). */ // A day as a card's cover (card({ date })): the weekday and month on a line, the day large under it (clear of the // corner's price). UTC, as the sites' dates are. function dateCover(value) { if (value == null || value === '') return ''; const t = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(String(value)); if (!Number.isFinite(t)) return ''; const d = new Date(t); return ``; } function card({ href = '#', title = '', type = '', ratio = '', image = '', alt = '', video = '', badge: bd = '', live = false, corner = '', peek = '', who = '', whoHtml = null, avatar = '', count = null, when: at = null, level = 3, save = null, remind = null, share = true, more: extra = null, sub = '', preview = '', date = null } = {}) { const ar = /^\d{1,2}\/\d{1,2}$/.test(String(ratio)) ? ratio : CARD_RATIO[type] || '16/9'; const link = url(href); const first = String(preview || '').split('\n').slice(0, 9).join('\n').slice(0, 480).trimEnd(); const pic = media(image) ? `${esc(alt)}` : first ? `` // No picture but a person (2026-11-01): their face, large, over a soft blur of it, where a lone type icon // in an empty box read as a missing image (Report's episodes, Tours' recordings). : media(avatar) ? `` : dateCover(date) || `${icon(CARD_ICON[type] || 'image')}`; const loop = media(video) ? `` : ''; const tag = live ? 'LIVE' : bd ? `${esc(bd)}` : ''; const n = Array.isArray(count) ? Number(count[0]) : Number(count); const counted = Number.isFinite(n) && n > 0 ? `${esc(fmtCount(n))}${Array.isArray(count) && count[1] ? ` ${esc(count[1])}` : ''}` : ''; const t = at != null && at !== '' ? when(at) : null; const meta = [whoHtml != null && whoHtml !== '' ? `${whoHtml}` : who ? `${esc(who)}` : '', counted, t && t.text ? `` : ''].filter(Boolean).join(''); const items = [ save ? { label: 'Save', icon: 'star', ...save } : null, remind ? { label: 'Remind me', icon: 'bell', ...remind } : null, share ? { label: 'Share', icon: 'open', href: link, share: true, title } : null, ].concat(extra || []).filter(Boolean); const h = Math.min(6, Math.max(2, Number(level) || 3)); return `
    ` + `
    ${pic}${loop}${tag}${corner ? `${esc(corner)}` : ''}${peek ? `${esc(peek)}` : ''}
    ` + `
    ${who || avatar ? mark({ name: who || title, src: avatar, size: 34 }) : ''}
    ` + `${esc(title)}${sub ? `

    ${esc(sub)}

    ` : ''}${meta ? `

    ${meta}

    ` : ''}
    ` + `${items.length ? more({ label: `More: ${title}`, items }) : ''}
    `; } /** * rail({ id, title, more: { href, label }, items: [card html], size: 's'|'m'|'l', label }) — a row that scrolls * sideways and snaps: about 1.2 cards on a phone, as many as fit on a computer, with ‹ › buttons there (kit.enhance). * size: 's' square things (memes, shoes, deals), 'm' (default) pictures 16:9, 'l' wide features. wrap: from 760 px * the row becomes a grid that shows every item (no arrows, no kit-web.js needed); phones still swipe. Empty → ''. */ function rail({ id = '', title = '', more: m = null, items = [], size = 'm', label = '', wrap = false, icon: ic = '' } = {}) { const list = (items || []).filter(Boolean); if (!list.length) return ''; const hid = id ? `${String(id).replace(/[^a-z0-9_-]/gi, '')}-h` : ''; const head = title || m ? `
    ${title ? `${ic ? icon(ic, { cls: 'ovk-heading-icon' }) : ''}${esc(title)}` : ''}${m && m.href ? (ic ? button({ href: m.href, label: m.label || 'See all', icon: m.icon || 'forward', kind: 'quiet' }) : `${esc(m.label || 'See all')}${icon('chevron')}`) : ''}
    ` : ''; const named = hid && title ? ` aria-labelledby="${hid}"` : ` aria-label="${esc(label || title || 'More')}"`; return `
    ${head}` + `
      ${list.map((x) => `
    • ${x}
    • `).join('')}
    ` + `` + `
    `; } // ── Kit 2 (4.36.0): the pieces data and tool pages need — steps, tables, charts, rankings, a drop zone, a result // with its receipt, a meter, and the page's whole shape in one call. Same rules: plain HTML that works without // JavaScript, escaped text, theme tokens, phone first (docs/kit.md "Kit 2"). /** A number the way a reader wants it: fmt 'int' | 'compact' | 'money' | 'percent' (0–100) | 'ms' | 'auto'. */ function num(v, fmt = 'auto') { const n = Number(v); if (v == null || v === '' || !Number.isFinite(n)) return '–'; if (typeof fmt === 'function') return String(fmt(n)); if (fmt === 'compact') return fmtCount(Math.round(n)); if (fmt === 'int') return Math.round(n).toLocaleString('en-US'); if (fmt === 'percent') return `${Math.abs(n) < 10 && n % 1 ? n.toFixed(1) : Math.round(n)}%`; if (fmt === 'ms') return n < 1000 ? `${Math.round(n)} ms` : `${(n / 1000).toFixed(n < 1e4 ? 1 : 0)} s`; if (fmt === 'money') { // Exact enough to compare: cents from ten cents up ($0.10, $12.50), two significant figures below ($0.027, $0.0041). const a = Math.abs(n); const s = a === 0 ? '0.00' : a >= 0.1 ? a.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(Number(a.toPrecision(2))); return `${n < 0 ? '−' : ''}$${s}`; } return n.toLocaleString('en-US', { maximumFractionDigits: Math.abs(n) < 10 ? 2 : Math.abs(n) < 100 ? 1 : 0 }); } /** * steps({ items: [{ label, href, note }], current: index, label }) — where someone is in a flow of a few steps. * Done steps link back (when they have an href), the current one says aria-current="step", later ones are plain. * A phone shows the dots and "Step 2 of 4 · Verify"; a tablet or computer shows every label. */ function steps({ items = [], current = 0, label = 'Steps' } = {}) { const list = (items || []).filter(Boolean); if (!list.length) return ''; const at = Math.min(Math.max(0, Number(current) || 0), list.length - 1); const li = list.map((s, i) => { const state = i < at ? 'is-done' : i === at ? 'is-current' : 'is-next'; const inner = `${esc(s.label)}${s.note ? `${esc(s.note)}` : ''}`; const sr = i < at ? ' (done)' : ''; return `
  • ${i < at && s.href ? `${inner}${sr}` : `${inner}${sr}`}
  • `; }).join(''); return ``; } /** * table({ columns: [{ key, label, num, fmt, primary, hide: 'sm', html }], rows: [{…}], caption, label, empty, stack }) * A real table on wide screens. Under 600 px each row becomes a card (stack, the default): the primary column is its * title and every other cell says its column's name. A cell is text (escaped), { text, href } (a link), or trusted * markup when its column says html: true. num: right-aligned, tabular figures, formatted with fmt (kit.num). */ function table({ columns = [], rows = [], caption = '', label = '', empty: none = '', stack = true, id = null } = {}) { const cols = (columns || []).filter((c) => c && c.key != null); if (!rows || !rows.length) return none ? `

    ${esc(none)}

    ` : ''; const primary = cols.find((c) => c.primary) || cols[0]; const cell = (c, v) => { if (c.html) return v == null ? '' : String(v); if (v && typeof v === 'object' && v.href) return `${esc(v.text != null ? v.text : v.href)}`; if (c.num) return esc(num(v, c.fmt || 'auto')); return esc(v == null ? '' : v); }; const cls = (c) => [c.num ? 'is-num' : '', c === primary ? 'is-primary' : '', c.hide === 'sm' ? 'is-hide-sm' : ''].filter(Boolean).join(' '); const head = `${cols.map((c) => `${esc(c.label)}`).join('')}`; const body = `${rows.map((r) => `${cols.map((c) => { const tag = c === primary ? 'th scope="row"' : 'td'; return `<${tag}${cls(c) ? ` class="${cls(c)}"` : ''} data-label="${esc(c.label)}">${cell(c, r[c.key])}`; }).join('')}`).join('')}`; const named = caption ? '' : ` aria-label="${esc(label || 'Table')}"`; return `
    ${caption ? `` : ''}${head}${body}
    ${esc(caption)}
    `; } /** Three to six round ticks between lo and hi (1, 2, 5 × 10ⁿ). */ function niceTicks(lo, hi, count = 4) { if (!(hi > lo)) { hi = lo === 0 ? 1 : lo + Math.abs(lo) * 0.1; } const raw = (hi - lo) / count; const mag = Math.pow(10, Math.floor(Math.log10(raw))); const f = raw / mag; const step = (f >= 7 ? 10 : f >= 3 ? 5 : f >= 1.5 ? 2 : 1) * mag; const a = Math.floor(lo / step + 1e-9) * step, b = Math.ceil(hi / step - 1e-9) * step; const ticks = []; for (let v = a; v <= b + step / 2; v += step) ticks.push(Number(v.toPrecision(12))); return { lo: a, hi: b, ticks }; } const HOUR = 3600000; const toMs = (x) => (x instanceof Date ? x.getTime() : typeof x === 'number' ? x : Date.parse(String(x))); /** Ticks on a time axis at round moments (hours, days, weeks, months), with labels that say only what changes. */ function timeTicks(lo, hi) { const span = hi - lo; const steps = [HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY, 14 * DAY]; const out = []; const d2 = (n) => String(n).padStart(2, '0'); if (span <= 70 * DAY) { const step = steps.find((s) => span / s <= 5) || 14 * DAY; const day = step >= DAY; for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) { const d = new Date(t); out.push([t, day ? `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}` : `${d2(d.getUTCHours())}:00`]); } return out; } const months = span <= 200 * DAY ? 1 : span <= 400 * DAY ? 3 : span <= 800 * DAY ? 6 : 12; const s = new Date(lo); let y = s.getUTCFullYear(), m = Math.ceil((s.getUTCMonth() + (s.getUTCDate() > 1 ? 1 : 0)) / months) * months; for (;;) { while (m >= 12) { m -= 12; y += 1; } const t = Date.UTC(y, m, 1); if (t > hi) break; out.push([t, m === 0 || months === 12 ? `${MONTHS[m]} ${y}` : MONTHS[m]]); m += months; } return out; } const pct = (v) => `${(Math.round(v * 100) / 100)}%`; /** * chart({ type: 'line' | 'area' | 'bar' | 'scatter', series, points, x: { time, label, fmt }, y: { label, fmt, zero }, * title, note, height, label }) * - line/area: series: [{ name, points: [[x, y], …] }] (y null = a gap). x.time: x is a time (ms, ISO or Date). * - bar: series: [{ name, points: [[label or time, value], …] }], one bar per point (counts per day, per kind). * - scatter: points: [{ x, y, label, href, mark: true }] — every point is focusable and says its values; mark: * true writes its label beside it. frontier: true draws the line through the points marked best. * The picture is an SVG stretched to the box (strokes keep their width); every word and number is real text at its * real size, so a phone reads it as well as a computer. A table with the same numbers is there for screen readers; * kit-web.js adds the hover and arrow-key readout on lines and areas. */ function chart({ type = 'line', series = [], points = [], x: xo = {}, y: yo = {}, title = '', note = '', height = 220, label = '', id = null } = {}) { const kind = ['line', 'area', 'bar', 'scatter'].includes(type) ? type : 'line'; const isTime = !!xo.time; const fx = (v) => (isTime ? (() => { const d = new Date(toMs(v)); return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}${xo.hours ? ` ${String(d.getUTCHours()).padStart(2, '0')}:00` : ''}`; })() : xo.fmt ? num(v, xo.fmt) : String(v)); const fy = (v) => num(v, yo.fmt || 'auto'); const name = title || label || 'Chart'; let plot = '', table = '', legend = '', data = null, yl = []; if (kind === 'scatter') { const ps = (points || []).filter((p) => p && Number.isFinite(Number(p.x)) && Number.isFinite(Number(p.y))); if (!ps.length) return ''; const xs = niceTicks(Math.min(...ps.map((p) => +p.x)) * (xo.zero === false ? 1 : 0), Math.max(...ps.map((p) => +p.x))); const ys = niceTicks(yo.zero === false ? Math.min(...ps.map((p) => +p.y)) : Math.min(0, ...ps.map((p) => +p.y)), Math.max(...ps.map((p) => +p.y))); const X = (v) => ((v - xs.lo) / (xs.hi - xs.lo)) * 100, Y = (v) => ((v - ys.lo) / (ys.hi - ys.lo)) * 100; const best = ps.filter((p) => p.best).sort((a, b) => a.x - b.x); const line = best.length > 1 ? `` : ''; const dots = ps.map((p) => { const say = `${p.label ? `${p.label}: ` : ''}${yo.label || 'y'} ${fy(p.y)}, ${xo.label || 'x'} ${fx(p.x)}`; const tag = p.href ? `a href="${esc(url(p.href))}"` : 'span tabindex="0"'; const right = X(+p.x) > 70; return `<${tag} class="ovk-chart-dot${p.best ? ' is-best' : ''}${right ? ' is-right' : ''}" style="left:${pct(X(+p.x))};bottom:${pct(Y(+p.y))}" aria-label="${esc(say)}">` + `${p.mark && p.label ? `` : ''}`; }).join(''); yl = ys.ticks.map(fy); plot = axes(xs.ticks.map((t) => [X(t), fx(t)]), ys.ticks.map((t) => [Y(t), fy(t)])) + line + dots; table = `${esc(xo.label ? 'Name' : '')}${esc(xo.label || 'x')}${esc(yo.label || 'y')}${ps.map((p) => `${esc(p.label || '')}${esc(fx(p.x))}${esc(fy(p.y))}`).join('')}`; } else { const ss = (series || []).filter((s) => s && Array.isArray(s.points) && s.points.length); if (!ss.length) return ''; if (kind === 'bar') { const s = ss[0]; const val = (p) => (p[1] == null || p[1] === '' ? NaN : Number(p[1])); // missing is not 0 const vals = s.points.map(val).filter(Number.isFinite); const ys = niceTicks(Math.min(0, ...vals), Math.max(0, ...vals)); const Y = (v) => ((v - ys.lo) / (ys.hi - ys.lo)) * 100; const every = Math.max(1, Math.ceil(s.points.length / 6)); const bars = s.points.map((p, i) => { const v = val(p); const lab = isTime ? fx(p[0]) : String(p[0]); return `${i % every === 0 ? `` : ''}`; }).join(''); yl = ys.ticks.map(fy); plot = axes([], ys.ticks.map((t) => [Y(t), fy(t)])) + `
    ${bars}
    `; table = `${esc(xo.label || '')}${esc(s.name || yo.label || '')}${s.points.map((p) => `${esc(isTime ? fx(p[0]) : p[0])}${esc(fy(p[1]))}`).join('')}`; } else { const all = ss.flatMap((s) => s.points); const xv = all.map((p) => (isTime ? toMs(p[0]) : Number(p[0]))).filter(Number.isFinite); const yNum = all.map((p) => (p[1] == null ? NaN : Number(p[1]))).filter(Number.isFinite); if (!xv.length || !yNum.length) return ''; const x0 = Math.min(...xv), x1 = Math.max(...xv); const ys = niceTicks(yo.zero === false ? Math.min(...yNum) : Math.min(0, ...yNum), Math.max(...yNum)); const X = (v) => (x1 > x0 ? ((v - x0) / (x1 - x0)) * 100 : 50), Y = (v) => ((v - ys.lo) / (ys.hi - ys.lo)) * 100; const xt = isTime ? timeTicks(x0, x1).map(([t, l]) => [X(t), l]) : niceTicks(x0, x1).ticks.filter((t) => t >= x0 && t <= x1).map((t) => [X(t), fx(t)]); const paths = ss.map((s, i) => { const pts = s.points.map((p) => [X(isTime ? toMs(p[0]) : Number(p[0])), p[1] == null ? null : Y(Number(p[1]))]); let d = '', open = false; for (const [px, py] of pts) { if (py == null || !Number.isFinite(py)) { open = false; continue; } d += `${open ? 'L' : 'M'}${(px * 10).toFixed(1)},${(1000 - py * 10).toFixed(1)}`; open = true; } const firstX = pts.find((p) => p[1] != null), lastX = [...pts].reverse().find((p) => p[1] != null); const area = kind === 'area' && firstX && lastX && !pts.some((p) => p[1] == null) ? `` : ''; return `${area}`; }).join(''); yl = ys.ticks.map(fy); plot = axes(xt, ys.ticks.map((t) => [Y(t), fy(t)])) + ``; // The readout's data (kit-web.js): x positions and labels, and per series its y positions and values. const xsAll = [...new Set(ss[0].points.map((p) => (isTime ? toMs(p[0]) : Number(p[0]))))]; data = { x: xsAll.map((v) => [Math.round(X(v) * 100) / 100, fx(v)]), s: ss.map((s, i) => ({ n: s.name || '', c: (i % 5) + 1, v: xsAll.map((xv0) => { const p = s.points.find((q) => (isTime ? toMs(q[0]) : Number(q[0])) === xv0); return p && p[1] != null ? [Math.round(Y(Number(p[1])) * 100) / 100, fy(p[1])] : null; }) })) }; const last = (s) => [...s.points].reverse().find((p) => p[1] != null); legend = `
      ${ss.map((s, i) => `
    • ${esc(s.name || '')}${last(s) ? ` ${esc(fy(last(s)[1]))}` : ''}
    • `).join('')}
    `; table = `${esc(xo.label || '')}${ss.map((s) => `${esc(s.name || '')}`).join('')}${xsAll.map((xv0) => `${esc(fx(xv0))}${ss.map((s) => { const p = s.points.find((q) => (isTime ? toMs(q[0]) : Number(q[0])) === xv0); return `${esc(p ? fy(p[1]) : '–')}`; }).join('')}`).join('')}`; } } // The left gutter fits the longest y label (about 7 px a character at the axis's size). const gutter = Math.min(72, 14 + 7 * Math.max(2, ...yl.map((t) => String(t).length))); const cap = title || note ? `
    ${title ? `${esc(title)}` : ''}${note ? `${esc(note)}` : ''}
    ` : ''; const axisNames = yo.label ? `` : ''; return `
    ${cap}${legend}${axisNames}` + `` + `${xo.label ? `` : ''}
    ${table}
    ${esc(name)}
    `; } /** The grid and the tick labels: x [[pos%, text]], y [[pos%, text]] — real text, placed by percent. */ function axes(xt, yt) { return yt.map(([p, t]) => ``).join('') + xt.map(([p, t]) => ``).join(''); } /** * rank({ items: [{ name, href, mark, value, fmt, interval, delta, note, badge }], fmt, base, label, start }) * A leaderboard: the place, the name, the value (± its interval), the change since last time, and a bar in * proportion (from `base`, 0 by default — give the floor for a rating that never nears 0, e.g. an Elo). */ function rank({ items = [], fmt = 'auto', base = 0, label = 'Ranking', start = 1, unit = '' } = {}) { const list = (items || []).filter((x) => x && x.name != null); if (!list.length) return ''; const top = Math.max(...list.map((x) => Number(x.value)).filter(Number.isFinite), base + 1e-9); return `
      ${list.map((x, i) => { const v = Number(x.value); const share = Number.isFinite(v) ? Math.max(0, Math.min(1, (v - base) / (top - base))) : 0; const d = Number(x.delta); const delta = Number.isFinite(d) && d !== 0 ? `${icon(d > 0 ? 'up' : 'down')}${Math.abs(d)}` : x.delta === 'new' ? 'new' : ''; const nameHtml = x.href ? `${esc(x.name)}` : `${esc(x.name)}`; return `
    1. ${x.mark || ''}` + `${nameHtml}${x.badge || ''}${x.note ? `${esc(x.note)}` : ''}` + `${esc(x.display != null ? x.display : num(v, x.fmt || fmt))}${unit ? ` ${esc(unit)}` : ''}${x.interval != null ? `± ${esc(num(x.interval, x.fmt || fmt))}` : ''}${delta}` + `
    2. `; }).join('')}
    `; } /** * meter({ value, max, label, text, tone }) — how full something is. tone 'ok' | 'warn' | 'bad', or 'auto' for use of * a limit (a quota, a budget, a disk): amber from 75 %, red from 90 %. Without a tone it is the accent, which suits * a level where more is better (a confidence, a progress). */ function meter({ value = 0, max = 100, label = '', text = '', tone = '' } = {}) { const v = Math.max(0, Math.min(Number(max) || 100, Number(value) || 0)); const share = v / (Number(max) || 100); const t = tone === 'auto' ? (share >= 0.9 ? 'bad' : share >= 0.75 ? 'warn' : '') : tone; return ``; } /** * drop({ name, accept, label, hint, multiple, camera, required, id, auto }) — the place a file goes: a real file input * (it works without JavaScript), with drag and drop, paste and, on phones, the camera added by kit-web.js. auto: * the form is sent as soon as a file is chosen. Put it inside the form that receives the file. */ function drop({ name = 'file', accept = '', label = 'Choose a file', hint = '', multiple = false, camera = false, required = false, id = null, auto = false } = {}) { const fid = id || `ovk-drop-${esc(name)}`; const cam = camera ? `` : ''; return `
    ` + `${cam}
    `; } /** * receipt({ title, body, facts: [[label, value]], actions: [html], tone }) — a result and what it cost: the answer * first, then one quiet line of facts (model, cost, time, confidence, how long it is kept). A value can be * { html } (trusted markup, e.g. a kit.meter). */ function receipt({ title = '', body = '', facts = [], actions = [], tone = '', id = null } = {}) { const fs2 = (facts || []).filter((f) => f && f[1] != null && f[1] !== ''); const acts = (actions || []).filter(Boolean).join(''); return `
    ` + `${title || acts ? `
    ${title ? `

    ${esc(title)}

    ` : ''}${acts ? `${acts}` : ''}
    ` : ''}` + `
    ${body}
    ` + `${fs2.length ? `
    ${fs2.map(([l, v]) => `
    ${esc(l)}
    ${v && typeof v === 'object' && v.html != null ? v.html : esc(v)}
    `).join('')}
    ` : ''}
    `; } // ── The tool frame (4.55.0, owner ask 11): one order for every tool page on every site ── // What it does, the box you type in, the answer and its next step, the family's other tools, then the rest folded // away. Phone first; from 1024 px the family sits in a column beside the tool. docs/kit.md "The tool frame". /** The tool's terms as a short list: text, or { text, icon }; blanks are left out. */ function factsList(facts) { const fs = (Array.isArray(facts) ? facts : [facts]).map((f) => (f && typeof f === 'object' ? f : { text: f })).filter((f) => f.text != null && String(f.text).trim()); return fs.length ? `
      ${fs.map((f) => `
    • ${f.icon ? icon(f.icon) : ''}${esc(f.text)}
    • `).join('')}
    ` : ''; } /** A family's tint as a hue (0–360): a number, or a key hashed the way kit.mark hashes names. */ const hueFor = (h, key) => (h != null && h !== '' && Number.isFinite(Number(h)) ? ((Math.round(Number(h)) % 360) + 360) % 360 : hueOf(key || '')); /** * toolHead({ icon, iconHtml, hue, family, crumb: { href, label }, title, task, facts, level }) — the tool's name and the * one sentence saying what it does. The icon sits on a tile in the family's tint (hue, else one hashed from family or * the crumb); buttons keep the site's own accent. facts: the plain terms, in a short line under the sentence ('Free', * 'Runs in your browser: nothing is uploaded', 'Files up to 50 MB', 'Deleted after 1 hour'); the market research's * first ask of a tool page is to say its limits, where the work happens and what it costs (4.56.0). A fact is text, * or { text, icon } with a kit icon name. */ function toolHead({ icon: ic = 'grid', iconHtml = null, hue = null, family: fam = '', crumb = null, title = '', task = '', facts = [], level = 1 } = {}) { const lv = Math.min(3, Math.max(1, Number(level) || 1)); const cr = crumb && crumb.label ? (crumb.href ? `${esc(crumb.label)}${icon('chevron')}` : `${esc(crumb.label)}`) : ''; return `
    ` + `${cr}` + `${esc(title)}${task ? `

    ${esc(task)}

    ` : ''}` + factsList(facts) + '
    '; } /** * tries({ name, target, run, items: [{ label, value, href }] }) — "Try" examples under the box: a tap fills it and * runs the tool (kit-web.js). Without JavaScript each is a link to ?=, so a tool page runs the tool its * query names. target: the box (a selector; default the [name] field in the same form); run: a button to press when * the box is in no form. */ function tries({ name = 'q', target = '', run = '', items = [], label = 'Try' } = {}) { const its = (items || []).filter((it) => it && (it.value != null || it.href)); if (!its.length) return ''; return `

    ${esc(label)}${its.map((it) => { const v = it.value == null ? '' : String(it.value); const href = it.href ? url(it.href) : `?${encodeURIComponent(name)}=${encodeURIComponent(v)}`; return `${esc(it.label || v)}`; }).join('')}

    `; } /** * toolInput({ action, method, name, label, placeholder, value, button, icon, type, inputmode, id, required, pattern, * maxlength, hidden, tries }) — the one box most tools need: a big field (17 px, so phones do not zoom in), a clear * button, and the tool's button under it on a phone and beside it on a computer. */ function toolInput({ action: to = '', method = 'get', name = 'q', label = '', placeholder = '', value = '', button: btn = 'Go', icon: ic = 'search', type = 'text', inputmode = null, id = null, required = true, pattern = null, maxlength = null, hidden = {}, tries: tr = null, autocomplete = 'off' } = {}) { const fid = id || `ovk-tool-${String(name).replace(/[^a-z0-9_-]/gi, '')}`; const t = ['text', 'search', 'url', 'email', 'number', 'tel'].includes(type) ? type : 'text'; return `
    ` + Object.keys(hidden || {}).map((k) => ``).join('') + `${label ? `` : ''}` + `
    ` + `` + `
    ` + (tr ? tries({ name, target: `#${fid}`, ...tr }) : '') + '
    '; } /** * answer({ title, badge: { text, tone }, actions: [html], rows: [[label, value, note]], body, next: [html], tone, id }) * — a tool's answer: labelled rows with the fact that matters marked (note: text or { text, tone }, a small pill), * then the next step (watch it for changes, look up the same name's DNS, compress the picture just made). A value * can be { html } (trusted markup). tone 'bad' draws an error answer. */ function answer({ title = '', badge: bd = null, actions = [], rows = [], body = '', next = [], tone = '', id = null, live = true } = {}) { const rs = (rows || []).filter((r) => r && r[1] != null && r[1] !== ''); const acts = (actions || []).filter(Boolean).join(''); const nx = (next || []).filter(Boolean).join(''); const pill = (n) => { if (n == null || n === '') return ''; const o = typeof n === 'object' ? n : { text: n }; return ` ${esc(o.text)}`; }; const val = (v) => (v && typeof v === 'object' && v.html != null ? v.html : esc(v)); return `
    ` + (title || (bd && bd.text) || acts ? `
    ${title ? `

    ${esc(title)}

    ` : ''}${bd && bd.text ? badge(bd.text, bd.tone || '') : ''}${acts ? `${acts}` : ''}
    ` : '') + (rs.length ? `
    ${rs.map(([l, v, n]) => `
    ${esc(l)}
    ${val(v)}${pill(n)}
    `).join('')}
    ` : '') + (body ? `
    ${body}
    ` : '') + (nx ? `
    ${nx}
    ` : '') + '
    '; } /** * family({ title, more: { href, label }, items: [{ href, label, short, current }] }) — the family's other tools: one * row that scrolls sideways on a phone (short names), a list beside the tool on a computer (full names). */ function family({ title = '', more: m = null, items = [], label = '' } = {}) { const its = (items || []).filter((it) => it && it.label); if (!its.length) return ''; return ``; } /** * tool({ head, input, answer, family, folds: [html], related, id }) — the whole frame. input: the card's markup * (kit.toolInput, kit.drop, an editor); answer: '' until there is one (the .ovk-tool-out slot stays, so a page that * draws its answer later fills it); folds: kit.fold()s joined into one card (About, Questions, Use it from code); * related: a kit.list of tools people also use. Folded text stays in the page for search engines. */ function tool({ head: h = '', input = '', answer: a = '', family: f = '', folds = [], related = '', id = null } = {}) { const fl = (Array.isArray(folds) ? folds : [folds]).filter(Boolean).join(''); return `
    ${h}` + (input ? `
    ${input}
    ` : '') + `
    ${a || ''}
    ${f}` + (fl ? `
    ${fl}
    ` : '') + (related ? `` : '') + '
    '; } /** * page({ head, body, side, dock, wide, id }) — the app page's whole shape in one call: the sticky head (with its tabs), * the content, a side panel beside it on a computer (under it on a phone), and the dock. */ const page = ({ head: h = '', body = '', side = '', dock: d = '', wide = false, id = 'main' } = {}) => `
    ${h}${side ? `
    ${body}
    ` : body}${d}
    `; /** * Placeholders shaped like the content on its way (4.70.0, plan L52; the owner, 2026-11-01: "placeholder elements while * it loads, a fake thumbnail, so it is not jarring"). Same sizes as the real thing, so it lands without a jump; a soft * shimmer (never a spinner), still under reduced motion; announced once as busy. * skeleton({ kind: 'card' | 'row' | 'tile' | 'chip' | 'line' | 'chat' | 'stat', count = 3, label = 'Loading' }) */ function skeleton({ kind = 'row', count = 3, label = 'Loading' } = {}) { const n = Math.max(1, Math.min(48, Number(count) || 1)); const one = { card: '
    ', row: '
    ', tile: '', chip: '', line: '', chat: '
    ', stat: '
    ', }[kind] || ''; return `
    ${one.repeat(n)}
    `; } return { skeleton, esc, url, mediaW, icon, ICONS, PATHS, KIT_PATHS, when, hueOf, mark, button, share, askAgent, iconButton, action, badge, dot, card, rail, CARD_RATIO, head, pageHead, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar, switchRow, rangeRow, selectRow, seg, segmented, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton, enhance, VERSION: 2, num, steps, table, chart, rank, meter, drop, receipt, page, toolHead, toolInput, tries, answer, family, tool, }; });