openvibers/openvibe.shared Commits
4.169.0: kit.switchRow redrawn and kit.segmented (the paste form's switches) (#383)
Changes
@@@ -1,5 +1,14 @@# Changelog++## 4.169.0 — 2026-11-09++++- **kit.switchRow, redrawn** (ask 44, the paste form: "polish and align it: switches, sizes"): an icon tile, a title and++ one muted line, then an iOS-style switch; the whole 56 px row is the target and the switch is the checkbox itself++ (`role="switch"`), so a form still posts without JavaScript. The older `label`/`hint`/`on` names still work.++- **kit.segmented**: three or four equal choices in one 44 px control, icons before the words; radios underneath, so++ the value posts as a radio group's. Without a label it stands alone (a form puts its own label above it).++- Glyphs: `flame` and `eye-off` (Burn after reading, Hide).++## 4.168.0 — 2026-11-09- **The Dock's Agents tab, content-aware** (plan 2026-11-09 §7–8; the owner, 11-09: "the Agents tab's 34 identical rows@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.168.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.169.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.168.0",++ "version": "4.169.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -158,16 +158,20 @@ uses the same icon tile and a labelled action button. Handwritten headings can u### Settings rows```js--kit.switchRow({ name, label, hint, on })++kit.switchRow({ name, icon, title, line, checked }) // also label/hint/on, the older nameskit.rangeRow({ name, label, value, min, max, step, unit: '%' })kit.selectRow({ name, label, hint, options: [[value, label]], value })kit.seg({ name, label, options: [[value, label]], value })++kit.segmented({ name, items: [{ value, label, icon }], value, label })kit.chips({ name, label, options: [[value, label, checked]], single })kit.saveBar({ label: 'Save settings' })```--Inside `<form class="ovk-form">`, each row keeps its label on the left and its control on the right. The save bar--stays in reach above the app bar.++A switch row is 56 px tall and the whole row is the target: an icon tile (`kit.ICONS`), the title and one muted line,++and an iOS-style switch on the right. The switch is the native checkbox with `role="switch"`, so a form still works++without JavaScript. A segmented control puts three or four equal choices in one 44 px control (icons before the++words); without a `label` it is the control alone, its radio group named by the field name. The save bar stays in++reach above the app bar.### Search, add, compose@@@ -277,6 +277,7 @@"window-restore": "E112","wizard": "E113","book-open": "E114",-- "puzzle": "E115"++ "puzzle": "E115",++ "flame": "E116"}}@@@ -1,22 +1,22 @@{"family": "OV Glyphs",-- "glyphs": 277,-- "source_sha256": "76333a213b6f20d937b3f2c966aabcb6e58caa6fde691d34898bb72cb6e3db18",++ "glyphs": 278,++ "source_sha256": "714990697a5b6dd2ea31bdab706d17664d2f10d70a3ec4155279f667498c09cc","files": {-- "ov-glyphs.woff2": 23140,-- "glyph-fonts/ov-glyphs.ttf": 54012,-- "glyph-fonts/ov-glyphs.otf": 67676,-- "ov-glyphs-thin.woff2": 23328,-- "glyph-fonts/ov-glyphs-thin.ttf": 58516,-- "glyph-fonts/ov-glyphs-thin.otf": 68384,-- "ov-glyphs-light.woff2": 23780,-- "glyph-fonts/ov-glyphs-light.ttf": 58960,-- "glyph-fonts/ov-glyphs-light.otf": 68480,-- "ov-glyphs-bold.woff2": 23500,-- "glyph-fonts/ov-glyphs-bold.ttf": 53880,-- "glyph-fonts/ov-glyphs-bold.otf": 66836,-- "ov-glyphs-solid.woff2": 10504,-- "glyph-fonts/ov-glyphs-solid.ttf": 22212,-- "glyph-fonts/ov-glyphs-solid.otf": 20680++ "ov-glyphs.woff2": 23228,++ "glyph-fonts/ov-glyphs.ttf": 54204,++ "glyph-fonts/ov-glyphs.otf": 67884,++ "ov-glyphs-thin.woff2": 23448,++ "glyph-fonts/ov-glyphs-thin.ttf": 58724,++ "glyph-fonts/ov-glyphs-thin.otf": 68608,++ "ov-glyphs-light.woff2": 23928,++ "glyph-fonts/ov-glyphs-light.ttf": 59168,++ "glyph-fonts/ov-glyphs-light.otf": 68700,++ "ov-glyphs-bold.woff2": 23632,++ "glyph-fonts/ov-glyphs-bold.ttf": 54076,++ "glyph-fonts/ov-glyphs-bold.otf": 67044,++ "ov-glyphs-solid.woff2": 10524,++ "glyph-fonts/ov-glyphs-solid.ttf": 22228,++ "glyph-fonts/ov-glyphs-solid.otf": 20696}}BBinary files a/glyph-fonts/ov-glyphs-bold.otf and b/glyph-fonts/ov-glyphs-bold.otf differBBinary files a/glyph-fonts/ov-glyphs-bold.ttf and b/glyph-fonts/ov-glyphs-bold.ttf differBBinary files a/glyph-fonts/ov-glyphs-light.otf and b/glyph-fonts/ov-glyphs-light.otf differBBinary files a/glyph-fonts/ov-glyphs-light.ttf and b/glyph-fonts/ov-glyphs-light.ttf differBBinary files a/glyph-fonts/ov-glyphs-solid.otf and b/glyph-fonts/ov-glyphs-solid.otf differBBinary files a/glyph-fonts/ov-glyphs-solid.ttf and b/glyph-fonts/ov-glyphs-solid.ttf differBBinary files a/glyph-fonts/ov-glyphs-thin.otf and b/glyph-fonts/ov-glyphs-thin.otf differBBinary files a/glyph-fonts/ov-glyphs-thin.ttf and b/glyph-fonts/ov-glyphs-thin.ttf differBBinary files a/glyph-fonts/ov-glyphs.otf and b/glyph-fonts/ov-glyphs.otf differBBinary files a/glyph-fonts/ov-glyphs.ttf and b/glyph-fonts/ov-glyphs.ttf differ@@@ -215,6 +215,12 @@ div.ovk-set-row { cursor: default; }.ovk-set-row select { flex: none; max-width: 52%; margin: 0; padding: 7px 8px; border-radius: var(--ov-radius-1); border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: .9rem; }.ovk-set-range input[type=range] { flex: 0 1 45%; min-width: 120px; margin: 0; accent-color: var(--ovk-fill); }.ovk-set-chips { flex-wrap: wrap; }++/* A switch row (4.164.0): the icon tile, the title and its one line, then the switch; the whole 56 px row is the++ target, and the switch is the native checkbox (role="switch") drawn as one, so it works without JavaScript. */++.ovk-set-switch { min-height: 56px; }++.ovk-set-ic { flex: none; display: grid; place-items: center; width: 36px; height: 36px; border-radius: var(--ov-radius-1); background: color-mix(in srgb, var(--ovk-accent) 12%, transparent); color: var(--ovk-accent); }++.ovk-set-ic .ovk-i { width: 19px; height: 19px; }++.ovk-set-row .ovk-switch { margin-left: auto; }.ovk-switch { appearance: none; -webkit-appearance: none; flex: none; position: relative; width: 44px; height: 26px; margin: 0; padding: 0; border-radius: 999px; border: 0; background: var(--ovk-line); cursor: pointer; transition: background .15s; }.ovk-switch::after { content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: var(--ov-elev-1); transition: transform .15s; }.ovk-switch:checked { background: var(--ovk-fill); }@@@ -225,6 +231,13 @@ div.ovk-set-row { cursor: default; }.ovk-seg span { display: block; padding: 6px 12px; border-radius: max(0px, calc(var(--ov-radius-1) - 2px)); color: var(--ovk-muted); font-size: .86rem; font-weight: 650; cursor: pointer; }.ovk-seg input:checked + span { background: var(--ovk-fill); color: var(--ovk-on-fill); }.ovk-seg input:focus-visible + span, .ovk-chip input:focus-visible + span { outline: 2px solid var(--ovk-accent); outline-offset: 1px; }++/* A segmented control (4.164.0): three or four equal choices in one 44 px control; the icons sit before the words. */++.ovk-seg--fill { display: flex; flex: 1 1 auto; flex-wrap: nowrap; width: 100%; min-width: 0; }++.ovk-seg--fill .ovk-seg-item { flex: 1 1 0; min-width: 0; }++.ovk-seg--fill span { display: flex; align-items: center; justify-content: center; gap: 6px; min-height: 38px; box-sizing: border-box; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.ovk-seg--fill .ovk-i { flex: none; width: 16px; height: 16px; }++/* Straight in a section (no label row around it), it keeps the rows' inset instead of running to the card's edges. */++.ovk-section > .ovk-seg--fill { width: auto; margin: 10px 12px 6px; }.ovk-chips { display: flex; flex-wrap: wrap; gap: 6px; }.ovk-chip span { display: inline-flex; align-items: center; min-height: 32px; box-sizing: border-box; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--ovk-line); color: var(--ovk-muted); font-size: .86rem; font-weight: 650; cursor: pointer; }.ovk-chip input:checked + span { border-color: color-mix(in srgb, var(--ovk-accent) 55%, transparent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); color: var(--ovk-text); }@@@ -20,13 +20,14 @@* tabs: kit.tabs({ base: '/settings', items: [{ key: 'look', label: 'Look' }, …], active }) })* kit.tabOf(req.query, ['look', 'sound']) → the open tab (the first when none or unknown)* kit.list([kit.row({ href, mark: kit.mark({ name }), title, meta: kit.when(ts).text, sub, unread: 2 }), …])-- * kit.section({ title: 'Messages look', body: kit.switchRow({ name, label, hint, on }) + kit.rangeRow(…) + … })++ * kit.section({ title: 'Messages look', body: kit.switchRow({ name, icon, title, line, checked }) + kit.rangeRow(…) + … })++ * kit.segmented({ name, items: [{ value, label, icon }] }) three or four equal choices in one control* kit.fold({ id, title, meta, open, body }) a card that opens: setup, extras, danger* kit.find({ action, name, placeholder, icon: 'search' }) a search or "add by name" row* kit.dock({ action, name, placeholder, label }) the composer pinned at the bottom* kit.stats([[1234, 'messages this week'], …]) a strip of numbers (scrolls sideways on phones)* kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items })-- * kit.button({ href | type, label, icon, kind }) · kit.share({ title, url, kind, id }) · kit.askAgent({ text }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)++ * kit.button({ href | type, label, icon, kind }) · kit.share({ title, url, kind, id }) · kit.askAgent({ text }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.segmented(…) · kit.icon(name)* kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)* kit.mediaW(url, width) a Media object at a resize width (64/96/160/320/480/640)* kit.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar,@@@ -96,6 +97,8 @@gift: '<rect x="4" y="9" width="16" height="11" rx="1"/><path d="M3 9h18M12 9v11M12 9c-1.5-3-5-4-5-1.5S12 9 12 9s5-1 5-3.5S13.5 6 12 9"/>',trophy: '<path d="M8 4h8v5a4 4 0 0 1-8 0ZM8 6H5a3 3 0 0 0 3 4M16 6h3a3 3 0 0 1-3 4M12 13v4M8 20h8"/>',flag: '<path d="M6.5 21V4M6.5 4h11l-2 4 2 4H6.5"/>', eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="3"/>',++ 'eye-off': '<path d="M2.5 12S6 5.5 12 5.5c1.6 0 3 .5 4.2 1.2M21.5 12S18 18.5 12 18.5c-1.6 0-3-.5-4.2-1.2M4 4l16 16"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/>',++ flame: '<path d="M12 21c3.6 0 6-2.4 6-5.7 0-4.2-4.4-7.7-6-12.3-1.6 4.6-6 8.1-6 12.3C6 18.6 8.4 21 12 21Z"/><path d="M12 17.5c1.4 0 2.4-1 2.4-2.3 0-1.4-1.2-2.6-2.4-4.2-1.2 1.6-2.4 2.8-2.4 4.2 0 1.3 1 2.3 2.4 2.3Z"/>',map: '<path d="M9 4 3 6v14l6-2 6 2 6-2V4l-6 2Z"/><path d="M9 4v14M15 6v14"/>', book: '<path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2Z"/><path d="M4 19V5"/>',game: '<rect x="2.5" y="7" width="19" height="11" rx="5"/><path d="M7 11v3M5.5 12.5h3M15.5 12h.01M18 13.5h.01"/>',leave: '<path d="M13 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-4M9 16l-4 -4 4 -4M5 12h10"/>',@@@ -326,8 +329,17 @@}// ── Settings rows ──-- /** A switch row: what it does (one muted hint line when needed) on the left, the switch on the right. */-- const switchRow = ({ name, label, hint = '', on = false, value = '1' }) => `<label class="ovk-set-row"><span class="ovk-set-text">${esc(label)}${hint ? `<small>${esc(hint)}</small>` : ''}</span><input type="checkbox" class="ovk-switch" name="${esc(name)}" value="${esc(value)}"${on ? ' checked' : ''}></label>`;++ /**++ * A switch row: an icon tile, a title and one muted line on the left, an iOS-style switch on the right. The whole++ * row is the target (56 px), and the switch is the checkbox itself (role="switch"), so the form works without++ * JavaScript. `label`/`hint`/`on` are the older names, kept working.++ */++ const switchRow = ({ name, icon: ic = null, title = null, line = null, label = null, hint = null, checked = null, on = null, value = '1' } = {}) => {++ const t = title != null ? title : (label != null ? label : '');++ const l = line != null ? line : (hint || '');++ const c = checked != null ? !!checked : !!on;++ return `<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>`;++ };/** A slider row (0-100 by 5 unless told otherwise); the value is spoken as a percent when unit is '%'. */function rangeRow({ name, label, value = 0, min = 0, max = 100, step = 5, unit = '%' }) {const v = Number(value);@@@ -340,6 +352,17 @@const id = `ovk-seg-${esc(name)}`;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>`;}++ /**++ * A segmented choice (three or four) as radios that look like one control: items [{ value, label, icon }]. The++ * value still posts as the radio group's; the input is the browser's, only drawn differently, so it works without++ * JavaScript. Without a label the control stands alone (a form puts its own label above it) and `ariaLabel` names++ * the group; label draws a labelled row.++ */++ function segmented({ name, items = [], value = '', label = '', ariaLabel = '' } = {}) {++ const id = `ovk-seg-${String(name).replace(/[^A-Za-z0-9_-]/g, '-')}`;++ const control = `<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>`;++ return label ? `<div class="ovk-set-row ovk-set-seg"><span class="ovk-set-text" id="${id}">${esc(label)}</span>${control}</div>` : control;++ }/** Chips: checkboxes (or radios: single) that look like small pills, for filters and "which of these". options: [[value, label, checked]]. */function chips({ name, options = [], single = false, label = '' }) {const box = options.map(([v, l, on]) => `<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('');@@@ -915,7 +938,7 @@skeleton,esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, share, askAgent, iconButton, action, badge, dot, card, rail, CARD_RATIO,head, pageHead, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,-- switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton,++ switchRow, rangeRow, selectRow, seg, segmented, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton,enhance, VERSION: 2,num, steps, table, chart, rank, meter, drop, receipt, page,toolHead, toolInput, tries, answer, family, tool,BBinary files a/ov-glyphs-bold.woff2 and b/ov-glyphs-bold.woff2 differ@@@ -100,6 +100,7 @@.ovi-fish::before{content:"\e0dd"}.ovi-fist::before{content:"\e0a5"}.ovi-flag::before{content:"\e034"}++.ovi-flame::before{content:"\e116"}.ovi-flask::before{content:"\e0de"}.ovi-focus::before{content:"\e0df"}.ovi-folder::before{content:"\e035"}BBinary files a/ov-glyphs-light.woff2 and b/ov-glyphs-light.woff2 differBBinary files a/ov-glyphs-solid.woff2 and b/ov-glyphs-solid.woff2 differBBinary files a/ov-glyphs-thin.woff2 and b/ov-glyphs-thin.woff2 differ@@@ -540,6 +540,11 @@<symbol id="ovg-flag-bold" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 21V4M6.5 4h11l-2 4 2 4H6.5"/></symbol><symbol id="ovg-flag-solid" viewBox="0 0 24 24" fill="currentColor"><path fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" d="M6.5 21V3.8"/><path d="M7.1 3.6h11.6l-2.1 4.4 2.1 4.4H7.1Z"/></symbol><symbol id="ovg-flag-duotone" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g fill="currentColor" stroke="none" opacity=".2"><path class="ln" d="M6.5 21V3.8"/><path d="M7.1 3.6h11.6l-2.1 4.4 2.1 4.4H7.1Z"/></g><path d="M6.5 21V4M6.5 4h11l-2 4 2 4H6.5"/></symbol>++<symbol id="ovg-flame" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21c3.6 0 6-2.4 6-5.7 0-4.2-4.4-7.7-6-12.3-1.6 4.6-6 8.1-6 12.3C6 18.6 8.4 21 12 21Z"/><path d="M12 17.5c1.4 0 2.4-1 2.4-2.3 0-1.4-1.2-2.6-2.4-4.2-1.2 1.6-2.4 2.8-2.4 4.2 0 1.3 1 2.3 2.4 2.3Z"/></symbol>++<symbol id="ovg-flame-thin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21c3.6 0 6-2.4 6-5.7 0-4.2-4.4-7.7-6-12.3-1.6 4.6-6 8.1-6 12.3C6 18.6 8.4 21 12 21Z"/><path d="M12 17.5c1.4 0 2.4-1 2.4-2.3 0-1.4-1.2-2.6-2.4-4.2-1.2 1.6-2.4 2.8-2.4 4.2 0 1.3 1 2.3 2.4 2.3Z"/></symbol>++<symbol id="ovg-flame-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21c3.6 0 6-2.4 6-5.7 0-4.2-4.4-7.7-6-12.3-1.6 4.6-6 8.1-6 12.3C6 18.6 8.4 21 12 21Z"/><path d="M12 17.5c1.4 0 2.4-1 2.4-2.3 0-1.4-1.2-2.6-2.4-4.2-1.2 1.6-2.4 2.8-2.4 4.2 0 1.3 1 2.3 2.4 2.3Z"/></symbol>++<symbol id="ovg-flame-regular" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21c3.6 0 6-2.4 6-5.7 0-4.2-4.4-7.7-6-12.3-1.6 4.6-6 8.1-6 12.3C6 18.6 8.4 21 12 21Z"/><path d="M12 17.5c1.4 0 2.4-1 2.4-2.3 0-1.4-1.2-2.6-2.4-4.2-1.2 1.6-2.4 2.8-2.4 4.2 0 1.3 1 2.3 2.4 2.3Z"/></symbol>++<symbol id="ovg-flame-bold" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21c3.6 0 6-2.4 6-5.7 0-4.2-4.4-7.7-6-12.3-1.6 4.6-6 8.1-6 12.3C6 18.6 8.4 21 12 21Z"/><path d="M12 17.5c1.4 0 2.4-1 2.4-2.3 0-1.4-1.2-2.6-2.4-4.2-1.2 1.6-2.4 2.8-2.4 4.2 0 1.3 1 2.3 2.4 2.3Z"/></symbol><symbol id="ovg-flask" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 3.5h5M10 3.5V9.5L4.8 18.2a1.5 1.5 0 0 0 1.3 2.3h11.8a1.5 1.5 0 0 0 1.3-2.3L14 9.5V3.5"/><path d="M7.2 15h9.6"/></symbol><symbol id="ovg-flask-thin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 3.5h5M10 3.5V9.5L4.8 18.2a1.5 1.5 0 0 0 1.3 2.3h11.8a1.5 1.5 0 0 0 1.3-2.3L14 9.5V3.5"/><path d="M7.2 15h9.6"/></symbol><symbol id="ovg-flask-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 3.5h5M10 3.5V9.5L4.8 18.2a1.5 1.5 0 0 0 1.3 2.3h11.8a1.5 1.5 0 0 0 1.3-2.3L14 9.5V3.5"/><path d="M7.2 15h9.6"/></symbol>BBinary files a/ov-glyphs.woff2 and b/ov-glyphs.woff2 differ@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.168.0",++ "version": "4.169.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.168.0",++ "version": "4.169.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.168.0",++ "version": "4.169.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {nnew file mode 100644@@@ -0,0 +1,82 @@++'use strict';++// kit.switchRow and kit.segmented in a real headless Chrome (4.164.0, the owner's /new paste form, 11-09): a switch row++// is 56 px with a 36 px icon tile, and the whole row is the target around a native checkbox; a segmented control is++// one 44 px control of three equal choices with icons, and neither the row nor the control scrolls sideways at 390.++// Skipped without Chrome.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('../browser-harness');++const kit = require('../kit');++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('kit-switch-seg-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const ROOT = path.join(__dirname, '..');++const ROWS = kit.switchRow({ name: 'burn_after_read', icon: 'flame', title: 'Burn after reading', line: 'Gone once it is opened' })++ + kit.switchRow({ name: 'is_nsfw', icon: 'eye-off', title: 'Mark as NSFW', line: 'Blurred until it is opened' })++ + kit.switchRow({ name: 'lock_it', icon: 'lock', title: 'Lock it', line: 'OpenVibe can’t read what’s inside', checked: true });++const SEG = kit.segmented({ name: 'visibility', value: 'unlisted', items: [++ { value: 'public', label: 'Public', icon: 'globe' },++ { value: 'unlisted', label: 'Unlisted', icon: 'link' },++ { value: 'private', label: 'Private', icon: 'lock' },++] });++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Switch rows</title><link rel="icon" href="data:,">++<link rel="stylesheet" href="/kit.css"><style>body{margin:0;padding:16px;background:#0b0f17;color:#e6edf7;font:16px/1.4 system-ui}</style></head>++<body><form class="ovk-form" id="f">${ROWS}<div class="ovk-field">${SEG}</div></form></body></html>`;++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE); }++ if (u.pathname === '/kit.css') { res.writeHead(200, { 'content-type': 'text/css' }); return res.end(fs.readFileSync(path.join(ROOT, 'kit.css'))); }++ res.writeHead(404); return res.end();++});++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-switch-'));++ const browser = await h.launch({ tmpDir });++ try {++ const context = await browser.newContext();++ const p = await h.openPage(browser, context, { width: 390, height: 844, block: ['*openvibe.network*', '*openvibe.tools*'] });++ await p.goto(`${base}/`, { maxMs: 6000 });++ const rows = await p.evaluate(`[...document.querySelectorAll('.ovk-set-switch')].map((r) => {++ const ic = r.querySelector('.ovk-set-ic'), sw = r.querySelector('input[role=switch]');++ const rr = r.getBoundingClientRect(), ir = ic.getBoundingClientRect(), sr = sw.getBoundingClientRect();++ return { h: Math.round(rr.height), icon: Math.round(ir.width) + 'x' + Math.round(ir.height), switch: Math.round(sr.width) + 'x' + Math.round(sr.height), type: sw.type, role: sw.getAttribute('role') };++ })`);++ assert.strictEqual(rows.length, 3, 'three switch rows');++ for (const r of rows) {++ assert.ok(r.h >= 56, `a switch row is 56 px (${r.h})`);++ assert.strictEqual(r.icon, '36x36', 'the icon tile is 36 px');++ assert.strictEqual(r.switch, '44x26', 'the switch is 44 px wide');++ assert.strictEqual(r.type, 'checkbox', 'the switch is the checkbox');++ assert.strictEqual(r.role, 'switch', 'spoken as a switch');++ }++ // The whole row is the target: a tap on the title toggles the checkbox.++ assert.strictEqual(await p.evaluate("document.querySelector('.ovk-set-switch .ovk-set-text').click(), document.querySelector('input[name=burn_after_read]').checked"), true, 'the title toggles it');++ assert.strictEqual(await p.evaluate("document.querySelector('input[name=burn_after_read]').checked = false, document.querySelector('.ovk-set-switch .ovk-set-ic').click(), document.querySelector('input[name=burn_after_read]').checked"), true, 'the icon tile toggles it');++ // The segmented control: one row, 44 px tall, three equal choices, the checked one filled.++ const seg = await p.evaluate(`(() => {++ const box = document.querySelector('.ovk-seg--fill'), labels = [...box.querySelectorAll('.ovk-seg-item')], spans = [...box.querySelectorAll('.ovk-seg-item > span')];++ const br = box.getBoundingClientRect();++ return { h: Math.round(br.height), n: labels.length, w: labels.map((l) => Math.round(l.getBoundingClientRect().width)), top: new Set(labels.map((l) => Math.round(l.getBoundingClientRect().top))).size,++ checked: box.querySelector('input:checked').value, fill: getComputedStyle(box.querySelector('input:checked + span')).backgroundColor, icons: box.querySelectorAll('.ovk-i').length, over: Math.round(document.documentElement.scrollWidth - innerWidth) };++ })()`);++ assert.ok(seg.h >= 44, `the segmented control is 44 px (${seg.h})`);++ assert.strictEqual(seg.n, 3, 'three choices');++ assert.strictEqual(seg.top, 1, 'one row');++ assert.ok(Math.max(...seg.w) - Math.min(...seg.w) <= 1, `equal widths (${seg.w})`);++ assert.strictEqual(seg.checked, 'unlisted', 'the value is the radio group’s');++ assert.notStrictEqual(seg.fill, 'rgba(0, 0, 0, 0)', 'the chosen one is filled');++ assert.strictEqual(seg.icons, 3, 'each choice has its icon');++ assert.strictEqual(seg.over, 0, 'no sideways scroll at 390');++ // The radio still posts: the form carries the chosen visibility.++ assert.strictEqual(await p.evaluate("new FormData(document.getElementById('f')).get('visibility')"), 'unlisted');++ console.log('kit-switch-seg-chrome: a 56 px switch row, the whole row a target, a 44 px segmented control of three equal choices');++ } finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -54,10 +54,21 @@ const kit = require('../kit');// ── Settings rows, folds, menus ──{assert.ok(kit.switchRow({ name: 'compact', label: 'Compact', hint: 'Less space', on: true }).includes('class="ovk-switch" name="compact" value="1" checked'));++ const sw = kit.switchRow({ name: 'burn', icon: 'flame', title: 'Burn after reading', line: 'Gone once it is read', checked: true });++ assert.ok(sw.startsWith('<label class="ovk-set-row ovk-set-switch">') && sw.includes('class="ovk-set-ic" aria-hidden="true"><svg'));++ assert.ok(sw.includes('role="switch"') && sw.includes('type="checkbox"') && sw.includes('value="1" checked'), 'the switch is a working checkbox');++ assert.ok(sw.includes('Burn after reading') && sw.includes('Gone once it is read'), 'title and one line');++ assert.ok(!kit.switchRow({ name: 'n', title: 'No icon', line: 'x' }).includes('ovk-set-ic'), 'no icon, no tile');++ assert.ok(kit.switchRow({ name: 'n', label: 'Old name', hint: 'Old hint', on: true }).includes('>Old name<small>Old hint</small>'), 'label/hint/on still work');assert.ok(kit.rangeRow({ name: 'v', label: 'Volume', value: 40 }).includes('value="40"') && kit.rangeRow({ name: 'v', label: 'V', value: 40 }).includes('aria-valuetext="40 percent"'));assert.ok(kit.selectRow({ name: 'who', label: 'Who', options: [['all', 'Everyone'], ['none', 'Nobody']], value: 'none' }).includes('<option value="none" selected>Nobody</option>'));const seg = kit.seg({ name: 'size', label: 'Text size', options: [['s', 'Small'], ['m', 'Default']], value: 'm' });assert.ok(seg.includes('role="radiogroup"') && seg.includes('value="m" checked'));++ const segments = kit.segmented({ name: 'visibility', value: 'unlisted', items: [{ value: 'public', label: 'Public', icon: 'globe' }, { value: 'unlisted', label: 'Unlisted', icon: 'link' }, { value: 'private', label: 'Private', icon: 'lock' }] });++ assert.ok(segments.startsWith('<span class="ovk-seg ovk-seg--fill" role="radiogroup"') && segments.includes('aria-label="visibility"'));++ assert.ok(segments.includes('value="unlisted" checked') && segments.includes('class="ovk-seg-item"') && segments.includes('class="ovk-i"'), 'segments carry icons');++ assert.ok(kit.segmented({ name: 'v', ariaLabel: 'Who sees it', items: [{ value: 'a', label: 'A' }] }).includes('aria-label="Who sees it"'), 'a standalone group still has words');++ assert.ok(kit.segmented({ name: 'v', label: 'Who sees it', items: [{ value: 'a', label: 'A' }] }).includes('aria-labelledby="ovk-seg-v"'), 'a label names the group');assert.ok(kit.chips({ name: 'src', options: [['kick', 'Kick', true], ['yt', 'YouTube', false]] }).includes('name="src_kick" value="1" checked'));const f = kit.fold({ id: 'oc-ai', title: 'AI members', meta: '2', body: '<p>x</p>' });assert.ok(f.startsWith('<details class="ovk-fold" id="oc-ai">') && !f.includes(' open'));
@@ -1,5 +1,14 @@
@@ -1,5 +1,14 @@
# Changelog
# Changelog
+## 4.169.0 — 2026-11-09
+
+- **kit.switchRow, redrawn** (ask 44, the paste form: "polish and align it: switches, sizes"): an icon tile, a title and
+ one muted line, then an iOS-style switch; the whole 56 px row is the target and the switch is the checkbox itself
+ (`role="switch"`), so a form still posts without JavaScript. The older `label`/`hint`/`on` names still work.
+- **kit.segmented**: three or four equal choices in one 44 px control, icons before the words; radios underneath, so
+ the value posts as a radio group's. Without a label it stands alone (a form puts its own label above it).
+- Glyphs: `flame` and `eye-off` (Burn after reading, Hide).
+
## 4.168.0 — 2026-11-09
## 4.168.0 — 2026-11-09
- **The Dock's Agents tab, content-aware** (plan 2026-11-09 §7–8; the owner, 11-09: "the Agents tab's 34 identical rows
- **The Dock's Agents tab, content-aware** (plan 2026-11-09 §7–8; the owner, 11-09: "the Agents tab's 34 identical rows
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.168.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.169.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.168.0",
+ "version": "4.169.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -158,16 +158,20 @@ uses the same icon tile and a labelled action button. Handwritten headings can u
@@ -158,16 +158,20 @@ uses the same icon tile and a labelled action button. Handwritten headings can u
### Settings rows
### Settings rows
```js
```js
-kit.switchRow({ name, label, hint, on })
+kit.switchRow({ name, icon, title, line, checked }) // also label/hint/on, the older names
kit.rangeRow({ name, label, value, min, max, step, unit: '%' })
kit.rangeRow({ name, label, value, min, max, step, unit: '%' })
kit.selectRow({ name, label, hint, options: [[value, label]], value })
kit.selectRow({ name, label, hint, options: [[value, label]], value })
kit.seg({ name, label, options: [[value, label]], value })
kit.seg({ name, label, options: [[value, label]], value })
+kit.segmented({ name, items: [{ value, label, icon }], value, label })
kit.chips({ name, label, options: [[value, label, checked]], single })
kit.chips({ name, label, options: [[value, label, checked]], single })
kit.saveBar({ label: 'Save settings' })
kit.saveBar({ label: 'Save settings' })
```
```
-Inside `<form class="ovk-form">`, each row keeps its label on the left and its control on the right. The save bar
-stays in reach above the app bar.
+A switch row is 56 px tall and the whole row is the target: an icon tile (`kit.ICONS`), the title and one muted line,
+and an iOS-style switch on the right. The switch is the native checkbox with `role="switch"`, so a form still works
+without JavaScript. A segmented control puts three or four equal choices in one 44 px control (icons before the
+words); without a `label` it is the control alone, its radio group named by the field name. The save bar stays in
+reach above the app bar.
### Search, add, compose
### Search, add, compose
@@ -277,6 +277,7 @@
@@ -277,6 +277,7 @@
"window-restore": "E112",
"window-restore": "E112",
"wizard": "E113",
"wizard": "E113",
"book-open": "E114",
"book-open": "E114",
- "puzzle": "E115"
+ "puzzle": "E115",
+ "flame": "E116"
}
}
}
}
@@ -1,22 +1,22 @@
@@ -1,22 +1,22 @@
{
{
"family": "OV Glyphs",
"family": "OV Glyphs",
- "glyphs": 277,
- "source_sha256": "76333a213b6f20d937b3f2c966aabcb6e58caa6fde691d34898bb72cb6e3db18",
+ "glyphs": 278,
+ "source_sha256": "714990697a5b6dd2ea31bdab706d17664d2f10d70a3ec4155279f667498c09cc",
"files": {
"files": {
- "ov-glyphs.woff2": 23140,
- "glyph-fonts/ov-glyphs.ttf": 54012,
- "glyph-fonts/ov-glyphs.otf": 67676,
- "ov-glyphs-thin.woff2": 23328,
- "glyph-fonts/ov-glyphs-thin.ttf": 58516,
- "glyph-fonts/ov-glyphs-thin.otf": 68384,
- "ov-glyphs-light.woff2": 23780,
- "glyph-fonts/ov-glyphs-light.ttf": 58960,
- "glyph-fonts/ov-glyphs-light.otf": 68480,
- "ov-glyphs-bold.woff2": 23500,
- "glyph-fonts/ov-glyphs-bold.ttf": 53880,
- "glyph-fonts/ov-glyphs-bold.otf": 66836,
- "ov-glyphs-solid.woff2": 10504,
- "glyph-fonts/ov-glyphs-solid.ttf": 22212,
- "glyph-fonts/ov-glyphs-solid.otf": 20680
+ "ov-glyphs.woff2": 23228,
+ "glyph-fonts/ov-glyphs.ttf": 54204,
+ "glyph-fonts/ov-glyphs.otf": 67884,
+ "ov-glyphs-thin.woff2": 23448,
+ "glyph-fonts/ov-glyphs-thin.ttf": 58724,
+ "glyph-fonts/ov-glyphs-thin.otf": 68608,
+ "ov-glyphs-light.woff2": 23928,
+ "glyph-fonts/ov-glyphs-light.ttf": 59168,
+ "glyph-fonts/ov-glyphs-light.otf": 68700,
+ "ov-glyphs-bold.woff2": 23632,
+ "glyph-fonts/ov-glyphs-bold.ttf": 54076,
+ "glyph-fonts/ov-glyphs-bold.otf": 67044,
+ "ov-glyphs-solid.woff2": 10524,
+ "glyph-fonts/ov-glyphs-solid.ttf": 22228,
+ "glyph-fonts/ov-glyphs-solid.otf": 20696
}
}
}
}
Binary files a/glyph-fonts/ov-glyphs-bold.otf and b/glyph-fonts/ov-glyphs-bold.otf differ
Binary files a/glyph-fonts/ov-glyphs-bold.otf and b/glyph-fonts/ov-glyphs-bold.otf differ
Binary files a/glyph-fonts/ov-glyphs-bold.ttf and b/glyph-fonts/ov-glyphs-bold.ttf differ
Binary files a/glyph-fonts/ov-glyphs-bold.ttf and b/glyph-fonts/ov-glyphs-bold.ttf differ
Binary files a/glyph-fonts/ov-glyphs-light.otf and b/glyph-fonts/ov-glyphs-light.otf differ
Binary files a/glyph-fonts/ov-glyphs-light.otf and b/glyph-fonts/ov-glyphs-light.otf differ
Binary files a/glyph-fonts/ov-glyphs-light.ttf and b/glyph-fonts/ov-glyphs-light.ttf differ
Binary files a/glyph-fonts/ov-glyphs-light.ttf and b/glyph-fonts/ov-glyphs-light.ttf differ
Binary files a/glyph-fonts/ov-glyphs-solid.otf and b/glyph-fonts/ov-glyphs-solid.otf differ
Binary files a/glyph-fonts/ov-glyphs-solid.otf and b/glyph-fonts/ov-glyphs-solid.otf differ
Binary files a/glyph-fonts/ov-glyphs-solid.ttf and b/glyph-fonts/ov-glyphs-solid.ttf differ
Binary files a/glyph-fonts/ov-glyphs-solid.ttf and b/glyph-fonts/ov-glyphs-solid.ttf differ
Binary files a/glyph-fonts/ov-glyphs-thin.otf and b/glyph-fonts/ov-glyphs-thin.otf differ
Binary files a/glyph-fonts/ov-glyphs-thin.otf and b/glyph-fonts/ov-glyphs-thin.otf differ
Binary files a/glyph-fonts/ov-glyphs-thin.ttf and b/glyph-fonts/ov-glyphs-thin.ttf differ
Binary files a/glyph-fonts/ov-glyphs-thin.ttf and b/glyph-fonts/ov-glyphs-thin.ttf differ
Binary files a/glyph-fonts/ov-glyphs.otf and b/glyph-fonts/ov-glyphs.otf differ
Binary files a/glyph-fonts/ov-glyphs.otf and b/glyph-fonts/ov-glyphs.otf differ
Binary files a/glyph-fonts/ov-glyphs.ttf and b/glyph-fonts/ov-glyphs.ttf differ
Binary files a/glyph-fonts/ov-glyphs.ttf and b/glyph-fonts/ov-glyphs.ttf differ
@@ -215,6 +215,12 @@ div.ovk-set-row { cursor: default; }
@@ -215,6 +215,12 @@ div.ovk-set-row { cursor: default; }
.ovk-set-row select { flex: none; max-width: 52%; margin: 0; padding: 7px 8px; border-radius: var(--ov-radius-1); border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: .9rem; }
.ovk-set-row select { flex: none; max-width: 52%; margin: 0; padding: 7px 8px; border-radius: var(--ov-radius-1); border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: .9rem; }
.ovk-set-range input[type=range] { flex: 0 1 45%; min-width: 120px; margin: 0; accent-color: var(--ovk-fill); }
.ovk-set-range input[type=range] { flex: 0 1 45%; min-width: 120px; margin: 0; accent-color: var(--ovk-fill); }
.ovk-set-chips { flex-wrap: wrap; }
.ovk-set-chips { flex-wrap: wrap; }
+/* A switch row (4.164.0): the icon tile, the title and its one line, then the switch; the whole 56 px row is the
+ target, and the switch is the native checkbox (role="switch") drawn as one, so it works without JavaScript. */
+.ovk-set-switch { min-height: 56px; }
+.ovk-set-ic { flex: none; display: grid; place-items: center; width: 36px; height: 36px; border-radius: var(--ov-radius-1); background: color-mix(in srgb, var(--ovk-accent) 12%, transparent); color: var(--ovk-accent); }
+.ovk-set-ic .ovk-i { width: 19px; height: 19px; }
+.ovk-set-row .ovk-switch { margin-left: auto; }
.ovk-switch { appearance: none; -webkit-appearance: none; flex: none; position: relative; width: 44px; height: 26px; margin: 0; padding: 0; border-radius: 999px; border: 0; background: var(--ovk-line); cursor: pointer; transition: background .15s; }
.ovk-switch { appearance: none; -webkit-appearance: none; flex: none; position: relative; width: 44px; height: 26px; margin: 0; padding: 0; border-radius: 999px; border: 0; background: var(--ovk-line); cursor: pointer; transition: background .15s; }
.ovk-switch::after { content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: var(--ov-elev-1); transition: transform .15s; }
.ovk-switch::after { content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: var(--ov-elev-1); transition: transform .15s; }
.ovk-switch:checked { background: var(--ovk-fill); }
.ovk-switch:checked { background: var(--ovk-fill); }
@@ -225,6 +231,13 @@ div.ovk-set-row { cursor: default; }
@@ -225,6 +231,13 @@ div.ovk-set-row { cursor: default; }
.ovk-seg span { display: block; padding: 6px 12px; border-radius: max(0px, calc(var(--ov-radius-1) - 2px)); color: var(--ovk-muted); font-size: .86rem; font-weight: 650; cursor: pointer; }
.ovk-seg span { display: block; padding: 6px 12px; border-radius: max(0px, calc(var(--ov-radius-1) - 2px)); color: var(--ovk-muted); font-size: .86rem; font-weight: 650; cursor: pointer; }
.ovk-seg input:checked + span { background: var(--ovk-fill); color: var(--ovk-on-fill); }
.ovk-seg input:checked + span { background: var(--ovk-fill); color: var(--ovk-on-fill); }
.ovk-seg input:focus-visible + span, .ovk-chip input:focus-visible + span { outline: 2px solid var(--ovk-accent); outline-offset: 1px; }
.ovk-seg input:focus-visible + span, .ovk-chip input:focus-visible + span { outline: 2px solid var(--ovk-accent); outline-offset: 1px; }
+/* A segmented control (4.164.0): three or four equal choices in one 44 px control; the icons sit before the words. */
+.ovk-seg--fill { display: flex; flex: 1 1 auto; flex-wrap: nowrap; width: 100%; min-width: 0; }
+.ovk-seg--fill .ovk-seg-item { flex: 1 1 0; min-width: 0; }
+.ovk-seg--fill span { display: flex; align-items: center; justify-content: center; gap: 6px; min-height: 38px; box-sizing: border-box; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.ovk-seg--fill .ovk-i { flex: none; width: 16px; height: 16px; }
+/* Straight in a section (no label row around it), it keeps the rows' inset instead of running to the card's edges. */
+.ovk-section > .ovk-seg--fill { width: auto; margin: 10px 12px 6px; }
.ovk-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.ovk-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.ovk-chip span { display: inline-flex; align-items: center; min-height: 32px; box-sizing: border-box; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--ovk-line); color: var(--ovk-muted); font-size: .86rem; font-weight: 650; cursor: pointer; }
.ovk-chip span { display: inline-flex; align-items: center; min-height: 32px; box-sizing: border-box; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--ovk-line); color: var(--ovk-muted); font-size: .86rem; font-weight: 650; cursor: pointer; }
.ovk-chip input:checked + span { border-color: color-mix(in srgb, var(--ovk-accent) 55%, transparent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); color: var(--ovk-text); }
.ovk-chip input:checked + span { border-color: color-mix(in srgb, var(--ovk-accent) 55%, transparent); background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); color: var(--ovk-text); }
@@ -20,13 +20,14 @@
@@ -20,13 +20,14 @@
* tabs: kit.tabs({ base: '/settings', items: [{ key: 'look', label: 'Look' }, …], active }) })
* tabs: kit.tabs({ base: '/settings', items: [{ key: 'look', label: 'Look' }, …], active }) })
* kit.tabOf(req.query, ['look', 'sound']) → the open tab (the first when none or unknown)
* kit.tabOf(req.query, ['look', 'sound']) → the open tab (the first when none or unknown)
* kit.list([kit.row({ href, mark: kit.mark({ name }), title, meta: kit.when(ts).text, sub, unread: 2 }), …])
* kit.list([kit.row({ href, mark: kit.mark({ name }), title, meta: kit.when(ts).text, sub, unread: 2 }), …])
- * kit.section({ title: 'Messages look', body: kit.switchRow({ name, label, hint, on }) + kit.rangeRow(…) + … })
+ * kit.section({ title: 'Messages look', body: kit.switchRow({ name, icon, title, line, checked }) + kit.rangeRow(…) + … })
+ * kit.segmented({ name, items: [{ value, label, icon }] }) three or four equal choices in one control
* kit.fold({ id, title, meta, open, body }) a card that opens: setup, extras, danger
* kit.fold({ id, title, meta, open, body }) a card that opens: setup, extras, danger
* kit.find({ action, name, placeholder, icon: 'search' }) a search or "add by name" row
* kit.find({ action, name, placeholder, icon: 'search' }) a search or "add by name" row
* kit.dock({ action, name, placeholder, label }) the composer pinned at the bottom
* kit.dock({ action, name, placeholder, label }) the composer pinned at the bottom
* kit.stats([[1234, 'messages this week'], …]) a strip of numbers (scrolls sideways on phones)
* kit.stats([[1234, 'messages this week'], …]) a strip of numbers (scrolls sideways on phones)
* kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items })
* kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items })
- * kit.button({ href | type, label, icon, kind }) · kit.share({ title, url, kind, id }) · kit.askAgent({ text }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)
+ * kit.button({ href | type, label, icon, kind }) · kit.share({ title, url, kind, id }) · kit.askAgent({ text }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.segmented(…) · kit.icon(name)
* kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)
* kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)
* kit.mediaW(url, width) a Media object at a resize width (64/96/160/320/480/640)
* kit.mediaW(url, width) a Media object at a resize width (64/96/160/320/480/640)
* kit.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar,
* kit.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar,
@@ -96,6 +97,8 @@
@@ -96,6 +97,8 @@
gift: '<rect x="4" y="9" width="16" height="11" rx="1"/><path d="M3 9h18M12 9v11M12 9c-1.5-3-5-4-5-1.5S12 9 12 9s5-1 5-3.5S13.5 6 12 9"/>',
gift: '<rect x="4" y="9" width="16" height="11" rx="1"/><path d="M3 9h18M12 9v11M12 9c-1.5-3-5-4-5-1.5S12 9 12 9s5-1 5-3.5S13.5 6 12 9"/>',
trophy: '<path d="M8 4h8v5a4 4 0 0 1-8 0ZM8 6H5a3 3 0 0 0 3 4M16 6h3a3 3 0 0 1-3 4M12 13v4M8 20h8"/>',
trophy: '<path d="M8 4h8v5a4 4 0 0 1-8 0ZM8 6H5a3 3 0 0 0 3 4M16 6h3a3 3 0 0 1-3 4M12 13v4M8 20h8"/>',
flag: '<path d="M6.5 21V4M6.5 4h11l-2 4 2 4H6.5"/>', eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="3"/>',
flag: '<path d="M6.5 21V4M6.5 4h11l-2 4 2 4H6.5"/>', eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="3"/>',
+ 'eye-off': '<path d="M2.5 12S6 5.5 12 5.5c1.6 0 3 .5 4.2 1.2M21.5 12S18 18.5 12 18.5c-1.6 0-3-.5-4.2-1.2M4 4l16 16"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/>',
+ flame: '<path d="M12 21c3.6 0 6-2.4 6-5.7 0-4.2-4.4-7.7-6-12.3-1.6 4.6-6 8.1-6 12.3C6 18.6 8.4 21 12 21Z"/><path d="M12 17.5c1.4 0 2.4-1 2.4-2.3 0-1.4-1.2-2.6-2.4-4.2-1.2 1.6-2.4 2.8-2.4 4.2 0 1.3 1 2.3 2.4 2.3Z"/>',
map: '<path d="M9 4 3 6v14l6-2 6 2 6-2V4l-6 2Z"/><path d="M9 4v14M15 6v14"/>', book: '<path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2Z"/><path d="M4 19V5"/>',
map: '<path d="M9 4 3 6v14l6-2 6 2 6-2V4l-6 2Z"/><path d="M9 4v14M15 6v14"/>', book: '<path d="M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2Z"/><path d="M4 19V5"/>',
game: '<rect x="2.5" y="7" width="19" height="11" rx="5"/><path d="M7 11v3M5.5 12.5h3M15.5 12h.01M18 13.5h.01"/>',
game: '<rect x="2.5" y="7" width="19" height="11" rx="5"/><path d="M7 11v3M5.5 12.5h3M15.5 12h.01M18 13.5h.01"/>',
leave: '<path d="M13 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-4M9 16l-4 -4 4 -4M5 12h10"/>',
leave: '<path d="M13 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-4M9 16l-4 -4 4 -4M5 12h10"/>',
@@ -326,8 +329,17 @@
@@ -326,8 +329,17 @@
}
}
// ── Settings rows ──
// ── Settings rows ──
- /** A switch row: what it does (one muted hint line when needed) on the left, the switch on the right. */
- const switchRow = ({ name, label, hint = '', on = false, value = '1' }) => `<label class="ovk-set-row"><span class="ovk-set-text">${esc(label)}${hint ? `<small>${esc(hint)}</small>` : ''}</span><input type="checkbox" class="ovk-switch" name="${esc(name)}" value="${esc(value)}"${on ? ' checked' : ''}></label>`;
+ /**
+ * A switch row: an icon tile, a title and one muted line on the left, an iOS-style switch on the right. The whole
+ * row is the target (56 px), and the switch is the checkbox itself (role="switch"), so the form works without
+ * JavaScript. `label`/`hint`/`on` are the older names, kept working.
+ */
+ const switchRow = ({ name, icon: ic = null, title = null, line = null, label = null, hint = null, checked = null, on = null, value = '1' } = {}) => {
+ const t = title != null ? title : (label != null ? label : '');
+ const l = line != null ? line : (hint || '');
+ const c = checked != null ? !!checked : !!on;
+ return `<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>`;
+ };
/** A slider row (0-100 by 5 unless told otherwise); the value is spoken as a percent when unit is '%'. */
/** A slider row (0-100 by 5 unless told otherwise); the value is spoken as a percent when unit is '%'. */
function rangeRow({ name, label, value = 0, min = 0, max = 100, step = 5, unit = '%' }) {
function rangeRow({ name, label, value = 0, min = 0, max = 100, step = 5, unit = '%' }) {
const v = Number(value);
const v = Number(value);
@@ -340,6 +352,17 @@
@@ -340,6 +352,17 @@
const id = `ovk-seg-${esc(name)}`;
const id = `ovk-seg-${esc(name)}`;
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>`;
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>`;
}
}
+ /**
+ * A segmented choice (three or four) as radios that look like one control: items [{ value, label, icon }]. The
+ * value still posts as the radio group's; the input is the browser's, only drawn differently, so it works without
+ * JavaScript. Without a label the control stands alone (a form puts its own label above it) and `ariaLabel` names
+ * the group; label draws a labelled row.
+ */
+ function segmented({ name, items = [], value = '', label = '', ariaLabel = '' } = {}) {
+ const id = `ovk-seg-${String(name).replace(/[^A-Za-z0-9_-]/g, '-')}`;
+ const control = `<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>`;
+ return label ? `<div class="ovk-set-row ovk-set-seg"><span class="ovk-set-text" id="${id}">${esc(label)}</span>${control}</div>` : control;
+ }
/** Chips: checkboxes (or radios: single) that look like small pills, for filters and "which of these". options: [[value, label, checked]]. */
/** Chips: checkboxes (or radios: single) that look like small pills, for filters and "which of these". options: [[value, label, checked]]. */
function chips({ name, options = [], single = false, label = '' }) {
function chips({ name, options = [], single = false, label = '' }) {
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('');
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('');
@@ -915,7 +938,7 @@
@@ -915,7 +938,7 @@
skeleton,
skeleton,
esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, share, askAgent, iconButton, action, badge, dot, card, rail, CARD_RATIO,
esc, url, mediaW, icon, ICONS, PATHS, when, hueOf, mark, button, share, askAgent, iconButton, action, badge, dot, card, rail, CARD_RATIO,
head, pageHead, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
head, pageHead, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
- switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton,
+ switchRow, rangeRow, selectRow, seg, segmented, chips, saveBar, find, dock, stats, labels, labelsAttr, LABEL_NAMES, empty, notice, snippet, sheet, sheetButton,
enhance, VERSION: 2,
enhance, VERSION: 2,
num, steps, table, chart, rank, meter, drop, receipt, page,
num, steps, table, chart, rank, meter, drop, receipt, page,
toolHead, toolInput, tries, answer, family, tool,
toolHead, toolInput, tries, answer, family, tool,
Binary files a/ov-glyphs-bold.woff2 and b/ov-glyphs-bold.woff2 differ
Binary files a/ov-glyphs-bold.woff2 and b/ov-glyphs-bold.woff2 differ
@@ -100,6 +100,7 @@
@@ -100,6 +100,7 @@
.ovi-fish::before{content:"\e0dd"}
.ovi-fish::before{content:"\e0dd"}
.ovi-fist::before{content:"\e0a5"}
.ovi-fist::before{content:"\e0a5"}
.ovi-flag::before{content:"\e034"}
.ovi-flag::before{content:"\e034"}
+.ovi-flame::before{content:"\e116"}
.ovi-flask::before{content:"\e0de"}
.ovi-flask::before{content:"\e0de"}
.ovi-focus::before{content:"\e0df"}
.ovi-focus::before{content:"\e0df"}
.ovi-folder::before{content:"\e035"}
.ovi-folder::before{content:"\e035"}
Binary files a/ov-glyphs-light.woff2 and b/ov-glyphs-light.woff2 differ
Binary files a/ov-glyphs-light.woff2 and b/ov-glyphs-light.woff2 differ
Binary files a/ov-glyphs-solid.woff2 and b/ov-glyphs-solid.woff2 differ
Binary files a/ov-glyphs-solid.woff2 and b/ov-glyphs-solid.woff2 differ
Binary files a/ov-glyphs-thin.woff2 and b/ov-glyphs-thin.woff2 differ
Binary files a/ov-glyphs-thin.woff2 and b/ov-glyphs-thin.woff2 differ
@@ -540,6 +540,11 @@
@@ -540,6 +540,11 @@
<symbol id="ovg-flag-bold" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 21V4M6.5 4h11l-2 4 2 4H6.5"/></symbol>
<symbol id="ovg-flag-bold" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.5 21V4M6.5 4h11l-2 4 2 4H6.5"/></symbol>
<symbol id="ovg-flag-solid" viewBox="0 0 24 24" fill="currentColor"><path fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" d="M6.5 21V3.8"/><path d="M7.1 3.6h11.6l-2.1 4.4 2.1 4.4H7.1Z"/></symbol>
<symbol id="ovg-flag-solid" viewBox="0 0 24 24" fill="currentColor"><path fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" d="M6.5 21V3.8"/><path d="M7.1 3.6h11.6l-2.1 4.4 2.1 4.4H7.1Z"/></symbol>
<symbol id="ovg-flag-duotone" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g fill="currentColor" stroke="none" opacity=".2"><path class="ln" d="M6.5 21V3.8"/><path d="M7.1 3.6h11.6l-2.1 4.4 2.1 4.4H7.1Z"/></g><path d="M6.5 21V4M6.5 4h11l-2 4 2 4H6.5"/></symbol>
<symbol id="ovg-flag-duotone" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g fill="currentColor" stroke="none" opacity=".2"><path class="ln" d="M6.5 21V3.8"/><path d="M7.1 3.6h11.6l-2.1 4.4 2.1 4.4H7.1Z"/></g><path d="M6.5 21V4M6.5 4h11l-2 4 2 4H6.5"/></symbol>
+<symbol id="ovg-flame" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21c3.6 0 6-2.4 6-5.7 0-4.2-4.4-7.7-6-12.3-1.6 4.6-6 8.1-6 12.3C6 18.6 8.4 21 12 21Z"/><path d="M12 17.5c1.4 0 2.4-1 2.4-2.3 0-1.4-1.2-2.6-2.4-4.2-1.2 1.6-2.4 2.8-2.4 4.2 0 1.3 1 2.3 2.4 2.3Z"/></symbol>
+<symbol id="ovg-flame-thin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21c3.6 0 6-2.4 6-5.7 0-4.2-4.4-7.7-6-12.3-1.6 4.6-6 8.1-6 12.3C6 18.6 8.4 21 12 21Z"/><path d="M12 17.5c1.4 0 2.4-1 2.4-2.3 0-1.4-1.2-2.6-2.4-4.2-1.2 1.6-2.4 2.8-2.4 4.2 0 1.3 1 2.3 2.4 2.3Z"/></symbol>
+<symbol id="ovg-flame-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21c3.6 0 6-2.4 6-5.7 0-4.2-4.4-7.7-6-12.3-1.6 4.6-6 8.1-6 12.3C6 18.6 8.4 21 12 21Z"/><path d="M12 17.5c1.4 0 2.4-1 2.4-2.3 0-1.4-1.2-2.6-2.4-4.2-1.2 1.6-2.4 2.8-2.4 4.2 0 1.3 1 2.3 2.4 2.3Z"/></symbol>
+<symbol id="ovg-flame-regular" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21c3.6 0 6-2.4 6-5.7 0-4.2-4.4-7.7-6-12.3-1.6 4.6-6 8.1-6 12.3C6 18.6 8.4 21 12 21Z"/><path d="M12 17.5c1.4 0 2.4-1 2.4-2.3 0-1.4-1.2-2.6-2.4-4.2-1.2 1.6-2.4 2.8-2.4 4.2 0 1.3 1 2.3 2.4 2.3Z"/></symbol>
+<symbol id="ovg-flame-bold" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21c3.6 0 6-2.4 6-5.7 0-4.2-4.4-7.7-6-12.3-1.6 4.6-6 8.1-6 12.3C6 18.6 8.4 21 12 21Z"/><path d="M12 17.5c1.4 0 2.4-1 2.4-2.3 0-1.4-1.2-2.6-2.4-4.2-1.2 1.6-2.4 2.8-2.4 4.2 0 1.3 1 2.3 2.4 2.3Z"/></symbol>
<symbol id="ovg-flask" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 3.5h5M10 3.5V9.5L4.8 18.2a1.5 1.5 0 0 0 1.3 2.3h11.8a1.5 1.5 0 0 0 1.3-2.3L14 9.5V3.5"/><path d="M7.2 15h9.6"/></symbol>
<symbol id="ovg-flask" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 3.5h5M10 3.5V9.5L4.8 18.2a1.5 1.5 0 0 0 1.3 2.3h11.8a1.5 1.5 0 0 0 1.3-2.3L14 9.5V3.5"/><path d="M7.2 15h9.6"/></symbol>
<symbol id="ovg-flask-thin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 3.5h5M10 3.5V9.5L4.8 18.2a1.5 1.5 0 0 0 1.3 2.3h11.8a1.5 1.5 0 0 0 1.3-2.3L14 9.5V3.5"/><path d="M7.2 15h9.6"/></symbol>
<symbol id="ovg-flask-thin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 3.5h5M10 3.5V9.5L4.8 18.2a1.5 1.5 0 0 0 1.3 2.3h11.8a1.5 1.5 0 0 0 1.3-2.3L14 9.5V3.5"/><path d="M7.2 15h9.6"/></symbol>
<symbol id="ovg-flask-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 3.5h5M10 3.5V9.5L4.8 18.2a1.5 1.5 0 0 0 1.3 2.3h11.8a1.5 1.5 0 0 0 1.3-2.3L14 9.5V3.5"/><path d="M7.2 15h9.6"/></symbol>
<symbol id="ovg-flask-light" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 3.5h5M10 3.5V9.5L4.8 18.2a1.5 1.5 0 0 0 1.3 2.3h11.8a1.5 1.5 0 0 0 1.3-2.3L14 9.5V3.5"/><path d="M7.2 15h9.6"/></symbol>
Binary files a/ov-glyphs.woff2 and b/ov-glyphs.woff2 differ
Binary files a/ov-glyphs.woff2 and b/ov-glyphs.woff2 differ
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.168.0",
+ "version": "4.169.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.168.0",
+ "version": "4.169.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.168.0",
+ "version": "4.169.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+'use strict';
+// kit.switchRow and kit.segmented in a real headless Chrome (4.164.0, the owner's /new paste form, 11-09): a switch row
+// is 56 px with a 36 px icon tile, and the whole row is the target around a native checkbox; a segmented control is
+// one 44 px control of three equal choices with icons, and neither the row nor the control scrolls sideways at 390.
+// Skipped without Chrome.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('../browser-harness');
+const kit = require('../kit');
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('kit-switch-seg-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const ROOT = path.join(__dirname, '..');
+const ROWS = kit.switchRow({ name: 'burn_after_read', icon: 'flame', title: 'Burn after reading', line: 'Gone once it is opened' })
+ + kit.switchRow({ name: 'is_nsfw', icon: 'eye-off', title: 'Mark as NSFW', line: 'Blurred until it is opened' })
+ + kit.switchRow({ name: 'lock_it', icon: 'lock', title: 'Lock it', line: 'OpenVibe can’t read what’s inside', checked: true });
+const SEG = kit.segmented({ name: 'visibility', value: 'unlisted', items: [
+ { value: 'public', label: 'Public', icon: 'globe' },
+ { value: 'unlisted', label: 'Unlisted', icon: 'link' },
+ { value: 'private', label: 'Private', icon: 'lock' },
+] });
+const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Switch rows</title><link rel="icon" href="data:,">
+<link rel="stylesheet" href="/kit.css"><style>body{margin:0;padding:16px;background:#0b0f17;color:#e6edf7;font:16px/1.4 system-ui}</style></head>
+<body><form class="ovk-form" id="f">${ROWS}<div class="ovk-field">${SEG}</div></form></body></html>`;
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(PAGE); }
+ if (u.pathname === '/kit.css') { res.writeHead(200, { 'content-type': 'text/css' }); return res.end(fs.readFileSync(path.join(ROOT, 'kit.css'))); }
+ res.writeHead(404); return res.end();
+});
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-switch-'));
+ const browser = await h.launch({ tmpDir });
+ try {
+ const context = await browser.newContext();
+ const p = await h.openPage(browser, context, { width: 390, height: 844, block: ['*openvibe.network*', '*openvibe.tools*'] });
+ await p.goto(`${base}/`, { maxMs: 6000 });
+ const rows = await p.evaluate(`[...document.querySelectorAll('.ovk-set-switch')].map((r) => {
+ const ic = r.querySelector('.ovk-set-ic'), sw = r.querySelector('input[role=switch]');
+ const rr = r.getBoundingClientRect(), ir = ic.getBoundingClientRect(), sr = sw.getBoundingClientRect();
+ return { h: Math.round(rr.height), icon: Math.round(ir.width) + 'x' + Math.round(ir.height), switch: Math.round(sr.width) + 'x' + Math.round(sr.height), type: sw.type, role: sw.getAttribute('role') };
+ })`);
+ assert.strictEqual(rows.length, 3, 'three switch rows');
+ for (const r of rows) {
+ assert.ok(r.h >= 56, `a switch row is 56 px (${r.h})`);
+ assert.strictEqual(r.icon, '36x36', 'the icon tile is 36 px');
+ assert.strictEqual(r.switch, '44x26', 'the switch is 44 px wide');
+ assert.strictEqual(r.type, 'checkbox', 'the switch is the checkbox');
+ assert.strictEqual(r.role, 'switch', 'spoken as a switch');
+ }
+ // The whole row is the target: a tap on the title toggles the checkbox.
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovk-set-switch .ovk-set-text').click(), document.querySelector('input[name=burn_after_read]').checked"), true, 'the title toggles it');
+ assert.strictEqual(await p.evaluate("document.querySelector('input[name=burn_after_read]').checked = false, document.querySelector('.ovk-set-switch .ovk-set-ic').click(), document.querySelector('input[name=burn_after_read]').checked"), true, 'the icon tile toggles it');
+ // The segmented control: one row, 44 px tall, three equal choices, the checked one filled.
+ const seg = await p.evaluate(`(() => {
+ const box = document.querySelector('.ovk-seg--fill'), labels = [...box.querySelectorAll('.ovk-seg-item')], spans = [...box.querySelectorAll('.ovk-seg-item > span')];
+ const br = box.getBoundingClientRect();
+ return { h: Math.round(br.height), n: labels.length, w: labels.map((l) => Math.round(l.getBoundingClientRect().width)), top: new Set(labels.map((l) => Math.round(l.getBoundingClientRect().top))).size,
+ checked: box.querySelector('input:checked').value, fill: getComputedStyle(box.querySelector('input:checked + span')).backgroundColor, icons: box.querySelectorAll('.ovk-i').length, over: Math.round(document.documentElement.scrollWidth - innerWidth) };
+ })()`);
+ assert.ok(seg.h >= 44, `the segmented control is 44 px (${seg.h})`);
+ assert.strictEqual(seg.n, 3, 'three choices');
+ assert.strictEqual(seg.top, 1, 'one row');
+ assert.ok(Math.max(...seg.w) - Math.min(...seg.w) <= 1, `equal widths (${seg.w})`);
+ assert.strictEqual(seg.checked, 'unlisted', 'the value is the radio group’s');
+ assert.notStrictEqual(seg.fill, 'rgba(0, 0, 0, 0)', 'the chosen one is filled');
+ assert.strictEqual(seg.icons, 3, 'each choice has its icon');
+ assert.strictEqual(seg.over, 0, 'no sideways scroll at 390');
+ // The radio still posts: the form carries the chosen visibility.
+ assert.strictEqual(await p.evaluate("new FormData(document.getElementById('f')).get('visibility')"), 'unlisted');
+ console.log('kit-switch-seg-chrome: a 56 px switch row, the whole row a target, a 44 px segmented control of three equal choices');
+ } finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -54,10 +54,21 @@ const kit = require('../kit');
@@ -54,10 +54,21 @@ const kit = require('../kit');
// ── Settings rows, folds, menus ──
// ── Settings rows, folds, menus ──
{
{
assert.ok(kit.switchRow({ name: 'compact', label: 'Compact', hint: 'Less space', on: true }).includes('class="ovk-switch" name="compact" value="1" checked'));
assert.ok(kit.switchRow({ name: 'compact', label: 'Compact', hint: 'Less space', on: true }).includes('class="ovk-switch" name="compact" value="1" checked'));
+ const sw = kit.switchRow({ name: 'burn', icon: 'flame', title: 'Burn after reading', line: 'Gone once it is read', checked: true });
+ assert.ok(sw.startsWith('<label class="ovk-set-row ovk-set-switch">') && sw.includes('class="ovk-set-ic" aria-hidden="true"><svg'));
+ assert.ok(sw.includes('role="switch"') && sw.includes('type="checkbox"') && sw.includes('value="1" checked'), 'the switch is a working checkbox');
+ assert.ok(sw.includes('Burn after reading') && sw.includes('Gone once it is read'), 'title and one line');
+ assert.ok(!kit.switchRow({ name: 'n', title: 'No icon', line: 'x' }).includes('ovk-set-ic'), 'no icon, no tile');
+ assert.ok(kit.switchRow({ name: 'n', label: 'Old name', hint: 'Old hint', on: true }).includes('>Old name<small>Old hint</small>'), 'label/hint/on still work');
assert.ok(kit.rangeRow({ name: 'v', label: 'Volume', value: 40 }).includes('value="40"') && kit.rangeRow({ name: 'v', label: 'V', value: 40 }).includes('aria-valuetext="40 percent"'));
assert.ok(kit.rangeRow({ name: 'v', label: 'Volume', value: 40 }).includes('value="40"') && kit.rangeRow({ name: 'v', label: 'V', value: 40 }).includes('aria-valuetext="40 percent"'));
assert.ok(kit.selectRow({ name: 'who', label: 'Who', options: [['all', 'Everyone'], ['none', 'Nobody']], value: 'none' }).includes('<option value="none" selected>Nobody</option>'));
assert.ok(kit.selectRow({ name: 'who', label: 'Who', options: [['all', 'Everyone'], ['none', 'Nobody']], value: 'none' }).includes('<option value="none" selected>Nobody</option>'));
const seg = kit.seg({ name: 'size', label: 'Text size', options: [['s', 'Small'], ['m', 'Default']], value: 'm' });
const seg = kit.seg({ name: 'size', label: 'Text size', options: [['s', 'Small'], ['m', 'Default']], value: 'm' });
assert.ok(seg.includes('role="radiogroup"') && seg.includes('value="m" checked'));
assert.ok(seg.includes('role="radiogroup"') && seg.includes('value="m" checked'));
+ const segments = kit.segmented({ name: 'visibility', value: 'unlisted', items: [{ value: 'public', label: 'Public', icon: 'globe' }, { value: 'unlisted', label: 'Unlisted', icon: 'link' }, { value: 'private', label: 'Private', icon: 'lock' }] });
+ assert.ok(segments.startsWith('<span class="ovk-seg ovk-seg--fill" role="radiogroup"') && segments.includes('aria-label="visibility"'));
+ assert.ok(segments.includes('value="unlisted" checked') && segments.includes('class="ovk-seg-item"') && segments.includes('class="ovk-i"'), 'segments carry icons');
+ assert.ok(kit.segmented({ name: 'v', ariaLabel: 'Who sees it', items: [{ value: 'a', label: 'A' }] }).includes('aria-label="Who sees it"'), 'a standalone group still has words');
+ assert.ok(kit.segmented({ name: 'v', label: 'Who sees it', items: [{ value: 'a', label: 'A' }] }).includes('aria-labelledby="ovk-seg-v"'), 'a label names the group');
assert.ok(kit.chips({ name: 'src', options: [['kick', 'Kick', true], ['yt', 'YouTube', false]] }).includes('name="src_kick" value="1" checked'));
assert.ok(kit.chips({ name: 'src', options: [['kick', 'Kick', true], ['yt', 'YouTube', false]] }).includes('name="src_kick" value="1" checked'));
const f = kit.fold({ id: 'oc-ai', title: 'AI members', meta: '2', body: '<p>x</p>' });
const f = kit.fold({ id: 'oc-ai', title: 'AI members', meta: '2', body: '<p>x</p>' });
assert.ok(f.startsWith('<details class="ovk-fold" id="oc-ai">') && !f.includes(' open'));
assert.ok(f.startsWith('<details class="ovk-fold" id="oc-ai">') && !f.includes(' open'));