openvibers/openvibe.shared Commits

One left sidebar for every product: kit.sidebar (4.60.0) (#212)

OP OpenVibers committed 13 days ago · 13d3dafffd · 13 files, +371 −6

* One left sidebar for every product: kit.sidebar (4.60.0, L35)

The owner: "the navigation makes more sense in a vertical way… make sure the left sidebar… is part of the
openvibe.design shared styling across everything."

- kit.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), the sections under it (they scroll on their own) and a foot.
  kit.withSidebar puts it beside the page; kit.sidebarButton opens it on a phone.
- A column on a computer; a drawer over everything on a phone, opened and closed on the document in kit-web.js
  (the scrim, its close button, Escape, a page move), so a page boost swapped in needs no wiring.
- In the component contract (openvibe.design/components) with Compose and SwiftUI reference views, a kit-document
  form, and the native renderers draw it; the Compose imports the check compiles with.

* The sidebar's SwiftUI view keeps its key path (\.self) through the template string

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 557fa3e..b21261c 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/README.md b/README.md
iindex 05418ee..d134caf 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 774ef4e..dc4d1b4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/components.js b/components.js
iindex cf16be3..8f6c6a2 100644
---- a/components.js
++++ b/components.js
@@@ -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 });
ddiff --git a/docs/kit.md b/docs/kit.md
iindex 7391764..1305c92 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -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
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
```js
ddiff --git a/kit-web.js b/kit-web.js
iindex d3c003d..78f8d9c 100644
---- a/kit-web.js
++++ b/kit-web.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); };
ddiff --git a/kit.css b/kit.css
iindex 7ead736..c0b0d19 100644
---- a/kit.css
++++ b/kit.css
@@@ -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; } }
ddiff --git a/kit.js b/kit.js
iindex 39254c6..789db44 100644
---- a/kit.js
++++ b/kit.js
@@@ -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,
ddiff --git a/package.json b/package.json
iindex fbc45fd..679f6fe 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/scripts/native-check.js b/scripts/native-check.js
iindex 1f10bde..2d38c1c 100644
---- a/scripts/native-check.js
++++ b/scripts/native-check.js
@@@ -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',
ddiff --git a/scripts/native/compose/KitRenderer.kt b/scripts/native/compose/KitRenderer.kt
iindex 798dd7c..6a42cd2 100644
---- a/scripts/native/compose/KitRenderer.kt
++++ b/scripts/native/compose/KitRenderer.kt
@@@ -17,7 +17,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
++import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
++import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import 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),
++ )
++ }
++ }
++}
ddiff --git a/scripts/native/swiftui/KitRenderer.swift b/scripts/native/swiftui/KitRenderer.swift
iindex fca2541..3d6ec3b 100644
---- a/scripts/native/swiftui/KitRenderer.swift
++++ b/scripts/native/swiftui/KitRenderer.swift
@@@ -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: String
public 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.props
case .fold(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 .notice(let node): return node.props
case .empty(let node): return node.props
@@@ -527,6 +573,7 @@ public enum KitNode: Codable {
case .section(let node): return node.children
case .fold(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 .notice(let node): return node.children
case .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.
@ViewBuilder
private 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
ddiff --git a/test/kit.test.js b/test/kit.test.js
iindex 31bd8f7..e0966f0 100644
---- a/test/kit.test.js
++++ b/test/kit.test.js
@@@ -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('&lt;b&gt;x&lt;/b&gt;') && 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 &lt;it&gt;') && 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');