openvibers/openvibe.shared Commits
One left sidebar for every product: kit.sidebar (4.60.0) (#212)
Changes
@@@ -6,6 +6,23 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.60.0 — 2026-10-31++++- **One left sidebar for every product: `kit.sidebar`** (plan L35; the owner: "the navigation makes more sense in a++ vertical way… part of the openvibe.design shared styling across everything").++ - **The order, top to bottom:**++ - the main action (New chat, Go live, New post) and search;++ - the navigation as vertical rows: an icon and a name, the page you are on marked, a count or a dot;++ - the sections (Pinned, Today…), which scroll on their own;++ - a foot.++ - **On a computer:** a column beside the page. **On a phone:** a drawer over everything, which `kit.sidebarButton`++ opens and the scrim, its close button, Escape and a page move close. The handlers are on the document in++ kit-web.js, so a page that boost swapped in needs no wiring (Codes' rail lost its handler that way, Codes #209).++ - **New builders and icon:** `kit.withSidebar` puts the sidebar and the page side by side; a new kit icon,++ `sidebar`.++ - **In the component contract** (openvibe.design/components): `sidebar`, with a Compose `ModalNavigationDrawer` and++ a SwiftUI `NavigationSplitView` reference view, and a kit-document form (the navigation).++## 4.59.0 — 2026-10-31- **The OpenVibe mark is the Pair** (the owner, from OpenVibe Mark Systems: "Pair, halo … I like this icon the most.@@@ -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.59.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.60.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.59.0",++ "version": "4.60.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)",@@@ -377,6 +377,51 @@ fun OvTabs(tabs: List<String>, selected: Int, onSelect: (Int) -> Unit) {Picker("Sections", selection: $selected) { ForEach(tabs.indices, id: \\.self) { Text(tabs[$0]).tag($0) } }.pickerStyle(.segmented)}++}`,++ },++ },++ {++ id: 'sidebar', name: 'Sidebar', web: 'kit.sidebar',++ summary: "An app's left sidebar: the main action and search on top, the navigation as vertical rows (an icon and a name, the page you are on marked, a count or a dot), then its sections. A column beside the page on a computer, a drawer on a phone.",++ role: 'complementary, with the navigation as a nav; the page you are on is aria-current',++ anatomy: [++ { part: 'panel', tokens: { background: 'color.bg.raised', line: 'color.line.base' }, note: '272 px wide on a computer; a drawer of up to 320 px on a phone' },++ { part: 'action', tokens: { minHeight: 'size.tap', radius: 'radius.md', color: 'color.text.primary', icon: 'color.accent.base' }, note: 'the one thing to start: New chat, Go live, New post' },++ { part: 'link', tokens: { minHeight: 'size.tap', radius: 'radius.md', color: 'color.text.muted', icon: 'size.icon' }, note: 'the page you are on: color.accent.base icon on a tint of it, color.text.primary' },++ { part: 'count', optional: true, tokens: { background: 'color.accent.strong', size: 'type.size.caption', weight: 'type.weight.heavy', radius: 'radius.pill' } },++ { part: 'section heading', optional: true, tokens: { color: 'color.text.muted', size: 'type.size.caption', weight: 'type.weight.heavy' }, note: 'uppercase' },++ { part: 'item', optional: true, tokens: { color: 'color.text.primary', size: 'type.size.body', hover: 'color.bg.hover' }, note: 'one line, a mark before it and a time or a dot after it' },++ ],++ props: { id: { type: 'string' }, label: { type: 'string' }, action: { type: 'link' }, nav: { type: 'navs', required: true } },++ states: ['default', 'current', 'focused', 'open'],++ a11y: { name: 'its label (the product)', keys: 'Tab down the rows; Enter opens; Escape closes the drawer and focus returns', minTarget: 'rows 38 px, 44 px with their gap on touch' },++ layout: { phone: 'a drawer over the page, opened from the bar, closed by the scrim, its close button, Escape or a page move', computer: 'a column beside the page; its sections scroll on their own' },++ native: {++ compose: `@Composable++fun OvSidebar(items: List<Pair<String, ImageVector>>, current: Int, onPick: (Int) -> Unit, drawer: DrawerState, content: @Composable () -> Unit) {++ ModalNavigationDrawer(drawerState = drawer, drawerContent = {++ ModalDrawerSheet(Modifier.widthIn(max = 320.dp)) {++ Column(Modifier.padding(OvScale.SpaceMd)) {++ items.forEachIndexed { i, (label, icon) ->++ NavigationDrawerItem(label = { Text(label) }, icon = { Icon(icon, null, Modifier.size(OvScale.SizeIcon)) }, selected = i == current,++ onClick = { onPick(i) }, shape = RoundedCornerShape(OvScale.RadiusMd), modifier = Modifier.heightIn(min = OvScale.SizeTap))++ }++ }++ }++ }, content = content)++}`,++ swiftui: `struct OvSidebar<Detail: View>: View {++ let items: [(label: String, icon: String)]++ @Binding var current: Int?++ @ViewBuilder var detail: () -> Detail++ var body: some View {++ NavigationSplitView {++ List(selection: $current) {++ ForEach(items.indices, id: \\.self) { i in Label(items[i].label, systemImage: items[i].icon).frame(minHeight: OvScale.sizeTap).tag(i) }++ }++ .listStyle(.sidebar)++ } detail: { detail() }++ }}`,},},@@@ -527,6 +572,7 @@ enum OvKind { case primary, quiet, danger }`,case 'count': return (typeof v === 'number' && Number.isFinite(v)) || (Array.isArray(v) && v.length <= 2 && Number.isFinite(Number(v[0])) && (v[1] == null || isStr(v[1])));case 'stats': return Array.isArray(v) && v.length <= LIMITS.items && v.every((x) => Array.isArray(x) && x.length >= 2 && x.length <= 3 && (isStr(x[0]) || Number.isFinite(x[0])) && isStr(x[1]) && (x[2] == null || isUrl(x[2])));case 'tabs': return Array.isArray(v) && v.length <= LIMITS.items && v.every((x) => x && typeof x === 'object' && isStr(x.key) && isStr(x.label) && (x.count == null || Number.isFinite(Number(x.count))) && (x.href == null || isUrl(x.href)));++ case 'navs': return Array.isArray(v) && v.length <= LIMITS.items && v.every((x) => x && typeof x === 'object' && isUrl(x.href) && isStr(x.label) && (x.icon == null || isStr(x.icon)) && (x.here == null || typeof x.here === 'boolean') && (x.count == null || Number.isFinite(Number(x.count))));default: return false;}}@@@ -585,6 +631,7 @@ enum OvKind { case primary, quiet, danger }`,case 'section': return kit.section({ id: p.id || null, title: p.title || '', count: p.count == null ? null : p.count, note: p.note || '', body: kids.join('') });case 'fold': return kit.fold({ id: p.id || null, title: p.title, meta: p.meta || '', open: !!p.open, tone: p.tone || '', body: kids.join('') });case 'tabs': return kit.tabs({ base: p.base || '', items: p.items, active: p.active || '', param: p.param || 'tab', label: p.label || 'Sections' });++ case 'sidebar': return kit.sidebar({ id: p.id || 'ovk-sidebar', label: p.label || 'Navigation', action: p.action ? { href: p.action.href, label: p.action.label || 'New' } : null, nav: p.nav.map((x) => ({ href: x.href, label: x.label, icon: x.icon || 'chevron', here: !!x.here, count: x.count == null ? null : Number(x.count) })) });case 'stats': return kit.stats(p.items, { label: p.label || 'In numbers' });case 'notice': return kit.notice({ kind: p.kind || 'info', text: p.text });case 'empty': return kit.empty({ title: p.title, text: p.text || '', icon: p.icon || '', action: p.action ? { href: p.action.href, label: p.action.label || 'Go' } : null });@@@ -444,6 +444,36 @@ the ring. The gap is a mask, so the mark sits on any ground: a photo, a gradient`scripts/build-social-cards.js`); the loading screen (`loading.js`) shows it at work, and its V turns into "!" whenloading fails.++## The sidebar++++`kit.sidebar` is an app's left sidebar, one for every OpenVibe product (4.60.0; the owner, 2026-10-31: "the++navigation makes more sense in a vertical way… part of the openvibe.design shared styling across everything").++++```js++kit.withSidebar({ sidebar: kit.sidebar({++ id: 'side', label: 'Codes',++ action: { href: '/', label: 'New chat', icon: 'pencil' }, // the one thing to start++ find: { action: '/tasks', placeholder: 'Search conversations' },++ nav: [{ href: '/agents', label: 'Agents', icon: 'members' }, { href: '/machines', label: 'Machines', icon: 'computer', here: true },++ { href: '/approvals', label: 'Approvals', icon: 'hand', count: 2 }],++ sections: [{ title: 'Today', items: [{ href: '/t/1', label: 'Fix the phone home page', mark: kit.mark({ name: 'C' }), meta: '30m', dot: 'busy' }] }],++}), body: kit.sidebarButton({ id: 'side', label: 'Your conversations' }) + page })++```++++- **Order, top to bottom:**++ - the main action and search;++ - the navigation as vertical rows (an icon and a name; the page you are on marked, `aria-current`; a count or a++ dot: `true`, `'busy'`, `'needs'` or `'bad'`);++ - then the sections, which scroll on their own;++ - and an optional foot.++- **On a computer:** a 272 px column beside the page (`--ovk-sidebar-w`), sticky under the navbar.++- **On a phone (760 px and under):** a drawer over everything.++ - `kit.sidebarButton` opens it.++ - The scrim, its close button, Escape and a page move close it.++ - The handlers are on the document (kit-web.js), so a page that boost swapped in works without wiring.++- **Contract:** in openvibe-shared/components as `sidebar` (Compose `ModalNavigationDrawer`, SwiftUI++ `NavigationSplitView`).++## The app bar```js@@@ -13,6 +13,8 @@* tabs a sideways-scrolling tab strip keeps the open tab in view* rails .ovk-rail: ‹ › scroll a page of cards on devices with a pointer, hidden at either end* share [data-ovk-share]: the system share sheet, else the link copied (the menu item says "Link copied")++ * sidebars kit.sidebar on a phone: [data-ovk-sidebar-open] opens the drawer; the scrim, its close button, Escape++ * and a page move close it (document-level, so boost-swapped pages need no wiring)* card previews .ovk-card video[data-src]: a muted loop while the card is hovered or focused (pointers only, never* under reduced motion; the file loads only then)*@@@ -22,6 +24,17 @@'use strict';const CHECK = '<svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';++ /** Opens or closes a sidebar drawer: its scrim, its buttons' aria-expanded, and the focus into it. */++ function setSidebar(sb, on) {++ if (!sb) return;++ sb.classList.toggle('is-open', on);++ const wrap = sb.closest('.ovk-with-sidebar') || document;++ const scrim = wrap.querySelector('[data-ovk-sidebar-scrim]');++ if (scrim) scrim.hidden = !on;++ Array.prototype.forEach.call(document.querySelectorAll('[data-ovk-sidebar-open]'), (b) => { if (!b.getAttribute('aria-controls') || b.getAttribute('aria-controls') === sb.id) b.setAttribute('aria-expanded', on ? 'true' : 'false'); });++ if (on) { const first = sb.querySelector('.ovk-sidebar-link.is-here, .ovk-sidebar-action, a, input'); if (first && first.focus) first.focus({ preventScroll: true }); }++ }++function enhance(rootEl) {if (typeof document === 'undefined') return;const scope = rootEl || document;@@@ -70,7 +83,18 @@document.addEventListener('keydown', (e) => {if (e.key !== 'Escape') return;Array.prototype.forEach.call(document.querySelectorAll('details.ovk-more[open]'), (d) => { d.open = false; const s = d.querySelector('summary'); if (s) s.focus(); });++ Array.prototype.forEach.call(document.querySelectorAll('.ovk-sidebar.is-open'), (sb) => setSidebar(sb, false));++ });++ // The left sidebar on a phone (kit.sidebar): its button opens it; the scrim, its close button, Escape and a page++ // move close it. Handled on the document, so a page boost swapped in works without being wired again.++ document.addEventListener('click', (e) => {++ const t = e.target && e.target.closest ? e.target : null;++ if (!t) return;++ const open = t.closest('[data-ovk-sidebar-open]');++ if (open) { setSidebar(document.getElementById(open.getAttribute('aria-controls') || '') || document.querySelector('[data-ovk-sidebar]'), true); return; }++ if (t.closest('[data-ovk-sidebar-close], [data-ovk-sidebar-scrim]')) Array.prototype.forEach.call(document.querySelectorAll('.ovk-sidebar.is-open'), (sb) => setSidebar(sb, false));});++ document.addEventListener('ov:boost:before', () => Array.prototype.forEach.call(document.querySelectorAll('.ovk-sidebar.is-open'), (sb) => setSidebar(sb, false)));// The head casts a shadow once the page has scrolled under it.let ticking = false;const mark = () => { ticking = false; document.documentElement.classList.toggle('ovk-scrolled', (window.scrollY || 0) > 4); };@@@ -273,3 +273,50 @@ div.ovk-set-row { cursor: default; }.ovk-go::after { content: ''; position: absolute; inset: -3px; }.ovk-find input, .ovk-find select { min-height: 44px; }}++++/* ── The left sidebar (kit.sidebar, L35): one for every product. A column beside the page from 761 px; on a phone a++ drawer over everything (kit.sidebarButton opens it; the scrim, its close button and Escape close it, kit-web.js). The++ main action and search on top, the navigation as vertical rows, then the sections, which scroll on their own. ── */++.ovk-with-sidebar { display: grid; grid-template-columns: var(--ovk-sidebar-w, 272px) minmax(0, 1fr); min-height: calc(100dvh - var(--navbar-h, 52px)); }++.ovk-with-sidebar-main { min-width: 0; }++.ovk-sidebar { position: sticky; top: var(--navbar-h, 52px); height: calc(100dvh - var(--navbar-h, 52px)); box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: 12px 10px 10px; border-right: 1px solid var(--ovk-line); background: var(--ovk-panel); color: var(--ovk-text); overflow: hidden; }++.ovk-sidebar-top { display: flex; align-items: center; gap: 6px; }++.ovk-sidebar-action { flex: 1; display: flex; align-items: center; gap: 10px; min-height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--ovk-line); background: var(--ovk-bg); color: var(--ovk-text); font-weight: 650; text-decoration: none; transition: border-color .18s, background .18s; }++.ovk-sidebar-action:hover { border-color: color-mix(in srgb, var(--ovk-accent) 55%, var(--ovk-line)); }++.ovk-sidebar-action .ovk-i { width: 18px; height: 18px; color: var(--ovk-accent); }++.ovk-sidebar-close, .ovk-sidebar-open { display: none; }++.ovk-sidebar-find { position: relative; display: flex; align-items: center; margin: 0; }++.ovk-sidebar-find .ovk-i { position: absolute; left: 11px; width: 16px; height: 16px; color: var(--ovk-muted); pointer-events: none; }++.ovk-sidebar-find input { width: 100%; min-height: 38px; box-sizing: border-box; padding: 0 12px 0 34px; border-radius: 10px; border: 1px solid transparent; background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 14px; }++.ovk-sidebar-find input:focus { outline: none; border-color: var(--ovk-accent); }++.ovk-sidebar-nav { display: grid; gap: 1px; padding-bottom: 8px; border-bottom: 1px solid var(--ovk-line); }++.ovk-sidebar-link { display: flex; align-items: center; gap: 11px; min-height: 38px; padding: 0 10px; border-radius: 10px; color: var(--ovk-muted); font-size: 14.5px; font-weight: 600; text-decoration: none; transition: background .15s, color .15s; }++.ovk-sidebar-link .ovk-i { width: 19px; height: 19px; flex: none; }++.ovk-sidebar-link span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovk-sidebar-link:hover { background: var(--ovk-panel-2); color: var(--ovk-text); }++.ovk-sidebar-link.is-here { background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); color: var(--ovk-text); }++.ovk-sidebar-link.is-here .ovk-i { color: var(--ovk-accent); }++.ovk-sidebar-link:focus-visible, .ovk-sidebar-item:focus-visible, .ovk-sidebar-action:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: -2px; }++.ovk-sidebar-count { min-width: 20px; box-sizing: border-box; padding: 1px 6px; border-radius: 999px; background: var(--ovk-fill); color: var(--ovk-on-fill); font-size: 11.5px; font-weight: 700; text-align: center; }++.ovk-sidebar-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--ovk-accent); }++.ovk-sidebar-dot.is-busy { animation: ovk-sidebar-breathe 1.6s ease-in-out infinite; }++.ovk-sidebar-dot.is-needs { background: var(--ovk-warn); }++.ovk-sidebar-dot.is-bad { background: var(--ovk-bad); }++@keyframes ovk-sidebar-breathe { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.75); } }++.ovk-sidebar-secs { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; margin: 0 -4px; padding: 0 4px 8px; }++.ovk-sidebar-h { margin: 12px 10px 4px; font-size: 11.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--ovk-muted); }++.ovk-sidebar-item { display: flex; align-items: center; gap: 9px; min-height: 36px; padding: 4px 10px; border-radius: 10px; color: var(--ovk-text); font-size: 14px; text-decoration: none; }++.ovk-sidebar-item:hover { background: var(--ovk-panel-2); }++.ovk-sidebar-item.is-here { background: var(--ovk-panel-2); font-weight: 600; }++.ovk-sidebar-t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovk-sidebar-m { flex: none; color: var(--ovk-muted); font-size: 12px; }++.ovk-sidebar-empty { margin: 8px 10px; color: var(--ovk-muted); font-size: 13.5px; }++.ovk-sidebar-foot { padding-top: 8px; border-top: 1px solid var(--ovk-line); }++@media (max-width: 760px) {++ .ovk-with-sidebar { grid-template-columns: minmax(0, 1fr); }++ .ovk-sidebar { position: fixed; z-index: 10060; top: 0; left: 0; bottom: 0; height: auto; width: min(320px, 86vw); padding-top: max(12px, env(safe-area-inset-top, 0px)); padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)); border-right: 0; translate: -100% 0; visibility: hidden; transition: translate .25s ease, visibility 0s linear .25s; box-shadow: 12px 0 32px rgba(0, 0, 0, .25); }++ .ovk-sidebar.is-open { translate: 0 0; visibility: visible; transition: translate .25s ease; }++ .ovk-sidebar-close, .ovk-sidebar-open { display: inline-grid; }++ .ovk-sidebar-scrim { position: fixed; inset: 0; z-index: 10050; background: rgba(0, 0, 0, .4); }++}++@media (prefers-reduced-motion: reduce) { .ovk-sidebar, .ovk-sidebar.is-open { transition: none; } .ovk-sidebar-dot.is-busy { animation: none; } }@@@ -28,6 +28,8 @@* kit.empty({ title, text, action: { href, label } }) · kit.notice({ kind, text }) · kit.more({ label, items })* kit.button({ href | type, label, icon, kind }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)* kit.mark({ name, src, hue, square }) · kit.when(ts) → { iso, text } · kit.esc(text) · kit.ICONS (names)++ * kit.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar,++ * body }) the left sidebar: vertical navigation, a drawer on phones** The tool frame, one order for every tool page: kit.tool({ head: kit.toolHead({ icon, hue, crumb, title, task, facts }),* input: kit.toolInput({ name, label, placeholder, button, tries: { items } }) | kit.drop(…) | an editor,@@@ -56,7 +58,7 @@plus: '<path d="M12 5v14M5 12h14"/>', minus: '<path d="M5 12h14"/>', check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',send: '<path d="M12 19V5M5 12l7-7 7 7"/>', search: '<circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/>',more: '<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>',-- menu: '<path d="M4 7h16M4 12h16M4 17h16"/>', filter: '<path d="M4 6h16M7 12h10M10 18h4"/>',++ menu: '<path d="M4 7h16M4 12h16M4 17h16"/>', sidebar: '<rect x="3.5" y="4.5" width="17" height="15" rx="2.5"/><path d="M9.5 4.5v15"/>', filter: '<path d="M4 6h16M7 12h10M10 18h4"/>',home: '<path d="M4 11 12 4l8 7v8a1 1 0 0 1-1 1h-4v-6h-6v6H5a1 1 0 0 1-1-1Z"/>',chat: '<path d="M4 5h16v11H8l-4 4Z"/>', mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m4 7 8 6 8-6"/>',bell: '<path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15ZM10 20a2 2 0 0 0 4 0"/>',@@@ -183,6 +185,45 @@+ (actions && actions.length ? `<div class="ovk-head-tools">${actions.join('')}</div>` : '')+ `</div>${tabs || ''}</header>`;}++ // ── The left sidebar (L35) ──++ /**++ * An app's left sidebar, one for every OpenVibe product (the owner, 2026-10-31: "the navigation makes more sense++ * in a vertical way… part of the openvibe.design shared styling across everything"): the main action and search at++ * the top, the navigation as vertical rows (an icon and a name, the page you are on marked, a count or a dot), the++ * sections under it (conversations, recent things) and a foot. A column beside the page from 761 px; on a phone a++ * drawer that kit.sidebarButton opens and the scrim, its close button or Escape close (kit-web.js, boost-safe).++ * kit.sidebar({ id, label, action: { href, label, icon }, find: { action, name, placeholder, label },++ * nav: [{ href, label, icon, iconHtml, here, count, dot }],++ * sections: [{ title, items: [{ href, label, meta, mark, here, dot }], empty }], foot })++ * kit.sidebarButton({ id, label }) the button that opens it on a phone (shown only there)++ * kit.withSidebar({ sidebar, body }) the sidebar and the page side by side++ * dot: true, or 'busy' | 'needs' | 'bad'. mark and foot take trusted markup; iconHtml replaces a kit icon.++ */++ function sidebar({ id = 'ovk-sidebar', label = 'Navigation', action: act = null, find: f = null, nav = [], sections = [], foot = '' } = {}) {++ const dotHtml = (d) => (d ? `<i class="ovk-sidebar-dot${d === true ? '' : ` is-${esc(d)}`}" aria-hidden="true"></i>` : '');++ const link = (x) => `<a class="ovk-sidebar-link${x.here ? ' is-here' : ''}" href="${esc(url(x.href))}"${x.here ? ' aria-current="page"' : ''}>`++ + `${x.iconHtml != null ? x.iconHtml : icon(x.icon || 'chevron')}<span>${esc(x.label)}</span>`++ + `${Number(x.count) > 0 ? `<b class="ovk-sidebar-count">${Number(x.count) > 99 ? '99+' : Number(x.count)}</b>` : dotHtml(x.dot)}</a>`;++ const item = (x) => `<a class="ovk-sidebar-item${x.here ? ' is-here' : ''}" href="${esc(url(x.href))}"${x.here ? ' aria-current="page"' : ''}>`++ + `${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)}</a>`;++ const secs = (sections || []).map((sec) => {++ const items = sec.items || [];++ return `<section class="ovk-sidebar-sec">${sec.title ? `<h2 class="ovk-sidebar-h">${esc(sec.title)}</h2>` : ''}${items.map(item).join('')}`++ + `${!items.length && sec.empty ? `<p class="ovk-sidebar-empty">${esc(sec.empty)}</p>` : ''}</section>`;++ }).join('');++ return `<aside class="ovk-sidebar" id="${esc(id)}" aria-label="${esc(label)}" data-ovk-sidebar>`++ + `<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>` : ''}`++ + `<button class="ovk-icon-btn ovk-sidebar-close" type="button" data-ovk-sidebar-close aria-label="Close">${icon('close')}</button></div>`++ + (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>`++ + `${icon('search')}<input id="${esc(id)}-q" name="${esc(f.name || 'q')}" placeholder="${esc(f.placeholder || 'Search')}" autocomplete="off"></form>` : '')++ + (nav && nav.length ? `<nav class="ovk-sidebar-nav" aria-label="${esc(label)}">${nav.map(link).join('')}</nav>` : '')++ + (secs ? `<div class="ovk-sidebar-secs">${secs}</div>` : '')++ + (foot ? `<div class="ovk-sidebar-foot">${foot}</div>` : '')++ + '</aside>';++ }++ 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>`;++ 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>`;++/*** Tabs as links (?tab=key, or ?<param>=key): the server builds only the open one, so a long page becomes a few short* ones and nothing needs JavaScript. items: [{ key, label, count, icon, href }].@@@ -774,7 +815,7 @@return {esc, url, icon, ICONS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,-- head, tabs, tabOf, list, row, section, fold, more,++ head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,enhance, VERSION: 2,num, steps, table, chart, rank, meter, drop, receipt, page,@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.59.0",++ "version": "4.60.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -81,10 +81,14 @@ const COMPOSE_IMPORTS = ['androidx.compose.material.icons.filled.Info','androidx.compose.material3.Button','androidx.compose.material3.ButtonDefaults',++ 'androidx.compose.material3.DrawerState','androidx.compose.material3.Icon','androidx.compose.material3.ListItem','androidx.compose.material3.LocalTextStyle','androidx.compose.material3.MaterialTheme',++ 'androidx.compose.material3.ModalDrawerSheet',++ 'androidx.compose.material3.ModalNavigationDrawer',++ 'androidx.compose.material3.NavigationDrawerItem','androidx.compose.material3.OutlinedButton','androidx.compose.material3.ScrollableTabRow','androidx.compose.material3.Surface',@@@ -17,7 +17,9 @@ import androidx.compose.foundation.layout.fillMaxWidthimport androidx.compose.foundation.layout.paddingimport androidx.compose.foundation.lazy.itemsimport androidx.compose.foundation.shape.RoundedCornerShape++import androidx.compose.material3.Iconimport androidx.compose.material3.MaterialTheme++import androidx.compose.material3.NavigationDrawerItemimport androidx.compose.material3.Surfaceimport androidx.compose.material3.Textimport androidx.compose.runtime.Composable@@@ -47,6 +49,9 @@ data class KitLink(val href: String, val label: String)/** One tab: its key (what the web puts in the URL), its label and its count. */data class KitTab(val key: String, val label: String, val count: Int? = null)++/** One row of a sidebar's navigation: where it goes, its name, its icon, whether you are on it, and a count. */++data class KitNav(val href: String, val label: String, val icon: String? = null, val here: Boolean = false, val count: Int? = null)++/** One number with its label. */data class KitStat(val value: String, val label: String, val href: String? = null)@@@ -194,6 +199,15 @@ sealed class KitNode {}}++ /** Sidebar: an app's navigation, as rows (on a phone the app shows it in its drawer). */++ data class Sidebar(++ val label: String = "Navigation",++ val nav: List<KitNav> = emptyList(),++ override val children: List<KitNode> = emptyList(),++ ) : KitNode() {++ override val type: String = "sidebar"++ }++/** Stats: the numbers, in a row. */data class Stats(val items: List<KitStat> = emptyList(),@@@ -309,6 +323,10 @@ sealed class KitNode {param = props.str("param") ?: "tab",label = props.str("label") ?: "Sections",)++ "sidebar" -> Sidebar(++ label = props.str("label") ?: "Navigation",++ nav = navsOf(props.array("nav")),++ )"stats" -> Stats(items = statsOf(props.array("items")),label = props.str("label") ?: "In numbers",@@@ -355,6 +373,18 @@ sealed class KitNode {return out}++ /** The rows of a sidebar's navigation. */++ private fun navsOf(array: JSONArray?): List<KitNav> {++ if (array == null) return emptyList()++ val out = ArrayList<KitNav>()++ for (i in 0 until array.length()) {++ val row = array.optJSONObject(i) ?: continue++ val href = row.str("href") ?: continue++ out.add(KitNav(href, row.str("label") ?: href, row.str("icon"), row.bool("here"), row.int("count")))++ }++ return out++ }++/** The numbers of a stats row. */private fun statsOf(array: JSONArray?): List<KitStat> {if (array == null) return emptyList()@@@ -543,6 +573,7 @@ fun KitNodeView(var selected by rememberSaveable { mutableStateOf(node.selectedIndex) }OvTabs(tabs = labels, selected = selected, onSelect = { selected = it })}++ is KitNode.Sidebar -> OvSidebarNav(node, icons, onAction)is KitNode.Stats -> OvStats(items = node.items.map { it.value to it.label })is KitNode.Notice -> OvNotice(text = node.text, kind = node.kind)is KitNode.Empty -> {@@@ -578,3 +609,22 @@ private fun OvInitials(name: String) {)}}++++/** A sidebar's navigation, drawn as a drawer's rows: the one you are on is selected, and a tap opens its link. */++@Composable++fun OvSidebarNav(node: KitNode.Sidebar, icons: Map<String, ImageVector>, onAction: (KitAction) -> Unit) {++ Column(Modifier.fillMaxWidth().padding(OvScale.SpaceMd)) {++ node.nav.forEach { item ->++ val vector = item.icon?.let { icons[it] }++ val count = item.count++ NavigationDrawerItem(++ label = { Text(item.label) },++ selected = item.here,++ onClick = { onAction(KitAction.Open(item.href)) },++ icon = if (vector != null) ({ Icon(vector, contentDescription = null) }) else null,++ badge = if (count != null && count > 0) ({ Text(count.toString()) }) else null,++ shape = RoundedCornerShape(OvScale.RadiusMd),++ )++ }++ }++}@@@ -124,6 +124,23 @@ public struct KitLink {}/// One tab: its key (what the web puts in the URL), its label and its count.++/// One row of a sidebar's navigation.++public struct KitNav {++ public let href: String++ public let label: String++ public let icon: String?++ public let here: Bool++ public let count: Int?++++ public init(href: String, label: String, icon: String? = nil, here: Bool = false, count: Int? = nil) {++ self.href = href++ self.label = label++ self.icon = icon++ self.here = here++ self.count = count++ }++}++public struct KitTab {public let key: Stringpublic let label: String@@@ -365,6 +382,31 @@ public struct KitTabs: KitComponent {}}++/// Sidebar: an app's navigation, as rows (on a phone the app shows it in its drawer).++public struct KitSidebar: KitComponent {++ public let props: [String: KitValue]++ public let children: [KitNode]++++ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {++ self.props = props++ self.children = children++ }++++ /// What the sidebar is named for (the product).++ public var label: String { props.string("label") ?? "Navigation" }++++ /// The rows, as the document gives them.++ public var nav: [KitNav] {++ guard let items = props.array("nav") else { return [] }++ var out: [KitNav] = []++ for item in items {++ guard let row = item.objectValue, let href = row.string("href") else { continue }++ out.append(KitNav(href: href, label: row.string("label") ?? href, icon: row.string("icon"), here: row.bool("here") ?? false, count: row.int("count")))++ }++ return out++ }++}++/// Stats: the numbers, in a row.public struct KitStats: KitComponent {public let props: [String: KitValue]@@@ -443,6 +485,7 @@ public enum KitNode: Codable {case section(KitSection)case fold(KitFold)case tabs(KitTabs)++ case sidebar(KitSidebar)case stats(KitStats)case notice(KitNotice)case empty(KitEmpty)@@@ -461,6 +504,7 @@ public enum KitNode: Codable {case "section": self = .section(KitSection(props: props, children: children))case "fold": self = .fold(KitFold(props: props, children: children))case "tabs": self = .tabs(KitTabs(props: props, children: children))++ case "sidebar": self = .sidebar(KitSidebar(props: props, children: children))case "stats": self = .stats(KitStats(props: props, children: children))case "notice": self = .notice(KitNotice(props: props, children: children))case "empty": self = .empty(KitEmpty(props: props, children: children))@@@ -493,6 +537,7 @@ public enum KitNode: Codable {case .section: return "section"case .fold: return "fold"case .tabs: return "tabs"++ case .sidebar: return "sidebar"case .stats: return "stats"case .notice: return "notice"case .empty: return "empty"@@@ -510,6 +555,7 @@ public enum KitNode: Codable {case .section(let node): return node.propscase .fold(let node): return node.propscase .tabs(let node): return node.props++ case .sidebar(let node): return node.propscase .stats(let node): return node.propscase .notice(let node): return node.propscase .empty(let node): return node.props@@@ -527,6 +573,7 @@ public enum KitNode: Codable {case .section(let node): return node.childrencase .fold(let node): return node.childrencase .tabs(let node): return node.children++ case .sidebar(let node): return node.childrencase .stats(let node): return node.childrencase .notice(let node): return node.childrencase .empty(let node): return node.children@@@ -593,6 +640,7 @@ private struct KitNodeBody: View {case .section(let n): sectionView(n)case .fold(let n): foldView(n)case .tabs(let n): tabsView(n)++ case .sidebar(let n): sidebarView(n)case .stats(let n): statsView(n)case .notice(let n): noticeView(n)case .empty(let n): emptyView(n)@@@ -714,6 +762,21 @@ private struct KitNodeBody: View {KitTabsHost(tabs: node.tabs.map { $0.label }, selected: node.selectedIndex)}++ /// A sidebar's navigation: a row per link, the one you are on marked; a tap opens it.++ @ViewBuilder++ private func sidebarView(_ node: KitSidebar) -> some View {++ VStack(alignment: .leading, spacing: 2) {++ ForEach(node.nav.indices, id: \.self) { index in++ Button { self.act(.open(node.nav[index].href)) } label: {++ OvSidebarNavRow(label: node.nav[index].label, here: node.nav[index].here, count: node.nav[index].count)++ }++ .buttonStyle(.plain)++ }++ }++ .accessibilityElement(children: .contain)++ .accessibilityLabel(node.label)++ }++/// The numbers.@ViewBuilderprivate func statsView(_ node: KitStats) -> some View {@@@ -744,6 +807,25 @@ private struct KitNodeBody: View {}/// The tabs of a document: the open one is the app's business, so it lives here.++/// One row of a sidebar's navigation: its name, marked when you are on it, and a count.++struct OvSidebarNavRow: View {++ let label: String++ let here: Bool++ let count: Int?++++ var body: some View {++ HStack(spacing: OvScale.spaceMd) {++ Text(label).fontWeight(here ? .bold : .regular)++ Spacer(minLength: 0)++ if let count, count > 0 { Text("\(count)").font(.caption.bold()) }++ }++ .frame(minHeight: OvScale.sizeTap)++ .padding(.horizontal, OvScale.spaceMd)++ .background(here ? Color.accentColor.opacity(0.14) : Color.clear, in: RoundedRectangle(cornerRadius: OvScale.radiusMd))++ .accessibilityAddTraits(here ? .isSelected : [])++ }++}++struct KitTabsHost: View {let tabs: [String]let selected: Int@@@ -262,4 +262,27 @@ const kit = require('../kit');}}--console.log('kit: builders, escaping, tabs, rows, settings, find/dock, icons, time and Kit 2 (steps, table, chart, rank, meter, drop, receipt, page) and the tool frame ok');++// The left sidebar (4.60.0, L35): vertical navigation, sections, a drawer on a phone opened and closed on the document.++{++ const html = kit.sidebar({++ id: 'side', label: 'Codes', action: { href: '/', label: 'New chat', icon: 'pencil' }, find: { action: '/tasks', placeholder: 'Search' },++ nav: [{ href: '/agents', label: 'Agents', icon: 'members' }, { href: '/machines', label: 'Machines', icon: 'computer', here: true }, { href: '/approvals', label: 'Approvals', icon: 'hand', count: 140 }, { href: 'javascript:x', label: '<b>x</b>', dot: 'needs' }],++ sections: [{ title: 'Today', items: [{ href: '/t/1', label: 'Fix <it>', meta: '2m', dot: 'busy' }] }, { title: 'Earlier', items: [], empty: 'Nothing yet' }],++ });++ assert.match(html, /^<aside class="ovk-sidebar" id="side" aria-label="Codes" data-ovk-sidebar>/);++ assert.ok(/<nav class="ovk-sidebar-nav" aria-label="Codes">/.test(html) && html.indexOf('ovk-sidebar-nav') < html.indexOf('ovk-sidebar-secs'), 'the navigation is a vertical list above the sections');++ assert.match(html, /<a class="ovk-sidebar-link is-here" href="\/machines" aria-current="page">/, 'the page you are on is marked');++ assert.match(html, /<b class="ovk-sidebar-count">99\+<\/b>/, 'a count, capped');++ assert.ok(html.includes('href="#"') && html.includes('<b>x</b>') && html.includes('ovk-sidebar-dot is-needs'), 'unsafe links dropped, text escaped, a dot by kind');++ assert.ok(html.includes('<h2 class="ovk-sidebar-h">Today</h2>') && html.includes('Fix <it>') && html.includes('<p class="ovk-sidebar-empty">Nothing yet</p>'), 'sections, and an empty one says so');++ assert.ok(html.includes('data-ovk-sidebar-close') && html.includes('<label class="ovk-sr" for="side-q">'), 'a close button and a labelled search');++ assert.match(kit.sidebarButton({ id: 'side' }), /data-ovk-sidebar-open aria-controls="side" aria-expanded="false"/);++ assert.match(kit.withSidebar({ sidebar: html, body: '<p>x</p>' }), /^<div class="ovk-with-sidebar"><aside[\s\S]*<div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden><\/div><div class="ovk-with-sidebar-main"><p>x<\/p><\/div><\/div>$/);++ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'kit.css'), 'utf8');++ assert.ok(/@media \(max-width: 760px\) \{\s*\.ovk-with-sidebar \{ grid-template-columns: minmax\(0, 1fr\); \}\s*\.ovk-sidebar \{ position: fixed; z-index: 10060;/.test(css), 'a drawer over everything on a phone');++ const web = require('fs').readFileSync(require('path').join(__dirname, '..', 'kit-web.js'), 'utf8');++ assert.ok(/document\.addEventListener\('click', \(e\) => \{\s*const t = e\.target[\s\S]{0,200}\[data-ovk-sidebar-open\]/.test(web), 'opened from the document, so a page boost swaps in needs no wiring');++ assert.ok(/ov:boost:before', \(\) => Array\.prototype\.forEach\.call\(document\.querySelectorAll\('\.ovk-sidebar\.is-open'\)/.test(web), 'and closed by a page move');++}++++console.log('kit: builders, escaping, tabs, rows, settings, find/dock, icons, time and Kit 2 (steps, table, chart, rank, meter, drop, receipt, page), the tool frame and the sidebar ok');
@@ -6,6 +6,23 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,23 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.60.0 — 2026-10-31
+
+- **One left sidebar for every product: `kit.sidebar`** (plan L35; the owner: "the navigation makes more sense in a
+ vertical way… part of the openvibe.design shared styling across everything").
+ - **The order, top to bottom:**
+ - the main action (New chat, Go live, New post) and search;
+ - the navigation as vertical rows: an icon and a name, the page you are on marked, a count or a dot;
+ - the sections (Pinned, Today…), which scroll on their own;
+ - a foot.
+ - **On a computer:** a column beside the page. **On a phone:** a drawer over everything, which `kit.sidebarButton`
+ opens and the scrim, its close button, Escape and a page move close. The handlers are on the document in
+ kit-web.js, so a page that boost swapped in needs no wiring (Codes' rail lost its handler that way, Codes #209).
+ - **New builders and icon:** `kit.withSidebar` puts the sidebar and the page side by side; a new kit icon,
+ `sidebar`.
+ - **In the component contract** (openvibe.design/components): `sidebar`, with a Compose `ModalNavigationDrawer` and
+ a SwiftUI `NavigationSplitView` reference view, and a kit-document form (the navigation).
+
## 4.59.0 — 2026-10-31
## 4.59.0 — 2026-10-31
- **The OpenVibe mark is the Pair** (the owner, from OpenVibe Mark Systems: "Pair, halo … I like this icon the most.
- **The OpenVibe mark is the Pair** (the owner, from OpenVibe Mark Systems: "Pair, halo … I like this icon the most.
@@ -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.59.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.60.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.59.0",
+ "version": "4.60.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)",
@@ -377,6 +377,51 @@ fun OvTabs(tabs: List<String>, selected: Int, onSelect: (Int) -> Unit) {
@@ -377,6 +377,51 @@ fun OvTabs(tabs: List<String>, selected: Int, onSelect: (Int) -> Unit) {
Picker("Sections", selection: $selected) { ForEach(tabs.indices, id: \\.self) { Text(tabs[$0]).tag($0) } }
Picker("Sections", selection: $selected) { ForEach(tabs.indices, id: \\.self) { Text(tabs[$0]).tag($0) } }
.pickerStyle(.segmented)
.pickerStyle(.segmented)
}
}
+}`,
+ },
+ },
+ {
+ id: 'sidebar', name: 'Sidebar', web: 'kit.sidebar',
+ summary: "An app's left sidebar: the main action and search on top, the navigation as vertical rows (an icon and a name, the page you are on marked, a count or a dot), then its sections. A column beside the page on a computer, a drawer on a phone.",
+ role: 'complementary, with the navigation as a nav; the page you are on is aria-current',
+ anatomy: [
+ { part: 'panel', tokens: { background: 'color.bg.raised', line: 'color.line.base' }, note: '272 px wide on a computer; a drawer of up to 320 px on a phone' },
+ { part: 'action', tokens: { minHeight: 'size.tap', radius: 'radius.md', color: 'color.text.primary', icon: 'color.accent.base' }, note: 'the one thing to start: New chat, Go live, New post' },
+ { part: 'link', tokens: { minHeight: 'size.tap', radius: 'radius.md', color: 'color.text.muted', icon: 'size.icon' }, note: 'the page you are on: color.accent.base icon on a tint of it, color.text.primary' },
+ { part: 'count', optional: true, tokens: { background: 'color.accent.strong', size: 'type.size.caption', weight: 'type.weight.heavy', radius: 'radius.pill' } },
+ { part: 'section heading', optional: true, tokens: { color: 'color.text.muted', size: 'type.size.caption', weight: 'type.weight.heavy' }, note: 'uppercase' },
+ { part: 'item', optional: true, tokens: { color: 'color.text.primary', size: 'type.size.body', hover: 'color.bg.hover' }, note: 'one line, a mark before it and a time or a dot after it' },
+ ],
+ props: { id: { type: 'string' }, label: { type: 'string' }, action: { type: 'link' }, nav: { type: 'navs', required: true } },
+ states: ['default', 'current', 'focused', 'open'],
+ a11y: { name: 'its label (the product)', keys: 'Tab down the rows; Enter opens; Escape closes the drawer and focus returns', minTarget: 'rows 38 px, 44 px with their gap on touch' },
+ layout: { phone: 'a drawer over the page, opened from the bar, closed by the scrim, its close button, Escape or a page move', computer: 'a column beside the page; its sections scroll on their own' },
+ native: {
+ compose: `@Composable
+fun OvSidebar(items: List<Pair<String, ImageVector>>, current: Int, onPick: (Int) -> Unit, drawer: DrawerState, content: @Composable () -> Unit) {
+ ModalNavigationDrawer(drawerState = drawer, drawerContent = {
+ ModalDrawerSheet(Modifier.widthIn(max = 320.dp)) {
+ Column(Modifier.padding(OvScale.SpaceMd)) {
+ items.forEachIndexed { i, (label, icon) ->
+ NavigationDrawerItem(label = { Text(label) }, icon = { Icon(icon, null, Modifier.size(OvScale.SizeIcon)) }, selected = i == current,
+ onClick = { onPick(i) }, shape = RoundedCornerShape(OvScale.RadiusMd), modifier = Modifier.heightIn(min = OvScale.SizeTap))
+ }
+ }
+ }
+ }, content = content)
+}`,
+ swiftui: `struct OvSidebar<Detail: View>: View {
+ let items: [(label: String, icon: String)]
+ @Binding var current: Int?
+ @ViewBuilder var detail: () -> Detail
+ var body: some View {
+ NavigationSplitView {
+ List(selection: $current) {
+ ForEach(items.indices, id: \\.self) { i in Label(items[i].label, systemImage: items[i].icon).frame(minHeight: OvScale.sizeTap).tag(i) }
+ }
+ .listStyle(.sidebar)
+ } detail: { detail() }
+ }
}`,
}`,
},
},
},
},
@@ -527,6 +572,7 @@ enum OvKind { case primary, quiet, danger }`,
@@ -527,6 +572,7 @@ enum OvKind { case primary, quiet, danger }`,
case 'count': return (typeof v === 'number' && Number.isFinite(v)) || (Array.isArray(v) && v.length <= 2 && Number.isFinite(Number(v[0])) && (v[1] == null || isStr(v[1])));
case 'count': return (typeof v === 'number' && Number.isFinite(v)) || (Array.isArray(v) && v.length <= 2 && Number.isFinite(Number(v[0])) && (v[1] == null || isStr(v[1])));
case 'stats': return Array.isArray(v) && v.length <= LIMITS.items && v.every((x) => Array.isArray(x) && x.length >= 2 && x.length <= 3 && (isStr(x[0]) || Number.isFinite(x[0])) && isStr(x[1]) && (x[2] == null || isUrl(x[2])));
case 'stats': return Array.isArray(v) && v.length <= LIMITS.items && v.every((x) => Array.isArray(x) && x.length >= 2 && x.length <= 3 && (isStr(x[0]) || Number.isFinite(x[0])) && isStr(x[1]) && (x[2] == null || isUrl(x[2])));
case 'tabs': return Array.isArray(v) && v.length <= LIMITS.items && v.every((x) => x && typeof x === 'object' && isStr(x.key) && isStr(x.label) && (x.count == null || Number.isFinite(Number(x.count))) && (x.href == null || isUrl(x.href)));
case 'tabs': return Array.isArray(v) && v.length <= LIMITS.items && v.every((x) => x && typeof x === 'object' && isStr(x.key) && isStr(x.label) && (x.count == null || Number.isFinite(Number(x.count))) && (x.href == null || isUrl(x.href)));
+ case 'navs': return Array.isArray(v) && v.length <= LIMITS.items && v.every((x) => x && typeof x === 'object' && isUrl(x.href) && isStr(x.label) && (x.icon == null || isStr(x.icon)) && (x.here == null || typeof x.here === 'boolean') && (x.count == null || Number.isFinite(Number(x.count))));
default: return false;
default: return false;
}
}
}
}
@@ -585,6 +631,7 @@ enum OvKind { case primary, quiet, danger }`,
@@ -585,6 +631,7 @@ enum OvKind { case primary, quiet, danger }`,
case 'section': return kit.section({ id: p.id || null, title: p.title || '', count: p.count == null ? null : p.count, note: p.note || '', body: kids.join('') });
case 'section': return kit.section({ id: p.id || null, title: p.title || '', count: p.count == null ? null : p.count, note: p.note || '', body: kids.join('') });
case 'fold': return kit.fold({ id: p.id || null, title: p.title, meta: p.meta || '', open: !!p.open, tone: p.tone || '', body: kids.join('') });
case 'fold': return kit.fold({ id: p.id || null, title: p.title, meta: p.meta || '', open: !!p.open, tone: p.tone || '', body: kids.join('') });
case 'tabs': return kit.tabs({ base: p.base || '', items: p.items, active: p.active || '', param: p.param || 'tab', label: p.label || 'Sections' });
case 'tabs': return kit.tabs({ base: p.base || '', items: p.items, active: p.active || '', param: p.param || 'tab', label: p.label || 'Sections' });
+ case 'sidebar': return kit.sidebar({ id: p.id || 'ovk-sidebar', label: p.label || 'Navigation', action: p.action ? { href: p.action.href, label: p.action.label || 'New' } : null, nav: p.nav.map((x) => ({ href: x.href, label: x.label, icon: x.icon || 'chevron', here: !!x.here, count: x.count == null ? null : Number(x.count) })) });
case 'stats': return kit.stats(p.items, { label: p.label || 'In numbers' });
case 'stats': return kit.stats(p.items, { label: p.label || 'In numbers' });
case 'notice': return kit.notice({ kind: p.kind || 'info', text: p.text });
case 'notice': return kit.notice({ kind: p.kind || 'info', text: p.text });
case 'empty': return kit.empty({ title: p.title, text: p.text || '', icon: p.icon || '', action: p.action ? { href: p.action.href, label: p.action.label || 'Go' } : null });
case 'empty': return kit.empty({ title: p.title, text: p.text || '', icon: p.icon || '', action: p.action ? { href: p.action.href, label: p.action.label || 'Go' } : null });
@@ -444,6 +444,36 @@ the ring. The gap is a mask, so the mark sits on any ground: a photo, a gradient
@@ -444,6 +444,36 @@ the ring. The gap is a mask, so the mark sits on any ground: a photo, a gradient
`scripts/build-social-cards.js`); the loading screen (`loading.js`) shows it at work, and its V turns into "!" when
`scripts/build-social-cards.js`); the loading screen (`loading.js`) shows it at work, and its V turns into "!" when
loading fails.
loading fails.
+## The sidebar
+
+`kit.sidebar` is an app's left sidebar, one for every OpenVibe product (4.60.0; the owner, 2026-10-31: "the
+navigation makes more sense in a vertical way… part of the openvibe.design shared styling across everything").
+
+```js
+kit.withSidebar({ sidebar: kit.sidebar({
+ id: 'side', label: 'Codes',
+ action: { href: '/', label: 'New chat', icon: 'pencil' }, // the one thing to start
+ find: { action: '/tasks', placeholder: 'Search conversations' },
+ nav: [{ href: '/agents', label: 'Agents', icon: 'members' }, { href: '/machines', label: 'Machines', icon: 'computer', here: true },
+ { href: '/approvals', label: 'Approvals', icon: 'hand', count: 2 }],
+ sections: [{ title: 'Today', items: [{ href: '/t/1', label: 'Fix the phone home page', mark: kit.mark({ name: 'C' }), meta: '30m', dot: 'busy' }] }],
+}), body: kit.sidebarButton({ id: 'side', label: 'Your conversations' }) + page })
+```
+
+- **Order, top to bottom:**
+ - the main action and search;
+ - the navigation as vertical rows (an icon and a name; the page you are on marked, `aria-current`; a count or a
+ dot: `true`, `'busy'`, `'needs'` or `'bad'`);
+ - then the sections, which scroll on their own;
+ - and an optional foot.
+- **On a computer:** a 272 px column beside the page (`--ovk-sidebar-w`), sticky under the navbar.
+- **On a phone (760 px and under):** a drawer over everything.
+ - `kit.sidebarButton` opens it.
+ - The scrim, its close button, Escape and a page move close it.
+ - The handlers are on the document (kit-web.js), so a page that boost swapped in works without wiring.
+- **Contract:** in openvibe-shared/components as `sidebar` (Compose `ModalNavigationDrawer`, SwiftUI
+ `NavigationSplitView`).
+
## The app bar
## The app bar
```js
```js
@@ -13,6 +13,8 @@
@@ -13,6 +13,8 @@
* tabs a sideways-scrolling tab strip keeps the open tab in view
* tabs a sideways-scrolling tab strip keeps the open tab in view
* rails .ovk-rail: ‹ › scroll a page of cards on devices with a pointer, hidden at either end
* rails .ovk-rail: ‹ › scroll a page of cards on devices with a pointer, hidden at either end
* share [data-ovk-share]: the system share sheet, else the link copied (the menu item says "Link copied")
* share [data-ovk-share]: the system share sheet, else the link copied (the menu item says "Link copied")
+ * sidebars kit.sidebar on a phone: [data-ovk-sidebar-open] opens the drawer; the scrim, its close button, Escape
+ * and a page move close it (document-level, so boost-swapped pages need no wiring)
* card previews .ovk-card video[data-src]: a muted loop while the card is hovered or focused (pointers only, never
* card previews .ovk-card video[data-src]: a muted loop while the card is hovered or focused (pointers only, never
* under reduced motion; the file loads only then)
* under reduced motion; the file loads only then)
*
*
@@ -22,6 +24,17 @@
@@ -22,6 +24,17 @@
'use strict';
'use strict';
const CHECK = '<svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';
const CHECK = '<svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12.5 4.5 4.5L19 7.5"/></svg>';
+ /** Opens or closes a sidebar drawer: its scrim, its buttons' aria-expanded, and the focus into it. */
+ function setSidebar(sb, on) {
+ if (!sb) return;
+ sb.classList.toggle('is-open', on);
+ const wrap = sb.closest('.ovk-with-sidebar') || document;
+ const scrim = wrap.querySelector('[data-ovk-sidebar-scrim]');
+ if (scrim) scrim.hidden = !on;
+ Array.prototype.forEach.call(document.querySelectorAll('[data-ovk-sidebar-open]'), (b) => { if (!b.getAttribute('aria-controls') || b.getAttribute('aria-controls') === sb.id) b.setAttribute('aria-expanded', on ? 'true' : 'false'); });
+ if (on) { const first = sb.querySelector('.ovk-sidebar-link.is-here, .ovk-sidebar-action, a, input'); if (first && first.focus) first.focus({ preventScroll: true }); }
+ }
+
function enhance(rootEl) {
function enhance(rootEl) {
if (typeof document === 'undefined') return;
if (typeof document === 'undefined') return;
const scope = rootEl || document;
const scope = rootEl || document;
@@ -70,7 +83,18 @@
@@ -70,7 +83,18 @@
document.addEventListener('keydown', (e) => {
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (e.key !== 'Escape') return;
Array.prototype.forEach.call(document.querySelectorAll('details.ovk-more[open]'), (d) => { d.open = false; const s = d.querySelector('summary'); if (s) s.focus(); });
Array.prototype.forEach.call(document.querySelectorAll('details.ovk-more[open]'), (d) => { d.open = false; const s = d.querySelector('summary'); if (s) s.focus(); });
+ Array.prototype.forEach.call(document.querySelectorAll('.ovk-sidebar.is-open'), (sb) => setSidebar(sb, false));
+ });
+ // The left sidebar on a phone (kit.sidebar): its button opens it; the scrim, its close button, Escape and a page
+ // move close it. Handled on the document, so a page boost swapped in works without being wired again.
+ document.addEventListener('click', (e) => {
+ const t = e.target && e.target.closest ? e.target : null;
+ if (!t) return;
+ const open = t.closest('[data-ovk-sidebar-open]');
+ if (open) { setSidebar(document.getElementById(open.getAttribute('aria-controls') || '') || document.querySelector('[data-ovk-sidebar]'), true); return; }
+ if (t.closest('[data-ovk-sidebar-close], [data-ovk-sidebar-scrim]')) Array.prototype.forEach.call(document.querySelectorAll('.ovk-sidebar.is-open'), (sb) => setSidebar(sb, false));
});
});
+ document.addEventListener('ov:boost:before', () => Array.prototype.forEach.call(document.querySelectorAll('.ovk-sidebar.is-open'), (sb) => setSidebar(sb, false)));
// The head casts a shadow once the page has scrolled under it.
// The head casts a shadow once the page has scrolled under it.
let ticking = false;
let ticking = false;
const mark = () => { ticking = false; document.documentElement.classList.toggle('ovk-scrolled', (window.scrollY || 0) > 4); };
const mark = () => { ticking = false; document.documentElement.classList.toggle('ovk-scrolled', (window.scrollY || 0) > 4); };
@@ -273,3 +273,50 @@ div.ovk-set-row { cursor: default; }
@@ -273,3 +273,50 @@ div.ovk-set-row { cursor: default; }
.ovk-go::after { content: ''; position: absolute; inset: -3px; }
.ovk-go::after { content: ''; position: absolute; inset: -3px; }
.ovk-find input, .ovk-find select { min-height: 44px; }
.ovk-find input, .ovk-find select { min-height: 44px; }
}
}
+
+/* ── The left sidebar (kit.sidebar, L35): one for every product. A column beside the page from 761 px; on a phone a
+ drawer over everything (kit.sidebarButton opens it; the scrim, its close button and Escape close it, kit-web.js). The
+ main action and search on top, the navigation as vertical rows, then the sections, which scroll on their own. ── */
+.ovk-with-sidebar { display: grid; grid-template-columns: var(--ovk-sidebar-w, 272px) minmax(0, 1fr); min-height: calc(100dvh - var(--navbar-h, 52px)); }
+.ovk-with-sidebar-main { min-width: 0; }
+.ovk-sidebar { position: sticky; top: var(--navbar-h, 52px); height: calc(100dvh - var(--navbar-h, 52px)); box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; min-width: 0; padding: 12px 10px 10px; border-right: 1px solid var(--ovk-line); background: var(--ovk-panel); color: var(--ovk-text); overflow: hidden; }
+.ovk-sidebar-top { display: flex; align-items: center; gap: 6px; }
+.ovk-sidebar-action { flex: 1; display: flex; align-items: center; gap: 10px; min-height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--ovk-line); background: var(--ovk-bg); color: var(--ovk-text); font-weight: 650; text-decoration: none; transition: border-color .18s, background .18s; }
+.ovk-sidebar-action:hover { border-color: color-mix(in srgb, var(--ovk-accent) 55%, var(--ovk-line)); }
+.ovk-sidebar-action .ovk-i { width: 18px; height: 18px; color: var(--ovk-accent); }
+.ovk-sidebar-close, .ovk-sidebar-open { display: none; }
+.ovk-sidebar-find { position: relative; display: flex; align-items: center; margin: 0; }
+.ovk-sidebar-find .ovk-i { position: absolute; left: 11px; width: 16px; height: 16px; color: var(--ovk-muted); pointer-events: none; }
+.ovk-sidebar-find input { width: 100%; min-height: 38px; box-sizing: border-box; padding: 0 12px 0 34px; border-radius: 10px; border: 1px solid transparent; background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 14px; }
+.ovk-sidebar-find input:focus { outline: none; border-color: var(--ovk-accent); }
+.ovk-sidebar-nav { display: grid; gap: 1px; padding-bottom: 8px; border-bottom: 1px solid var(--ovk-line); }
+.ovk-sidebar-link { display: flex; align-items: center; gap: 11px; min-height: 38px; padding: 0 10px; border-radius: 10px; color: var(--ovk-muted); font-size: 14.5px; font-weight: 600; text-decoration: none; transition: background .15s, color .15s; }
+.ovk-sidebar-link .ovk-i { width: 19px; height: 19px; flex: none; }
+.ovk-sidebar-link span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovk-sidebar-link:hover { background: var(--ovk-panel-2); color: var(--ovk-text); }
+.ovk-sidebar-link.is-here { background: color-mix(in srgb, var(--ovk-accent) 14%, transparent); color: var(--ovk-text); }
+.ovk-sidebar-link.is-here .ovk-i { color: var(--ovk-accent); }
+.ovk-sidebar-link:focus-visible, .ovk-sidebar-item:focus-visible, .ovk-sidebar-action:focus-visible { outline: 2px solid var(--ovk-accent); outline-offset: -2px; }
+.ovk-sidebar-count { min-width: 20px; box-sizing: border-box; padding: 1px 6px; border-radius: 999px; background: var(--ovk-fill); color: var(--ovk-on-fill); font-size: 11.5px; font-weight: 700; text-align: center; }
+.ovk-sidebar-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--ovk-accent); }
+.ovk-sidebar-dot.is-busy { animation: ovk-sidebar-breathe 1.6s ease-in-out infinite; }
+.ovk-sidebar-dot.is-needs { background: var(--ovk-warn); }
+.ovk-sidebar-dot.is-bad { background: var(--ovk-bad); }
+@keyframes ovk-sidebar-breathe { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .45; transform: scale(.75); } }
+.ovk-sidebar-secs { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; margin: 0 -4px; padding: 0 4px 8px; }
+.ovk-sidebar-h { margin: 12px 10px 4px; font-size: 11.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--ovk-muted); }
+.ovk-sidebar-item { display: flex; align-items: center; gap: 9px; min-height: 36px; padding: 4px 10px; border-radius: 10px; color: var(--ovk-text); font-size: 14px; text-decoration: none; }
+.ovk-sidebar-item:hover { background: var(--ovk-panel-2); }
+.ovk-sidebar-item.is-here { background: var(--ovk-panel-2); font-weight: 600; }
+.ovk-sidebar-t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovk-sidebar-m { flex: none; color: var(--ovk-muted); font-size: 12px; }
+.ovk-sidebar-empty { margin: 8px 10px; color: var(--ovk-muted); font-size: 13.5px; }
+.ovk-sidebar-foot { padding-top: 8px; border-top: 1px solid var(--ovk-line); }
+@media (max-width: 760px) {
+ .ovk-with-sidebar { grid-template-columns: minmax(0, 1fr); }
+ .ovk-sidebar { position: fixed; z-index: 10060; top: 0; left: 0; bottom: 0; height: auto; width: min(320px, 86vw); padding-top: max(12px, env(safe-area-inset-top, 0px)); padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)); border-right: 0; translate: -100% 0; visibility: hidden; transition: translate .25s ease, visibility 0s linear .25s; box-shadow: 12px 0 32px rgba(0, 0, 0, .25); }
+ .ovk-sidebar.is-open { translate: 0 0; visibility: visible; transition: translate .25s ease; }
+ .ovk-sidebar-close, .ovk-sidebar-open { display: inline-grid; }
+ .ovk-sidebar-scrim { position: fixed; inset: 0; z-index: 10050; background: rgba(0, 0, 0, .4); }
+}
+@media (prefers-reduced-motion: reduce) { .ovk-sidebar, .ovk-sidebar.is-open { transition: none; } .ovk-sidebar-dot.is-busy { animation: none; } }
@@ -28,6 +28,8 @@
@@ -28,6 +28,8 @@
* 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.iconButton(…) · kit.chips(…) · kit.seg(…) · kit.icon(name)
* kit.button({ href | type, label, icon, kind }) · kit.iconButton(…) · kit.chips(…) · kit.seg(…) · 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.sidebar({ id, label, action, find, nav, sections, foot }) · kit.sidebarButton({ id }) · kit.withSidebar({ sidebar,
+ * body }) the left sidebar: vertical navigation, a drawer on phones
*
*
* The tool frame, one order for every tool page: kit.tool({ head: kit.toolHead({ icon, hue, crumb, title, task, facts }),
* The tool frame, one order for every tool page: kit.tool({ head: kit.toolHead({ icon, hue, crumb, title, task, facts }),
* input: kit.toolInput({ name, label, placeholder, button, tries: { items } }) | kit.drop(…) | an editor,
* input: kit.toolInput({ name, label, placeholder, button, tries: { items } }) | kit.drop(…) | an editor,
@@ -56,7 +58,7 @@
@@ -56,7 +58,7 @@
plus: '<path d="M12 5v14M5 12h14"/>', minus: '<path d="M5 12h14"/>', check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
plus: '<path d="M12 5v14M5 12h14"/>', minus: '<path d="M5 12h14"/>', check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
send: '<path d="M12 19V5M5 12l7-7 7 7"/>', search: '<circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/>',
send: '<path d="M12 19V5M5 12l7-7 7 7"/>', search: '<circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/>',
more: '<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>',
more: '<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>',
- menu: '<path d="M4 7h16M4 12h16M4 17h16"/>', filter: '<path d="M4 6h16M7 12h10M10 18h4"/>',
+ menu: '<path d="M4 7h16M4 12h16M4 17h16"/>', sidebar: '<rect x="3.5" y="4.5" width="17" height="15" rx="2.5"/><path d="M9.5 4.5v15"/>', filter: '<path d="M4 6h16M7 12h10M10 18h4"/>',
home: '<path d="M4 11 12 4l8 7v8a1 1 0 0 1-1 1h-4v-6h-6v6H5a1 1 0 0 1-1-1Z"/>',
home: '<path d="M4 11 12 4l8 7v8a1 1 0 0 1-1 1h-4v-6h-6v6H5a1 1 0 0 1-1-1Z"/>',
chat: '<path d="M4 5h16v11H8l-4 4Z"/>', mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m4 7 8 6 8-6"/>',
chat: '<path d="M4 5h16v11H8l-4 4Z"/>', mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m4 7 8 6 8-6"/>',
bell: '<path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15ZM10 20a2 2 0 0 0 4 0"/>',
bell: '<path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15ZM10 20a2 2 0 0 0 4 0"/>',
@@ -183,6 +185,45 @@
@@ -183,6 +185,45 @@
+ (actions && actions.length ? `<div class="ovk-head-tools">${actions.join('')}</div>` : '')
+ (actions && actions.length ? `<div class="ovk-head-tools">${actions.join('')}</div>` : '')
+ `</div>${tabs || ''}</header>`;
+ `</div>${tabs || ''}</header>`;
}
}
+ // ── The left sidebar (L35) ──
+ /**
+ * An app's left sidebar, one for every OpenVibe product (the owner, 2026-10-31: "the navigation makes more sense
+ * in a vertical way… part of the openvibe.design shared styling across everything"): the main action and search at
+ * the top, the navigation as vertical rows (an icon and a name, the page you are on marked, a count or a dot), the
+ * sections under it (conversations, recent things) and a foot. A column beside the page from 761 px; on a phone a
+ * drawer that kit.sidebarButton opens and the scrim, its close button or Escape close (kit-web.js, boost-safe).
+ * kit.sidebar({ id, label, action: { href, label, icon }, find: { action, name, placeholder, label },
+ * nav: [{ href, label, icon, iconHtml, here, count, dot }],
+ * sections: [{ title, items: [{ href, label, meta, mark, here, dot }], empty }], foot })
+ * kit.sidebarButton({ id, label }) the button that opens it on a phone (shown only there)
+ * kit.withSidebar({ sidebar, body }) the sidebar and the page side by side
+ * dot: true, or 'busy' | 'needs' | 'bad'. mark and foot take trusted markup; iconHtml replaces a kit icon.
+ */
+ function sidebar({ id = 'ovk-sidebar', label = 'Navigation', action: act = null, find: f = null, nav = [], sections = [], foot = '' } = {}) {
+ const dotHtml = (d) => (d ? `<i class="ovk-sidebar-dot${d === true ? '' : ` is-${esc(d)}`}" aria-hidden="true"></i>` : '');
+ const link = (x) => `<a class="ovk-sidebar-link${x.here ? ' is-here' : ''}" href="${esc(url(x.href))}"${x.here ? ' aria-current="page"' : ''}>`
+ + `${x.iconHtml != null ? x.iconHtml : icon(x.icon || 'chevron')}<span>${esc(x.label)}</span>`
+ + `${Number(x.count) > 0 ? `<b class="ovk-sidebar-count">${Number(x.count) > 99 ? '99+' : Number(x.count)}</b>` : dotHtml(x.dot)}</a>`;
+ const item = (x) => `<a class="ovk-sidebar-item${x.here ? ' is-here' : ''}" href="${esc(url(x.href))}"${x.here ? ' aria-current="page"' : ''}>`
+ + `${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)}</a>`;
+ const secs = (sections || []).map((sec) => {
+ const items = sec.items || [];
+ return `<section class="ovk-sidebar-sec">${sec.title ? `<h2 class="ovk-sidebar-h">${esc(sec.title)}</h2>` : ''}${items.map(item).join('')}`
+ + `${!items.length && sec.empty ? `<p class="ovk-sidebar-empty">${esc(sec.empty)}</p>` : ''}</section>`;
+ }).join('');
+ return `<aside class="ovk-sidebar" id="${esc(id)}" aria-label="${esc(label)}" data-ovk-sidebar>`
+ + `<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>` : ''}`
+ + `<button class="ovk-icon-btn ovk-sidebar-close" type="button" data-ovk-sidebar-close aria-label="Close">${icon('close')}</button></div>`
+ + (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>`
+ + `${icon('search')}<input id="${esc(id)}-q" name="${esc(f.name || 'q')}" placeholder="${esc(f.placeholder || 'Search')}" autocomplete="off"></form>` : '')
+ + (nav && nav.length ? `<nav class="ovk-sidebar-nav" aria-label="${esc(label)}">${nav.map(link).join('')}</nav>` : '')
+ + (secs ? `<div class="ovk-sidebar-secs">${secs}</div>` : '')
+ + (foot ? `<div class="ovk-sidebar-foot">${foot}</div>` : '')
+ + '</aside>';
+ }
+ 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>`;
+ 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>`;
+
/**
/**
* Tabs as links (?tab=key, or ?<param>=key): the server builds only the open one, so a long page becomes a few short
* Tabs as links (?tab=key, or ?<param>=key): the server builds only the open one, so a long page becomes a few short
* ones and nothing needs JavaScript. items: [{ key, label, count, icon, href }].
* ones and nothing needs JavaScript. items: [{ key, label, count, icon, href }].
@@ -774,7 +815,7 @@
@@ -774,7 +815,7 @@
return {
return {
esc, url, icon, ICONS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,
esc, url, icon, ICONS, when, hueOf, mark, button, iconButton, action, badge, dot, card, rail, CARD_RATIO,
- head, tabs, tabOf, list, row, section, fold, more,
+ head, tabs, tabOf, list, row, section, fold, more, sidebar, sidebarButton, withSidebar,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, empty, notice, snippet, sheet, sheetButton,
switchRow, rangeRow, selectRow, seg, chips, saveBar, find, dock, stats, 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,
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.59.0",
+ "version": "4.60.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": {
@@ -81,10 +81,14 @@ const COMPOSE_IMPORTS = [
@@ -81,10 +81,14 @@ const COMPOSE_IMPORTS = [
'androidx.compose.material.icons.filled.Info',
'androidx.compose.material.icons.filled.Info',
'androidx.compose.material3.Button',
'androidx.compose.material3.Button',
'androidx.compose.material3.ButtonDefaults',
'androidx.compose.material3.ButtonDefaults',
+ 'androidx.compose.material3.DrawerState',
'androidx.compose.material3.Icon',
'androidx.compose.material3.Icon',
'androidx.compose.material3.ListItem',
'androidx.compose.material3.ListItem',
'androidx.compose.material3.LocalTextStyle',
'androidx.compose.material3.LocalTextStyle',
'androidx.compose.material3.MaterialTheme',
'androidx.compose.material3.MaterialTheme',
+ 'androidx.compose.material3.ModalDrawerSheet',
+ 'androidx.compose.material3.ModalNavigationDrawer',
+ 'androidx.compose.material3.NavigationDrawerItem',
'androidx.compose.material3.OutlinedButton',
'androidx.compose.material3.OutlinedButton',
'androidx.compose.material3.ScrollableTabRow',
'androidx.compose.material3.ScrollableTabRow',
'androidx.compose.material3.Surface',
'androidx.compose.material3.Surface',
@@ -17,7 +17,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
@@ -17,7 +17,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composable
@@ -47,6 +49,9 @@ data class KitLink(val href: String, val label: String)
@@ -47,6 +49,9 @@ data class KitLink(val href: String, val label: String)
/** One tab: its key (what the web puts in the URL), its label and its count. */
/** One tab: its key (what the web puts in the URL), its label and its count. */
data class KitTab(val key: String, val label: String, val count: Int? = null)
data class KitTab(val key: String, val label: String, val count: Int? = null)
+/** One row of a sidebar's navigation: where it goes, its name, its icon, whether you are on it, and a count. */
+data class KitNav(val href: String, val label: String, val icon: String? = null, val here: Boolean = false, val count: Int? = null)
+
/** One number with its label. */
/** One number with its label. */
data class KitStat(val value: String, val label: String, val href: String? = null)
data class KitStat(val value: String, val label: String, val href: String? = null)
@@ -194,6 +199,15 @@ sealed class KitNode {
@@ -194,6 +199,15 @@ sealed class KitNode {
}
}
}
}
+ /** Sidebar: an app's navigation, as rows (on a phone the app shows it in its drawer). */
+ data class Sidebar(
+ val label: String = "Navigation",
+ val nav: List<KitNav> = emptyList(),
+ override val children: List<KitNode> = emptyList(),
+ ) : KitNode() {
+ override val type: String = "sidebar"
+ }
+
/** Stats: the numbers, in a row. */
/** Stats: the numbers, in a row. */
data class Stats(
data class Stats(
val items: List<KitStat> = emptyList(),
val items: List<KitStat> = emptyList(),
@@ -309,6 +323,10 @@ sealed class KitNode {
@@ -309,6 +323,10 @@ sealed class KitNode {
param = props.str("param") ?: "tab",
param = props.str("param") ?: "tab",
label = props.str("label") ?: "Sections",
label = props.str("label") ?: "Sections",
)
)
+ "sidebar" -> Sidebar(
+ label = props.str("label") ?: "Navigation",
+ nav = navsOf(props.array("nav")),
+ )
"stats" -> Stats(
"stats" -> Stats(
items = statsOf(props.array("items")),
items = statsOf(props.array("items")),
label = props.str("label") ?: "In numbers",
label = props.str("label") ?: "In numbers",
@@ -355,6 +373,18 @@ sealed class KitNode {
@@ -355,6 +373,18 @@ sealed class KitNode {
return out
return out
}
}
+ /** The rows of a sidebar's navigation. */
+ private fun navsOf(array: JSONArray?): List<KitNav> {
+ if (array == null) return emptyList()
+ val out = ArrayList<KitNav>()
+ for (i in 0 until array.length()) {
+ val row = array.optJSONObject(i) ?: continue
+ val href = row.str("href") ?: continue
+ out.add(KitNav(href, row.str("label") ?: href, row.str("icon"), row.bool("here"), row.int("count")))
+ }
+ return out
+ }
+
/** The numbers of a stats row. */
/** The numbers of a stats row. */
private fun statsOf(array: JSONArray?): List<KitStat> {
private fun statsOf(array: JSONArray?): List<KitStat> {
if (array == null) return emptyList()
if (array == null) return emptyList()
@@ -543,6 +573,7 @@ fun KitNodeView(
@@ -543,6 +573,7 @@ fun KitNodeView(
var selected by rememberSaveable { mutableStateOf(node.selectedIndex) }
var selected by rememberSaveable { mutableStateOf(node.selectedIndex) }
OvTabs(tabs = labels, selected = selected, onSelect = { selected = it })
OvTabs(tabs = labels, selected = selected, onSelect = { selected = it })
}
}
+ is KitNode.Sidebar -> OvSidebarNav(node, icons, onAction)
is KitNode.Stats -> OvStats(items = node.items.map { it.value to it.label })
is KitNode.Stats -> OvStats(items = node.items.map { it.value to it.label })
is KitNode.Notice -> OvNotice(text = node.text, kind = node.kind)
is KitNode.Notice -> OvNotice(text = node.text, kind = node.kind)
is KitNode.Empty -> {
is KitNode.Empty -> {
@@ -578,3 +609,22 @@ private fun OvInitials(name: String) {
@@ -578,3 +609,22 @@ private fun OvInitials(name: String) {
)
)
}
}
}
}
+
+/** A sidebar's navigation, drawn as a drawer's rows: the one you are on is selected, and a tap opens its link. */
+@Composable
+fun OvSidebarNav(node: KitNode.Sidebar, icons: Map<String, ImageVector>, onAction: (KitAction) -> Unit) {
+ Column(Modifier.fillMaxWidth().padding(OvScale.SpaceMd)) {
+ node.nav.forEach { item ->
+ val vector = item.icon?.let { icons[it] }
+ val count = item.count
+ NavigationDrawerItem(
+ label = { Text(item.label) },
+ selected = item.here,
+ onClick = { onAction(KitAction.Open(item.href)) },
+ icon = if (vector != null) ({ Icon(vector, contentDescription = null) }) else null,
+ badge = if (count != null && count > 0) ({ Text(count.toString()) }) else null,
+ shape = RoundedCornerShape(OvScale.RadiusMd),
+ )
+ }
+ }
+}
@@ -124,6 +124,23 @@ public struct KitLink {
@@ -124,6 +124,23 @@ public struct KitLink {
}
}
/// One tab: its key (what the web puts in the URL), its label and its count.
/// One tab: its key (what the web puts in the URL), its label and its count.
+/// One row of a sidebar's navigation.
+public struct KitNav {
+ public let href: String
+ public let label: String
+ public let icon: String?
+ public let here: Bool
+ public let count: Int?
+
+ public init(href: String, label: String, icon: String? = nil, here: Bool = false, count: Int? = nil) {
+ self.href = href
+ self.label = label
+ self.icon = icon
+ self.here = here
+ self.count = count
+ }
+}
+
public struct KitTab {
public struct KitTab {
public let key: String
public let key: String
public let label: String
public let label: String
@@ -365,6 +382,31 @@ public struct KitTabs: KitComponent {
@@ -365,6 +382,31 @@ public struct KitTabs: KitComponent {
}
}
}
}
+/// Sidebar: an app's navigation, as rows (on a phone the app shows it in its drawer).
+public struct KitSidebar: KitComponent {
+ public let props: [String: KitValue]
+ public let children: [KitNode]
+
+ public init(props: [String: KitValue] = [:], children: [KitNode] = []) {
+ self.props = props
+ self.children = children
+ }
+
+ /// What the sidebar is named for (the product).
+ public var label: String { props.string("label") ?? "Navigation" }
+
+ /// The rows, as the document gives them.
+ public var nav: [KitNav] {
+ guard let items = props.array("nav") else { return [] }
+ var out: [KitNav] = []
+ for item in items {
+ guard let row = item.objectValue, let href = row.string("href") else { continue }
+ out.append(KitNav(href: href, label: row.string("label") ?? href, icon: row.string("icon"), here: row.bool("here") ?? false, count: row.int("count")))
+ }
+ return out
+ }
+}
+
/// Stats: the numbers, in a row.
/// Stats: the numbers, in a row.
public struct KitStats: KitComponent {
public struct KitStats: KitComponent {
public let props: [String: KitValue]
public let props: [String: KitValue]
@@ -443,6 +485,7 @@ public enum KitNode: Codable {
@@ -443,6 +485,7 @@ public enum KitNode: Codable {
case section(KitSection)
case section(KitSection)
case fold(KitFold)
case fold(KitFold)
case tabs(KitTabs)
case tabs(KitTabs)
+ case sidebar(KitSidebar)
case stats(KitStats)
case stats(KitStats)
case notice(KitNotice)
case notice(KitNotice)
case empty(KitEmpty)
case empty(KitEmpty)
@@ -461,6 +504,7 @@ public enum KitNode: Codable {
@@ -461,6 +504,7 @@ public enum KitNode: Codable {
case "section": self = .section(KitSection(props: props, children: children))
case "section": self = .section(KitSection(props: props, children: children))
case "fold": self = .fold(KitFold(props: props, children: children))
case "fold": self = .fold(KitFold(props: props, children: children))
case "tabs": self = .tabs(KitTabs(props: props, children: children))
case "tabs": self = .tabs(KitTabs(props: props, children: children))
+ case "sidebar": self = .sidebar(KitSidebar(props: props, children: children))
case "stats": self = .stats(KitStats(props: props, children: children))
case "stats": self = .stats(KitStats(props: props, children: children))
case "notice": self = .notice(KitNotice(props: props, children: children))
case "notice": self = .notice(KitNotice(props: props, children: children))
case "empty": self = .empty(KitEmpty(props: props, children: children))
case "empty": self = .empty(KitEmpty(props: props, children: children))
@@ -493,6 +537,7 @@ public enum KitNode: Codable {
@@ -493,6 +537,7 @@ public enum KitNode: Codable {
case .section: return "section"
case .section: return "section"
case .fold: return "fold"
case .fold: return "fold"
case .tabs: return "tabs"
case .tabs: return "tabs"
+ case .sidebar: return "sidebar"
case .stats: return "stats"
case .stats: return "stats"
case .notice: return "notice"
case .notice: return "notice"
case .empty: return "empty"
case .empty: return "empty"
@@ -510,6 +555,7 @@ public enum KitNode: Codable {
@@ -510,6 +555,7 @@ public enum KitNode: Codable {
case .section(let node): return node.props
case .section(let node): return node.props
case .fold(let node): return node.props
case .fold(let node): return node.props
case .tabs(let node): return node.props
case .tabs(let node): return node.props
+ case .sidebar(let node): return node.props
case .stats(let node): return node.props
case .stats(let node): return node.props
case .notice(let node): return node.props
case .notice(let node): return node.props
case .empty(let node): return node.props
case .empty(let node): return node.props
@@ -527,6 +573,7 @@ public enum KitNode: Codable {
@@ -527,6 +573,7 @@ public enum KitNode: Codable {
case .section(let node): return node.children
case .section(let node): return node.children
case .fold(let node): return node.children
case .fold(let node): return node.children
case .tabs(let node): return node.children
case .tabs(let node): return node.children
+ case .sidebar(let node): return node.children
case .stats(let node): return node.children
case .stats(let node): return node.children
case .notice(let node): return node.children
case .notice(let node): return node.children
case .empty(let node): return node.children
case .empty(let node): return node.children
@@ -593,6 +640,7 @@ private struct KitNodeBody: View {
@@ -593,6 +640,7 @@ private struct KitNodeBody: View {
case .section(let n): sectionView(n)
case .section(let n): sectionView(n)
case .fold(let n): foldView(n)
case .fold(let n): foldView(n)
case .tabs(let n): tabsView(n)
case .tabs(let n): tabsView(n)
+ case .sidebar(let n): sidebarView(n)
case .stats(let n): statsView(n)
case .stats(let n): statsView(n)
case .notice(let n): noticeView(n)
case .notice(let n): noticeView(n)
case .empty(let n): emptyView(n)
case .empty(let n): emptyView(n)
@@ -714,6 +762,21 @@ private struct KitNodeBody: View {
@@ -714,6 +762,21 @@ private struct KitNodeBody: View {
KitTabsHost(tabs: node.tabs.map { $0.label }, selected: node.selectedIndex)
KitTabsHost(tabs: node.tabs.map { $0.label }, selected: node.selectedIndex)
}
}
+ /// A sidebar's navigation: a row per link, the one you are on marked; a tap opens it.
+ @ViewBuilder
+ private func sidebarView(_ node: KitSidebar) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ ForEach(node.nav.indices, id: \.self) { index in
+ Button { self.act(.open(node.nav[index].href)) } label: {
+ OvSidebarNavRow(label: node.nav[index].label, here: node.nav[index].here, count: node.nav[index].count)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel(node.label)
+ }
+
/// The numbers.
/// The numbers.
@ViewBuilder
@ViewBuilder
private func statsView(_ node: KitStats) -> some View {
private func statsView(_ node: KitStats) -> some View {
@@ -744,6 +807,25 @@ private struct KitNodeBody: View {
@@ -744,6 +807,25 @@ private struct KitNodeBody: View {
}
}
/// The tabs of a document: the open one is the app's business, so it lives here.
/// The tabs of a document: the open one is the app's business, so it lives here.
+/// One row of a sidebar's navigation: its name, marked when you are on it, and a count.
+struct OvSidebarNavRow: View {
+ let label: String
+ let here: Bool
+ let count: Int?
+
+ var body: some View {
+ HStack(spacing: OvScale.spaceMd) {
+ Text(label).fontWeight(here ? .bold : .regular)
+ Spacer(minLength: 0)
+ if let count, count > 0 { Text("\(count)").font(.caption.bold()) }
+ }
+ .frame(minHeight: OvScale.sizeTap)
+ .padding(.horizontal, OvScale.spaceMd)
+ .background(here ? Color.accentColor.opacity(0.14) : Color.clear, in: RoundedRectangle(cornerRadius: OvScale.radiusMd))
+ .accessibilityAddTraits(here ? .isSelected : [])
+ }
+}
+
struct KitTabsHost: View {
struct KitTabsHost: View {
let tabs: [String]
let tabs: [String]
let selected: Int
let selected: Int
@@ -262,4 +262,27 @@ const kit = require('../kit');
@@ -262,4 +262,27 @@ const kit = require('../kit');
}
}
}
}
-console.log('kit: builders, escaping, tabs, rows, settings, find/dock, icons, time and Kit 2 (steps, table, chart, rank, meter, drop, receipt, page) and the tool frame ok');
+// The left sidebar (4.60.0, L35): vertical navigation, sections, a drawer on a phone opened and closed on the document.
+{
+ const html = kit.sidebar({
+ id: 'side', label: 'Codes', action: { href: '/', label: 'New chat', icon: 'pencil' }, find: { action: '/tasks', placeholder: 'Search' },
+ nav: [{ href: '/agents', label: 'Agents', icon: 'members' }, { href: '/machines', label: 'Machines', icon: 'computer', here: true }, { href: '/approvals', label: 'Approvals', icon: 'hand', count: 140 }, { href: 'javascript:x', label: '<b>x</b>', dot: 'needs' }],
+ sections: [{ title: 'Today', items: [{ href: '/t/1', label: 'Fix <it>', meta: '2m', dot: 'busy' }] }, { title: 'Earlier', items: [], empty: 'Nothing yet' }],
+ });
+ assert.match(html, /^<aside class="ovk-sidebar" id="side" aria-label="Codes" data-ovk-sidebar>/);
+ assert.ok(/<nav class="ovk-sidebar-nav" aria-label="Codes">/.test(html) && html.indexOf('ovk-sidebar-nav') < html.indexOf('ovk-sidebar-secs'), 'the navigation is a vertical list above the sections');
+ assert.match(html, /<a class="ovk-sidebar-link is-here" href="\/machines" aria-current="page">/, 'the page you are on is marked');
+ assert.match(html, /<b class="ovk-sidebar-count">99\+<\/b>/, 'a count, capped');
+ assert.ok(html.includes('href="#"') && html.includes('<b>x</b>') && html.includes('ovk-sidebar-dot is-needs'), 'unsafe links dropped, text escaped, a dot by kind');
+ assert.ok(html.includes('<h2 class="ovk-sidebar-h">Today</h2>') && html.includes('Fix <it>') && html.includes('<p class="ovk-sidebar-empty">Nothing yet</p>'), 'sections, and an empty one says so');
+ assert.ok(html.includes('data-ovk-sidebar-close') && html.includes('<label class="ovk-sr" for="side-q">'), 'a close button and a labelled search');
+ assert.match(kit.sidebarButton({ id: 'side' }), /data-ovk-sidebar-open aria-controls="side" aria-expanded="false"/);
+ assert.match(kit.withSidebar({ sidebar: html, body: '<p>x</p>' }), /^<div class="ovk-with-sidebar"><aside[\s\S]*<div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden><\/div><div class="ovk-with-sidebar-main"><p>x<\/p><\/div><\/div>$/);
+ const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'kit.css'), 'utf8');
+ assert.ok(/@media \(max-width: 760px\) \{\s*\.ovk-with-sidebar \{ grid-template-columns: minmax\(0, 1fr\); \}\s*\.ovk-sidebar \{ position: fixed; z-index: 10060;/.test(css), 'a drawer over everything on a phone');
+ const web = require('fs').readFileSync(require('path').join(__dirname, '..', 'kit-web.js'), 'utf8');
+ assert.ok(/document\.addEventListener\('click', \(e\) => \{\s*const t = e\.target[\s\S]{0,200}\[data-ovk-sidebar-open\]/.test(web), 'opened from the document, so a page boost swaps in needs no wiring');
+ assert.ok(/ov:boost:before', \(\) => Array\.prototype\.forEach\.call\(document\.querySelectorAll\('\.ovk-sidebar\.is-open'\)/.test(web), 'and closed by a page move');
+}
+
+console.log('kit: builders, escaping, tabs, rows, settings, find/dock, icons, time and Kit 2 (steps, table, chart, rank, meter, drop, receipt, page), the tool frame and the sidebar ok');