openvibers/openvibe.shared kit.js

kit.js

main 124.6 KB · 1173 lines · javascript

1/**
2 * openvibe-shared/kit.js — the OpenVibe Kit: the pieces an app page is made of, one look on every OpenVibe site (and on
3 * anyone's: the package is MIT), phone first. Draw with /shared/kit.css. docs/kit.md describes every piece and how it
4 * behaves on a phone, a tablet and a computer.
5 *
6 * The owner, 2026-10-28: "the pages are so dang long… use tabs or collapsible areas… fixed position elements… on a
7 * phone it should act like an app, not a huge long web page… one app ecosystem across all our systems." So a page is:
8 * a sticky head (back, title, one muted line, icon actions) with its tabs, content in lists of rows and cards that fold,
9 * a dock pinned at the bottom for the one thing you type, and the navbar's app bar under it on phones.
10 *
11 * The same file runs on the server (require('openvibe-shared/kit'): builders return HTML strings, every text escaped,
12 * so pages are complete without JavaScript) and in the browser (window.OpenVibeKit: the same builders for pages that
13 * draw themselves). The behaviour (copy buttons, menus that close, Enter to send, sheets, confirms) is kit-web.js, a
14 * small file of its own: a server-rendered site loads only that; a page that draws itself loads both and calls
15 * OpenVibeKit.enhance(container) after adding markup.
16 *
17 * const kit = require('openvibe-shared/kit');
18 * kit.head({ title: 'Settings', sub: 'Saved to your account', back: { href: '/', label: 'Home' },
19 * actions: [kit.iconButton({ href: '/help', icon: 'help', label: 'Help' })],
20 * tabs: kit.tabs({ base: '/settings', items: [{ key: 'look', label: 'Look' }, …], active }) })
21 * kit.tabOf(req.query, ['look', 'sound']) → the open tab (the first when none or unknown)
22 * kit.list([kit.row({ href, mark: kit.mark({ name }), title, meta: kit.when(ts).text, sub, unread: 2 }), …])
23 * kit.section({ title: 'Messages look', body: kit.switchRow({ name, icon, title, line, checked }) + kit.rangeRow(…) + … })
24 * kit.segmented({ name, items: [{ value, label, icon }] }) three or four equal choices in one control
25 * kit.fold({ id, title, meta, open, body }) a card that opens: setup, extras, danger
26 * kit.find({ action, name, placeholder, icon: 'search' }) a search or "add by name" row
27 * kit.dock({ action, name, placeholder, label }) the composer pinned at the bottom
28 * kit.stats([[1234, 'messages this week'], …]) a strip of numbers (scrolls sideways on phones)
29 * kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items })
30 * 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)
31 * kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)
32 * kit.mediaW(url, width) a Media object at a resize width (64/96/160/320/480/640)
33 * kit.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar,
34 * body }) the left sidebar: vertical navigation, a drawer on phones
35 *
36 * The tool frame, one order for every tool page: kit.tool({ head: kit.toolHead({ icon, hue, crumb, title, task, facts }),
37 * input: kit.toolInput({ name, label, placeholder, button, tries: { items } }) | kit.drop(…) | an editor,
38 * answer: kit.answer({ title, badge, rows: [[label, value, note]], next }), family: kit.family({ title, more, items }),
39 * folds: [kit.fold(…)], related: kit.list(…) })
40 *
41 * Text arguments are escaped; arguments named body, html, before, after, actions, trail, badge, extra and mark take
42 * trusted markup (usually another builder's output). URLs are kept only when they are http(s) or same-site paths.
43 */
44(function (root, factory) {
45 const api = factory(root);
46 if (typeof module === 'object' && module.exports) { module.exports = api; return; }
47 root.OpenVibeKit = api;
48})(typeof globalThis !== 'undefined' ? globalThis : this, function (root) {
49 'use strict';
50
51 const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
52 /** A URL kept only when it is http(s), a same-site path, a query or a fragment; anything else becomes '#'. */
53 const url = (u) => { const s = String(u == null ? '' : u).trim(); return /^(https?:\/\/|\/(?!\/)|\?|#)/i.test(s) ? s : '#'; };
54 // A Media object (openvibe.media/o/med_…) at a width from Media's resize ladder (64, 96, 160, 320, 480, 640); any other
55 // URL as url() gives it. Avatars and pictures are stored as uploaded: Report and Community drew a 3072 px, 3 MB
56 // avatar at 34 px (4.87.0).
57 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; };
58 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('');
59
60 // ── Icons: 24-unit line drawings in the text's colour (stroke 2, round), one set for every site ──
61 const P = {
62 back: '<path d=\"M16.1 3.6l-8.4 8.4l8.4 8.4\"/>', forward: '<path d=\"M7.9 3.6l8.4 8.4l-8.4 8.4\"/>', chevron: '<path d=\"M7.9 3.6l8.4 8.4l-8.4 8.4\"/>',
63 up: '<path d=\"M3.7 16.1l8.3-8.3l8.3 8.3\"/>', down: '<path d=\"M3.6 7.9l8.4 8.4l8.4-8.4\"/>', close: '<path d=\"M3.7 3.7l16.7 16.7M20.4 3.7L3.7 20.4\"/>',
64 plus: '<path d=\"M12 3.5v17M3.5 12h17\"/>', minus: '<path d=\"M3.5 12h17\"/>', check: '<path d=\"M3.7 12.1l5.4 5.4L20.4 6.1\"/>',
65 send: '<path d=\"M12 20.7V4.2M3.7 12.4l8.3-8.3l8.3 8.3\"/>', search: '<circle cx=\"10.6\" cy=\"10.6\" r=\"7\"/><path d=\"M20.4 20.4l-4.5-4.5\"/>',
66 more: '<circle cx=\"5.2\" cy=\"12\" r=\"1.6\"/><circle cx=\"12\" cy=\"12\" r=\"1.6\"/><circle cx=\"18.8\" cy=\"12\" r=\"1.6\"/>',
67 menu: '<path d=\"M3.5 6.7h17M3.5 12h17M3.5 17.3h17\"/>', sidebar: '<rect x="3.5" y="4.5" width="17" height="15" rx="2.5"/><path d="M9.5 4.5v15"/>', filter: '<path d=\"M3.5 6h17M6.7 12.4h10.6M9.9 18.7h4.2\"/>',
68 home: '<path d=\"M3.6 10.6L12 3.3l8.4 7.3v8.4a1 1 0 0 1-1 1h-4.2v-6.3h-6.3v6.3H4.7a1 1 0 0 1-1-1Z\"/>',
69 chat: '<path d=\"M3.8 4.3h16.8v11.7H8l-4.2 4.2Z\"/>', mail: '<rect x=\"3.5\" y=\"5.5\" width=\"17\" height=\"13.2\" rx=\"1.9\"/><path d=\"M4.4 7.4l7.6 5.7l7.6-5.7\"/>',
70 bell: '<path d=\"M12 3.5a1.22 1.22 0 0 1 1.22 1.22v0.47a5.44 5.44 0 0 1 4.22 5.25v3.38c0 0.84 0.28 1.59 0.84 2.25l0.75 0.84a0.75 0.75 0 0 1 -0.56 1.31H5.53a0.75 0.75 0 0 1 -0.56 -1.31l0.75 -0.84c0.56 -0.66 0.84 -1.41 0.84 -2.25v-3.38a5.44 5.44 0 0 1 4.22 -5.25v-0.47A1.22 1.22 0 0 1 12 3.5ZM9.94 18.97a2.16 2.16 0 0 0 4.13 0\"/>',
71 user: '<circle cx=\"12\" cy=\"7.4\" r=\"3.8\"/><path d=\"M4.3 20.5c0-3.9 3.4-6.6 7.7-6.6s7.7 2.6 7.7 6.6\"/>',
72 members: '<circle cx=\"9.2\" cy=\"7.9\" r=\"3\"/><path d=\"M3.6 19.1c0-3.1 2.6-5.1 5.6-5.1s5.6 2.1 5.6 5.1M15.7 5a3 3 0 0 1 0 5.9M17.6 14.3c1.8 0.6 2.8 2.3 2.8 4.8\"/>',
73 invite: '<circle cx=\"9.2\" cy=\"8.2\" r=\"2.9\"/><path d=\"M3.8 18.8c0-3 2.4-4.8 5.3-4.8s5.3 2 5.3 4.8M17.9 8.2v5.3M15.3 10.9h5.3\"/>',
74 gear: '<circle cx=\"12\" cy=\"12.1\" r=\"2.3\"/><path d=\"M17.6 14.3a1.3 1.3 0 0 0 0.2 1.5l0.1 0.1a1.5 1.5 0 1 1-2.4 2.4l-0.1-0.1a1.3 1.3 0 0 0-1.5-0.2a1.3 1.3 0 0 0-0.8 1.3V18.8a1.5 1.5 0 1 1-3.4 0v-0.1a1.3 1.3 0 0 0-0.9-1.3a1.3 1.3 0 0 0-1.5 0.2l-0.1 0.1a1.5 1.5 0 1 1-2.4-2.4l0.1-0.1a1.3 1.3 0 0 0 0.2-1.5a1.3 1.3 0 0 0-1.3-0.8H5.4a1.5 1.5 0 1 1 0-3.4h0.1a1.3 1.3 0 0 0 1.3-0.9a1.3 1.3 0 0 0-0.2-1.5l-0.1-0.1a1.5 1.5 0 1 1 2.4-2.4l0.1 0.1a1.3 1.3 0 0 0 1.5 0.2H9.8a1.3 1.3 0 0 0 0.8-1.3V5.4a1.5 1.5 0 1 1 3.4 0v0.1a1.3 1.3 0 0 0 0.8 1.3a1.3 1.3 0 0 0 1.5-0.2l0.1-0.1a1.5 1.5 0 1 1 2.4 2.4l-0.1 0.1a1.3 1.3 0 0 0-0.2 1.5V9.8a1.3 1.3 0 0 0 1.3 0.8H18.7a1.5 1.5 0 1 1 0 3.4h-0.1a1.3 1.3 0 0 0-1.3 0.8Z\"/>',
75 pencil: '<path d=\"M11.9 19.6h8.4M16.1 4.2a2 2 0 0 1 2.8 2.8L7.2 18.7l-3.7 0.9l0.9-3.7Z\"/>', trash: '<path d=\"M4.1 6.7h15.8M10 10.6v5.9M14 10.6v5.9M6.1 6.7l1 11.8a2 2 0 0 0 2 2h5.9a2 2 0 0 0 2-2l1-11.8M9 6.7V3.7h5.9v3\"/>',
76 copy: '<rect x=\"8\" y=\"8\" width=\"12.5\" height=\"12.5\" rx=\"2.1\"/><path d=\"M16.3 8V5.8a2.1 2.1 0 0 0-2.1-2.1H5.8a2.1 2.1 0 0 0-2.1 2.1v8.4a2.1 2.1 0 0 0 2.1 2.1h2.1\"/>',
77 open: '<path d=\"M14 3.8h6.3v6.3M20.2 3.8l-9.4 9.4M18.2 14.2v4.2a2.1 2.1 0 0 1-2.1 2.1H5.6a2.1 2.1 0 0 1-2.1-2.1V7.9a2.1 2.1 0 0 1 2.1-2.1h4.2\"/>',
78 link: '<path d=\"M9.9 14.1a4.3 4.3 0 0 0 6.1 0l3.2-3.2a4.3 4.3 0 0 0-6.1-6.1l-1.1 1.1M14.1 9.9a4.3 4.3 0 0 0-6.1 0l-3.2 3.2a4.3 4.3 0 0 0 6.1 6.1l1.1-1.1\"/>',
79 download: '<path d=\"M12 3.2v11.5M6.8 9.5l5.2 5.2l5.2-5.2M4.7 20h14.7\"/>', upload: '<path d=\"M12 20.8V9.2M6.8 14.5l5.2-5.2l5.2 5.2M4.7 4h14.7\"/>',
80 lock: '<rect x=\"4.7\" y=\"10.7\" width=\"14.5\" height=\"9.4\" rx=\"2.1\"/><path d=\"M7.8 10.7V7.5a4 4 0 0 1 8.3 0v3.2\"/>',
81 key: '<circle cx=\"8.1\" cy=\"15.9\" r=\"4.3\"/><path d=\"M11.1 12.9l9.4-9.4M16.8 7.3l3.2 3.2M14.6 9.4l2.2 2.2\"/>',
82 ban: '<circle cx="12" cy="12" r="8.5"/><path d="m6 6 12 12"/>', help: '<circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.3a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.8M12 17h.01"/>',
83 info: '<circle cx="12" cy="12" r="8.5"/><path d="M12 11v5M12 8h.01"/>', alert: '<path d=\"M12 4.5L3.6 18.8h16.9Z\"/><path d=\"M12 10v3.7M12 16.5h0\"/>',
84 clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>', calendar: '<rect x=\"4\" y=\"5.5\" width=\"16\" height=\"15\" rx=\"2\"/><path d=\"M4 10.5h16M9 3.5v4M15 3.5v4\"/>',
85 spark: '<path d=\"M10.4 3.6l1.7 4.8L17 10.1l-4.8 1.7L10.4 16.7l-1.7-4.8L3.9 10.1l4.8-1.7ZM17 14.8l0.8 2.1L19.8 17.6l-2.1 0.8L17 20.3l-0.8-2.1L14.3 17.6l2.1-0.8Z\"/>',
86 star: '<path d=\"M12 4l2.6 5.3l5.7 0.6l-4.3 3.9l1.2 5.7L12 16.8l-5.2 2.9l1.2-5.7l-4.3-3.9l5.7-0.6Z\"/>', heart: '<path d=\"M11.9 20.3s-8.1-5.1-8.1-11.6a4.6 4.6 0 0 1 8.1-3A4.6 4.6 0 0 1 20 9.2c0 6.5-8.1 11.6-8.1 11.6Z\"/>',
87 play: '<path d=\"M5.6 3.6v16.9l13.4-8.5Z\"/>', pause: '<path d=\"M7.1 3.5v17M16.9 3.5v17\"/>', stop: '<circle cx="12" cy="12" r="8.5"/><path d="m8.5 8.5 7 7"/>',
88 live: '<circle cx=\"12\" cy=\"11.8\" r=\"2\"/><path d=\"M8.6 8.4a4.9 4.9 0 0 0 0 7.1M15.4 8.4a4.9 4.9 0 0 1 0 7.1M6.3 6.1a8.1 8.1 0 0 0 0 11.9M17.7 6.1a8.1 8.1 0 0 1 0 11.9\"/>',
89 video: '<rect x=\"3.4\" y=\"6.4\" width=\"12.3\" height=\"11.3\" rx=\"1.9\"/><path d=\"M15.6 10.2l4.7-2.8v9.4l-4.7-2.8\"/>', mic: '<rect x=\"9.2\" y=\"3.5\" width=\"5.7\" height=\"10.4\" rx=\"2.8\"/><path d=\"M5.4 11.1a6.6 6.6 0 0 0 13.2 0M12 17.7v2.8\"/>',
90 image: '<rect x=\"3.5\" y=\"5.4\" width=\"16.7\" height=\"13\" rx=\"2.8\"/><circle cx=\"9.1\" cy=\"10\" r=\"1.5\"/><path d=\"M20.3 15.6l-4.7-4.7l-7.4 7.4\"/>',
91 file: '<path d=\"M6.4 3.5h7.6l3.8 3.8v13.2H6.4Z\"/><path d=\"M13.9 3.5v3.8h3.8\"/>', folder: '<path d=\"M3.8 7.3a1.9 1.9 0 0 1 1.9-1.9h3.6l1.9 1.9h7.4a1.9 1.9 0 0 1 1.9 1.9v7.4a1.9 1.9 0 0 1-1.9 1.9H5.8a1.9 1.9 0 0 1-1.9-1.9Z\"/>',
92 code: '<path d=\"M7.8 7.8l-4.2 4.2l4.2 4.2M16.2 7.8l4.2 4.2l-4.2 4.2M14.2 4.6l-4.2 14.8\"/>', terminal: '<rect x=\"3.7\" y=\"4.6\" width=\"16.7\" height=\"14.9\" rx=\"1.9\"/><path d=\"M7.4 9.2l2.8 2.8l-2.8 2.8M12.9 14.8h3.7\"/>',
93 computer: '<rect x=\"3.5\" y=\"4.5\" width=\"17\" height=\"11.3\" rx=\"1.9\"/><path d=\"M8.2 19.7h7.6M12 15.9v3.8\"/>', phone: '<path d=\"M5.7 3.4h3.9l2 4.9l-2.5 1.5a10.8 10.8 0 0 0 4.9 4.9L15.6 12.3l4.9 2v3.9a2 2 0 0 1-2 2A15.8 15.8 0 0 1 3.8 5.4a2 2 0 0 1 2-2\"/>',
94 globe: '<circle cx=\"11.9\" cy=\"11.9\" r=\"8.3\"/><path d=\"M3.6 11.9h16.7M11.9 3.6c2.7 2.7 2.7 13.9 0 16.7M11.9 3.6c-2.7 2.7-2.7 13.9 0 16.7\"/>',
95 tag: '<path d=\"M3.8 11.2V3.8h7.4l9.3 9.3l-7.4 7.4Z\"/><circle cx=\"8\" cy=\"8\" r=\"1.2\"/>', cart: '<path d=\"M3.5 4.1h1.9l2.2 10.2h10.2L20.3 6.9H6.5\"/><circle cx=\"9.1\" cy=\"18.6\" r=\"1.2\"/><circle cx=\"16.5\" cy=\"18.6\" r=\"1.2\"/>',
96 coin: '<circle cx="12" cy="12" r="8.5"/><path d="M14.5 9.2c-.5-.8-1.4-1.2-2.5-1.2-1.5 0-2.5.8-2.5 2s1 1.6 2.5 2 2.5.8 2.5 2-1 2-2.5 2c-1.1 0-2-.4-2.5-1.2M12 6.5v11"/>',
97 gift: '<rect x=\"4.4\" y=\"8.8\" width=\"15.1\" height=\"10.4\" rx=\"0.9\"/><path d=\"M3.5 8.8h17M12 8.8v10.4M12 8.8c-1.4-2.8-4.7-3.8-4.7-1.4S12 8.8 12 8.8s4.7-0.9 4.7-3.3S13.4 6 12 8.8\"/>',
98 trophy: '<path d=\"M7.9 4h8.4v5.2a4.1 4.1 0 0 1-8.4 0ZM7.9 6.1H4.7a3.2 3.2 0 0 0 3.2 4.1M16.2 6.1h3.2a3.2 3.2 0 0 1-3.2 4.1M12 13.4v4.1M7.9 20.8h8.4\"/>',
99 flag: '<path d=\"M6.7 20.7V4M6.7 4h10.8l-2 3.9l2 3.9H6.7\"/>', eye: '<path d=\"M3.5 12S6.6 6.2 12 6.2S20.5 12 20.5 12S17.4 17.8 12 17.8S3.5 12 3.5 12Z\"/><circle cx=\"12\" cy=\"12\" r=\"2.7\"/>',
100 'eye-off': '<path d=\"M3.6 12.1S6.7 6.3 11.9 6.3c1.4 0 2.6 0.4 3.7 1M20.4 12.1S17.3 17.7 11.9 17.7c-1.4 0-2.6-0.4-3.7-1M4.9 5l14.1 14.1\"/><path d=\"M10.1 10.1a2.6 2.6 0 0 0 3.7 3.7\"/>',
101 flame: '<path d=\"M12 20c3.3 0 5.6-2.3 5.6-5.3c0-3.9-4.1-7.2-5.6-11.4c-1.5 4.2-5.6 7.6-5.6 11.4C6.4 17.8 8.6 20 12 20Z\"/><path d=\"M12 16.7c1.3 0 2.3-0.9 2.3-2.2c0-1.3-1.1-2.5-2.3-3.9c-1.1 1.5-2.3 2.6-2.3 3.9c0 1.2 0.9 2.2 2.3 2.2Z\"/>',
102 map: '<path d=\"M9.1 4.6L3.5 6.5v13l5.6-1.9l5.6 1.9l5.6-1.9V4.6l-5.6 1.9Z\"/><path d=\"M9.1 4.6v13M14.7 6.5v13\"/>', book: '<path d=\"M5 5.7a1.9 1.9 0 0 1 1.9-1.9h12.1v14.9H6.8a1.9 1.9 0 0 0-1.9 1.9Z\"/><path d=\"M5 18.7V5.7\"/>',
103 game: '<rect x=\"3.7\" y=\"7.2\" width=\"16.7\" height=\"9.7\" rx=\"4.4\"/><path d=\"M7.6 10.7v2.7M6.4 12h2.7M15.1 11.6h0M17.4 12.9h0\"/>',
104 leave: '<path d=\"M12.7 3.5h4.2a2.1 2.1 0 0 1 2.1 2.1v12.7a2.1 2.1 0 0 1-2.1 2.1h-4.2M8.5 16.2l-4.2-4.2l4.2-4.2M4.2 12h10.6\"/>',
105 retry: '<path d=\"M3.7 12a8.2 8.2 0 0 1 14.1-5.9L20.2 8.4M20.2 3.8v4.6h-4.6M20.2 12a8.2 8.2 0 0 1-14.1 5.9L3.7 15.6M3.7 20.3v-4.6h4.6\"/>',
106 task: '<rect x=\"3.7\" y=\"3.6\" width=\"16.7\" height=\"16.7\" rx=\"3.2\"/><path d=\"M7.8 12l3.2 3.2l5.2-6.3\"/>', steps: '<path d=\"M9.7 5.7h11M9.7 11.8h11M9.7 17.8h11\"/><path d=\"M3.7 5.7l1.5 1.5L7.7 4.8M3.7 11.8l1.5 1.5L7.7 10.8M3.7 17.8l1.5 1.5L7.7 16.8\"/>',
107 hand: '<path d=\"M9.9 13.6V6.2a1.5 1.5 0 0 1 3 0V11.7M12.8 10.7V5.3a1.5 1.5 0 0 1 3 0V11.7M15.8 11.2V6.7a1.5 1.5 0 0 1 3 0v6.9c0 3.9-2.5 6.9-6.4 6.9c-2.6 0-4.1-1.3-5.6-3.5L4.9 14.2a1.5 1.5 0 0 1 2.4-1.8L9.9 15.6\"/>',
108 shield: '<path d=\"M12 3.7L5.5 6.6v5.6c0 4.1 2.8 7.1 6.5 8.4c3.7-1.3 6.5-4.2 6.5-8.4V6.6Z\"/>', zap: '<path d=\"M13.4 3.5L5.9 12.9h5.7l-0.9 7.6l7.6-9.4h-5.7Z\"/>',
109 cloud: '<path d=\"M7.8 17.6h8.9a3.4 3.4 0 0 0 0.5-7A5.2 5.2 0 0 0 6.9 10.1A3.7 3.7 0 0 0 7.8 17.6Z\"/>', server: '<rect x=\"3.5\" y=\"3.5\" width=\"17\" height=\"7.4\" rx=\"2.1\"/><rect x=\"3.5\" y=\"13.1\" width=\"17\" height=\"7.4\" rx=\"2.1\"/><path d=\"M7.8 7.2h0M7.8 16.8h0\"/>',
110 sun: '<circle cx=\"11.9\" cy=\"11.9\" r=\"3.4\"/><path d=\"M11.9 3.5v1.8M11.9 18.6v1.8M6 6l1.2 1.2M16.7 16.7l1.2 1.2M3.5 11.9h1.8M18.6 11.9h1.8M6 17.9l1.2-1.2M16.7 7.2l1.2-1.2\"/>',
111 moon: '<path d=\"M20.5 14.6A8.6 8.6 0 1 1 9.4 3.5a6.9 6.9 0 0 0 11.2 11.2Z\"/>', grid: '<rect x=\"3.5\" y=\"3.5\" width=\"7.4\" height=\"7.4\" rx=\"1.6\"/><rect x=\"13.1\" y=\"3.5\" width=\"7.4\" height=\"7.4\" rx=\"1.6\"/><rect x=\"3.5\" y=\"13.1\" width=\"7.4\" height=\"7.4\" rx=\"1.6\"/><rect x=\"13.1\" y=\"13.1\" width=\"7.4\" height=\"7.4\" rx=\"1.6\"/>',
112 list: '<path d=\"M8.2 5.5h11.9M8.2 12h11.9M8.2 18.5h11.9M3.3 5.5h0M3.3 12h0M3.3 18.5h0\"/>',
113 chart: '<path d=\"M3.9 3.4v16.7h16.7\"/><path d=\"M7.7 14.5l3.6-4.1l3.2 2.7l5.2-6.3\"/>', swap: '<path d=\"M3.5 7.8h14.9l-3.7-3.7M20.5 16.3H5.6l3.7 3.7\"/>',
114 wave: '<path d=\"M3.7 16.2c2.1 0 2.1-1.7 4.2-1.7s2.1 1.7 4.2 1.7s2.1-1.7 4.2-1.7s2.1 1.7 4.2 1.7\"/><path d=\"M6.5 12.7A5.6 5.6 0 0 1 16.4 9.4c-1.9-0.4-3.5 0.3-4 2\"/>',
115 shoe: '<path d=\"M3.7 14.8v-4.8l3.5-0.4l2.6-3.5l2.8 1.2l-0.8 2.3l3.5 1.9l4.1 0.9a1.7 1.7 0 0 1 1.3 1.7V14.8Z\"/><path d=\"M3.7 17.2h16.6\"/>',
116 palette: '<path d=\"M12.2 3.8a8.4 8.4 0 1 0 0 16.8c1.1 0 1.7-0.9 1.7-1.6c0-1.1-0.9-1.5-0.9-2.6s0.9-1.6 1.9-1.6H16.9a3.7 3.7 0 0 0 3.7-3.7C20.6 7.2 16.9 3.8 12.2 3.8Z\"/><circle cx=\"8\" cy=\"11.4\" r=\"1.1\"/><circle cx=\"10.3\" cy=\"7.9\" r=\"1.1\"/><circle cx=\"14.5\" cy=\"7.9\" r=\"1.1\"/>', robot: '<rect x=\"5.4\" y=\"8.3\" width=\"13.2\" height=\"10.4\" rx=\"2.8\"/><path d=\"M12 4.6v3.8M9.2 13.1h0M14.8 13.1h0M9.6 16.4h4.7M3.5 13.1v1.9M20.5 13.1v1.9\"/>',
117 };
118 // ── The glyphs the kit does not draw itself, generated from glyphs.js (scripts/build-kit-glyphs.js) ──
119 // kit.icon has to draw every glyph a page can name: a page that asks for compass or binoculars gets the same
120 // registry's drawing, not an empty <svg>. The block is generated and tested, never edited by hand.
121 // <generated:kit-glyphs>
122 const MORE = Object.freeze({
123 'bell-ring': '<path d="M12 3.5a1.22 1.22 0 0 1 1.22 1.22v0.47a5.44 5.44 0 0 1 4.22 5.25v3.38c0 0.84 0.28 1.59 0.84 2.25l0.75 0.84a0.75 0.75 0 0 1 -0.56 1.31H5.53a0.75 0.75 0 0 1 -0.56 -1.31l0.75 -0.84c0.56 -0.66 0.84 -1.41 0.84 -2.25v-3.38a5.44 5.44 0 0 1 4.22 -5.25v-0.47A1.22 1.22 0 0 1 12 3.5ZM9.94 18.97a2.16 2.16 0 0 0 4.13 0"/><path d="M3.84 9.31a8.63 8.63 0 0 1 2.34 -4.6M20.16 9.31a8.63 8.63 0 0 0 -2.34 -4.6"/>',
124 scissors: '<circle cx="6" cy="7" r="2.6"/><circle cx="6" cy="17" r="2.6"/><path d="M8.2 8.6 20 18M8.2 15.4 20 6"/>',
125 tree: '<path d="M12.1 3.6L7 10.6h2.8L5.6 16.6h13l-4.2-6h2.8ZM12.1 16.6v3.7"/>',
126 pin: '<path d="M12 20.7s-6.2-5.4-6.2-10.5a6.2 6.2 0 0 1 12.4 0C18.2 15.2 12 20.7 12 20.7Z"/><circle cx="12" cy="10" r="2.3"/>',
127 volume: '<path d="M3.5 9.5h3.7L11.8 5.8v12.6l-4.7-3.7H3.5Z"/><path d="M15.5 9.5a3.7 3.7 0 0 1 0 5.2M18.1 6.9a7.3 7.3 0 0 1 0 10.5"/>',
128 mute: '<path d="M3.9 9.4h3.6L12.2 5.8v12.4l-4.6-3.6H3.9Z"/><path d="M16.3 9.9l4.1 4.1M20.5 9.9l-4.1 4.1"/>',
129 tent: '<path d="M3.5 18.4h17M12 4.9L4.8 18.4M12 4.9l7.2 13.4M12 12.5L9.3 18.4M12 12.5l2.7 5.8"/>',
130 food: '<path d="M8.2 4v6.5M6.3 4v4.2a1.9 1.9 0 0 0 3.7 0V4M8.2 10.5v10.2M17.5 20.8V4c-1.9 1.1-3 3.3-3 6.5v2.8H17.5"/>',
131 train: '<rect x="6.4" y="3.8" width="11.2" height="12.1" rx="2.8"/><path d="M6.4 10.3h11.2M9.2 13.1h0M14.8 13.1h0M8.7 20.5l1.9-3.3M15.3 20.5l-1.9-3.3"/>',
132 water: '<path d="M3.5 10.2c2.1 0 2.1-1.8 4.3-1.8s2.1 1.8 4.2 1.8s2.1-1.8 4.3-1.8s2.1 1.8 4.2 1.8M3.5 15.6c2.1 0 2.1-1.8 4.3-1.8s2.1 1.8 4.2 1.8s2.1-1.8 4.3-1.8s2.1 1.8 4.2 1.8"/>',
133 dish: '<path d="M3.8 9.2A10.9 10.9 0 0 0 14.8 20.2Z"/><path d="M9 15l5.2-5.2M14.8 3.4a5.8 5.8 0 0 1 5.8 5.8M14.8 7.5a1.8 1.8 0 0 1 1.8 1.8"/>',
134 history: '<path d="M3.8 12.1a8.3 8.3 0 1 0 2.5-5.9L3.8 8.5M3.8 4.2v4.2h4.2"/><path d="M12 7.9v4.3l3.1 1.9"/>',
135 film: '<rect x="3.5" y="4.4" width="17" height="15.1" rx="1.9"/><path d="M7.7 4.4v15.1M16.3 4.4v15.1M3.5 9.2h4.3M3.5 14.8h4.3M16.3 9.2H20.5M16.3 14.8H20.5"/>',
136 circle: '<circle cx="12" cy="12" r="8.5"/>',
137 sliders: '<path d="M3.5 6.8h10M18.7 6.8H20.3M3.5 17.2h3.6M12.4 17.2H20.3"/><circle cx="16.2" cy="6.8" r="2.4"/><circle cx="9.8" cy="17.2" r="2.4"/>',
138 leaf: '<path d="M3.8 20.1c0-9.5 5.6-15.2 16.7-16.7c-1 11.1-6.7 16.7-16.7 16.7ZM3.8 20.1l8.9-8.9"/>',
139 clipboard: '<rect x="5.7" y="5" width="12.6" height="15.5" rx="1.9"/><path d="M9.1 5V3.5h5.8V5M9.1 10.8h5.8M9.1 14.7h3.9"/>',
140 gauge: '<path d="M3.6 16.9a8.4 8.4 0 1 1 16.7 0"/><path d="M11.9 13.2l4.1-5.7"/><circle cx="11.9" cy="13.2" r="1.4"/>',
141 save: '<path d="M4.7 3.5h11.5l3.2 3.2v13.6H4.7Z"/><path d="M7.8 3.5v5.2h7.3V3.5M7.8 20.2v-6.3h8.4v6.3"/>',
142 plug: '<path d="M9.2 4v4.6M14.8 4v4.6M6.9 8.7h10.2v2.8a5.1 5.1 0 0 1-10.2 0ZM12 16.6V20.7"/>',
143 puzzle: '<path d="M3.8 7h4.2a2.6 2.6 0 1 1 4.8 0h4.2v4.2a2.6 2.6 0 1 1 0 4.8v4.2h-4.2a2.6 2.6 0 1 0-4.8 0H3.8v-4.2a2.6 2.6 0 1 0 0-4.8Z"/>',
144 compress: '<path d="M8.8 3.5v5.3H3.5M15.2 3.5v5.3h5.3M8.8 20.5v-5.3H3.5M15.2 20.5v-5.3h5.3"/>',
145 expand: '<path d="M3.5 8.8V3.5h5.3M20.5 8.8V3.5h-5.3M3.5 15.2v5.3h5.3M20.5 15.2v5.3h-5.3"/>',
146 care: '<path d="M12 10.6s-3.8-2.3-3.8-5a2.1 2.1 0 0 1 3.8-1.3a2.1 2.1 0 0 1 3.8 1.3c0 2.7-3.8 5-3.8 5Z"/><path d="M3.5 13.5v6.5M3.5 15h3.3l3.4 2.8h7.7a1.8 1.8 0 0 0 0-3.6h-4.1"/>',
147 city: '<path d="M3.5 19.7h17M5.4 19.7V8.3h4.7v11.3M10.1 19.7V3.6h5.7v16.1M15.8 19.7v-7.6h3.3v7.6M7.8 11.2h0M7.8 14.5h0M12.9 6.9h0M12.9 10.2h0M12.9 13.5h0"/>',
148 wrench: '<path d="M14.6 3.6a5.2 5.2 0 0 0-4.6 7.3L3.7 17.3V20.3h3.1l6.4-6.4a5.2 5.2 0 0 0 7.3-4.6l-3.2 1.2l-2.5-2.5l1.2-3.2Z"/>',
149 music: '<path d="M8.6 17.7V5.6l11.1-2.1v12.2"/><circle cx="6" cy="17.7" r="2.6"/><circle cx="17.2" cy="15.7" r="2.6"/>',
150 'eye-off': '<path d="M10.6 6.1c0.5-0.1 0.9-0.1 1.4-0.1c5.2 0 8.5 6 8.5 6s-0.6 1.1-1.7 2.4M6.9 7.6C4.7 9.2 3.5 12 3.5 12s3.3 6 8.5 6c1.6 0 3-0.5 4.2-1.2"/><path d="M10 10a2.8 2.8 0 0 0 4 4M4.5 4.5l15 15"/>',
151 plane: '<path d="M20 4L3.5 10.8l6.6 3.1l3.1 6.6Z"/><path d="M10.1 13.9l9.9-9.9"/>',
152 fire: '<path d="M12 20.5a6.8 6.8 0 0 0 6.8-6.8c0-4.3-2.9-6.8-4.2-10.1c-2 2.3-2.6 4.5-2.6 4.5S10.7 5.8 9.5 3.8C7 6.6 5.2 10 5.2 13.7a6.8 6.8 0 0 0 6.8 6.8Z"/>',
153 nodes: '<circle cx="12" cy="5.4" r="2.1"/><circle cx="5.8" cy="17.9" r="2.1"/><circle cx="18.2" cy="17.9" r="2.1"/><path d="M11 7.4L6.8 15.9M13 7.4l4.3 8.6M7.9 17.9h8.3"/>',
154 bridge: '<path d="M3.5 16h17M7 19.5V5M17 19.5V5"/><path d="M3.5 10c1.8 0 2.8-2.3 3.5-5c2.4 5.2 7.6 5.2 10 0c.7 2.7 1.7 5 3.5 5"/><path d="M12 10.9V16"/>',
155 'check-circle': '<circle cx="12" cy="12" r="8.5"/><path d="m8 12.4 2.8 2.8L16 9.6"/>',
156 'x-circle': '<circle cx="12" cy="12" r="8.5"/><path d="m9 9 6 6M15 9l-6 6"/>',
157 'alert-circle': '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5v5.5M12 16.5h.01"/>',
158 news: '<path d="M3.5 3.5h13.8v14.9a2.1 2.1 0 0 0 2.1 2.1H5.6a2.1 2.1 0 0 1-2.1-2.1Z"/><path d="M17.3 7.7h3.2v10.6a2.1 2.1 0 0 1-2.1 2.1M7.2 7.7h6.4M7.2 11.5h6.4M7.2 15.2h3.7"/>',
159 camera: '<path d="M3.5 8h3.4l1.8-2.8h6.6L17.1 8h3.4v11.5h-17Z"/><circle cx="12" cy="13.3" r="3.4"/>',
160 crown: '<path d="M3.5 8.3L7.8 12L11.9 5.5l4.2 6.5l4.2-3.7L18.4 18.1H5.4Z"/>',
161 wallet: '<path d="M3.5 6.8A2.7 2.7 0 0 1 6.2 4.2H18.4v4.2M3.5 6.8V18a2.1 2.1 0 0 0 2.1 2.1h14.9V8.4H6.2A2.7 2.7 0 0 1 3.5 6.8Z"/><path d="M16.2 14.2h0"/>',
162 chip: '<rect x="7.3" y="7.3" width="9.4" height="9.4" rx="1.4"/><path d="M10.1 3.5v3.8M13.9 3.5v3.8M10.1 16.7v3.8M13.9 16.7v3.8M3.5 10.1h3.8M3.5 13.9h3.8M16.7 10.1h3.8M16.7 13.9h3.8"/>',
163 id: '<rect x="3.7" y="5.5" width="16.7" height="13" rx="1.9"/><circle cx="9.2" cy="11" r="1.9"/><path d="M6.4 15.6c0.5-1.4 1.6-2.2 2.8-2.2s2.4 0.8 2.8 2.2M14.8 10.1h2.8M14.8 13.4h2.8"/>',
164 brain: '<path d="M9.2 4.2a2.8 2.8 0 0 0-2.8 2.8a2.8 2.8 0 0 0-1.9 4.8a2.8 2.8 0 0 0 1.9 4.8a2.8 2.8 0 0 0 5.8 1.1V5.8A1.9 1.9 0 0 0 9.2 4.2ZM14.9 4.2a2.8 2.8 0 0 1 2.8 2.8a2.8 2.8 0 0 1 1.9 4.8a2.8 2.8 0 0 1-1.9 4.8a2.8 2.8 0 0 1-5.8 1.1"/>',
165 snow: '<path d="M12 3.5v17M4.6 7.8l14.7 8.5M4.6 16.3l14.7-8.5M9.6 4.9L12 7.3l2.4-2.4M9.6 19.1L12 16.7l2.4 2.4"/>',
166 mountain: '<path d="M3.6 17.5L9.3 6l3.7 6.3L15.5 9.1L20.3 17.5Z"/>',
167 'mic-off': '<rect x="9.2" y="3.6" width="5.7" height="10.4" rx="2.8"/><path d="M5.5 11.1a6.6 6.6 0 0 0 11 5M18.7 11.1c0 0.8-0.1 1.5-0.4 2.2M12.1 17.7v2.8M4.5 4.5l15.1 15.1"/>',
168 database: '<ellipse cx="12" cy="6.4" rx="6.6" ry="2.8"/><path d="M5.4 6.4v11.3c0 1.6 2.9 2.8 6.6 2.8s6.6-1.2 6.6-2.8V6.4M5.4 12c0 1.6 2.9 2.8 6.6 2.8s6.6-1.2 6.6-2.8"/>',
169 layers: '<path d="M12 5.4l8.3 4.6l-8.3 4.6l-8.3-4.6Z"/><path d="M3.8 14l8.3 4.6l8.3-4.6"/>',
170 hash: '<path d="M5 9h15M4 15h15M10 3.5 8 20.5M16 3.5l-2 17"/>',
171 text: '<path d="M5.2 20.3L10.9 3.6h2.1l5.7 16.7M7.4 14h9.2"/>',
172 enter: '<path d="M13.8 3.6h4.1a2.1 2.1 0 0 1 2.1 2.1v12.5a2.1 2.1 0 0 1-2.1 2.1h-4.1M9.5 7.9l4.1 4.1l-4.1 4.1M13.8 12H3.2"/>',
173 language: '<path d="M4.6 5.4h8.4M8.8 3.6v1.9M6.4 5.4c0.5 2.8 2.3 5.6 5.6 7M11.1 5.4c-0.6 3.2-2.6 6.3-6 7.9M12 20.3l3.7-8.4l3.7 8.4M13.3 17.5h4.8"/>',
174 idea: '<path d="M9.1 17.6h5.8M10.1 20.5h3.9M8.7 14.3A5.8 5.8 0 1 1 15.2 14.3c-0.7 0.6-1.4 1.5-1.4 2.3h-3.9c0-0.9-0.7-1.7-1.4-2.3Z"/>',
175 dollar: '<path d="M15.7 8C15 6.6 13.7 5.6 12 5.6C9.6 5.6 8.3 6.9 8.3 8.5s1.6 2.5 3.7 2.8s3.7 1.3 3.7 3.1s-1.5 3-3.7 3c-1.8 0-3.2-0.9-3.7-2.4M12 3.7v16.7"/>',
176 target: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5"/>',
177 'arrow-right': '<path d="M3.2 12h16.7M13.7 5.7l6.3 6.3l-6.3 6.3"/>',
178 'arrow-left': '<path d="M20.8 12H4M10.3 5.7l-6.3 6.3l6.3 6.3"/>',
179 'arrow-up': '<path d="M12 20.8V4M5.7 10.3l6.3-6.3l6.3 6.3"/>',
180 'arrow-down': '<path d="M12 3.2v16.7M5.7 13.7l6.3 6.3l6.3-6.3"/>',
181 shirt: '<path d="M8.2 3.8L3.7 6.6l1.8 3.8l1.8-0.9v11.2h9.3V9.4l1.8 0.9l1.8-3.8l-4.7-2.7c-0.5 1.4-1.8 2.3-3.8 2.3S8.8 5.1 8.2 3.8Z"/>',
182 route: '<circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="6" r="2.4"/><path d="M8.4 18H16a3 3 0 0 0 0-6H8a3 3 0 0 1 0-6h7.6"/>',
183 reply: '<path d="M9.2 5.8L4 11l5.1 5.1M4 11h10.3a6.2 6.2 0 0 1 6.2 6.2v1"/>',
184 smile: '<circle cx="12" cy="12" r="8.5"/><path d="M8.5 14a4 4 0 0 0 7 0M9 9.5h.01M15 9.5h.01"/>',
185 archive: '<rect x="3.5" y="4.8" width="17" height="4.7" rx="0.9"/><path d="M5.4 9.5v9.4a0.9 0.9 0 0 0 0.9 0.9h11.3a0.9 0.9 0 0 0 0.9-0.9V9.5M10.1 13.3h3.8"/>',
186 'user-check': '<circle cx="9.2" cy="8.1" r="2.8"/><path d="M4 19c0-2.9 2.4-4.9 5.4-4.9s5.4 1.9 5.4 4.9M15.4 10.8l1.8 1.8l3.6-3.6"/>',
187 ticket: '<path d="M3.6 7.4h16.7v2.8a1.9 1.9 0 0 0 0 3.7v2.8H3.6v-2.8a1.9 1.9 0 0 0 0-3.7Z"/><path d="M13.8 7.4v9.3"/>',
188 tack: '<path d="M9.3 4h5.6l-0.9 4.6l2.8 2.8H7.4l2.8-2.8ZM12 11.4v9.3"/>',
189 store: '<path d="M3.7 8.8L5.3 3.7h13.4L20.3 8.8M3.7 8.8h16.5v1.6a2.8 2.8 0 0 1-5.4 0a2.8 2.8 0 0 1-5.6 0A2.8 2.8 0 0 1 3.7 10.4ZM4.8 12.9v7.2h14.5v-7.2M9.9 20.1v-4.1h4.1v4.1"/>',
190 keyboard: '<rect x="3.5" y="6.6" width="17" height="10.7" rx="1.8"/><path d="M6.6 10.2h0M9.3 10.2h0M12 10.2h0M14.7 10.2h0M17.4 10.2h0M8.4 13.8h7.2"/>',
191 gavel: '<path d="M13.7 4.1l5.1 5.1M10.5 7.4l5.1 5.1M12.1 5.8l-5.6 5.6l5 5l5.6-5.6M3.5 19.5l5.9-5.9M13.7 19.5h6.5"/>',
192 drop: '<path d="M12 3.5s5.9 6.4 5.9 10.8a5.9 5.9 0 0 1-11.8 0c0-4.4 5.9-10.8 5.9-10.8Z"/>',
193 wifi: '<path d="M3.7 9.4a12.4 12.4 0 0 1 16.7 0M6.4 12.5a8.4 8.4 0 0 1 11.5 0M9.1 15.6a4.4 4.4 0 0 1 6.2 0M12.1 18.6h0"/>',
194 branch: '<circle cx="6.7" cy="5.6" r="1.9"/><circle cx="6.7" cy="18.6" r="1.9"/><circle cx="17.9" cy="7.5" r="1.9"/><path d="M6.7 7.5v9.3M17.9 9.3c0 5.1-6 3.7-10.2 7.9"/>',
195 binoculars: '<rect x="4.4" y="7.4" width="6" height="12.8" rx="3"/><rect x="13.6" y="7.4" width="6" height="12.8" rx="3"/><path d="M10.4 10.2h3.2M10.4 15.8h3.2"/><path d="M6.9 7.4V5.7a1.6 1.6 0 0 1 3.2 0v1.7M13.9 7.4V5.7a1.6 1.6 0 0 1 3.2 0v1.7"/>',
196 telescope: '<path d="M3.1 11.4l14.4-7.2l2.5 5l-14.4 7.2Z"/><path d="M9.1 14.9l-1.6 4.7M9.1 14.9l5.5 4.7M8.2 17.3h6.4"/>',
197 microscope: '<path d="M3.4 19.9h17.2M6.2 19.9v-3.4h5.8v3.4M9.2 16.5h8.6M14.1 16.5V7.4M14.1 7.4a4 4 0 0 1 4-4h1.2M19.3 3.4v4.8M9.9 9.9h3.6"/>',
198 gpu: '<rect x="3.5" y="5.5" width="14.4" height="10" rx="1.6"/><circle cx="8.2" cy="10.5" r="2.8"/><path d="M8.2 8.3v4.4M6 10.5h4.4"/><path d="M17.9 8h2.6v5h-2.6M3.5 15.5v2.5"/>',
199 cpu: '<rect x="7" y="7" width="10" height="10" rx="1.6"/><rect x="10.4" y="10.4" width="3.2" height="3.2" rx="0.6"/><path d="M9.5 3.5v3.5M14.5 3.5v3.5M9.5 17v3.5M14.5 17v3.5M3.5 9.5h3.5M3.5 14.5h3.5M17 9.5h3.5M17 14.5h3.5"/>',
200 memory: '<rect x="3.5" y="5.5" width="17" height="7.5" rx="1.2"/><path d="M3.5 15v3.5M6.6 15v3.5M9.7 15v3.5M12.8 15v3.5M15.9 15v3.5M19 15v3.5"/><path d="M6.7 8h4.8M6.7 10.8h3M13.4 8h3.9M13.4 10.8h2.4"/>',
201 joystick: '<path d="M4.3 19.6h15.4l-2.6-7.3H6.9Z"/><path d="M12 12.3V7.6"/><circle cx="12" cy="5.4" r="2.1"/><circle cx="8.3" cy="16.6" r="1.1"/><circle cx="15.7" cy="16.6" r="1.1"/>',
202 wand: '<path d="M3.7 20.3L13.9 10.1M12.1 8.3l3.6 3.6"/><path d="M17.9 3.7l0.9 2.3l2.3 0.9l-2.3 0.9l-0.9 2.3l-0.9-2.3l-2.3-0.9l2.3-0.9Z"/>',
203 'layers-plus': '<path d="M3.8 9.8l8.2-4.8l8.2 4.8M3.8 14l8.2 4.8l4.8-2.8"/><path d="M18.6 14.4v5M16.1 16.9h5"/>',
204 restroom: '<circle cx="7.1" cy="5.3" r="1.7"/><circle cx="16.4" cy="5.3" r="1.7"/><path d="M11.7 3.6v16.7M5.6 20.3v-5.1H4.7V10.6A1.4 1.4 0 0 1 6.1 9.2h1.9a1.4 1.4 0 0 1 1.4 1.4v4.6H8.5V20.3M15.5 20.3v-3.7h-2.4l1.9-6.1a0.9 0.9 0 0 1 0.9-0.9h0.9a0.9 0.9 0 0 1 0.9 0.9l1.9 6.1H17.3v3.7"/>',
205 shelter: '<path d="M3.7 10.5L12 4l8.3 6.5M5.9 9.2v10.4M18.1 9.2v10.4"/><circle cx="12" cy="11" r="1.6"/><path d="M9.8 19.6v-3.9a2.2 2.2 0 0 1 4.3 0V19.6"/>',
206 'hot-tub': '<circle cx="8.6" cy="5.8" r="1.7"/><path d="M3.8 11.9h16.6v3.3a4.2 4.2 0 0 1-4.2 4.2h-8.4A4.2 4.2 0 0 1 3.8 15.2ZM6.8 11.9v-1.4A2.4 2.4 0 0 1 8.9 8.2H9.4M13.7 9.5c0.8-0.9-0.8-1.9 0-2.8M17.3 9.5c0.8-0.9-0.8-1.9 0-2.8"/>',
207 umbrella: '<path d="M3.8 12.5a8.4 8.4 0 0 1 16.6 0ZM12.1 12.5v6a1.9 1.9 0 0 1-3.7 0M12.1 4.1v0"/>',
208 parking: '<rect x="3.7" y="3.7" width="16.7" height="16.7" rx="3"/><path d="M9.6 17V7.2h3.4a3 3 0 0 1 0 5.9H9.6"/>',
209 skull: '<path d="M12 3.7a6.5 6.5 0 0 0-6.5 6.5c0 2.2 1 3.6 2.4 4.6v2.8h8.4v-2.8c1.3-1 2.4-2.6 2.4-4.6a6.5 6.5 0 0 0-6.5-6.5Z"/><circle cx="9.6" cy="10.7" r="1.3"/><circle cx="14.4" cy="10.7" r="1.3"/><path d="M10.6 17.7v2.8M13.4 17.7v2.8"/>',
210 group: '<path d="M3.5 7.3V3.5h3.8M16.7 3.5h3.8v3.8M20.5 16.7v3.8h-3.8M7.3 20.5H3.5v-3.8"/><rect x="7.3" y="7.3" width="5.7" height="4.7" rx="0.9"/><rect x="11.1" y="12" width="5.7" height="4.7" rx="0.9"/>',
211 medical: '<rect x="3.5" y="7.1" width="17" height="12.3" rx="1.9"/><path d="M9.2 7.1V5.2a0.9 0.9 0 0 1 0.9-0.9h3.8a0.9 0.9 0 0 1 0.9 0.9v1.9M12 10.4v5.7M9.2 13.3h5.7"/>',
212 shapes: '<circle cx="7.5" cy="7.7" r="3.4"/><path d="M16.4 3.7l3.9 6.9h-7.9ZM13.9 14.1h5.9v5.9h-5.9ZM3.6 20l3.4-5.9l3.4 5.9Z"/>',
213 headphones: '<path d="M4.4 13.9v-2.8a7.5 7.5 0 0 1 14.9 0v2.8"/><rect x="3.5" y="13" width="4.2" height="6" rx="1.4"/><rect x="16.1" y="13" width="4.2" height="6" rx="1.4"/>',
214 drive: '<rect x="3.5" y="12.3" width="17" height="6.6" rx="1.9"/><path d="M3.9 12.3L6.8 4.7h10.4l2.8 7.6M16.7 15.6h0M13.8 15.6h0"/>',
215 'check-double': '<path d="M3.6 11.7l3.6 3.6L14.6 7.7M11.1 15.2l0.9 0.9L20.4 7.7"/>',
216 tv: '<rect x="3.5" y="7.8" width="17" height="11.6" rx="1.8"/><path d="M8.4 4.3l3.6 3.6l3.6-3.6"/>',
217 toolbox: '<rect x="3.5" y="8.1" width="17" height="11.3" rx="1.9"/><path d="M9.2 8.1V5.8A1.4 1.4 0 0 1 10.6 4.3h2.8A1.4 1.4 0 0 1 14.8 5.8V8.1M3.5 12.8h17M8.2 11.4v2.8M15.8 11.4v2.8"/>',
218 table: '<rect x="3.5" y="4.3" width="17" height="15.1" rx="1.9"/><path d="M3.5 9.5h17M3.5 14.7h17M9.2 9.5V19.4M14.8 9.5V19.4"/>',
219 scale: '<path d="M12 4.3V19.5M8.5 19.5h6.9M5.5 7.4h13M12 4.3h0M3.7 13.4L5.9 7.8L8.1 13.4M15.9 13.4L18.1 7.8l2.2 5.6M3.7 13.4a2.2 2.2 0 0 0 4.3 0M15.9 13.4a2.2 2.2 0 0 0 4.3 0"/>',
220 road: '<path d="M8.2 3.5L4.4 20.5M15.8 3.5l3.8 17M12 4.4v2.4M12 10.6v2.8M12 17.2V19.6"/>',
221 money: '<rect x="3.5" y="6.6" width="17" height="10.7" rx="1.8"/><circle cx="12" cy="12" r="2.2"/><path d="M6.6 9.8v0M17.4 14.2v0"/>',
222 'heart-pulse': '<path d="M19 12.3c-1.7 3.6-7 6.7-7 6.7s-5.3-3.1-7-6.7M4.3 9.7A4.3 4.3 0 0 1 12 7.4a4.3 4.3 0 0 1 7.7 2.2"/><path d="M3.5 11.9h4l1.3-2.2l2.2 4.5l1.8-3.1h7.6"/>',
223 heading: '<path d="M5.6 3.5v17M18.4 3.5v17M5.6 12h12.7M3.5 3.5h4.2M16.2 3.5h4.2M3.5 20.5h4.2M16.2 20.5h4.2"/>',
224 fist: '<path d="M6.4 10.1V6.4a1.6 1.6 0 0 1 3.2 0V9.1M9.5 8.5v-3.2a1.6 1.6 0 0 1 3.2 0v3.2M12.6 8.5v-2.1a1.6 1.6 0 0 1 3.2 0V9.1M15.7 9.1V8a1.6 1.6 0 0 1 3.2 0v5.2a7.3 7.3 0 0 1-7.3 7.3H11.1a5.7 5.7 0 0 1-5.7-5.7V12.3A2.1 2.1 0 0 1 7.4 11.2h2.1a1.6 1.6 0 0 1 0 3.2H8"/>',
225 'fast-forward': '<path d="M4 6.4v11.2L11.6 12ZM13.1 6.4v11.2L20.7 12Z"/>',
226 rewind: '<path d="M20 6.4v11.2L12.4 12ZM10.9 6.4v11.2L3.3 12Z"/>',
227 'file-code': '<path d="M13.9 3.5H7.4a1.9 1.9 0 0 0-1.9 1.9v13a1.9 1.9 0 0 0 1.9 1.9h9.3a1.9 1.9 0 0 0 1.9-1.9V8.2Z"/><path d="M13.9 3.5v4.6h4.6M10.1 12.4L8.3 14.3l1.9 1.9M13.9 12.4l1.9 1.9l-1.9 1.9"/>',
228 dice: '<rect x="3.7" y="3.6" width="16.7" height="16.7" rx="3"/><path d="M8.6 8.6h0M15.5 8.6h0M12.1 12h0M8.6 15.4h0M15.5 15.4h0" stroke-width="2.6"/>',
229 contrast: '<circle cx="11.7" cy="12" r="8.4"/><path d="M11.7 3.6v16.7M11.7 7.9h6.5M11.7 12h8.4M11.7 16.2h6.5"/>',
230 briefcase: '<rect x="3.5" y="7.2" width="17" height="12.3" rx="1.9"/><path d="M9.2 7.2V5.7A1.4 1.4 0 0 1 10.6 4.3h2.8A1.4 1.4 0 0 1 14.8 5.7V7.2M3.5 12.3h7.1M13.4 12.3H20.5M10.6 11.4h2.8v2.4h-2.8Z"/>',
231 timeline: '<path d="M3.5 12h0.9M8.2 12h1.9M13.9 12h1.9M19.6 12h0.9"/><circle cx="6.3" cy="12" r="1.9"/><circle cx="12" cy="12" r="1.9"/><circle cx="17.7" cy="12" r="1.9"/><path d="M6.3 10.1V5.9M12 13.9v4.3M17.7 10.1V5.9"/>',
232 seedling: '<path d="M12 20.8v-9.8M12 11C12 6.7 9.3 4 4.4 4c0 4.3 2.7 7 7.6 7ZM12 13.7c0-4.3 2.7-7 7.6-7c0 4.3-2.7 7-7.6 7Z"/>',
233 rocket: '<path d="M11.8 15.7L8.4 12.3C9.9 6.9 13.8 3.9 20.2 3.9C20.2 10.3 17.3 14.2 11.8 15.7ZM8.4 12.3L4.9 11.8L7.4 8.3h3.9M11.8 15.7l0.5 3.4l3.4-2.5v-3.9M6.4 17.7c-1.5 0-3 1.5-3 3c1.5 0 3-1.5 3-3Z"/><circle cx="15.3" cy="8.8" r="1.5"/>',
234 'list-ordered': '<path d="M9.7 6.8h10.8M9.7 12.8h10.8M9.7 18.8h10.8M3.8 5.4L4.7 5v4.4M3.8 9.3h2M3.8 13.7a1 1 0 0 1 2 0.3c0 1.1-2 1.7-2 2.7h2.2"/>',
235 lifebuoy: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="3.8"/><path d="M6 6l3.4 3.4M14.6 14.7l3.4 3.4M18 6l-3.4 3.4M9.4 14.7l-3.4 3.4"/>',
236 infinity: '<path d="M12 12c-1.8-2.4-3.3-3.6-4.9-3.6a3.6 3.6 0 0 0 0 7.2c1.6 0 3.1-1.2 4.9-3.6Zm0 0c1.8 2.4 3.3 3.6 4.9 3.6a3.6 3.6 0 0 0 0-7.2c-1.6 0-3.1 1.2-4.9 3.6Z"/>',
237 inbox: '<path d="M3.5 12.7L5.9 4.8h12.1l2.4 7.9V17.8a0.9 0.9 0 0 1-0.9 0.9H4.4a0.9 0.9 0 0 1-0.9-0.9ZM3.5 12.7h4.6l1.4 2.4h4.6l1.4-2.4h4.6"/>',
238 door: '<path d="M13.7 4.7L6.3 3.2v16.7l7.5-1.4ZM13.7 5.1h3.3a0.9 0.9 0 0 1 0.9 0.9v14M3.5 20h16.7M11 11.6h0"/>',
239 card: '<rect x="3.7" y="5.8" width="16.7" height="12.3" rx="1.8"/><path d="M3.7 10.3h16.7M6.7 14.6h3.5"/>',
240 storm: '<path d="M7.8 16a3.8 3.8 0 1 1 0.8-7.4A4.5 4.5 0 0 1 17.4 10.5a3.1 3.1 0 0 1-0.4 6.2"/><path d="M12.9 12.2l-2.1 3.3h2.5L11.3 18.9"/>',
241 'trend-up': '<path d="M3.2 16.8L8.8 11.3l3.8 3.8l7.4-7.4"/><path d="M14.4 7.4h5.6v5.6"/>',
242 'trend-down': '<path d="M3.2 7.2l5.6 5.6l3.8-3.8l7.4 7.4"/><path d="M14.4 16.6h5.6v-5.6"/>',
243 'chevrons-left': '<path d="M10.9 19.3l-7.3-7.3l7.3-7.3M20.4 19.3l-7.3-7.3l7.3-7.3"/>',
244 'chevrons-right': '<path d="M3.6 19.3l7.3-7.3l-7.3-7.3M13.1 19.3l7.3-7.3l-7.3-7.3"/>',
245 sort: '<path d="M3.5 5.8h9.2M3.5 12h6.2M3.5 18.2h3M17.5 4.8v14.4M14.3 16.2l3 3l3-3"/>',
246 'sort-asc': '<path d="M3.4 5.4h3M3.4 11.6h6.3M3.4 17.9h9.3M17.5 4.3v14.6M14.3 16l3 3l3-3"/>',
247 share: '<path d="M12 15.2V3.3M7.4 8.1L12 3.3L16.6 8.1M4.9 12.9v6.1a1 1 0 0 0 1 1h12.2a1 1 0 0 0 1-1v-6.1"/>',
248 'arrows-h': '<path d="M3.8 12h16.5M7.6 8.1l-3.9 3.9l3.9 3.9M16.4 8.1l3.9 3.9l-3.9 3.9"/>',
249 move: '<path d="M11.9 3.6v16.7M3.6 11.9h16.7M9 6.5l2.9-2.9l2.9 2.9M9 17.3l2.9 2.9l2.9-2.9M6.5 9l-2.9 2.9l2.9 2.9M17.3 9l2.9 2.9l-2.9 2.9"/>',
250 focus: '<circle cx="12" cy="12" r="2.8"/><path d="M3.5 9V5.9A2.4 2.4 0 0 1 5.9 3.5H9M15 3.5h3.1A2.4 2.4 0 0 1 20.5 5.9V9M20.5 15v3.1a2.4 2.4 0 0 1-2.4 2.4H15M9 20.5H5.9A2.4 2.4 0 0 1 3.5 18.1V15"/>',
251 at: '<circle cx="12" cy="12" r="3.4"/><path d="M15.4 12v1.3a2.5 2.5 0 0 0 4.9 0V12a8.3 8.3 0 1 0-3.2 6.5"/>',
252 box: '<path d="M3.6 7.9L11.9 3.9l8.4 3.9v8.4L11.9 20.1l-8.4-3.9Z"/><path d="M3.6 7.9L11.9 11.8l8.4-3.9M11.9 11.8v8.4M7.8 5.9l8.4 3.9"/>',
253 bug: '<rect x="7.2" y="6.2" width="9.6" height="13.3" rx="4.8"/><path d="M9.3 6.5a2.7 2.7 0 0 1 5.3 0M12 10.9v8.5M7.2 12H3.5M20.5 12h-3.7M7.6 8.3L4.6 5.6M16.4 8.3L19.4 5.6M7.6 16.3L4.6 18.9M16.4 16.3L19.4 18.9"/>',
254 megaphone: '<path d="M3.6 9.5v4a1 1 0 0 0 1 1H7.1l9 4.5v-15L7.1 8.5H4.5a1 1 0 0 0-1 1Z"/><path d="M7.1 14.5l1.3 5h2.4L10.1 15.1M19.1 9a3.2 3.2 0 0 1 0 5"/>',
255 burst: '<path d="M11.9 3.7L13.6 7.8L17.8 6.2L16.2 10.4L20.3 12.1L16.2 13.8L17.8 18L13.6 16.4L11.9 20.4L10.2 16.4L6 18L7.6 13.8L3.6 12.1L7.6 10.4L6 6.2L10.2 7.8Z"/>',
256 'camera-flip': '<path d="M3.5 8h3.4l1.8-2.8h6.6L17.1 8h3.4v11.5h-17Z"/><path d="M15 12.6a3.1 3.1 0 0 0-5.6-1.2M9 14.4a3.1 3.1 0 0 0 5.6 1.2M9.3 9.5v2h2M14.7 17.5v-2h-2"/>',
257 'camera-off': '<path d="M3.7 7.9h3.3l1.7-2.7h6.4L16.9 7.9h3.3v11.2h-16.5Z"/><circle cx="11.9" cy="13" r="3.3"/><path d="M3.7 3.5l16.5 16.5"/>',
258 car: '<path d="M3.5 15.8v-4.5L6.2 5.8h11.6l2.7 5.5V15.8Z"/><path d="M3.5 11.3h17M6.5 15.8v2.5M17.5 15.8v2.5M7.2 13.8h0M16.8 13.8h0"/>',
259 child: '<circle cx="12" cy="5.8" r="2.1"/><path d="M7.9 10.5L12 9.2l4.1 1.4M12 9.2v5.6M9.2 20.4l2.8-5.6l2.8 5.6"/>',
260 donate: '<circle cx="11.9" cy="8.4" r="5.2"/><path d="M11.9 6v4.3M3.7 15.6h16.5V20H3.7ZM8.3 17.7h7.2"/>',
261 record: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="3"/>',
262 'plus-circle': '<circle cx="12" cy="12" r="8.5"/><path d="M12 8v8M8 12h8"/>',
263 'minus-circle': '<circle cx="12" cy="12" r="8.5"/><path d="M8 12h8"/>',
264 clapper: '<path d="M4 10.4h16.3v8.6a1 1 0 0 1-1 1h-14.4a1 1 0 0 1-1-1Z"/><path d="M4 10.4L3.4 7.2l15.7-2.9l0.7 3.3ZM8.5 6.3l2.2 3.2M13.3 5.4l2.2 3.2"/>',
265 'clipboard-check': '<rect x="5.7" y="4.9" width="12.6" height="15.5" rx="1.9"/><path d="M9.1 4.9V3.5h5.8V4.9M9.1 13.2l1.9 1.9l3.9-3.9"/>',
266 cc: '<rect x="3.5" y="5.4" width="17" height="13.2" rx="2.4"/><path d="M10.7 10.2a2.3 2.3 0 1 0 0 3.6M16.8 10.2a2.3 2.3 0 1 0 0 3.6"/>',
267 'cloud-off': '<path d="M7.5 17.6h9.5a3.8 3.8 0 0 0 0.5-7.5A5.7 5.7 0 0 0 6.4 9.6A4.1 4.1 0 0 0 7.5 17.6Z"/><path d="M4 3.9l16.1 16.1"/>',
268 commit: '<circle cx="12" cy="12" r="3.4"/><path d="M3.5 12h5.1M15.4 12H20.5"/>',
269 'chat-off': '<path d="M4.2 5.1h15.7v10.9H8l-4 4Z"/><path d="M3.7 3.7l16.7 16.7"/>',
270 compass: '<circle cx="12" cy="12" r="8.5"/><path d="m15.6 8.4-2.1 5.1-5.1 2.1 2.1-5.1Z"/>',
271 cookie: '<path d="M20.6 12.3a8.4 8.4 0 1 1-8.9-8.9a3 3 0 0 0 3.9 3.5a3 3 0 0 0 4.9 5.3Z"/><path d="M8.8 9.4h0M12.2 13.8h0M8.3 15.3h0M15.7 15.3h0"/>',
272 couch: '<path d="M5.4 10.1V7.2a1.9 1.9 0 0 1 1.9-1.9h9.4a1.9 1.9 0 0 1 1.9 1.9v2.8"/><path d="M3.5 11.9a1.9 1.9 0 0 1 3.8 0v1.4h9.4V11.9a1.9 1.9 0 0 1 3.8 0v4.7H3.5ZM5.4 16.7v1.9M18.6 16.7v1.9"/>',
273 cubes: '<path d="M12 3.2L8.1 5.2v4.4l3.9 2l3.9-2V5.2ZM7.6 11.6L3.6 13.6v4.4l3.9 2l3.9-2V13.6ZM16.4 11.6l-3.9 2v4.4l3.9 2l3.9-2V13.6Z"/>',
274 ear: '<path d="M6.2 9.5a5.7 5.7 0 1 1 10.8 2.4c-0.9 1.9-3.1 3-3.1 5.2a3.4 3.4 0 0 1-6.5 1.6"/><path d="M9.7 9.9a2.4 2.4 0 1 1 3.8 1.5"/>',
275 eraser: '<path d="M14.3 4.4l5.5 5.5l-9 9H6.6l-2.6-2.6a1.5 1.5 0 0 1 0-2.1Z"/><path d="M9.3 9.4l5.5 5.5M10.8 18.9H20.3"/>',
276 laugh: '<circle cx="12" cy="12" r="8.5"/><path d="M8 13.5h8a4 4 0 0 1-8 0ZM7.5 8.6l2.2 1.2-2.2 1.2M16.5 8.6l-2.2 1.2 2.2 1.2"/>',
277 fish: '<path d="M9 12c1.8-3.2 4.3-4.8 7.1-4.8c2.6 0 4.2 2 4.9 4.8c-0.7 2.8-2.3 4.8-4.9 4.8c-2.8 0-5.3-1.6-7.1-4.8Z"/><path d="M9 12L4.2 8.6M9 12l-4.8 3.4M17.3 10.6h0.01"/>',
278 finish: '<path d="M5.2 20.7V4"/><rect x="5.2" y="4.6" width="13.9" height="8.6" rx="1"/><path d="M5.2 8.8h13.9M9.6 4.6v8.6M14.3 4.6v8.6"/>',
279 flask: '<path d="M9.5 3.2h4.9M10 3.2V9.2L4.9 17.7a1.5 1.5 0 0 0 1.3 2.3h11.6a1.5 1.5 0 0 0 1.3-2.3L14 9.2V3.2"/><path d="M7.3 14.6h9.5"/>',
280 'folder-tree': '<path d="M4.1 3.3v13.2a1 1 0 0 0 1 1H9.7M4.1 8.3H9.7"/><path d="M10.2 5.3h3.1l1 1.5h5.6V10.9h-9.7ZM10.2 14.5h3.1l1 1.5h5.6V20.1h-9.7Z"/>',
281 skip: '<path d="M4.8 3.5l10.8 8.4l-10.8 8.4Z"/><path d="M19.7 3.5v16.7"/>',
282 gem: '<path d="M6.8 4.9h10.3L20.2 9.5l-8.3 10.3L3.6 9.5Z"/><path d="M3.6 9.5h16.7M9.6 4.9L8.1 9.5l3.8 10.3M14.2 4.9L15.7 9.5l-3.8 10.3"/>',
283 ghost: '<path d="M4.7 20V10.6a7.3 7.3 0 0 1 14.7 0v9.4l-2.4-1.9l-2.5 1.9l-2.4-1.9L9.6 20l-2.5-1.9Z"/><path d="M9.4 10.1h0M14.6 10.1h0"/>',
284 grip: '<path d="M8.3 4.5h0M15.8 4.5h0M8.3 12h0M15.8 12h0M8.3 19.5h0M15.8 19.5h0" stroke-width="3.8"/>',
285 hammer: '<path d="M13.1 3.7L19.3 9.9l-2.7 2.7l-6.2-6.2Z"/><path d="M13.6 9.4L5 18.1a1.3 1.3 0 0 0 1.9 1.9l8.6-8.6"/>',
286 handshake: '<path d="M3.6 9.9v5.3M20.3 9.9v5.3"/><path d="M3.6 10.8h2.2l3.5-2.7l2.9 1.3L14.5 8.2l3.5 2.7h2.2"/><path d="M5.7 10.8l5.6 5.6a1.4 1.4 0 0 0 2-2M18 10.8l-4.7 4.5M17.5 12.8l-4-4l-2.3 2.1a1.3 1.3 0 0 1-1.9-1.9l1.9-2"/>',
287 wizard: '<path d="M3.6 20h16.7M6.3 20L9.9 9.1L15.7 3.3L14.1 10.1L17.8 20"/><path d="M8.4 15.4h7.3"/>',
288 'heart-broken': '<path d="M11.9 20.3s-8.1-5.1-8.1-11.6a4.6 4.6 0 0 1 8.1-3A4.6 4.6 0 0 1 20 9.2c0 6.5-8.1 11.6-8.1 11.6Z"/><path d="M11.9 5.8L10 9.8l3.5 2.3l-1.6 3.9"/>',
289 hourglass: '<path d="M6 3.5h12M6 20.5h12M7.5 3.5c0 4.5 4.5 5.5 4.5 8.5s-4.5 4-4.5 8.5M16.5 3.5c0 4.5-4.5 5.5-4.5 8.5s4.5 4 4.5 8.5"/>',
290 lungs: '<path d="M12 3.5v8M12 9.4l-3 2.1M12 9.4l3 2.1M8.4 6.5C5.6 6.5 3.5 10.4 3.5 15.2c0 2.8 1.1 4.8 3.2 4.8s2.7-2.1 2.7-4.8V8.3M15.6 6.5C18.4 6.5 20.5 10.4 20.5 15.2c0 2.8-1.1 4.8-3.2 4.8s-2.7-2.1-2.7-4.8V8.3"/>',
291 'link-off': '<path d="M10 13.8a4 4 0 0 0 5.6 0l2.9-2.9a4 4 0 0 0-5.6-5.6l-1 1M13.8 10a4 4 0 0 0-5.6 0l-2.9 2.9a4 4 0 0 0 5.6 5.6l1-1"/><path d="M3.6 3.6l16.7 16.7"/>',
292 mask: '<path d="M3.5 7c3 1.3 5.8 1.8 8.5 1.8S17.5 8.3 20.5 7c0 6-2.5 10-5 10-1.4 0-2-1.5-3.5-1.5S9.9 17 8.5 17c-2.5 0-5-4-5-10Z"/><path d="M7.5 11.5h2M14.5 11.5h2"/>',
293 mug: '<path d="M4.8 9.1h11v7a4 4 0 0 1-4 4h-3a4 4 0 0 1-4-4Z"/><path d="M15.8 10.5H17.3a2.5 2.5 0 0 1 0 5h-1.5M8.3 3.5c-0.8 1 0.8 2 0 3M11.8 3.5c-0.8 1 0.8 2 0 3"/>',
294 paw: '<path d="M7.8 18.5c0-3 1.9-5.3 4.2-5.3s4.2 2.3 4.2 5.3c0 1.4-1.1 2.1-2.1 2.1c-0.8 0-1.4-0.5-2.1-0.5s-1.3 0.5-2.1 0.5c-1.1 0-2.1-0.7-2.1-2.1Z"/><circle cx="5.4" cy="10.6" r="1.9"/><circle cx="9.2" cy="5.7" r="1.9"/><circle cx="14.8" cy="5.7" r="1.9"/><circle cx="18.6" cy="10.6" r="1.9"/>',
295 walk: '<circle cx="12.3" cy="5.1" r="1.7"/><path d="M8.8 20.3l2.4-5.5l1.9 2.2V20.3M11 14.8l0.9-5.2l-2.7 1.4l-1.4 2.7M11.9 9.6l1.8 2.7l2.7 0.9"/>',
296 'phone-off': '<path d="M5.6 4.1h3.7l1.9 4.7l-2.4 1.4a10.3 10.3 0 0 0 4.7 4.7L15.1 12.5l4.7 1.9v3.7a1.9 1.9 0 0 1-1.9 1.9A15.1 15.1 0 0 1 3.8 5.9a1.9 1.9 0 0 1 1.9-1.9"/><path d="M4.3 3.6l16.1 16.1"/>',
297 piggy: '<path d="M3.5 11.4c0-3.2 3.3-5.7 7.7-5.7c1.3 0 2.4 0.2 3.4 0.6L17.4 4.7v3.3c0.8 0.7 1.4 1.6 1.6 2.5h1.4v3.4H18.8c-0.5 1-1.3 1.7-2.3 2.3V19.1h-2.9v-1.9c-0.8 0.2-1.7 0.3-2.5 0.3c-0.8 0-1.6-0.1-2.3-0.3v1.9h-2.9v-2.9C4.4 15.1 3.5 13.4 3.5 11.4Z"/><path d="M14.9 10.1h0M8.7 8.1h2.9"/>',
298 airplane: '<path d="M12 3.2c0.9 0 1.4 0.9 1.4 2.4V9.8l7 4.2V15.4l-7-2.4V17.2l1.9 1.4V20L12 19.1l-3.3 0.9v-1.4l1.9-1.4v-4.2L3.6 15.4v-1.4L10.6 9.8V5.6C10.6 4.2 11.1 3.2 12 3.2Z"/>',
299 print: '<path d="M7.1 8.9V3.5h9.9V8.9"/><rect x="3.6" y="8.9" width="16.7" height="7.9" rx="2"/><path d="M7.1 13.8h9.9v6.4H7.1ZM16.4 11.9h0"/>',
300 quote: '<path d="M9.8 6.2C5.8 7.2 3.6 10 3.6 13.9v3.3h5.6v-5.6h-5.6M20.3 6.2c-3.9 1.1-6.1 3.9-6.1 7.8v3.3h5.6v-5.6h-5.6"/>',
301 radio: '<rect x="3.5" y="8.3" width="16.8" height="11.3" rx="1.9"/><path d="M7.3 8.3L16.7 3.9"/><circle cx="15.3" cy="14" r="2.4"/><path d="M6.8 12.4h3.8M6.8 15.4h3.8"/>',
302 ranking: '<path d="M3.5 19.9h17M4.5 19.9V13.8h4.7M9.2 19.9V10h5.7v9.9M14.9 19.9V15.7h4.7v4.3"/><path d="M12 3.4l0.9 1.7l1.9 0.3l-1.4 1.3l0.3 1.9L12 7.7l-1.7 0.9l0.3-1.9L9.3 5.4l1.9-0.3Z"/>',
303 recycle: '<path d="M7.8 18.2H5.7a1.2 1.2 0 0 1-1-1.8l2.3-3.9M10 5.5l0.9-1.5a1.2 1.2 0 0 1 2.1 0l2.3 3.9M17.7 12.4l1.6 2.7a1.2 1.2 0 0 1-1 1.8h-4.5"/><path d="M11.1 16.5l1.8 1.8l-1.8 1.8M4.5 10.4l2.4-0.7l0.7 2.5M17.2 6.1l-0.7 2.5l-2.5-0.7"/>',
304 rss: '<path d="M4 3.5a16.5 16.5 0 0 1 16.5 16.5M4 10.4A9.6 9.6 0 0 1 13.6 20.1"/><path d="M4.6 19.4h0" stroke-width="3.9"/>',
305 scroll: '<path d="M8.3 3.8h10.1a1.9 1.9 0 0 1 1.9 1.9v1.5H17M8.3 3.8a1.9 1.9 0 0 0-1.9 1.9v11.7a2.9 2.9 0 0 1-2.9 2.9h10.7a2.9 2.9 0 0 0 2.9-2.9v-11.7a1.9 1.9 0 0 0-1.9-1.9M10.2 9.2h3.9M10.2 13h3.9"/>',
306 signature: '<path d="M3.9 14c2.4 0 3.3-8.4 5.6-8.4c1.4 0 0.9 5.6 2.4 5.6c0.9 0 1.4-2.4 2.4-2.4s0.8 1.9 1.9 1.9c0.8 0 1.4-0.6 1.9-1.1M3.9 17.8h16.7"/>',
307 square: '<rect x="3.5" y="3.5" width="17" height="17" rx="2.7"/>',
308 sword: '<path d="M20.3 3.7v3.7L10.4 17.3L6.7 13.6L16.7 3.7Z"/><path d="M5.2 12L12 18.8M8.3 15.7L3.6 20.4"/>',
309 columns: '<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M12 4.5v15M3.5 9h17"/>',
310 'thumbs-up': '<path d="M7.3 10.4v9.6H4.8a1 1 0 0 1-1-1v-7.6a1 1 0 0 1 1-1Z"/><path d="M7.3 10.4L10.9 3.3a2.2 2.2 0 0 1 2.5 2.6L12.7 9.4H18.5a2 2 0 0 1 2 2.4l-1.3 6.6a2 2 0 0 1-2 1.6H7.3"/>',
311 undo: '<path d="M9.2 14.2L4 8.9l5.2-5.2"/><path d="M4 8.9h11a5.8 5.8 0 0 1 0 11.5H11.3"/>',
312 'user-circle': '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="10" r="3"/><path d="M6.6 18.3a6.5 6.5 0 0 1 10.8 0"/>',
313 'user-lock': '<circle cx="9.8" cy="7.3" r="3.3"/><path d="M3.5 18.8c0-3.4 2.8-5.7 6.2-5.7c1.2 0 2.2 0.3 3.1 0.7"/><rect x="14.6" y="14.5" width="5.7" height="4.8" rx="1"/><path d="M16 14.5v-1.2a1.5 1.5 0 0 1 2.9 0v1.2"/>',
314 'user-minus': '<circle cx="10.1" cy="8" r="3.3"/><path d="M4 19.3c0-3.3 2.7-5.7 6.1-5.7c1.1 0 2.2 0.3 3 0.7"/><path d="M15.1 17h5.7"/>',
315 'user-pen': '<circle cx="9.8" cy="8" r="3.3"/><path d="M3.8 19.1c0-3.3 2.7-5.6 6-5.6c1.1 0 2.1 0.3 3 0.7"/><path d="M19.1 12.1l1.4 1.4l-4.7 4.7l-1.9 0.5l0.5-1.9Z"/>',
316 'user-tag': '<circle cx="10" cy="7.6" r="3.3"/><path d="M3.8 18.9c0-3.3 2.8-5.6 6.1-5.6c1.1 0 2.2 0.3 3.1 0.7"/><path d="M14.2 14.2h3.7l2.4 2.4l-2.4 2.4h-3.7Z"/><path d="M16.1 16.5h0"/>',
317 'user-off': '<circle cx="12" cy="8.2" r="3.5"/><path d="M5.1 20c0-3.6 3-5.9 6.9-5.9s6.9 2.3 6.9 5.9"/><path d="M3.7 3.7l16.7 16.7"/>',
318 weight: '<circle cx="12" cy="5.2" r="2"/><path d="M7.1 8.7h9.9l3 11.3H4.1Z"/>',
319 wind: '<path d="M3.5 9.9h10.4a2.8 2.8 0 1 0-2.8-2.8M3.5 13.7h14.2a2.8 2.8 0 1 1-2.8 2.8M3.5 17.5h6.6"/>',
320 window: '<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M3.5 9h17"/>',
321 'window-restore': '<rect x="3.5" y="8.5" width="12" height="11" rx="2"/><path d="M8.5 8.5v-2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2h-3"/>',
322 'align-left': '<path d="M4 5.9h16.6M4 10h10.4M4 14.3h16.6M4 18.5h10.4"/>',
323 slash: '<path d="M3.6 3.6l16.7 16.7"/>',
324 'book-open': '<path d="M12 6.3C10 4.8 7 4.3 3.5 4.9v13.2c3.5-0.6 6.5-0.1 8.5 1.4c2-1.5 5-2 8.5-1.4V4.9C17 4.3 14 4.8 12 6.3Z"/><path d="M12 6.3v13.2"/>',
325 busy: '<path d="M4.7 12h0M12.1 12h0M19.4 12h0" stroke-width="4.2"/>',
326 });
327 // </generated:kit-glyphs>
328 const ALL = Object.freeze({ ...P, ...MORE });
329 const ICONS = Object.freeze(Object.keys(ALL));
330 /** Every icon's drawing (the inside of a 24-unit SVG), for openvibe-shared/glyphs and every kit builder. */
331 const PATHS = ALL;
332 /** The drawings this file was built with, before the glyphs.js block: glyphs.js adds the rest. */
333 const KIT_PATHS = P;
334 /** One icon as inline SVG (decorative: aria-hidden); '' for a name the set does not have. */
335 function icon(name, { size = null, cls = '' } = {}) {
336 const d = PATHS[name];
337 if (!d) return '';
338 return `<svg class="ovk-i${cls ? ` ${esc(cls)}` : ''}" viewBox="0 0 24 24"${size ? ` width="${Number(size) || 20}" height="${Number(size) || 20}"` : ''} aria-hidden="true" focusable="false">${d}</svg>`;
339 }
340
341 // ── Time and marks ──
342 const DAY = 86400000;
343 const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
344 const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
345 /** A time people read at a glance: 14:05 today, Yesterday, Mon this week, Oct 20 this year, else 2025-10-20 (UTC). */
346 function when(ts, now = Date.now()) {
347 const s = ts instanceof Date ? ts.toISOString() : typeof ts === 'number' ? new Date(ts).toISOString() : String(ts || '');
348 const t = Date.parse(/[TZ]/.test(s) || !s.includes(' ') ? s : `${s.replace(' ', 'T')}Z`);
349 if (!Number.isFinite(t)) return { iso: '', text: '' };
350 const d = new Date(t), n = new Date(now);
351 const today = Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate());
352 let text;
353 if (t >= today) text = d.toISOString().slice(11, 16);
354 else if (t >= today - DAY) text = 'Yesterday';
355 else if (t >= today - 6 * DAY) text = WEEKDAYS[d.getUTCDay()];
356 else if (d.getUTCFullYear() === n.getUTCFullYear()) text = `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;
357 else text = d.toISOString().slice(0, 10);
358 return { iso: d.toISOString(), text };
359 }
360 /** A steady hue (0-359) for a name or id, so the same thing has the same colour everywhere. */
361 function hueOf(key) { let h = 0; for (const ch of String(key || '')) h = (h * 31 + ch.codePointAt(0)) % 360; return h; }
362 /** A person's or a place's mark: their picture, or up to two letters on a colour from the name (square: a place). */
363 function mark({ name = '?', src = '', hue = null, square = false, size = null } = {}) {
364 const cls = `ovk-mark${square ? ' ovk-mark--square' : ''}`;
365 const st = size ? `--ovk-mark:${Number(size) || 40}px;` : '';
366 if (src && url(src) !== '#') return `<img class="${cls}" src="${esc(mediaW(src, (Number(size) || 40) > 48 ? 160 : 96))}" alt="" loading="lazy" decoding="async"${st ? ` style="${st}"` : ''}>`;
367 const words = String(name || '?').replace(/[^\p{L}\p{N} ]/gu, ' ').trim().split(/\s+/).filter(Boolean);
368 const letters = (words.length > 1 ? [...words[0]][0] + [...words[1]][0] : [...(words[0] || '?')].slice(0, 2).join('')).toUpperCase();
369 return `<span class="${cls}" style="${st}--ovk-hue:${hue == null ? hueOf(name) : Number(hue) || 0}" aria-hidden="true">${esc(letters)}</span>`;
370 }
371
372 // ── Controls ──
373 /** A button or a link that looks like one. kind: 'primary' (default), 'quiet', 'danger', 'ghost'; small: a pill. */
374 // 4.204.0, buttons that show what they do and what is happening (docs/kit.md#buttons): kind 'hero' | 'soft' | 'live'
375 // beside primary/quiet/ghost/danger; nudge (forward, back, add, open, down, refresh) moves the icon by meaning;
376 // iconEnd puts it after the words; attention marks the one next step; badge a count; dot ok|warn|bad|live; hold
377 // (ms) makes a destructive button hold-to-confirm; pressed makes it a toggle.
378 function button({ href = null, type = 'submit', label = '', icon: ic = '', iconEnd = '', kind = 'primary', small = false, name = null, value = null, attrs: extra = null,
379 nudge = null, attention = false, badge = null, dot = null, hold = null, pressed = null } = {}) {
380 const cls = `ovk-btn ovk-btn--${esc(kind)}${small ? ' ovk-btn--small' : ''}`;
381 const more = { ...(extra || {}) };
382 if (nudge) more['data-nudge'] = nudge;
383 if (attention) more['data-attention'] = '';
384 if (badge != null && badge !== '' && badge !== 0) more['data-badge'] = Number(badge) > 99 ? '99+' : String(badge);
385 if (dot) more['data-dot'] = dot;
386 if (hold) more['data-hold'] = String(Math.max(400, Math.min(4000, Number(hold) || 1200)));
387 if (pressed != null) more['aria-pressed'] = pressed ? 'true' : 'false';
388 extra = Object.keys(more).length ? more : null;
389 const inner = `${ic ? icon(ic) : ''}<span>${esc(label)}</span>${iconEnd ? icon(iconEnd) : ''}`;
390 if (href != null) return `<a class="${cls}" href="${esc(url(href))}"${attrs(extra)}>${inner}</a>`;
391 return `<button class="${cls}" type="${type === 'button' ? 'button' : type === 'reset' ? 'reset' : 'submit'}"${name ? ` name="${esc(name)}"` : ''}${value != null ? ` value="${esc(value)}"` : ''}${attrs(extra)}>${inner}</button>`;
392 }
393 /**
394 * The one Share button (4.114.0, docs/share.md): it opens the network's Share sheet (ov-share.js, which the navbar
395 * loads on the first press). url is what is shared (else the page's address); kind and id add "Who can open it" for
396 * the owner, where the service mounts openvibe-sdk/share's router.
397 */
398 function share({ title = '', url: link = null, text = '', image = null, embed = null, kind = null, id = null, label = 'Share', small = false, quiet = true } = {}) {
399 return button({ type: 'button', label, icon: 'upload', kind: quiet ? 'quiet' : 'primary', small,
400 attrs: { 'data-ov-share-sheet': true, 'data-title': title || null, 'data-url': link ? url(link) : null, 'data-text': text || null,
401 'data-image': image ? url(image) : null, 'data-embed': embed ? url(embed) : null, 'data-kind': kind || null, 'data-id': id || null } });
402 }
403 /**
404 * "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
405 * question waiting in its box, this page's context included, for them to read and send; it never sends for them.
406 * Signed out, the navbar sends them to sign in. text: the prefilled question ("About this stream: …").
407 */
408 function askAgent({ text = '', label = 'Ask your agent', small = true } = {}) {
409 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');
410 }
411 /** An icon-only control: a link (href), or a button (type) — it always carries its label for screen readers and a tooltip. */
412 function iconButton({ href = null, type = 'submit', icon: ic = 'more', label = '', name = null, value = null, kind = '', attrs: extra = null } = {}) {
413 const cls = `ovk-icon-btn${kind ? ` ovk-icon-btn--${esc(kind)}` : ''}`;
414 const a = ` aria-label="${esc(label)}" title="${esc(label)}"${attrs(extra)}`;
415 if (href != null) return `<a class="${cls}" href="${esc(url(href))}"${a}>${icon(ic)}</a>`;
416 return `<button class="${cls}" type="${type === 'button' ? 'button' : 'submit'}"${name ? ` name="${esc(name)}"` : ''}${value != null ? ` value="${esc(value)}"` : ''}${a}>${icon(ic)}</button>`;
417 }
418 /** A one-button form (a POST action as an icon or a button), e.g. leave, delete, revoke. confirm: asked first (kit.enhance). */
419 function action({ action: to, method = 'post', hidden = {}, confirm = '', ...btn }) {
420 const fields = Object.keys(hidden || {}).map((k) => `<input type="hidden" name="${esc(k)}" value="${esc(hidden[k])}">`).join('');
421 const control = btn.label && !btn.iconOnly ? button(btn) : iconButton(btn);
422 return `<form class="ovk-inline" method="${method === 'get' ? 'get' : 'post'}" action="${esc(url(to))}"${confirm ? ` data-ovk-confirm="${esc(confirm)}"` : ''}>${fields}${control}</form>`;
423 }
424 /** A small badge beside a name: tone 'accent' | 'ok' | 'warn' | 'bad' | 'live' | '' (quiet). */
425 const badge = (text, tone = '') => `<span class="ovk-badge${tone ? ` ovk-badge--${esc(tone)}` : ''}">${esc(text)}</span>`;
426 /** An unread count: 1-9, then 9+. */
427 const dot = (n) => (Number(n) > 0 ? `<span class="ovk-dot" aria-label="${Number(n)} new">${Number(n) > 9 ? '9+' : Number(n)}</span>` : '');
428
429 // ── The page's head and its tabs ──
430 /**
431 * The sticky head every app page starts with: back (an icon), the title and one muted line, icon actions on the right,
432 * and the page's tabs under it (kit.tabs). It stays under the navbar while the page scrolls.
433 */
434 function head({ title = '', titleHtml = null, sub = '', subHtml = null, back = null, icon: ic = '', actions = [], tabs = '', level = 1, sticky = true } = {}) {
435 const h = level === 2 ? 'h2' : 'h1';
436 return `<header class="ovk-head${sticky ? '' : ' ovk-head--static'}"><div class="ovk-head-row">`
437 + (back ? `<a class="ovk-icon-btn ovk-back" href="${esc(url(back.href))}" aria-label="${esc(back.label || 'Back')}" title="${esc(back.label || 'Back')}">${icon('back')}</a>` : '')
438 + (ic ? `<span class="ovk-heading-icon">${icon(ic)}</span>` : '')
439 + `<div class="ovk-head-id"><${h}>${titleHtml != null ? titleHtml : esc(title)}</${h}>${subHtml != null ? `<p class="ovk-sub">${subHtml}</p>` : sub ? `<p class="ovk-sub">${esc(sub)}</p>` : ''}</div>`
440 + (actions && actions.length ? `<div class="ovk-head-tools">${actions.join('')}</div>` : '')
441 + `</div>${tabs || ''}</header>`;
442 }
443 /** A page identity, with a named parent and a native, script-free mobile description disclosure.
444 * actions accepts iconButton options or trusted control markup, like head(). */
445 function pageHead({ back = null, icon: ic = 'home', title = '', lede = '', actions = [] } = {}) {
446 const tools = (actions || []).map(a => typeof a === 'string' ? a : iconButton(a)).join('');
447 return `<header class="ovk-page-head">`
448 + (back ? `<a class="ovk-page-back" href="${esc(url(back.href))}">${icon('back')}<span>${esc(back.label || 'Back')}</span></a>` : '')
449 + `<div class="ovk-page-head-row"><span class="ovk-heading-icon">${icon(ic)}</span><h1>${esc(title)}</h1>`
450 + (tools ? `<div class="ovk-page-actions">${tools}</div>` : '') + `</div>`
451 + (lede ? `<p class="ovk-page-lede">${esc(lede)}</p>` : '') // one copy, whole, on every screen
452 + `</header>`;
453 }
454 // ── The left sidebar (L35) ──
455 /**
456 * An app's left sidebar, one for every OpenVibe product (the owner, 2026-10-31: "the navigation makes more sense
457 * in a vertical way… part of the openvibe.design shared styling across everything"): the main action and search at
458 * the top, the navigation as vertical rows (an icon and a name, the page you are on marked, a count or a dot), the
459 * sections under it (conversations, recent things) and a foot. A column beside the page from 761 px; on a phone a
460 * drawer that kit.sidebarButton opens and the scrim, its close button or Escape close (kit-web.js, boost-safe).
461 * kit.sidebar({ id, label, action: { href, label, icon }, find: { action, name, placeholder, label },
462 * nav: [{ href, label, icon, iconHtml, here, count, dot }],
463 * sections: [{ title, items: [{ href, label, meta, mark, here, dot }], empty }], foot })
464 * kit.sidebarButton({ id, label }) the button that opens it on a phone (shown only there)
465 * kit.withSidebar({ sidebar, body }) the sidebar and the page side by side
466 * dot: true, or 'busy' | 'needs' | 'bad'. mark and foot take trusted markup; iconHtml replaces a kit icon.
467 */
468 function sidebar({ id = 'ovk-sidebar', label = 'Navigation', action: act = null, find: f = null, nav = [], sections = [], foot = '' } = {}) {
469 const dotHtml = (d) => (d ? `<i class="ovk-sidebar-dot${d === true ? '' : ` is-${esc(d)}`}" aria-hidden="true"></i>` : '');
470 const link = (x) => `<a class="ovk-sidebar-link${x.here ? ' is-here' : ''}" href="${esc(url(x.href))}"${x.here ? ' aria-current="page"' : ''}>`
471 + `${x.iconHtml != null ? x.iconHtml : icon(x.icon || 'chevron')}<span>${esc(x.label)}</span>`
472 + `${Number(x.count) > 0 ? `<b class="ovk-sidebar-count">${Number(x.count) > 99 ? '99+' : Number(x.count)}</b>` : dotHtml(x.dot)}</a>`;
473 // A row's data-* attributes (a site finds its rows by them: a conversation's id), nothing else.
474 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('');
475 const item = (x) => `<a class="ovk-sidebar-item${x.sub ? ' has-sub' : ''}${x.sub && x.mark ? ' has-mark' : ''}${x.here ? ' is-here' : ''}" href="${esc(url(x.href))}"${x.here ? ' aria-current="page"' : ''}${data(x.attrs)}>`
476 + `${x.mark || ''}<span class="ovk-sidebar-t">${esc(x.label)}</span>${x.meta ? `<span class="ovk-sidebar-m">${esc(x.meta)}</span>` : ''}${dotHtml(x.dot)}`
477 + `${x.sub ? `<span class="ovk-sidebar-s">${esc(x.sub)}</span>` : ''}</a>`;
478 const secs = (sections || []).map((sec) => {
479 const items = sec.items || [];
480 return `<section class="ovk-sidebar-sec">${sec.title ? `<h2 class="ovk-sidebar-h">${esc(sec.title)}</h2>` : ''}${items.map(item).join('')}`
481 + `${!items.length && sec.empty ? `<p class="ovk-sidebar-empty">${esc(sec.empty)}</p>` : ''}</section>`;
482 }).join('');
483 return `<aside class="ovk-sidebar" id="${esc(id)}" aria-label="${esc(label)}" data-ovk-sidebar>`
484 + `<div class="ovk-sidebar-top">${act ? `<a class="ovk-sidebar-action" href="${esc(url(act.href))}">${icon(act.icon || 'plus')}<span>${esc(act.label)}</span></a>` : ''}`
485 + `<button class="ovk-icon-btn ovk-sidebar-close" type="button" data-ovk-sidebar-close aria-label="Close">${icon('close')}</button></div>`
486 + (f ? `<form class="ovk-sidebar-find" role="search" method="get" action="${esc(url(f.action || '?'))}"><label class="ovk-sr" for="${esc(id)}-q">${esc(f.label || 'Search')}</label>`
487 + `${icon('search')}<input id="${esc(id)}-q" name="${esc(f.name || 'q')}" placeholder="${esc(f.placeholder || 'Search')}" autocomplete="off"></form>` : '')
488 + (nav && nav.length ? `<nav class="ovk-sidebar-nav" aria-label="${esc(label)}">${nav.map(link).join('')}</nav>` : '')
489 + (secs ? `<div class="ovk-sidebar-secs">${secs}</div>` : '')
490 + (foot ? `<div class="ovk-sidebar-foot">${foot}</div>` : '')
491 + '</aside>';
492 }
493 const sidebarButton = ({ id = 'ovk-sidebar', label = 'Menu' } = {}) => `<button class="ovk-icon-btn ovk-sidebar-open" type="button" data-ovk-sidebar-open aria-controls="${esc(id)}" aria-expanded="false" aria-label="${esc(label)}" title="${esc(label)}">${icon('sidebar')}</button>`;
494 const withSidebar = ({ sidebar: sb = '', body = '' } = {}) => `<div class="ovk-with-sidebar">${sb}<div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden></div><div class="ovk-with-sidebar-main">${body}</div></div>`;
495
496 /**
497 * Tabs as links (?tab=key, or ?<param>=key): the server builds only the open one, so a long page becomes a few short
498 * ones and nothing needs JavaScript. items: [{ key, label, count, icon, href }].
499 */
500 function tabs({ base = '', items = [], active = '', param = 'tab', label = 'Sections' } = {}) {
501 if (!items || items.length < 2) return '';
502 const sep = String(base).includes('?') ? '&' : '?';
503 return `<nav class="ovk-tabs" aria-label="${esc(label)}">${items.map((t) => `<a href="${esc(url(t.href || `${base}${sep}${param}=${encodeURIComponent(t.key)}`))}"${t.key === active ? ' aria-current="page"' : ''}>${t.icon ? icon(t.icon) : ''}<span>${esc(t.label)}</span>${t.count != null && t.count !== '' ? `<span class="ovk-tab-n">${esc(t.count)}</span>` : ''}</a>`).join('')}</nav>`;
504 }
505 /** The open tab from a query object: its value when it is one of keys, else the first key. */
506 function tabOf(query, keys, param = 'tab') {
507 const v = query && query[param] != null ? String(query[param]) : '';
508 return keys.includes(v) ? v : keys[0];
509 }
510
511 // ── Lists and rows ──
512 /** A list of rows (kit.row), in one rounded panel; title: a small heading above it. */
513 function list(rows = [], { title = '', more = null, flush = false, empty = '' } = {}) {
514 const headHtml = title ? `<div class="ovk-list-head"><h2>${esc(title)}</h2>${more ? `<a href="${esc(url(more.href))}">${esc(more.label || 'All')}</a>` : ''}</div>` : '';
515 if (!rows.length) return empty ? `${headHtml}<p class="ovk-empty-line">${esc(empty)}</p>` : '';
516 return `${headHtml}<ul class="ovk-list${flush ? ' ovk-list--flush' : ''}">${rows.join('')}</ul>`;
517 }
518 /**
519 * One row: its mark, the title (with a badge) and the time on one line, one muted line under it, a trailing control.
520 * href makes the whole row a link; without it the row is static and trail can hold its own forms. rel: the link's
521 * rel ('nofollow ugc' for links people posted, 'noopener' off-site).
522 */
523 function row({ href = null, rel = '', mark: markHtml = '', title = '', titleHtml = null, badge: badgeHtml = '', meta = '', metaIso = '', sub = '', subHtml = null, trail = '', unread = 0, attrs: extra = null } = {}) {
524 const top = `<span class="ovk-row-top"><strong>${titleHtml != null ? titleHtml : esc(title)}</strong>${badgeHtml || ''}${meta ? (metaIso ? `<time datetime="${esc(metaIso)}">${esc(meta)}</time>` : `<span class="ovk-row-meta">${esc(meta)}</span>`) : ''}</span>`;
525 const subLine = subHtml != null || sub || unread ? `<span class="ovk-row-sub"><span>${subHtml != null ? subHtml : esc(sub)}</span>${dot(unread)}</span>` : '';
526 const main = `${markHtml || ''}<span class="ovk-row-main">${top}${subLine}</span>`;
527 const cls = `ovk-row${unread ? ' ovk-row--unread' : ''}`;
528 if (href != null) return `<li class="${cls}"${attrs(extra)}><a href="${esc(url(href))}"${rel ? ` rel="${esc(rel)}"` : ''}>${main}${trail ? `<span class="ovk-row-trail">${trail}</span>` : ''}</a></li>`;
529 return `<li class="${cls} ovk-row--static"${attrs(extra)}>${main}${trail ? `<span class="ovk-row-trail">${trail}</span>` : ''}</li>`;
530 }
531
532 // ── Cards, sections, folds ──
533 /** A card with a small heading (settings groups, a page's parts): body is markup; note: one muted line under the title. */
534 function section({ title = '', count = null, note = '', body = '', id = null, cls = '', icon: ic = '', actions = [] } = {}) {
535 const hid = id ? `${id}-h` : null;
536 const heading = title ? `<h2${hid ? ` id="${esc(hid)}"` : ''}>${esc(title)}${count != null ? `<span class="ovk-count">${esc(count)}</span>` : ''}</h2>` : '';
537 return `<section class="ovk-section${cls ? ` ${esc(cls)}` : ''}"${id ? ` id="${esc(id)}"` : ''}${hid && title ? ` aria-labelledby="${esc(hid)}"` : ''}>`
538 + (ic || actions.length ? `<header class="ovk-section-head">${ic ? `<span class="ovk-heading-icon">${icon(ic)}</span>` : ''}${heading}${actions.length ? `<div class="ovk-section-actions">${actions.join('')}</div>` : ''}</header>` : heading)
539 + (note ? `<p class="ovk-note">${esc(note)}</p>` : '') + body + '</section>';
540 }
541 /** A card that opens and closes (setup, extras, the danger zone): closed unless open; meta: a muted word after the title. */
542 function fold({ id = null, title = '', meta = '', open = false, body = '', icon: ic = 'chevron', tone = '' } = {}) {
543 return `<details class="ovk-fold${tone ? ` ovk-fold--${esc(tone)}` : ''}"${id ? ` id="${esc(id)}"` : ''}${open ? ' open' : ''}><summary><span>${esc(title)}${meta ? ` <span class="ovk-muted">· ${esc(meta)}</span>` : ''}</span>${icon(ic)}</summary><div class="ovk-fold-body">${body}</div></details>`;
544 }
545 /** A "more" menu: an icon that opens a small panel of links and one-button forms. items: [{ href | action, label, icon, danger, hidden }]. */
546 function more({ label = 'More', items = [], icon: ic = 'more' } = {}) {
547 const item = (it) => {
548 const inner = `${it.icon ? icon(it.icon) : ''}<span>${esc(it.label)}</span>`;
549 const cls = `ovk-pop-item${it.danger ? ' ovk-pop-item--danger' : ''}`;
550 if (it.action) return `<form method="post" action="${esc(url(it.action))}"${it.confirm ? ` data-ovk-confirm="${esc(it.confirm)}"` : ''}>${Object.keys(it.hidden || {}).map((k) => `<input type="hidden" name="${esc(k)}" value="${esc(it.hidden[k])}">`).join('')}<button type="submit" class="${cls}" role="menuitem">${inner}</button></form>`;
551 if (it.share) return `<a class="${cls}" role="menuitem" href="${esc(url(it.href))}" data-ovk-share="${esc(it.title || '')}">${inner}</a>`;
552 return `<a class="${cls}" role="menuitem" href="${esc(url(it.href))}">${inner}</a>`;
553 };
554 return `<details class="ovk-more"><summary class="ovk-icon-btn" aria-label="${esc(label)}" title="${esc(label)}">${icon(ic)}</summary><div class="ovk-pop" role="menu">${items.filter(Boolean).map(item).join('')}</div></details>`;
555 }
556
557 // ── Settings rows ──
558 /**
559 * A switch row: an icon tile, a title and one muted line on the left, an iOS-style switch on the right. The whole
560 * row is the target (56 px), and the switch is the checkbox itself (role="switch"), so the form works without
561 * JavaScript. `label`/`hint`/`on` are the older names, kept working.
562 */
563 const switchRow = ({ name, icon: ic = null, title = null, line = null, label = null, hint = null, checked = null, on = null, value = '1' } = {}) => {
564 const t = title != null ? title : (label != null ? label : '');
565 const l = line != null ? line : (hint || '');
566 const c = checked != null ? !!checked : !!on;
567 return `<label class="ovk-set-row ovk-set-switch">${ic ? `<span class="ovk-set-ic" aria-hidden="true">${icon(ic)}</span>` : ''}<span class="ovk-set-text">${esc(t)}${l ? `<small>${esc(l)}</small>` : ''}</span><input type="checkbox" role="switch" class="ovk-switch" name="${esc(name)}" value="${esc(value)}"${c ? ' checked' : ''}></label>`;
568 };
569 /** A slider row (0-100 by 5 unless told otherwise); the value is spoken as a percent when unit is '%'. */
570 function rangeRow({ name, label, value = 0, min = 0, max = 100, step = 5, unit = '%' }) {
571 const v = Number(value);
572 return `<label class="ovk-set-row ovk-set-range"><span class="ovk-set-text">${esc(label)}</span><input type="range" name="${esc(name)}" min="${Number(min)}" max="${Number(max)}" step="${Number(step)}" value="${Number.isFinite(v) ? v : Number(min)}" data-ovk-unit="${esc(unit)}" aria-valuetext="${Number.isFinite(v) ? v : Number(min)}${unit === '%' ? ' percent' : ` ${esc(unit)}`}"></label>`;
573 }
574 /** A choice row: the label on the left, a select on the right. options: [[value, label], …]. */
575 const selectRow = ({ name, label, hint = '', options = [], value = '' }) => `<label class="ovk-set-row"><span class="ovk-set-text">${esc(label)}${hint ? `<small>${esc(hint)}</small>` : ''}</span><select name="${esc(name)}">${options.map(([v, l]) => `<option value="${esc(v)}"${String(v) === String(value) ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>`;
576 /** A segmented choice (2-4 options) as radios that look like one control. */
577 function seg({ name, options = [], value = '', label = '' }) {
578 const id = `ovk-seg-${esc(name)}`;
579 return `<div class="ovk-set-row"><span class="ovk-set-text" id="${id}">${esc(label)}</span><span class="ovk-seg" role="radiogroup" aria-labelledby="${id}">${options.map(([v, l]) => `<label><input type="radio" name="${esc(name)}" value="${esc(v)}"${String(v) === String(value) ? ' checked' : ''}><span>${esc(l)}</span></label>`).join('')}</span></div>`;
580 }
581 /**
582 * A segmented choice (three or four) as radios that look like one control: items [{ value, label, icon }]. The
583 * value still posts as the radio group's; the input is the browser's, only drawn differently, so it works without
584 * JavaScript. Without a label the control stands alone (a form puts its own label above it) and `ariaLabel` names
585 * the group; label draws a labelled row.
586 */
587 function segmented({ name, items = [], value = '', label = '', ariaLabel = '' } = {}) {
588 const id = `ovk-seg-${String(name).replace(/[^A-Za-z0-9_-]/g, '-')}`;
589 const control = `<span class="ovk-seg ovk-seg--fill" role="radiogroup"${label ? ` aria-labelledby="${id}"` : ` aria-label="${esc(ariaLabel || name)}"`}>${items.map((it) => `<label class="ovk-seg-item"><input type="radio" name="${esc(name)}" value="${esc(it.value)}"${String(it.value) === String(value) ? ' checked' : ''}><span>${it.icon ? icon(it.icon) : ''}${esc(it.label)}</span></label>`).join('')}</span>`;
590 return label ? `<div class="ovk-set-row ovk-set-seg"><span class="ovk-set-text" id="${id}">${esc(label)}</span>${control}</div>` : control;
591 }
592 /** Chips: checkboxes (or radios: single) that look like small pills, for filters and "which of these". options: [[value, label, checked]]. */
593 function chips({ name, options = [], single = false, label = '' }) {
594 const box = options.map(([v, l, on]) => `<label class="ovk-chip"><input type="${single ? 'radio' : 'checkbox'}" name="${esc(single ? name : `${name}_${v}`)}" value="${esc(single ? v : '1')}"${on ? ' checked' : ''}><span>${esc(l)}</span></label>`).join('');
595 return label ? `<div class="ovk-set-row ovk-set-chips"><span class="ovk-set-text">${esc(label)}</span><span class="ovk-chips">${box}</span></div>` : `<span class="ovk-chips">${box}</span>`;
596 }
597 /** A form's last line that stays in reach (sticky above the dock/app bar on phones). */
598 const saveBar = ({ label = 'Save', name = null, value = null } = {}) => `<p class="ovk-save">${button({ label, name, value })}</p>`;
599
600 // ── Search, add, compose ──
601 /**
602 * A one-line form in a pill: an icon, the input, optional extra controls (a select), and a round icon button. For
603 * search (method get) and for "add by name" (post). hidden: { name: value } fields.
604 */
605 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 } = {}) {
606 const fid = id || `ovk-find-${esc(name)}`;
607 const b = btn || { icon: method === 'get' ? 'forward' : 'plus', label: method === 'get' ? 'Search' : 'Add' };
608 return `<form class="ovk-find${inset ? ' ovk-find--inset' : ''}" method="${method === 'post' ? 'post' : 'get'}" action="${esc(url(to || '?'))}"${method === 'get' ? ' role="search"' : ''}>`
609 + Object.keys(hidden || {}).map((k) => `<input type="hidden" name="${esc(k)}" value="${esc(hidden[k])}">`).join('')
610 + `${ic ? icon(ic) : ''}<label for="${fid}" class="ovk-sr">${esc(label || placeholder)}</label><input id="${fid}" name="${esc(name)}" type="${type === 'text' ? 'text' : 'search'}" value="${esc(value)}" placeholder="${esc(placeholder)}" autocomplete="off"${required ? ' required' : ''}${pattern ? ` pattern="${esc(pattern)}"` : ''}${maxlength ? ` maxlength="${Number(maxlength)}"` : ''}>`
611 + `${extra || ''}<button type="submit" class="ovk-go" aria-label="${esc(b.label)}" title="${esc(b.label)}">${icon(b.icon)}</button></form>`;
612 }
613 /**
614 * The composer pinned at the bottom of a conversation (above the app bar on phones): a one-line box that grows, Enter
615 * sends (Shift+Enter: a new line; kit.enhance), a round send button. before: controls left of the box (attach).
616 */
617 function dock({ action: to = '', name = 'message', placeholder = 'Write a message', label = 'Message', maxlength = 2000, id = 'ovk-dock', before = '', hidden = {}, send = 'Send', value = '' } = {}) {
618 return `<div class="ovk-dock">${before || ''}<form class="ovk-compose" method="post" action="${esc(url(to))}" id="${esc(id)}">`
619 + Object.keys(hidden || {}).map((k) => `<input type="hidden" name="${esc(k)}" value="${esc(hidden[k])}">`).join('')
620 + `<label for="${esc(id)}-input" class="ovk-sr">${esc(label)}</label><textarea id="${esc(id)}-input" name="${esc(name)}" rows="1" maxlength="${Number(maxlength) || 2000}" required placeholder="${esc(placeholder)}" data-ovk-enter>${esc(value)}</textarea>`
621 + `<button type="submit" class="ovk-go ovk-go--send" aria-label="${esc(send)}" title="${esc(send)}">${icon('send')}</button></form></div>`;
622 }
623
624 // ── Numbers, empty, notices, code ──
625 /** A strip of numbers: [[value, label], …] (zero and unknown values left out); scrolls sideways on phones. */
626 function stats(items = [], { label = 'In numbers' } = {}) {
627 const fmt = (v) => (typeof v === 'number' ? v.toLocaleString('en-US') : String(v));
628 const shown = items.filter((x) => x && x[0] != null && x[0] !== '' && x[0] !== 0);
629 if (!shown.length) return '';
630 // tabindex: on a phone the row scrolls sideways, and a row with no link in it had no way in by keyboard
631 // (axe scrollable-region-focusable, 2026-11-01 sweep).
632 return `<dl class="ovk-stats" aria-label="${esc(label)}" tabindex="0">${shown.map(([v, l, href]) => `<div${href ? ' class="ovk-stat--link"' : ''}><dt>${href ? `<a href="${esc(url(href))}">${esc(l)}</a>` : esc(l)}</dt><dd>${esc(fmt(v))}</dd></div>`).join('')}</dl>`;
633 }
634 /**
635 * Content labels (L1, common.content-labels@1) as small chips beside a title: what a stream, recording, picture or
636 * post is labelled as. Only the labels in force: an AI or viewer suggestion is the creator's to confirm, never shown
637 * to viewers. The page also marks the labelled thing itself (data-ov-labels="…") so ov-labels.js applies each
638 * viewer's own choice; these chips only say what it carries.
639 * kit.labels(['language', '18+']) or kit.labels({ labels: [...], suggested: [...] })
640 */
641 const LABEL_NAMES = { nudity: 'Nudity', language: 'Strong language', graphic: 'Graphic', '18+': '18+', flashing: 'Flashing lights', spoilers: 'Spoilers' };
642 function labels(list, { label = 'Content labels' } = {}) {
643 const arr = Array.isArray(list) ? list : (list && Array.isArray(list.labels) ? list.labels : []);
644 const shown = [...new Set(arr)].filter((l) => Object.prototype.hasOwnProperty.call(LABEL_NAMES, l));
645 if (!shown.length) return '';
646 return `<ul class="ovk-labels" aria-label="${esc(label)}">${shown.map((l) => `<li class="ovk-label${l === '18+' ? ' ovk-label--adult' : ''}" data-label="${esc(l)}">${esc(LABEL_NAMES[l])}</li>`).join('')}</ul>`;
647 }
648 /** The value for data-ov-labels on the labelled element (empty when there is none). */
649 const labelsAttr = (list) => {
650 const arr = Array.isArray(list) ? list : (list && Array.isArray(list.labels) ? list.labels : []);
651 const shown = [...new Set(arr)].filter((l) => Object.prototype.hasOwnProperty.call(LABEL_NAMES, l));
652 return shown.length ? ` data-ov-labels="${esc(shown.join(','))}"` : '';
653 };
654 /** What an empty list says: a title, one line, and the one thing to do next. */
655 const empty = ({ title = '', text = '', action: act = null, icon: ic = '' } = {}) => `<div class="ovk-empty">${ic ? icon(ic) : ''}${title ? `<p class="ovk-empty-title">${esc(title)}</p>` : ''}${text ? `<p>${esc(text)}</p>` : ''}${act ? button({ href: act.href, label: act.label, kind: act.kind || 'primary', icon: act.icon || '' }) : ''}</div>`;
656 /** A notice in the page: kind 'ok' | 'error' | 'info' | 'warn'; errors are announced (role=alert). */
657 const notice = ({ kind = 'info', text = '', html = null } = {}) => `<p class="ovk-notice ovk-notice--${esc(kind)}" role="${kind === 'error' ? 'alert' : 'status'}">${icon(kind === 'ok' ? 'check' : kind === 'error' || kind === 'warn' ? 'alert' : 'info')}<span>${html != null ? html : esc(text)}</span></p>`;
658 /** A block of code people copy: the copy button in its corner (shown by kit.enhance), never taller than half a screen. */
659 function snippet({ code = '', id = null, label = 'Copy' } = {}) {
660 const sid = id || `ovk-snip-${hueOf(code)}-${String(code).length}`;
661 return `<div class="ovk-snip"><button type="button" class="ovk-icon-btn" data-ovk-copy="#${esc(sid)}" aria-label="${esc(label)}" title="${esc(label)}" hidden>${icon('copy')}</button><pre tabindex="0" id="${esc(sid)}"><code>${esc(code)}</code></pre></div>`;
662 }
663 /** A bottom sheet on phones, a dialog on computers: kit.sheetButton opens it (without JavaScript the sheet is a fold). */
664 const sheet = ({ id, title = '', body = '' }) => `<dialog class="ovk-sheet" id="${esc(id)}" aria-labelledby="${esc(id)}-t"><div class="ovk-sheet-head"><h2 id="${esc(id)}-t">${esc(title)}</h2><button type="button" class="ovk-icon-btn" data-ovk-close aria-label="Close" title="Close">${icon('close')}</button></div><div class="ovk-sheet-body">${body}</div></dialog>`;
665 const sheetButton = ({ sheet: sid, label = '', icon: ic = 'filter', iconOnly = true }) => (iconOnly
666 ? `<button type="button" class="ovk-icon-btn" data-ovk-sheet="#${esc(sid)}" aria-label="${esc(label)}" title="${esc(label)}">${icon(ic)}</button>`
667 : `<button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" data-ovk-sheet="#${esc(sid)}">${icon(ic)}<span>${esc(label)}</span></button>`);
668
669 // ── In the browser: the behaviour lives in kit-web.js (a server-rendered site loads only that); a page that draws
670 // markup with these builders loads both and calls enhance() on what it added. A no-op on the server.
671 const enhance = (rootEl) => (root.OpenVibeKitWeb ? root.OpenVibeKitWeb.enhance(rootEl) : undefined);
672
673 // ── Cards and rails (plan 2026-10-28 §3.4): one card for every kind of thing, in rows that snap ──
674 // The media's shape by what the card shows; `ratio` overrides it.
675 const CARD_RATIO = {
676 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',
677 event: '16/9', brief: '16/9', post: '16/9', meme: '1/1', shoe: '1/1', listing: '1/1', deal: '1/1', person: '1/1',
678 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',
679 };
680 // What a card without a picture draws in its media, by type.
681 const CARD_ICON = { stream: 'live', clip: 'play', vod: 'play', video: 'play', run: 'wave', map: 'map', cam: 'video', tour: 'map', trip: 'map',
682 event: 'calendar', brief: 'calendar', post: 'file', meme: 'image', shoe: 'shoe', listing: 'tag', deal: 'tag', person: 'user', toy: 'spark',
683 game: 'game', simulator: 'game', report: 'chart', dashboard: 'chart', theme: 'palette', kit: 'palette', benchmark: 'chart', site: 'computer', domain: 'globe' };
684 const media = (u) => /^(https:\/\/|\/(?!\/))/.test(String(u || '').trim());
685 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));
686 /**
687 * card({ href, title, type, ratio, image, alt, video, badge, live, corner, peek, who, avatar, count: [n, 'watching'],
688 * when, level, save, remind, share, more: [items] }) — one thing, the same anatomy everywhere:
689 * - media in the type's shape, a badge top-left (a word: NEW, −24 %, WR; live: true draws LIVE with a pulse) and a
690 * corner bottom-right (a length or a price); peek: one line over the media on hover or focus; video: a muted
691 * loop played while it is hovered (computers only, never under reduced motion, loaded only then)
692 * - the title (two lines at most); sub: one plain muted line under it (a city, a kind, a language); then who (with
693 * their picture or letters), one count and when; a count of 0 or none is left out, never shown as 0. whoHtml: the
694 * name as markup the caller escaped (a name with its decorations); who stays the plain name for the letters
695 * - preview: for a thing without a picture whose text is the point (a code paste, a note), its first lines drawn in
696 * the media instead of the type's icon
697 * - date: for a thing without a picture that happens on a day (a drop, an event, a deadline), the day itself drawn in
698 * the media (weekday and month, the day large; UTC) instead of a calendar icon repeated on every card
699 * - ⋯ with Save and Remind when given ({ href } or { action, hidden }), Share (always: the system share sheet, else
700 * the link copied) and any `more` items (kit.more's)
701 * The card is one link, the title's, stretched over it; ⋯ sits above it. level: the title's heading level (3).
702 */
703 // A day as a card's cover (card({ date })): the weekday and month on a line, the day large under it (clear of the
704 // corner's price). UTC, as the sites' dates are.
705 function dateCover(value) {
706 if (value == null || value === '') return '';
707 const t = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(String(value));
708 if (!Number.isFinite(t)) return '';
709 const d = new Date(t);
710 return `<span class="ovk-card-noimg ovk-card-noimg--date" aria-hidden="true"><span class="ovk-card-date-m"><span class="ovk-card-date-w">${WEEKDAYS[d.getUTCDay()]}</span> ${MONTHS[d.getUTCMonth()]}</span>`
711 + `<b class="ovk-card-date-d">${d.getUTCDate()}</b></span>`;
712 }
713 function card({ href = '#', title = '', type = '', ratio = '', image = '', alt = '', video = '', badge: bd = '', live = false, corner = '',
714 peek = '', who = '', whoHtml = null, avatar = '', count = null, when: at = null, level = 3, save = null, remind = null, share = true,
715 more: extra = null, sub = '', preview = '', date = null } = {}) {
716 const ar = /^\d{1,2}\/\d{1,2}$/.test(String(ratio)) ? ratio : CARD_RATIO[type] || '16/9';
717 const link = url(href);
718 const first = String(preview || '').split('\n').slice(0, 9).join('\n').slice(0, 480).trimEnd();
719 const pic = media(image)
720 ? `<img src="${esc(mediaW(image, 640))}" alt="${esc(alt)}" loading="lazy" decoding="async">`
721 : first ? `<pre class="ovk-card-pre" aria-hidden="true">${esc(first)}</pre>`
722 // No picture but a person (2026-11-01): their face, large, over a soft blur of it, where a lone type icon
723 // in an empty box read as a missing image (Report's episodes, Tours' recordings).
724 : media(avatar) ? `<span class="ovk-card-noimg ovk-card-noimg--who" aria-hidden="true"><img class="ovk-card-noimg-bg" src="${esc(mediaW(avatar, 160))}" alt="" loading="lazy" decoding="async"><img class="ovk-card-noimg-face" src="${esc(mediaW(avatar, 160))}" alt="" loading="lazy" decoding="async"></span>`
725 : dateCover(date) || `<span class="ovk-card-noimg">${icon(CARD_ICON[type] || 'image')}</span>`;
726 const loop = media(video) ? `<video class="ovk-card-video" data-src="${esc(video)}" muted loop playsinline preload="none" aria-hidden="true" tabindex="-1"></video>` : '';
727 const tag = live ? '<span class="ovk-card-badge is-live">LIVE</span>' : bd ? `<span class="ovk-card-badge">${esc(bd)}</span>` : '';
728 const n = Array.isArray(count) ? Number(count[0]) : Number(count);
729 const counted = Number.isFinite(n) && n > 0 ? `<span>${esc(fmtCount(n))}${Array.isArray(count) && count[1] ? ` ${esc(count[1])}` : ''}</span>` : '';
730 const t = at != null && at !== '' ? when(at) : null;
731 const meta = [whoHtml != null && whoHtml !== '' ? `<span class="ovk-card-who">${whoHtml}</span>` : who ? `<span class="ovk-card-who">${esc(who)}</span>` : '', counted, t && t.text ? `<time datetime="${esc(t.iso)}">${esc(t.text)}</time>` : ''].filter(Boolean).join('');
732 const items = [
733 save ? { label: 'Save', icon: 'star', ...save } : null,
734 remind ? { label: 'Remind me', icon: 'bell', ...remind } : null,
735 share ? { label: 'Share', icon: 'open', href: link, share: true, title } : null,
736 ].concat(extra || []).filter(Boolean);
737 const h = Math.min(6, Math.max(2, Number(level) || 3));
738 return `<article class="ovk-card${live ? ' is-live' : ''}${type ? ` ovk-card--${esc(type)}` : ''}" style="--ovk-ar:${ar}">`
739 + `<div class="ovk-card-media">${pic}${loop}${tag}${corner ? `<span class="ovk-card-corner">${esc(corner)}</span>` : ''}${peek ? `<span class="ovk-card-peek">${esc(peek)}</span>` : ''}</div>`
740 + `<div class="ovk-card-body">${who || avatar ? mark({ name: who || title, src: avatar, size: 34 }) : ''}<div class="ovk-card-text">`
741 + `<h${h} class="ovk-card-title"><a class="ovk-card-link" href="${esc(link)}">${esc(title)}</a></h${h}>${sub ? `<p class="ovk-card-sub">${esc(sub)}</p>` : ''}${meta ? `<p class="ovk-card-meta">${meta}</p>` : ''}</div>`
742 + `${items.length ? more({ label: `More: ${title}`, items }) : ''}</div></article>`;
743 }
744 /**
745 * rail({ id, title, more: { href, label }, items: [card html], size: 's'|'m'|'l', label }) — a row that scrolls
746 * sideways and snaps: about 1.2 cards on a phone, as many as fit on a computer, with ‹ › buttons there (kit.enhance).
747 * size: 's' square things (memes, shoes, deals), 'm' (default) pictures 16:9, 'l' wide features. wrap: from 760 px
748 * the row becomes a grid that shows every item (no arrows, no kit-web.js needed); phones still swipe. Empty → ''.
749 */
750 function rail({ id = '', title = '', more: m = null, items = [], size = 'm', label = '', wrap = false, icon: ic = '' } = {}) {
751 const list = (items || []).filter(Boolean);
752 if (!list.length) return '';
753 const hid = id ? `${String(id).replace(/[^a-z0-9_-]/gi, '')}-h` : '';
754 const head = title || m ? `<div class="ovk-rail-head">${title ? `<h2${ic ? ' class="ovk-titled"' : ''}${hid ? ` id="${hid}"` : ''}>${ic ? icon(ic, { cls: 'ovk-heading-icon' }) : ''}${esc(title)}</h2>` : ''}${m && m.href ? (ic ? button({ href: m.href, label: m.label || 'See all', icon: m.icon || 'forward', kind: 'quiet' }) : `<a class="ovk-rail-more" href="${esc(url(m.href))}">${esc(m.label || 'See all')}${icon('chevron')}</a>`) : ''}</div>` : '';
755 const named = hid && title ? ` aria-labelledby="${hid}"` : ` aria-label="${esc(label || title || 'More')}"`;
756 return `<section class="ovk-rail ovk-rail--${size === 's' || size === 'l' ? size : 'm'}${wrap ? ' ovk-rail--wrap' : ''}"${id ? ` id="${esc(id)}"` : ''}${named}>${head}`
757 + `<div class="ovk-rail-wrap"><ul class="ovk-rail-track" role="list">${list.map((x) => `<li>${x}</li>`).join('')}</ul>`
758 + `<button type="button" class="ovk-rail-btn ovk-rail-prev" aria-label="Scroll back" hidden>${icon('back')}</button>`
759 + `<button type="button" class="ovk-rail-btn ovk-rail-next" aria-label="Scroll on" hidden>${icon('forward')}</button></div></section>`;
760 }
761
762 // ── Kit 2 (4.36.0): the pieces data and tool pages need — steps, tables, charts, rankings, a drop zone, a result
763 // with its receipt, a meter, and the page's whole shape in one call. Same rules: plain HTML that works without
764 // JavaScript, escaped text, theme tokens, phone first (docs/kit.md "Kit 2").
765
766 /** A number the way a reader wants it: fmt 'int' | 'compact' | 'money' | 'percent' (0–100) | 'ms' | 'auto'. */
767 function num(v, fmt = 'auto') {
768 const n = Number(v);
769 if (v == null || v === '' || !Number.isFinite(n)) return '–';
770 if (typeof fmt === 'function') return String(fmt(n));
771 if (fmt === 'compact') return fmtCount(Math.round(n));
772 if (fmt === 'int') return Math.round(n).toLocaleString('en-US');
773 if (fmt === 'percent') return `${Math.abs(n) < 10 && n % 1 ? n.toFixed(1) : Math.round(n)}%`;
774 if (fmt === 'ms') return n < 1000 ? `${Math.round(n)} ms` : `${(n / 1000).toFixed(n < 1e4 ? 1 : 0)} s`;
775 if (fmt === 'money') {
776 // Exact enough to compare: cents from ten cents up ($0.10, $12.50), two significant figures below ($0.027, $0.0041).
777 const a = Math.abs(n);
778 const s = a === 0 ? '0.00' : a >= 0.1 ? a.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(Number(a.toPrecision(2)));
779 return `${n < 0 ? '−' : ''}$${s}`;
780 }
781 return n.toLocaleString('en-US', { maximumFractionDigits: Math.abs(n) < 10 ? 2 : Math.abs(n) < 100 ? 1 : 0 });
782 }
783
784 /**
785 * steps({ items: [{ label, href, note }], current: index, label }) — where someone is in a flow of a few steps.
786 * Done steps link back (when they have an href), the current one says aria-current="step", later ones are plain.
787 * A phone shows the dots and "Step 2 of 4 · Verify"; a tablet or computer shows every label.
788 */
789 function steps({ items = [], current = 0, label = 'Steps' } = {}) {
790 const list = (items || []).filter(Boolean);
791 if (!list.length) return '';
792 const at = Math.min(Math.max(0, Number(current) || 0), list.length - 1);
793 const li = list.map((s, i) => {
794 const state = i < at ? 'is-done' : i === at ? 'is-current' : 'is-next';
795 const inner = `<span class="ovk-steps-n" aria-hidden="true">${i < at ? icon('check') : i + 1}</span><span class="ovk-steps-l">${esc(s.label)}${s.note ? `<small>${esc(s.note)}</small>` : ''}</span>`;
796 const sr = i < at ? '<span class="ovk-sr"> (done)</span>' : '';
797 return `<li class="${state}"${i === at ? ' aria-current="step"' : ''}>${i < at && s.href ? `<a href="${esc(url(s.href))}">${inner}${sr}</a>` : `<span class="ovk-steps-item">${inner}${sr}</span>`}</li>`;
798 }).join('');
799 return `<nav class="ovk-steps" aria-label="${esc(label)}" style="--ovk-steps:${list.length}"><p class="ovk-steps-now"><span>Step ${at + 1} of ${list.length}</span> <strong>${esc(list[at].label)}</strong></p><ol>${li}</ol></nav>`;
800 }
801
802 /**
803 * table({ columns: [{ key, label, num, fmt, primary, hide: 'sm', html }], rows: [{…}], caption, label, empty, stack })
804 * A real table on wide screens. Under 600 px each row becomes a card (stack, the default): the primary column is its
805 * title and every other cell says its column's name. A cell is text (escaped), { text, href } (a link), or trusted
806 * markup when its column says html: true. num: right-aligned, tabular figures, formatted with fmt (kit.num).
807 */
808 function table({ columns = [], rows = [], caption = '', label = '', empty: none = '', stack = true, id = null } = {}) {
809 const cols = (columns || []).filter((c) => c && c.key != null);
810 if (!rows || !rows.length) return none ? `<p class="ovk-table-empty">${esc(none)}</p>` : '';
811 const primary = cols.find((c) => c.primary) || cols[0];
812 const cell = (c, v) => {
813 if (c.html) return v == null ? '' : String(v);
814 if (v && typeof v === 'object' && v.href) return `<a href="${esc(url(v.href))}">${esc(v.text != null ? v.text : v.href)}</a>`;
815 if (c.num) return esc(num(v, c.fmt || 'auto'));
816 return esc(v == null ? '' : v);
817 };
818 const cls = (c) => [c.num ? 'is-num' : '', c === primary ? 'is-primary' : '', c.hide === 'sm' ? 'is-hide-sm' : ''].filter(Boolean).join(' ');
819 const head = `<thead><tr>${cols.map((c) => `<th scope="col"${cls(c) ? ` class="${cls(c)}"` : ''}>${esc(c.label)}</th>`).join('')}</tr></thead>`;
820 const body = `<tbody>${rows.map((r) => `<tr>${cols.map((c) => {
821 const tag = c === primary ? 'th scope="row"' : 'td';
822 return `<${tag}${cls(c) ? ` class="${cls(c)}"` : ''} data-label="${esc(c.label)}">${cell(c, r[c.key])}</${tag.split(' ')[0]}>`;
823 }).join('')}</tr>`).join('')}</tbody>`;
824 const named = caption ? '' : ` aria-label="${esc(label || 'Table')}"`;
825 return `<div class="ovk-table-wrap${stack ? ' ovk-table--stack' : ''}" role="region" tabindex="0"${named}${id ? ` id="${esc(id)}"` : ''}><table class="ovk-table">${caption ? `<caption>${esc(caption)}</caption>` : ''}${head}${body}</table></div>`;
826 }
827
828 /** Three to six round ticks between lo and hi (1, 2, 5 × 10ⁿ). */
829 function niceTicks(lo, hi, count = 4) {
830 if (!(hi > lo)) { hi = lo === 0 ? 1 : lo + Math.abs(lo) * 0.1; }
831 const raw = (hi - lo) / count;
832 const mag = Math.pow(10, Math.floor(Math.log10(raw)));
833 const f = raw / mag;
834 const step = (f >= 7 ? 10 : f >= 3 ? 5 : f >= 1.5 ? 2 : 1) * mag;
835 const a = Math.floor(lo / step + 1e-9) * step, b = Math.ceil(hi / step - 1e-9) * step;
836 const ticks = [];
837 for (let v = a; v <= b + step / 2; v += step) ticks.push(Number(v.toPrecision(12)));
838 return { lo: a, hi: b, ticks };
839 }
840 const HOUR = 3600000;
841 const toMs = (x) => (x instanceof Date ? x.getTime() : typeof x === 'number' ? x : Date.parse(String(x)));
842 /** Ticks on a time axis at round moments (hours, days, weeks, months), with labels that say only what changes. */
843 function timeTicks(lo, hi) {
844 const span = hi - lo;
845 const steps = [HOUR, 3 * HOUR, 6 * HOUR, 12 * HOUR, DAY, 2 * DAY, 7 * DAY, 14 * DAY];
846 const out = [];
847 const d2 = (n) => String(n).padStart(2, '0');
848 if (span <= 70 * DAY) {
849 const step = steps.find((s) => span / s <= 5) || 14 * DAY;
850 const day = step >= DAY;
851 for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) {
852 const d = new Date(t);
853 out.push([t, day ? `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}` : `${d2(d.getUTCHours())}:00`]);
854 }
855 return out;
856 }
857 const months = span <= 200 * DAY ? 1 : span <= 400 * DAY ? 3 : span <= 800 * DAY ? 6 : 12;
858 const s = new Date(lo);
859 let y = s.getUTCFullYear(), m = Math.ceil((s.getUTCMonth() + (s.getUTCDate() > 1 ? 1 : 0)) / months) * months;
860 for (;;) {
861 while (m >= 12) { m -= 12; y += 1; }
862 const t = Date.UTC(y, m, 1);
863 if (t > hi) break;
864 out.push([t, m === 0 || months === 12 ? `${MONTHS[m]} ${y}` : MONTHS[m]]);
865 m += months;
866 }
867 return out;
868 }
869 const pct = (v) => `${(Math.round(v * 100) / 100)}%`;
870
871 /**
872 * chart({ type: 'line' | 'area' | 'bar' | 'scatter', series, points, x: { time, label, fmt }, y: { label, fmt, zero },
873 * title, note, height, label })
874 * - line/area: series: [{ name, points: [[x, y], …] }] (y null = a gap). x.time: x is a time (ms, ISO or Date).
875 * - bar: series: [{ name, points: [[label or time, value], …] }], one bar per point (counts per day, per kind).
876 * - scatter: points: [{ x, y, label, href, mark: true }] — every point is focusable and says its values; mark:
877 * true writes its label beside it. frontier: true draws the line through the points marked best.
878 * The picture is an SVG stretched to the box (strokes keep their width); every word and number is real text at its
879 * real size, so a phone reads it as well as a computer. A table with the same numbers is there for screen readers;
880 * kit-web.js adds the hover and arrow-key readout on lines and areas.
881 */
882 function chart({ type = 'line', series = [], points = [], x: xo = {}, y: yo = {}, title = '', note = '', height = 220, label = '', id = null } = {}) {
883 const kind = ['line', 'area', 'bar', 'scatter'].includes(type) ? type : 'line';
884 const isTime = !!xo.time;
885 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));
886 const fy = (v) => num(v, yo.fmt || 'auto');
887 const name = title || label || 'Chart';
888 let plot = '', table = '', legend = '', data = null, yl = [];
889
890 if (kind === 'scatter') {
891 const ps = (points || []).filter((p) => p && Number.isFinite(Number(p.x)) && Number.isFinite(Number(p.y)));
892 if (!ps.length) return '';
893 const xs = niceTicks(Math.min(...ps.map((p) => +p.x)) * (xo.zero === false ? 1 : 0), Math.max(...ps.map((p) => +p.x)));
894 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)));
895 const X = (v) => ((v - xs.lo) / (xs.hi - xs.lo)) * 100, Y = (v) => ((v - ys.lo) / (ys.hi - ys.lo)) * 100;
896 const best = ps.filter((p) => p.best).sort((a, b) => a.x - b.x);
897 const line = best.length > 1 ? `<svg class="ovk-chart-svg" viewBox="0 0 1000 1000" preserveAspectRatio="none" aria-hidden="true" focusable="false"><path class="ovk-chart-front" d="M${best.map((p) => `${(X(p.x) * 10).toFixed(1)},${(1000 - Y(p.y) * 10).toFixed(1)}`).join('L')}"/></svg>` : '';
898 const dots = ps.map((p) => {
899 const say = `${p.label ? `${p.label}: ` : ''}${yo.label || 'y'} ${fy(p.y)}, ${xo.label || 'x'} ${fx(p.x)}`;
900 const tag = p.href ? `a href="${esc(url(p.href))}"` : 'span tabindex="0"';
901 const right = X(+p.x) > 70;
902 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)}">`
903 + `${p.mark && p.label ? `<span class="ovk-chart-name" aria-hidden="true">${esc(p.label)}</span>` : ''}<span class="ovk-chart-pop" aria-hidden="true"><b>${esc(p.label || '')}</b>${esc(fy(p.y))} · ${esc(fx(p.x))}</span></${tag.split(' ')[0]}>`;
904 }).join('');
905 yl = ys.ticks.map(fy);
906 plot = axes(xs.ticks.map((t) => [X(t), fx(t)]), ys.ticks.map((t) => [Y(t), fy(t)])) + line + dots;
907 table = `<thead><tr><th scope="col">${esc(xo.label ? 'Name' : '')}</th><th scope="col">${esc(xo.label || 'x')}</th><th scope="col">${esc(yo.label || 'y')}</th></tr></thead><tbody>${ps.map((p) => `<tr><th scope="row">${esc(p.label || '')}</th><td>${esc(fx(p.x))}</td><td>${esc(fy(p.y))}</td></tr>`).join('')}</tbody>`;
908 } else {
909 const ss = (series || []).filter((s) => s && Array.isArray(s.points) && s.points.length);
910 if (!ss.length) return '';
911 if (kind === 'bar') {
912 const s = ss[0];
913 const val = (p) => (p[1] == null || p[1] === '' ? NaN : Number(p[1])); // missing is not 0
914 const vals = s.points.map(val).filter(Number.isFinite);
915 const ys = niceTicks(Math.min(0, ...vals), Math.max(0, ...vals));
916 const Y = (v) => ((v - ys.lo) / (ys.hi - ys.lo)) * 100;
917 const every = Math.max(1, Math.ceil(s.points.length / 6));
918 const bars = s.points.map((p, i) => {
919 const v = val(p);
920 const lab = isTime ? fx(p[0]) : String(p[0]);
921 return `<span class="ovk-chart-bar${Number.isFinite(v) ? '' : ' is-none'}" tabindex="0" style="--h:${pct(Number.isFinite(v) ? Y(v) - Y(Math.max(0, ys.lo)) : 0)}" aria-label="${esc(`${lab}: ${fy(v)}`)}"><span class="ovk-chart-pop" aria-hidden="true"><b>${esc(lab)}</b>${esc(fy(v))}</span>${i % every === 0 ? `<span class="ovk-chart-blab" aria-hidden="true">${esc(lab)}</span>` : ''}</span>`;
922 }).join('');
923 yl = ys.ticks.map(fy);
924 plot = axes([], ys.ticks.map((t) => [Y(t), fy(t)])) + `<div class="ovk-chart-bars" style="--n:${s.points.length}">${bars}</div>`;
925 table = `<thead><tr><th scope="col">${esc(xo.label || '')}</th><th scope="col">${esc(s.name || yo.label || '')}</th></tr></thead><tbody>${s.points.map((p) => `<tr><th scope="row">${esc(isTime ? fx(p[0]) : p[0])}</th><td>${esc(fy(p[1]))}</td></tr>`).join('')}</tbody>`;
926 } else {
927 const all = ss.flatMap((s) => s.points);
928 const xv = all.map((p) => (isTime ? toMs(p[0]) : Number(p[0]))).filter(Number.isFinite);
929 const yNum = all.map((p) => (p[1] == null ? NaN : Number(p[1]))).filter(Number.isFinite);
930 if (!xv.length || !yNum.length) return '';
931 const x0 = Math.min(...xv), x1 = Math.max(...xv);
932 const ys = niceTicks(yo.zero === false ? Math.min(...yNum) : Math.min(0, ...yNum), Math.max(...yNum));
933 const X = (v) => (x1 > x0 ? ((v - x0) / (x1 - x0)) * 100 : 50), Y = (v) => ((v - ys.lo) / (ys.hi - ys.lo)) * 100;
934 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)]);
935 const paths = ss.map((s, i) => {
936 const pts = s.points.map((p) => [X(isTime ? toMs(p[0]) : Number(p[0])), p[1] == null ? null : Y(Number(p[1]))]);
937 let d = '', open = false;
938 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; }
939 const firstX = pts.find((p) => p[1] != null), lastX = [...pts].reverse().find((p) => p[1] != null);
940 const area = kind === 'area' && firstX && lastX && !pts.some((p) => p[1] == null) ? `<path class="ovk-chart-area" d="${d}L${(lastX[0] * 10).toFixed(1)},${(1000 - Y(Math.max(ys.lo, 0)) * 10).toFixed(1)}L${(firstX[0] * 10).toFixed(1)},${(1000 - Y(Math.max(ys.lo, 0)) * 10).toFixed(1)}Z"/>` : '';
941 return `<g class="ovk-chart-s${(i % 5) + 1}">${area}<path class="ovk-chart-line" d="${d}"/></g>`;
942 }).join('');
943 yl = ys.ticks.map(fy);
944 plot = axes(xt, ys.ticks.map((t) => [Y(t), fy(t)])) + `<svg class="ovk-chart-svg" viewBox="0 0 1000 1000" preserveAspectRatio="none" aria-hidden="true" focusable="false">${paths}</svg>`;
945 // The readout's data (kit-web.js): x positions and labels, and per series its y positions and values.
946 const xsAll = [...new Set(ss[0].points.map((p) => (isTime ? toMs(p[0]) : Number(p[0]))))];
947 data = { x: xsAll.map((v) => [Math.round(X(v) * 100) / 100, fx(v)]),
948 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; }) })) };
949 const last = (s) => [...s.points].reverse().find((p) => p[1] != null);
950 legend = `<ul class="ovk-chart-legend">${ss.map((s, i) => `<li class="ovk-chart-s${(i % 5) + 1}"><i aria-hidden="true"></i>${esc(s.name || '')}${last(s) ? ` <b>${esc(fy(last(s)[1]))}</b>` : ''}</li>`).join('')}</ul>`;
951 table = `<thead><tr><th scope="col">${esc(xo.label || '')}</th>${ss.map((s) => `<th scope="col">${esc(s.name || '')}</th>`).join('')}</tr></thead><tbody>${xsAll.map((xv0) => `<tr><th scope="row">${esc(fx(xv0))}</th>${ss.map((s) => { const p = s.points.find((q) => (isTime ? toMs(q[0]) : Number(q[0])) === xv0); return `<td>${esc(p ? fy(p[1]) : '–')}</td>`; }).join('')}</tr>`).join('')}</tbody>`;
952 }
953 }
954 // The left gutter fits the longest y label (about 7 px a character at the axis's size).
955 const gutter = Math.min(72, 14 + 7 * Math.max(2, ...yl.map((t) => String(t).length)));
956 const cap = title || note ? `<figcaption>${title ? `<strong>${esc(title)}</strong>` : ''}${note ? `<span>${esc(note)}</span>` : ''}</figcaption>` : '';
957 const axisNames = yo.label ? `<span class="ovk-chart-yname" aria-hidden="true">${esc(yo.label)}</span>` : '';
958 return `<figure class="ovk-chart ovk-chart--${kind}"${id ? ` id="${esc(id)}"` : ''} style="--ovk-chart-h:${Math.max(120, Math.min(600, Number(height) || 220))}px;--ovk-chart-g:${gutter}px">${cap}${legend}${axisNames}`
959 + `<div class="ovk-chart-plot"${data ? ` tabindex="0" role="img" aria-label="${esc(name)}" data-ovk-chart="${esc(JSON.stringify(data))}"` : ` role="group" aria-label="${esc(name)}"`}>${plot}${data ? '<span class="ovk-chart-rule" hidden></span><span class="ovk-chart-tip" hidden></span>' : ''}</div>`
960 + `${xo.label ? `<p class="ovk-chart-xname" aria-hidden="true">${esc(xo.label)}</p>` : ''}<div class="ovk-sr"><table><caption>${esc(name)}</caption>${table}</table></div></figure>`;
961 }
962 /** The grid and the tick labels: x [[pos%, text]], y [[pos%, text]] — real text, placed by percent. */
963 function axes(xt, yt) {
964 return yt.map(([p, t]) => `<span class="ovk-chart-grid" style="bottom:${pct(p)}" aria-hidden="true"></span><span class="ovk-chart-yt" style="bottom:${pct(p)}" aria-hidden="true">${esc(t)}</span>`).join('')
965 + xt.map(([p, t]) => `<span class="ovk-chart-xt" style="left:${pct(p)}" aria-hidden="true">${esc(t)}</span>`).join('');
966 }
967
968 /**
969 * rank({ items: [{ name, href, mark, value, fmt, interval, delta, note, badge }], fmt, base, label, start })
970 * A leaderboard: the place, the name, the value (± its interval), the change since last time, and a bar in
971 * proportion (from `base`, 0 by default — give the floor for a rating that never nears 0, e.g. an Elo).
972 */
973 function rank({ items = [], fmt = 'auto', base = 0, label = 'Ranking', start = 1, unit = '' } = {}) {
974 const list = (items || []).filter((x) => x && x.name != null);
975 if (!list.length) return '';
976 const top = Math.max(...list.map((x) => Number(x.value)).filter(Number.isFinite), base + 1e-9);
977 return `<ol class="ovk-rank" aria-label="${esc(label)}" start="${Number(start) || 1}">${list.map((x, i) => {
978 const v = Number(x.value);
979 const share = Number.isFinite(v) ? Math.max(0, Math.min(1, (v - base) / (top - base))) : 0;
980 const d = Number(x.delta);
981 const delta = Number.isFinite(d) && d !== 0 ? `<span class="ovk-rank-delta ${d > 0 ? 'is-up' : 'is-down'}" aria-label="${d > 0 ? 'up' : 'down'} ${Math.abs(d)}">${icon(d > 0 ? 'up' : 'down')}${Math.abs(d)}</span>` : x.delta === 'new' ? '<span class="ovk-rank-delta is-new">new</span>' : '';
982 const nameHtml = x.href ? `<a class="ovk-rank-link" href="${esc(url(x.href))}">${esc(x.name)}</a>` : `<strong>${esc(x.name)}</strong>`;
983 return `<li class="ovk-rank-row${i < 3 && start === 1 ? ' is-top' : ''}"><span class="ovk-rank-n" aria-hidden="true">${(Number(start) || 1) + i}</span>${x.mark || ''}`
984 + `<span class="ovk-rank-main"><span class="ovk-rank-name">${nameHtml}${x.badge || ''}</span>${x.note ? `<small>${esc(x.note)}</small>` : ''}</span>`
985 + `<span class="ovk-rank-val"><b>${esc(x.display != null ? x.display : num(v, x.fmt || fmt))}${unit ? ` <small>${esc(unit)}</small>` : ''}</b>${x.interval != null ? `<small>± ${esc(num(x.interval, x.fmt || fmt))}</small>` : ''}</span>${delta}`
986 + `<span class="ovk-rank-bar" style="--v:${share.toFixed(3)}" aria-hidden="true"></span></li>`;
987 }).join('')}</ol>`;
988 }
989
990 /**
991 * meter({ value, max, label, text, tone }) — how full something is. tone 'ok' | 'warn' | 'bad', or 'auto' for use of
992 * a limit (a quota, a budget, a disk): amber from 75 %, red from 90 %. Without a tone it is the accent, which suits
993 * a level where more is better (a confidence, a progress).
994 */
995 function meter({ value = 0, max = 100, label = '', text = '', tone = '' } = {}) {
996 const v = Math.max(0, Math.min(Number(max) || 100, Number(value) || 0));
997 const share = v / (Number(max) || 100);
998 const t = tone === 'auto' ? (share >= 0.9 ? 'bad' : share >= 0.75 ? 'warn' : '') : tone;
999 return `<span class="ovk-meter${t ? ` ovk-meter--${esc(t)}` : ''}" role="meter" aria-valuemin="0" aria-valuemax="${Number(max) || 100}" aria-valuenow="${v}" aria-label="${esc(label)}"${text ? ` aria-valuetext="${esc(text)}"` : ''}><span style="--v:${share.toFixed(3)}"></span></span>`;
1000 }
1001
1002 /**
1003 * drop({ name, accept, label, hint, multiple, camera, required, id, auto }) — the place a file goes: a real file input
1004 * (it works without JavaScript), with drag and drop, paste and, on phones, the camera added by kit-web.js. auto:
1005 * the form is sent as soon as a file is chosen. Put it inside the form that receives the file.
1006 */
1007 function drop({ name = 'file', accept = '', label = 'Choose a file', hint = '', multiple = false, camera = false, required = false, id = null, auto = false } = {}) {
1008 const fid = id || `ovk-drop-${esc(name)}`;
1009 const cam = camera ? `<label class="ovk-btn ovk-btn--quiet ovk-btn--small ovk-drop-cam" hidden>${icon('image')}<span>Take a photo</span><input type="file" accept="image/*" capture="environment" class="ovk-sr" data-ovk-drop-cam></label>` : '';
1010 return `<div class="ovk-drop" data-ovk-drop${auto ? ' data-ovk-auto' : ''}><input type="file" class="ovk-drop-input" id="${esc(fid)}" name="${esc(name)}"${accept ? ` accept="${esc(accept)}"` : ''}${multiple ? ' multiple' : ''}${required ? ' required' : ''}>`
1011 + `<label for="${esc(fid)}" class="ovk-drop-zone">${icon('upload')}<strong>${esc(label)}</strong>${hint ? `<small>${esc(hint)}</small>` : ''}<span class="ovk-drop-name" aria-live="polite"></span></label>${cam}</div>`;
1012 }
1013
1014 /**
1015 * receipt({ title, body, facts: [[label, value]], actions: [html], tone }) — a result and what it cost: the answer
1016 * first, then one quiet line of facts (model, cost, time, confidence, how long it is kept). A value can be
1017 * { html } (trusted markup, e.g. a kit.meter).
1018 */
1019 function receipt({ title = '', body = '', facts = [], actions = [], tone = '', id = null } = {}) {
1020 const fs2 = (facts || []).filter((f) => f && f[1] != null && f[1] !== '');
1021 const acts = (actions || []).filter(Boolean).join('');
1022 return `<section class="ovk-receipt${tone ? ` ovk-receipt--${esc(tone)}` : ''}"${id ? ` id="${esc(id)}"` : ''}${title ? '' : ' aria-label="Result"'}>`
1023 + `${title || acts ? `<div class="ovk-receipt-head">${title ? `<h2>${esc(title)}</h2>` : '<span></span>'}${acts ? `<span class="ovk-receipt-acts">${acts}</span>` : ''}</div>` : ''}`
1024 + `<div class="ovk-receipt-body">${body}</div>`
1025 + `${fs2.length ? `<dl class="ovk-receipt-facts">${fs2.map(([l, v]) => `<div><dt>${esc(l)}</dt><dd>${v && typeof v === 'object' && v.html != null ? v.html : esc(v)}</dd></div>`).join('')}</dl>` : ''}</section>`;
1026 }
1027
1028 // ── The tool frame (4.55.0, owner ask 11): one order for every tool page on every site ──
1029 // What it does, the box you type in, the answer and its next step, the family's other tools, then the rest folded
1030 // away. Phone first; from 1024 px the family sits in a column beside the tool. docs/kit.md "The tool frame".
1031
1032 /** The tool's terms as a short list: text, or { text, icon }; blanks are left out. */
1033 function factsList(facts) {
1034 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());
1035 return fs.length ? `<ul class="ovk-tool-facts">${fs.map((f) => `<li>${f.icon ? icon(f.icon) : ''}${esc(f.text)}</li>`).join('')}</ul>` : '';
1036 }
1037 /** A family's tint as a hue (0–360): a number, or a key hashed the way kit.mark hashes names. */
1038 const hueFor = (h, key) => (h != null && h !== '' && Number.isFinite(Number(h)) ? ((Math.round(Number(h)) % 360) + 360) % 360 : hueOf(key || ''));
1039
1040 /**
1041 * toolHead({ icon, iconHtml, hue, family, crumb: { href, label }, title, task, facts, level }) — the tool's name and the
1042 * one sentence saying what it does. The icon sits on a tile in the family's tint (hue, else one hashed from family or
1043 * the crumb); buttons keep the site's own accent. facts: the plain terms, in a short line under the sentence ('Free',
1044 * 'Runs in your browser: nothing is uploaded', 'Files up to 50 MB', 'Deleted after 1 hour'); the market research's
1045 * 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,
1046 * or { text, icon } with a kit icon name.
1047 */
1048 function toolHead({ icon: ic = 'grid', iconHtml = null, hue = null, family: fam = '', crumb = null, title = '', task = '', facts = [], level = 1 } = {}) {
1049 const lv = Math.min(3, Math.max(1, Number(level) || 1));
1050 const cr = crumb && crumb.label ? (crumb.href ? `<a class="ovk-tool-crumb" href="${esc(url(crumb.href))}">${esc(crumb.label)}${icon('chevron')}</a>` : `<span class="ovk-tool-crumb">${esc(crumb.label)}</span>`) : '';
1051 return `<header class="ovk-tool-head${cr ? '' : ' ovk-tool-head--solo'}" style="--ovk-tint-h:${hueFor(hue, fam || (crumb && crumb.label) || title)}">`
1052 + `<span class="ovk-tool-tile" aria-hidden="true">${iconHtml != null ? iconHtml : (icon(ic) || icon('grid'))}</span>${cr}`
1053 + `<h${lv} class="ovk-tool-title">${esc(title)}</h${lv}>${task ? `<p class="ovk-tool-task">${esc(task)}</p>` : ''}`
1054 + factsList(facts) + '</header>';
1055 }
1056
1057 /**
1058 * tries({ name, target, run, items: [{ label, value, href }] }) — "Try" examples under the box: a tap fills it and
1059 * runs the tool (kit-web.js). Without JavaScript each is a link to ?<name>=<value>, so a tool page runs the tool its
1060 * query names. target: the box (a selector; default the [name] field in the same form); run: a button to press when
1061 * the box is in no form.
1062 */
1063 function tries({ name = 'q', target = '', run = '', items = [], label = 'Try' } = {}) {
1064 const its = (items || []).filter((it) => it && (it.value != null || it.href));
1065 if (!its.length) return '';
1066 return `<p class="ovk-try"><span>${esc(label)}</span>${its.map((it) => {
1067 const v = it.value == null ? '' : String(it.value);
1068 const href = it.href ? url(it.href) : `?${encodeURIComponent(name)}=${encodeURIComponent(v)}`;
1069 return `<a href="${esc(href)}"${it.value != null ? ` data-ovk-try="${esc(v)}" data-ovk-name="${esc(name)}"` : ''}${target ? ` data-ovk-for="${esc(target)}"` : ''}${run ? ` data-ovk-run="${esc(run)}"` : ''}>${esc(it.label || v)}</a>`;
1070 }).join('')}</p>`;
1071 }
1072
1073 /**
1074 * toolInput({ action, method, name, label, placeholder, value, button, icon, type, inputmode, id, required, pattern,
1075 * maxlength, hidden, tries }) — the one box most tools need: a big field (17 px, so phones do not zoom in), a clear
1076 * button, and the tool's button under it on a phone and beside it on a computer.
1077 */
1078 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' } = {}) {
1079 const fid = id || `ovk-tool-${String(name).replace(/[^a-z0-9_-]/gi, '')}`;
1080 const t = ['text', 'search', 'url', 'email', 'number', 'tel'].includes(type) ? type : 'text';
1081 return `<form class="ovk-tool-form" method="${method === 'post' ? 'post' : 'get'}" action="${esc(url(to || '?'))}">`
1082 + Object.keys(hidden || {}).map((k) => `<input type="hidden" name="${esc(k)}" value="${esc(hidden[k])}">`).join('')
1083 + `${label ? `<label class="ovk-tool-label" for="${esc(fid)}">${esc(label)}</label>` : ''}`
1084 + `<div class="ovk-tool-row"><span class="ovk-tool-field"><input id="${esc(fid)}" name="${esc(name)}" type="${t}" value="${esc(value)}"${label ? '' : ` aria-label="${esc(placeholder || btn)}"`} placeholder="${esc(placeholder)}" autocomplete="${esc(autocomplete)}" spellcheck="false" autocapitalize="off"${inputmode ? ` inputmode="${esc(inputmode)}"` : ''}${required ? ' required' : ''}${pattern ? ` pattern="${esc(pattern)}"` : ''}${maxlength ? ` maxlength="${Number(maxlength)}"` : ''}>`
1085 + `<button type="button" class="ovk-tool-clear" data-ovk-clear="#${esc(fid)}" aria-label="Clear" title="Clear"${value ? '' : ' hidden'}>${icon('close')}</button></span>`
1086 + `<button type="submit" class="ovk-btn ovk-btn--primary ovk-tool-go">${ic ? icon(ic) : ''}<span>${esc(btn)}</span></button></div>`
1087 + (tr ? tries({ name, target: `#${fid}`, ...tr }) : '') + '</form>';
1088 }
1089
1090 /**
1091 * answer({ title, badge: { text, tone }, actions: [html], rows: [[label, value, note]], body, next: [html], tone, id })
1092 * — a tool's answer: labelled rows with the fact that matters marked (note: text or { text, tone }, a small pill),
1093 * then the next step (watch it for changes, look up the same name's DNS, compress the picture just made). A value
1094 * can be { html } (trusted markup). tone 'bad' draws an error answer.
1095 */
1096 function answer({ title = '', badge: bd = null, actions = [], rows = [], body = '', next = [], tone = '', id = null, live = true } = {}) {
1097 const rs = (rows || []).filter((r) => r && r[1] != null && r[1] !== '');
1098 const acts = (actions || []).filter(Boolean).join('');
1099 const nx = (next || []).filter(Boolean).join('');
1100 const pill = (n) => { if (n == null || n === '') return ''; const o = typeof n === 'object' ? n : { text: n }; return ` <span class="ovk-pill${o.tone ? ` ovk-pill--${esc(o.tone)}` : ''}">${esc(o.text)}</span>`; };
1101 const val = (v) => (v && typeof v === 'object' && v.html != null ? v.html : esc(v));
1102 return `<section class="ovk-answer${tone ? ` ovk-answer--${esc(tone)}` : ''}"${id ? ` id="${esc(id)}"` : ''}${live ? ' aria-live="polite"' : ''}${title ? '' : ' aria-label="Answer"'}>`
1103 + (title || (bd && bd.text) || acts ? `<div class="ovk-answer-head">${title ? `<h2>${esc(title)}</h2>` : '<span></span>'}${bd && bd.text ? badge(bd.text, bd.tone || '') : ''}${acts ? `<span class="ovk-answer-acts">${acts}</span>` : ''}</div>` : '')
1104 + (rs.length ? `<dl class="ovk-answer-rows">${rs.map(([l, v, n]) => `<div><dt>${esc(l)}</dt><dd>${val(v)}${pill(n)}</dd></div>`).join('')}</dl>` : '')
1105 + (body ? `<div class="ovk-answer-body">${body}</div>` : '')
1106 + (nx ? `<div class="ovk-answer-next">${nx}</div>` : '') + '</section>';
1107 }
1108
1109 /**
1110 * family({ title, more: { href, label }, items: [{ href, label, short, current }] }) — the family's other tools: one
1111 * row that scrolls sideways on a phone (short names), a list beside the tool on a computer (full names).
1112 */
1113 function family({ title = '', more: m = null, items = [], label = '' } = {}) {
1114 const its = (items || []).filter((it) => it && it.label);
1115 if (!its.length) return '';
1116 return `<nav class="ovk-family" aria-label="${esc(label || title || 'More tools')}">`
1117 + `<div class="ovk-family-head">${title ? `<h2>${esc(title)}</h2>` : '<span></span>'}${m && m.href ? `<a href="${esc(url(m.href))}">${esc(m.label || 'All')}</a>` : ''}</div>`
1118 + `<div class="ovk-family-items">${its.map((it) => `<a href="${esc(url(it.href))}"${it.current ? ' aria-current="page"' : ''}>${it.short && it.short !== it.label ? `<span class="ovk-family-short">${esc(it.short)}</span><span class="ovk-family-full">${esc(it.label)}</span>` : esc(it.label)}</a>`).join('')}</div></nav>`;
1119 }
1120
1121 /**
1122 * tool({ head, input, answer, family, folds: [html], related, id }) — the whole frame. input: the card's markup
1123 * (kit.toolInput, kit.drop, an editor); answer: '' until there is one (the .ovk-tool-out slot stays, so a page that
1124 * draws its answer later fills it); folds: kit.fold()s joined into one card (About, Questions, Use it from code);
1125 * related: a kit.list of tools people also use. Folded text stays in the page for search engines.
1126 */
1127 function tool({ head: h = '', input = '', answer: a = '', family: f = '', folds = [], related = '', id = null } = {}) {
1128 const fl = (Array.isArray(folds) ? folds : [folds]).filter(Boolean).join('');
1129 return `<div class="ovk-tool"${id ? ` id="${esc(id)}"` : ''}>${h}`
1130 + (input ? `<div class="ovk-tool-card">${input}</div>` : '')
1131 + `<div class="ovk-tool-out">${a || ''}</div>${f}`
1132 + (fl ? `<div class="ovk-tool-folds">${fl}</div>` : '')
1133 + (related ? `<div class="ovk-tool-related">${related}</div>` : '') + '</div>';
1134 }
1135
1136 /**
1137 * page({ head, body, side, dock, wide, id }) — the app page's whole shape in one call: the sticky head (with its tabs),
1138 * the content, a side panel beside it on a computer (under it on a phone), and the dock.
1139 */
1140 const page = ({ head: h = '', body = '', side = '', dock: d = '', wide = false, id = 'main' } = {}) =>
1141 `<main class="ovk-page${wide ? ' ovk-page--wide' : ''}" id="${esc(id)}">${h}${side ? `<div class="ovk-split"><div class="ovk-main">${body}</div><aside class="ovk-side">${side}</aside></div>` : body}${d}</main>`;
1142
1143 /**
1144 * Placeholders shaped like the content on its way (4.70.0, plan L52; the owner, 2026-11-01: "placeholder elements while
1145 * it loads, a fake thumbnail, so it is not jarring"). Same sizes as the real thing, so it lands without a jump; a soft
1146 * shimmer (never a spinner), still under reduced motion; announced once as busy.
1147 * skeleton({ kind: 'card' | 'row' | 'tile' | 'chip' | 'line' | 'chat' | 'stat', count = 3, label = 'Loading' })
1148 */
1149 function skeleton({ kind = 'row', count = 3, label = 'Loading' } = {}) {
1150 const n = Math.max(1, Math.min(48, Number(count) || 1));
1151 const one = {
1152 card: '<div class="ovk-skel-card"><span class="ovk-skel ovk-skel-thumb"></span><span class="ovk-skel ovk-skel-l1"></span><span class="ovk-skel ovk-skel-l2"></span></div>',
1153 row: '<div class="ovk-skel-row"><span class="ovk-skel ovk-skel-avatar"></span><span class="ovk-skel-lines"><span class="ovk-skel ovk-skel-l1"></span><span class="ovk-skel ovk-skel-l2"></span></span></div>',
1154 tile: '<span class="ovk-skel ovk-skel-tile"></span>',
1155 chip: '<span class="ovk-skel ovk-skel-chip"></span>',
1156 line: '<span class="ovk-skel ovk-skel-line"></span>',
1157 chat: '<div class="ovk-skel-chat"><span class="ovk-skel ovk-skel-dot"></span><span class="ovk-skel ovk-skel-l1"></span></div>',
1158 stat: '<div class="ovk-skel-stat"><span class="ovk-skel ovk-skel-num"></span><span class="ovk-skel ovk-skel-l2"></span></div>',
1159 }[kind] || '<span class="ovk-skel ovk-skel-line"></span>';
1160 return `<div class="ovk-skels ovk-skels-${esc(kind)}" role="status" aria-busy="true" aria-label="${esc(label)}">${one.repeat(n)}</div>`;
1161 }
1162
1163 return {
1164 skeleton,
1165 esc, url, mediaW, icon, ICONS, PATHS, KIT_PATHS, when, hueOf, mark, button, share, askAgent, iconButton, action, badge, dot, card, rail, CARD_RATIO,
1166 head, pageHead, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
1167 switchRow, rangeRow, selectRow, seg, segmented, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton,
1168 enhance, VERSION: 2,
1169 num, steps, table, chart, rank, meter, drop, receipt, page,
1170 toolHead, toolInput, tries, answer, family, tool,
1171 };
1172});
1173