openvibers/openvibe.shared Commits
4.39.0: what the front-page passes added locally, in the kit (rail wrap, card sub/preview, 44 px targets, the twin's spacing) (#178)
Changes
@@@ -6,6 +6,25 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.39.0 — 2026-10-29++++What the front-page passes of 10-29 (Media, Quest, Actor, Rent, Services, Community) each had to add locally:++++- **`kit.rail({ wrap: true })`**: phones still swipe; from 760 px every item shows in a grid, with no arrows and no++ kit-web.js. Media and Quest drew this with their own rules.++- **`kit.card({ sub, preview, whoHtml })`**: one plain muted line under the title (Rent showed the city as a badge and the++ kind only on hover); a text thing's first nine lines in the media instead of an icon (Community's code pastes); the++ name as escaped markup with its decorations. **`kit.row({ rel })`** for links people posted.++- **No tile alone on a row in `showcase.apps`**: two to four tiles (or eight, twelve…) get a column count that fills++ their rows. Four are four across on a phone, two by two from 600 px and four from 960 px (Actor had local rules).++- **44 px to the touch**: the rail's See all (32 px), tab pills (36 px), a list's heading link and the launcher's See++ all, on touch screens and below 760 px.++- **A number that is a link is not underlined**: the whole stats tile is the target, with a hover border and a focus ring.++- **The Markdown twin reads kit markup as a person sees it**: a card's who, count and time keep their spaces++ ("Maticus12 viewsTue" was what agents read), buttons, icons, players and the ⋯ menu are left out, and a stats strip++ is `- label: value` lines.++- `scripts/kit-gallery.js --rooms`: a gallery page of front-page pieces (cards, both rails, stats links, launchers).++## 4.38.0 — 2026-10-29- **`showcase.apps`: a launcher.** A set of apps or sites as a phone's home screen shows them: icon tiles three or four@@@ -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.38.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.39.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.38.0",++ "version": "4.39.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)",@@@ -43,8 +43,15 @@ function markdownTwin({ html, title, canonical, updated = null }) {if (node.nodeType === 3) return md(node.textContent.replace(/\s+/g, ' '));if (node.nodeType !== 1) return '';const tag = node.localName;-- if (['script', 'style', 'template', 'nav', 'footer'].includes(tag) || node.hasAttribute('hidden') || node.getAttribute('aria-hidden') === 'true') return '';-- const inside = () => Array.from(node.childNodes).map(walk).join('');++ // Controls are not content: buttons, inputs, icons, players, and a card's ⋯ menu (Save, Share) say nothing to a reader.++ if (['script', 'style', 'template', 'nav', 'footer', 'button', 'svg', 'select', 'input', 'textarea', 'video', 'audio', 'dialog'].includes(tag)++ || node.hasAttribute('hidden') || node.getAttribute('aria-hidden') === 'true' || (tag === 'details' && /\bovk-more\b/.test(node.getAttribute('class') || ''))) return '';++ // Pieces the page sets side by side with no space between them (a card's who · count · when, a tile's name and++ // line) keep a space between them, so "Maticus12 viewsTue" reads "Maticus 12 views Tue".++ const inside = () => Array.from(node.childNodes).reduce((out, child) => {++ const piece = walk(child);++ return out && piece && child.nodeType === 1 && !['sup', 'sub', 'mark', 'wbr', 'abbr'].includes(child.localName) && !/\s$/.test(out) && !/^\s/.test(piece) && !/[([]$/.test(out) ? `${out} ${piece}` : out + piece;++ }, '');if (/^h[1-6]$/.test(tag)) return `\n\n${'#'.repeat(Number(tag[1]))} ${inside().trim()}\n\n`;if (tag === 'a') return node.hasAttribute('href') ? `[${inside()}](${destination(node.getAttribute('href'))})` : inside();if (tag === 'img') return ` || '')})`;@@@ -67,8 +74,20 @@ function markdownTwin({ html, title, canonical, updated = null }) {const line = row => '| ' + Array.from({ length: width }, (_, i) => row[i] || '').join(' | ') + ' |';return '\n\n' + [line(rows[0]), line(Array(width).fill('---')), ...rows.slice(1).map(line)].join('\n') + '\n\n';}++ if (tag === 'dl') {++ // A strip of numbers (kit.stats) or a definition list: one "- label: value" line each.++ const pairs = [];++ for (const n of node.querySelectorAll('dt')) {++ let dd = n.nextElementSibling;++ while (dd && dd.localName !== 'dd') dd = dd.nextElementSibling;++ if (!dd) dd = n.previousElementSibling && n.previousElementSibling.localName === 'dd' ? n.previousElementSibling : null;++ const k = walk(n).trim(), v = dd ? walk(dd).trim() : '';++ if (k || v) pairs.push(`- ${k}${v ? `: ${v}` : ''}`);++ }++ return pairs.length ? `\n\n${pairs.join('\n')}\n\n` : '';++ }const content = inside();-- return /^(p|div|section|article|main|blockquote|dl|dt|dd)$/.test(tag) ? `\n\n${content}\n\n` : content;++ return /^(p|div|section|article|main|blockquote|dt|dd)$/.test(tag) ? `\n\n${content}\n\n` : content;};const destination = value => String(value).replace(/[\s()<>]/g, c => encodeURIComponent(c).replace(/\(/g, '%28').replace(/\)/g, '%29'));const content = walk(document.querySelector('main') || document.body).replace(/\n[ \t]+\n/g, '\n\n').replace(/\n{3,}/g, '\n\n').trim();@@@ -61,7 +61,10 @@ adds the tags (only enable where twins are mounted).`markdownTwin({ html, title?, canonical, updated? })` uses the existing LinkeDOM parser,now a runtime dependency. It reads `<main>` (or the body), ignores scripts, styles, templates,--navigation, footers and explicitly hidden elements, and requires no client JavaScript.++navigation, footers and explicitly hidden elements, and requires no client JavaScript. Controls are not content:++buttons, form fields, icons (svg), players, dialogs and a card's ⋯ menu are left out (4.39.0). Pieces a page sets side by++side with no space between them (a card's who · count · when, a launcher tile's name and line) keep a space, and a++definition list or a kit.stats strip becomes one `- label: value` line each.It emits YAML `title`, `canonical`, `updated`, a title heading, and an HTML backlink.Unknown update times are `null`, never the request time. Real headings remain in order(including a page's own h1), links and image alt text come from the rendered page, missing@@@ -130,7 +130,7 @@ reset.```jskit.list(rows, { title: 'Your rooms', more: { href: '/rooms', label: 'All' }, flush, empty: 'No rooms yet.' })kit.row({ href, mark: kit.mark({ name, src, square }), title, badge: kit.badge('live', 'live'),-- meta: kit.when(ts).text, metaIso: kit.when(ts).iso, sub, unread, trail })++ meta: kit.when(ts).text, metaIso: kit.when(ts).iso, sub, unread, trail, rel: 'nofollow ugc' })```A row has a mark (a picture, or up to two letters on a colour from the name; `square` for places), the title with a@@@ -174,7 +174,7 @@ phone's own Enter, makes a new line), and a round send button.### Numbers, empty states, notices, code, sheets```js--kit.stats([[1234, 'messages this week'], [48, 'people chatting']]) // zero and unknown values are left out++kit.stats([[1234, 'messages this week'], [48, 'people chatting', '/people']]) // zero and unknown values are left out; a third item makes the whole tile a link (never underlined)kit.empty({ icon: 'chat', title, text, action: { href, label, icon } })kit.notice({ kind: 'ok' | 'error' | 'warn' | 'info', text })kit.snippet({ code, id }) // a copy button in the corner@@@ -199,9 +199,9 @@ kit.badge(text, 'accent' | 'ok' | 'warn' | 'bad' | 'live') · kit.dot(n) · kit.### Cards and rails (4.28.0)```js--kit.card({ href, title, type, ratio, image, alt, video, badge, live, corner, peek, who, avatar,++kit.card({ href, title, type, ratio, image, alt, video, badge, live, corner, peek, who, whoHtml, avatar, sub, preview,count: [1832, 'views'], when, level, save: { href }, remind: { href }, share, more: [items] })--kit.rail({ id, title, more: { href, label }, items: [kit.card(…), …], size: 's' | 'm' | 'l' })++kit.rail({ id, title, more: { href, label }, items: [kit.card(…), …], size: 's' | 'm' | 'l', wrap }))```One card for every kind of thing (plan 2026-10-28 §3.4):@@@ -213,8 +213,10 @@ One card for every kind of thing (plan 2026-10-28 §3.4):| Corner | Bottom right: a length or a price || Peek | One line over the media on hover or focus || Preview | `video`: a muted loop while the card is hovered. Computers only, never under reduced motion, and the file loads only then |++| Text preview | `preview` (4.39.0): for a thing whose text is the point (a code paste, a note), its first nine lines drawn in the media instead of the icon. A picture wins || Title | Two lines at most. The card is one link: the title's, stretched over it |--| Meta | Who (with their picture or letters), one count, when. A count of 0 or none is left out, never shown as 0 |++| Sub | `sub` (4.39.0): one plain muted line under the title: a city and a kind, a language. Not a badge, and not only on hover |++| Meta | Who (with their picture or letters), one count, when. A count of 0 or none is left out, never shown as 0. `whoHtml` shows the name as markup you escaped (with its Inventory decorations); `who` stays the plain name for the letters || ⋯ | Save and Remind when given, and Share always (the system share sheet, else the link copied, said in the item) |A rail scrolls sideways and snaps:@@@ -224,6 +226,9 @@ A rail scrolls sideways and snaps:| Phone | About 1.2 cards show, and the row bleeds to the screen's edge (the page keeps a 16 px gutter) || Wider | 190 px (`s`), 280 px (`m`) or 400 px (`l`) cards || Pointer | ‹ › buttons, each hidden at its end |++| `wrap: true` (4.39.0) | Phones still swipe; from 760 px every item shows in a grid (220 px columns, 160 for `s`, 320 for `l`), with no arrows and no kit-web.js. For a front page that shows a handful of things and links to the rest |++++The rail's See all, tab pills and a list's heading link are 44 px tall on touch screens and below 760 px.A card's menu inside a rail is placed on the screen, so the scroller never clips it. An empty rail is `''`.@@@ -477,7 +482,8 @@ still render as their own rows.apps as a launcher, the way a phone's home screen does: icon tiles three or four to a row with the name under the iconon a phone, rows with a one-line tagline from 600 px. Use it where a page lists sites or products to jump to (thenetwork's front door, /sites, a directory); it replaces a stack of feature cards, which on a phone is one screen per--four sites.++four sites. Since 4.39.0 no tile sits alone on a row: a set of two to four (or eight, twelve…) gets a column count++that fills its rows; four tiles are four across on a phone, two by two from 600 px and four from 960 px.`showcase.hero({ eyebrow, title, accent, lede, actions, note })` is the top of most product homes. On a phone (since4.23.0):@@@ -94,7 +94,9 @@/* ── Lists: rows that read like a phone's (a mark, the name and time on one line, one line under it) ── */.ovk-list-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 18px 0 8px; }.ovk-list-head.ovk-list-head h2 { margin: 0; color: var(--ovk-muted); font-family: inherit; font-size: .76rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }--.ovk-list-head a { color: var(--ovk-accent); font-size: .84rem; font-weight: 700; text-decoration: none; }++.ovk-list-head a { display: inline-flex; align-items: center; min-height: 32px; color: var(--ovk-accent); font-size: .84rem; font-weight: 700; text-decoration: none; }++/* 44 px to the touch: tab pills (36 px) and list links grow on touch screens and narrow windows. */++@media (pointer: coarse), (max-width: 759px) { .ovk-tabs a, .ovk-list-head a { min-height: 44px; } }.ovk-list { list-style: none; display: grid; grid-template-columns: minmax(0, 1fr); margin: 0 0 14px; padding: 0; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-panel); overflow: hidden; }.ovk-list--flush { margin: 0; border: 0; border-radius: 0; border-top: 1px solid var(--ovk-line); background: transparent; }.ovk-row { margin: 0; color: var(--ovk-text); }@@@ -211,7 +213,13 @@ div.ovk-set-row { cursor: default; }.ovk-stats::-webkit-scrollbar { display: none; }.ovk-stats > div { display: flex; flex-direction: column-reverse; flex: 0 0 auto; min-width: 104px; box-sizing: border-box; padding: 8px 14px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-panel); }.ovk-stats dt { margin: 0; color: var(--ovk-muted); font-size: .76rem; line-height: 1.25; }--.ovk-stats dt a { color: inherit; }++.ovk-stats dt a { color: inherit; text-decoration: none; }++/* A number that is a link: the whole tile is the target (the label's link stretched over it), never an underline. */++.ovk-stat--link { position: relative; transition: border-color .15s; }++.ovk-stat--link dt a::after { content: ""; position: absolute; inset: 0; border-radius: inherit; }++.ovk-stat--link:hover { border-color: var(--ovk-accent); }++.ovk-stat--link dt a:focus-visible { outline: none; }++.ovk-stat--link:focus-within { outline: 2px solid var(--ovk-accent); outline-offset: -2px; }.ovk-stats dd { margin: 0; color: var(--ovk-text); font-size: 1.2rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.2; }.ovk-empty { display: grid; justify-items: center; gap: 8px; margin: 0 0 14px; padding: 24px 16px; border: 1px dashed var(--ovk-line); border-radius: var(--ovk-radius); color: var(--ovk-muted); text-align: center; }.ovk-empty > .ovk-i { width: 28px; height: 28px; }@@@ -202,14 +202,15 @@}/*** One row: its mark, the title (with a badge) and the time on one line, one muted line under it, a trailing control.-- * href makes the whole row a link; without it the row is static and trail can hold its own forms.++ * href makes the whole row a link; without it the row is static and trail can hold its own forms. rel: the link's++ * rel ('nofollow ugc' for links people posted, 'noopener' off-site).*/-- function row({ href = null, mark: markHtml = '', title = '', titleHtml = null, badge: badgeHtml = '', meta = '', metaIso = '', sub = '', subHtml = null, trail = '', unread = 0, attrs: extra = null } = {}) {++ function row({ href = null, rel = '', mark: markHtml = '', title = '', titleHtml = null, badge: badgeHtml = '', meta = '', metaIso = '', sub = '', subHtml = null, trail = '', unread = 0, attrs: extra = null } = {}) {const top = `<span class="ovk-row-top"><strong>${titleHtml != null ? titleHtml : esc(title)}</strong>${badgeHtml || ''}${meta ? (metaIso ? `<time datetime="${esc(metaIso)}">${esc(meta)}</time>` : `<span class="ovk-row-meta">${esc(meta)}</span>`) : ''}</span>`;const subLine = subHtml != null || sub || unread ? `<span class="ovk-row-sub"><span>${subHtml != null ? subHtml : esc(sub)}</span>${dot(unread)}</span>` : '';const main = `${markHtml || ''}<span class="ovk-row-main">${top}${subLine}</span>`;const cls = `ovk-row${unread ? ' ovk-row--unread' : ''}`;-- if (href != null) return `<li class="${cls}"${attrs(extra)}><a href="${esc(url(href))}">${main}${trail ? `<span class="ovk-row-trail">${trail}</span>` : ''}</a></li>`;++ if (href != null) return `<li class="${cls}"${attrs(extra)}><a href="${esc(url(href))}"${rel ? ` rel="${esc(rel)}"` : ''}>${main}${trail ? `<span class="ovk-row-trail">${trail}</span>` : ''}</a></li>`;return `<li class="${cls} ovk-row--static"${attrs(extra)}>${main}${trail ? `<span class="ovk-row-trail">${trail}</span>` : ''}</li>`;}@@@ -330,25 +331,31 @@* - media in the type's shape, a badge top-left (a word: NEW, −24 %, WR; live: true draws LIVE with a pulse) and a* corner bottom-right (a length or a price); peek: one line over the media on hover or focus; video: a muted* loop played while it is hovered (computers only, never under reduced motion, loaded only then)-- * - the title (two lines at most), then who (with their picture or letters), one count and when; a count of 0 or-- * none is left out, never shown as 0++ * - the title (two lines at most); sub: one plain muted line under it (a city, a kind, a language); then who (with++ * their picture or letters), one count and when; a count of 0 or none is left out, never shown as 0. whoHtml: the++ * name as markup the caller escaped (a name with its decorations); who stays the plain name for the letters++ * - preview: for a thing without a picture whose text is the point (a code paste, a note), its first lines drawn in++ * the media instead of the type's icon* - ⋯ with Save and Remind when given ({ href } or { action, hidden }), Share (always: the system share sheet, else* the link copied) and any `more` items (kit.more's)* The card is one link, the title's, stretched over it; ⋯ sits above it. level: the title's heading level (3).*/function card({ href = '#', title = '', type = '', ratio = '', image = '', alt = '', video = '', badge: bd = '', live = false, corner = '',-- peek = '', who = '', avatar = '', count = null, when: at = null, level = 3, save = null, remind = null, share = true, more: extra = null } = {}) {++ peek = '', who = '', whoHtml = null, avatar = '', count = null, when: at = null, level = 3, save = null, remind = null, share = true,++ more: extra = null, sub = '', preview = '' } = {}) {const ar = /^\d{1,2}\/\d{1,2}$/.test(String(ratio)) ? ratio : CARD_RATIO[type] || '16/9';const link = url(href);++ const first = String(preview || '').split('\n').slice(0, 9).join('\n').slice(0, 480).trimEnd();const pic = media(image)? `<img src="${esc(image)}" alt="${esc(alt)}" loading="lazy" decoding="async">`-- : `<span class="ovk-card-noimg">${icon(CARD_ICON[type] || 'image')}</span>`;++ : first ? `<pre class="ovk-card-pre" aria-hidden="true">${esc(first)}</pre>`++ : `<span class="ovk-card-noimg">${icon(CARD_ICON[type] || 'image')}</span>`;const loop = media(video) ? `<video class="ovk-card-video" data-src="${esc(video)}" muted loop playsinline preload="none" aria-hidden="true" tabindex="-1"></video>` : '';const tag = live ? '<span class="ovk-card-badge is-live">LIVE</span>' : bd ? `<span class="ovk-card-badge">${esc(bd)}</span>` : '';const n = Array.isArray(count) ? Number(count[0]) : Number(count);const counted = Number.isFinite(n) && n > 0 ? `<span>${esc(fmtCount(n))}${Array.isArray(count) && count[1] ? ` ${esc(count[1])}` : ''}</span>` : '';const t = at != null && at !== '' ? when(at) : null;-- const meta = [who ? `<span class="ovk-card-who">${esc(who)}</span>` : '', counted, t && t.text ? `<time datetime="${esc(t.iso)}">${esc(t.text)}</time>` : ''].filter(Boolean).join('');++ const meta = [whoHtml != null && whoHtml !== '' ? `<span class="ovk-card-who">${whoHtml}</span>` : who ? `<span class="ovk-card-who">${esc(who)}</span>` : '', counted, t && t.text ? `<time datetime="${esc(t.iso)}">${esc(t.text)}</time>` : ''].filter(Boolean).join('');const items = [save ? { label: 'Save', icon: 'star', ...save } : null,remind ? { label: 'Remind me', icon: 'bell', ...remind } : null,@@@ -358,21 +365,22 @@return `<article class="ovk-card${live ? ' is-live' : ''}${type ? ` ovk-card--${esc(type)}` : ''}" style="--ovk-ar:${ar}">`+ `<div class="ovk-card-media">${pic}${loop}${tag}${corner ? `<span class="ovk-card-corner">${esc(corner)}</span>` : ''}${peek ? `<span class="ovk-card-peek">${esc(peek)}</span>` : ''}</div>`+ `<div class="ovk-card-body">${who || avatar ? mark({ name: who || title, src: avatar, size: 34 }) : ''}<div class="ovk-card-text">`-- + `<h${h} class="ovk-card-title"><a class="ovk-card-link" href="${esc(link)}">${esc(title)}</a></h${h}>${meta ? `<p class="ovk-card-meta">${meta}</p>` : ''}</div>`++ + `<h${h} class="ovk-card-title"><a class="ovk-card-link" href="${esc(link)}">${esc(title)}</a></h${h}>${sub ? `<p class="ovk-card-sub">${esc(sub)}</p>` : ''}${meta ? `<p class="ovk-card-meta">${meta}</p>` : ''}</div>`+ `${items.length ? more({ label: `More: ${title}`, items }) : ''}</div></article>`;}/*** rail({ id, title, more: { href, label }, items: [card html], size: 's'|'m'|'l', label }) — a row that scrolls* sideways and snaps: about 1.2 cards on a phone, as many as fit on a computer, with ‹ › buttons there (kit.enhance).-- * size: 's' square things (memes, shoes, deals), 'm' (default) pictures 16:9, 'l' wide features. Empty → ''.++ * size: 's' square things (memes, shoes, deals), 'm' (default) pictures 16:9, 'l' wide features. wrap: from 760 px++ * the row becomes a grid that shows every item (no arrows, no kit-web.js needed); phones still swipe. Empty → ''.*/-- function rail({ id = '', title = '', more: m = null, items = [], size = 'm', label = '' } = {}) {++ function rail({ id = '', title = '', more: m = null, items = [], size = 'm', label = '', wrap = false } = {}) {const list = (items || []).filter(Boolean);if (!list.length) return '';const hid = id ? `${String(id).replace(/[^a-z0-9_-]/gi, '')}-h` : '';const head = title || m ? `<div class="ovk-rail-head">${title ? `<h2${hid ? ` id="${hid}"` : ''}>${esc(title)}</h2>` : ''}${m && m.href ? `<a class="ovk-rail-more" href="${esc(url(m.href))}">${esc(m.label || 'See all')}${icon('chevron')}</a>` : ''}</div>` : '';const named = hid && title ? ` aria-labelledby="${hid}"` : ` aria-label="${esc(label || title || 'More')}"`;-- return `<section class="ovk-rail ovk-rail--${size === 's' || size === 'l' ? size : 'm'}"${id ? ` id="${esc(id)}"` : ''}${named}>${head}`++ return `<section class="ovk-rail ovk-rail--${size === 's' || size === 'l' ? size : 'm'}${wrap ? ' ovk-rail--wrap' : ''}"${id ? ` id="${esc(id)}"` : ''}${named}>${head}`+ `<div class="ovk-rail-wrap"><ul class="ovk-rail-track" role="list">${list.map((x) => `<li>${x}</li>`).join('')}</ul>`+ `<button type="button" class="ovk-rail-btn ovk-rail-prev" aria-label="Scroll back" hidden>${icon('back')}</button>`+ `<button type="button" class="ovk-rail-btn ovk-rail-next" aria-label="Scroll on" hidden>${icon('forward')}</button></div></section>`;@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.38.0",++ "version": "4.39.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.38.0",++ "version": "4.39.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.38.0",++ "version": "4.39.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -34,6 +34,9 @@.ovk-card-link:focus-visible::after { outline: 2px solid var(--room-accent, var(--ovk-accent)); outline-offset: 3px; }.ovk-card-meta { margin: 0; display: flex; flex-wrap: wrap; color: var(--ovk-muted); font-size: 13px; line-height: 1.45; min-width: 0; }.ovk-card-meta > * + *::before { content: "·"; margin: 0 6px; }++.ovk-card-sub { margin: 0; overflow: hidden; color: var(--text-secondary, var(--ovk-muted)); font-size: 13px; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }++/* preview: a text thing's first lines in its media, fading out at the bottom. */++.ovk-card-pre { position: absolute; inset: 0; margin: 0; padding: 12px 14px; overflow: hidden; color: var(--ovk-text); background: var(--ovk-panel-2); font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre; -webkit-mask-image: linear-gradient(#000 60%, transparent); mask-image: linear-gradient(#000 60%, transparent); }.ovk-card-who { font-weight: 600; color: var(--text-secondary, var(--ovk-muted)); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ovk-card .ovk-more { position: relative; z-index: 2; margin: -6px -8px 0 0; }@media (hover: hover) {@@@ -51,6 +54,7 @@.ovk-rail-head.ovk-rail-head h2 { margin: 0; font-size: 1.15rem; font-weight: 800; letter-spacing: -.01em; }.ovk-rail-more { display: inline-flex; align-items: center; gap: 2px; min-height: 32px; color: var(--room-strong, var(--ovk-accent)); font-weight: 700; font-size: .9rem; text-decoration: none; white-space: nowrap; }.ovk-rail-more:hover { text-decoration: underline; }++@media (pointer: coarse), (max-width: 759px) { .ovk-rail-more { min-height: 44px; } }.ovk-rail-wrap { position: relative; }.ovk-rail-track { list-style: none; margin: 0 calc(-1 * var(--ovk-rail-bleed)); padding: 2px var(--ovk-rail-bleed) 10px; display: grid; grid-auto-flow: column; grid-auto-columns: var(--ovk-rail-w); gap: 14px; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: var(--ovk-rail-bleed); overscroll-behavior-x: contain; scrollbar-width: none; }.ovk-rail-track::-webkit-scrollbar { display: none; }@@@ -73,6 +77,13 @@@media (min-width: 600px) {.ovk-rail--m { --ovk-rail-w: 280px; } .ovk-rail--s { --ovk-rail-w: 190px; } .ovk-rail--l { --ovk-rail-w: 400px; }}++/* rail({ wrap: true }): phones swipe; from 760 px every item shows in a grid, so a mouse needs no arrows. */++@media (min-width: 760px) {++ .ovk-rail--wrap .ovk-rail-track { grid-auto-flow: row; grid-auto-columns: auto; grid-template-columns: repeat(auto-fill, minmax(var(--ovk-rail-min, 220px), 1fr)); overflow: visible; margin: 0; padding: 2px 0 10px; }++ .ovk-rail--wrap.ovk-rail--s { --ovk-rail-min: 160px; }++ .ovk-rail--wrap.ovk-rail--l { --ovk-rail-min: 320px; }++ .ovk-rail--wrap .ovk-rail-btn { display: none; }++}@media (prefers-reduced-motion: reduce) { .ovk-card-media > img, .ovk-card-peek, .ovk-card-video { transition: none; } .ovk-card-badge.is-live::before { animation: none; } }html[data-ov-motion=reduced] .ovk-card-badge.is-live::before { animation: none; }@@@ -45,13 +45,38 @@ ${k.dock({ action: '#send', placeholder: 'Message #general' })}`;return page(body, 'OpenVibe Kit');}--function page(body, title) {++// The pieces of a product's front page (4.39.0): cards with a sub-line, a text preview and a decorated name, a rail that++// swipes and one that wraps from 760 px, numbers that are links, and the launcher at 4 tiles (no tile alone) and 15.++function galleryRooms() {++ const k = kit;++ const sc = require('../showcase');++ const code = 'const sdk = require(\'openvibe-sdk\');\nconst ov = sdk.createClient();\n\nconst { results } = await ov.search\n .query(\'go live\');\nconsole.log(results.length);';++ const cards = [++ k.card({ href: '#1', title: 'A sunny loft by the river, two rooms', type: 'listing', sub: 'Lisbon · Apartment', corner: '€1,240', who: 'Ana', count: [312, 'views'], when: new Date(Date.now() - 86400e3).toISOString() }),++ k.card({ href: '#2', title: 'search.js', type: 'post', preview: code, corner: 'JavaScript', who: 'Maticus', whoHtml: '<span style="color:var(--ovk-accent)">Maticus ✦</span>', count: [12, 'views'] }),++ k.card({ href: '#3', title: 'Release notes for the node, with a much longer title that wraps', type: 'post', sub: 'Markdown', who: 'OpenVibe', count: [48, 'views'] }),++ k.card({ href: '#4', title: 'Beach-side loading chaos', type: 'clip', corner: '0:24', live: false, who: 'OpenVibe AI', badge: 'AI' }),++ k.card({ href: '#5', title: 'Garden studio, quiet street', type: 'listing', sub: 'Porto · Room', corner: '€640' }),++ ];++ const tiles = (n) => Array.from({ length: n }, (_, i) => ({ icon: ['ov:live', 'ov:chat', 'ov:media', 'ov:tools', 'ov:games'][i % 5], name: ['Live', 'Chat', 'Media', 'Tools', 'Games', 'Search', 'Pay', 'Events'][i % 8], text: 'One line about it', href: `/app/${i}` }));++ const body = `<h1 class="ovk-title">Front page pieces</h1>++${k.stats([[26, 'services you can call', '#svc'], [106, 'capabilities an app can request', '#caps'], [239, 'event types'], [70, 'SDK modules']])}++${k.rail({ id: 'latest', title: 'Latest', more: { href: '#all', label: 'See all' }, items: cards })}++${k.rail({ id: 'wrapped', title: 'Wraps from 760 px', more: { href: '#all', label: 'See all' }, items: cards, wrap: true })}++${k.list([k.row({ href: 'https://example.com/', rel: 'nofollow ugc', title: 'A link someone posted', sub: 'example.com', meta: '17:29' })], { title: 'Across OpenVibe', more: { href: '#pulse', label: 'All of Pulse' } })}++${sc.apps({ id: 'four', title: 'Four tiles', items: tiles(4), more: { href: '/apps', label: 'All apps' } })}++${sc.apps({ id: 'many', title: 'Fifteen tiles', items: tiles(15) })}`;++ return page(body, 'OpenVibe Kit: rooms', ['room.css', 'showcase.css']);++}++++function page(body, title, extra = []) {return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><title>${title}</title><script src="theme-loader.js"></script><link rel="stylesheet" href="kit.css"><link rel="stylesheet" href="kit-data.css">++${extra.map((x) => `<link rel="stylesheet" href="${x}">`).join('\n')}<style>body { margin: 0; background: var(--bg-primary, #0b0f17); color: var(--text-primary, #e8ecf3); font-family: 'Inter', system-ui, sans-serif; }</style></head><body><div id="navbar-mount"></div>@@@ -109,7 +134,7 @@ ${k.section({ title: 'Your quota', body: `<div class="ovk-set-row"><span class="async function shots(dir, chromePath) {const root = path.join(__dirname, '..');-- const pages = { kit: gallery(), kit2: gallery2() };++ const pages = { kit: gallery(), kit2: gallery2(), rooms: galleryRooms() };const server = http.createServer((req, res) => {const name = decodeURIComponent(new URL(req.url, 'http://x').pathname.slice(1));if (!name || pages[name]) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(pages[name || 'kit']); }@@@ -123,7 +148,7 @@ async function shots(dir, chromePath) {const { chromium } = require(require.resolve('playwright', { paths: [process.cwd(), '/home/workstation/openvibe/work/OpenVibe.Games'] }));const browser = await chromium.launch(chromePath ? { executablePath: chromePath } : {});fs.mkdirSync(dir, { recursive: true });-- const only = process.argv.includes('--kit2') ? ['kit2'] : ['kit', 'kit2'];++ const only = process.argv.includes('--kit2') ? ['kit2'] : process.argv.includes('--rooms') ? ['rooms'] : ['kit', 'kit2', 'rooms'];for (const name of only) for (const scheme of ['dark', 'light']) for (const [w, h, mobile] of [[320, 700, true], [390, 844, true], [768, 1024, true], [1280, 900, false]]) {if (name === 'kit' && (scheme === 'light' || w === 320)) continue;const ctx = await browser.newContext({ viewport: { width: w, height: h }, isMobile: mobile, hasTouch: mobile, colorScheme: scheme });@@@ -149,6 +174,6 @@ if (require.main === module) {const a = process.argv.slice(2);const i = a.indexOf('--shots');if (i >= 0) shots(a[i + 1] || 'kit-shots', a.includes('--chrome') ? a[a.indexOf('--chrome') + 1] : null).catch((e) => { console.error(e); process.exit(1); });-- else process.stdout.write(a.includes('--kit2') ? gallery2() : gallery());++ else process.stdout.write(a.includes('--kit2') ? gallery2() : a.includes('--rooms') ? galleryRooms() : gallery());}--module.exports = { gallery };++module.exports = { gallery, gallery2, galleryRooms };@@@ -146,14 +146,19 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}.sc-apps b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:700}.sc-apps small{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.sc-apps-more{margin:14px 0 0;font-weight:700}--.sc-apps-more a{color:var(--accent-light,#60a5fa);text-decoration:none}++.sc-apps-more a{display:inline-flex;align-items:center;min-height:44px;color:var(--accent-light,#60a5fa);text-decoration:none}++.sc-apps-more a:hover{text-decoration:underline}++@media (max-width:599px){.sc-apps--c2{grid-template-columns:repeat(2,minmax(0,1fr))}.sc-apps--c3{grid-template-columns:repeat(3,minmax(0,1fr))}.sc-apps--c4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:600px){.sc-apps{gap:8px;grid-template-columns:repeat(auto-fill,minmax(230px,1fr))}.sc-apps a{flex-direction:row;align-items:center;gap:12px;padding:10px 12px;text-align:left;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}.sc-apps a:hover,.sc-apps a:focus-visible{border-color:var(--accent,#3b82f6);background:var(--bg-secondary,#101828)}.sc-apps b{font-size:15px}.sc-apps small{position:static;width:auto;height:auto;clip:auto;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary,#96a7c2);font-size:13px}++.sc-apps--c2,.sc-apps--c4{grid-template-columns:repeat(2,minmax(0,1fr))}++.sc-apps--c3{grid-template-columns:repeat(3,minmax(0,1fr))}}++@media (min-width:960px){.sc-apps--c4{grid-template-columns:repeat(4,minmax(0,1fr))}}.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}@@@ -332,14 +332,17 @@ function network({ id: sid, service, title = 'Part of OpenVibe', lede = 'One acc* way a phone's home screen shows them: each item { icon ('ov:live' or 'fa-…'), name, text, href }. Phones see icon* tiles three or four to a row with the name under the icon (the one-line text is for screen readers and wider* screens); from 600 px each tile is a row: icon, name, the text in one line. groups put a small heading over each-- * set (the network's neighbourhoods); items without groups is one grid. Empty → ''.++ * set (the network's neighbourhoods); items without groups is one grid. A set of two to four, or of eight, twelve…,++ * gets a column count that fills its rows (four tiles: four across on a phone, two by two from 600 px, four from++ * 960 px), so no tile sits alone on a row. Empty → ''.*/function apps({ id: sid, title = '', lede = '', groups = null, items = null, more = null } = {}) {const sets = (groups || [{ name: '', items: items || [] }]).map((g) => ({ name: g.name || '', items: (g.items || []).filter((x) => x && x.name && safeHref(x.href)) })).filter((g) => g.items.length);if (!sets.length) return '';const h = `sc-${slug(title || 'apps')}`;const tile = (x) => `<li><a href="${esc(safeHref(x.href))}"><span class="sc-ic sc-ic-ov">${icon(x.icon || 'ov:spark', 40)}</span><span class="sc-app-t"><b>${esc(x.name)}</b>${x.text ? `<small>${esc(x.text)}</small>` : ''}</span></a></li>`;-- const body = sets.map((g) => `${g.name ? `<h3 class="sc-apps-group">${esc(g.name)}</h3>` : ''}<ul class="sc-apps">${g.items.map(tile).join('')}</ul>`).join('');++ const cols = (n) => (n >= 2 && n <= 4 ? n : n % 4 === 0 ? 4 : 0);++ const body = sets.map((g) => `${g.name ? `<h3 class="sc-apps-group">${esc(g.name)}</h3>` : ''}<ul class="sc-apps${cols(g.items.length) ? ` sc-apps--c${cols(g.items.length)}` : ''}">${g.items.map(tile).join('')}</ul>`).join('');const end = more && safeHref(more.href) ? `<p class="sc-apps-more"><a href="${esc(safeHref(more.href))}">${esc(more.label || 'See all')} →</a></p>` : '';return `<section class="sc-sec sc-apps-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}${body}${end}</section>`;}@@@ -550,14 +553,19 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}.sc-apps b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:700}.sc-apps small{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.sc-apps-more{margin:14px 0 0;font-weight:700}--.sc-apps-more a{color:var(--accent-light,#60a5fa);text-decoration:none}++.sc-apps-more a{display:inline-flex;align-items:center;min-height:44px;color:var(--accent-light,#60a5fa);text-decoration:none}++.sc-apps-more a:hover{text-decoration:underline}++@media (max-width:599px){.sc-apps--c2{grid-template-columns:repeat(2,minmax(0,1fr))}.sc-apps--c3{grid-template-columns:repeat(3,minmax(0,1fr))}.sc-apps--c4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:600px){.sc-apps{gap:8px;grid-template-columns:repeat(auto-fill,minmax(230px,1fr))}.sc-apps a{flex-direction:row;align-items:center;gap:12px;padding:10px 12px;text-align:left;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}.sc-apps a:hover,.sc-apps a:focus-visible{border-color:var(--accent,#3b82f6);background:var(--bg-secondary,#101828)}.sc-apps b{font-size:15px}.sc-apps small{position:static;width:auto;height:auto;clip:auto;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary,#96a7c2);font-size:13px}++.sc-apps--c2,.sc-apps--c4{grid-template-columns:repeat(2,minmax(0,1fr))}++.sc-apps--c3{grid-template-columns:repeat(3,minmax(0,1fr))}}++@media (min-width:960px){.sc-apps--c4{grid-template-columns:repeat(4,minmax(0,1fr))}}.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}@@@ -62,6 +62,17 @@ app.use((err, req, res, next) => res.status(500).send(err.message));assert.equal((await fetch(base + '/guide.md', { method: 'POST' })).status, 404);assert.match((await fetch(base + '/guide', { headers: { Accept: 'text/html, text/markdown;q=0' } })).headers.get('content-type'), /text\/html/);assert.match(seo.llmsTxt({ name: 'Example', markdown: true, sections: [{ title: 'Pages', links: [{ title: 'Home', url: descriptor.home }] }], optional: [{ title: 'External', url: 'https://other.example/', html: true }] }), /index.md\)[\s\S]*## Optional[\s\S]*\(HTML\)/);++ // 4.39.0: kit markup reads as a person sees it: a card's who · count · when keep their spaces, its ⋯ menu and++ // buttons are not content, and a stats strip is "- label: value" lines.++ {++ const cards = '<main>' + kit.rail({ title: 'Latest', items: [kit.card({ href: '/p/1', title: 'Release notes', who: 'Maticus', count: [12, 'views'], sub: 'Markdown' })] })++ + kit.stats([[26, 'services you can call', '/docs/api'], [106, 'capabilities']]) + '</main>';++ const t = agent.markdownTwin({ html: cards, title: 'Home', canonical: 'https://example.com/' });++ assert.match(t, /Maticus 12 views/, t);++ assert.ok(!/Maticus12/.test(t) && !/Share|Scroll back|Scroll on/.test(t), 'no run-together text, no menu or arrow buttons');++ assert.match(t, /- \[services you can call\]\(\/docs\/api\): 26\n- capabilities: 106/);++ assert.match(agent.markdownTwin({ html: '<main><p>H<sub>2</sub>O and x<sup>2</sup></p></main>', title: 't', canonical: '/' }), /H2O and x2/, 'sub and sup stay inside the word');++ }console.log('agent-layer: manifest, real Frame/Kit twins, discovery, methods, errors and llms checks passed');} finally { await new Promise(resolve => server.close(resolve)); }})().catch(err => { console.error(err); process.exitCode = 1; });@@@ -56,6 +56,20 @@ const sc = require('../showcase');assert.ok(kit.card({ title: 'x', live: true }).includes('ovk-card-badge is-live">LIVE'), 'live says LIVE');assert.ok(kit.card({ title: 'x', video: '/v.mp4' }).includes('data-src="/v.mp4"') && !kit.card({ title: 'x', video: '/v.mp4' }).includes(' src="/v.mp4"'), 'the preview loads only when hovered');assert.ok(kit.card({ title: 'x', level: 2 }).includes('<h2 class="ovk-card-title">'));++ // 4.39.0: a plain sub-line, a text preview in place of the icon, a decorated name.++ const s = kit.card({ title: 'Loft', sub: 'Lisbon · <2 rooms>', who: 'ann' });++ assert.ok(s.includes('<p class="ovk-card-sub">Lisbon · <2 rooms></p>') && s.indexOf('ovk-card-sub') < s.indexOf('ovk-card-meta'), 'sub: one escaped line between the title and the meta');++ assert.ok(!kit.card({ title: 'x' }).includes('ovk-card-sub'), 'no sub, no line');++ const p = kit.card({ title: 'paste', type: 'post', preview: Array.from({ length: 20 }, (_, i) => `line ${i} <x>`).join('\n') });++ assert.ok(p.includes('<pre class="ovk-card-pre" aria-hidden="true">line 0 <x>') && !p.includes('line 9 ') && !p.includes('ovk-card-noimg'), 'preview: the first nine lines, escaped, instead of the icon');++ assert.ok(kit.card({ title: 'x', image: '/a.jpg', preview: 'code' }).includes('<img src="/a.jpg"') && !kit.card({ title: 'x', image: '/a.jpg', preview: 'code' }).includes('ovk-card-pre'), 'a picture wins over a preview');++ const w = kit.card({ title: 'x', who: 'ann', whoHtml: '<span class="deco">ann</span>' });++ assert.ok(w.includes('<span class="ovk-card-who"><span class="deco">ann</span></span>') && w.includes('ovk-mark'), 'whoHtml shows the decorated name; who still gives the letters');++}++// ── kit.row: rel ──++{++ assert.ok(kit.row({ href: 'https://x.example/', title: 't', rel: 'nofollow ugc' }).includes('href="https://x.example/" rel="nofollow ugc"'));++ assert.ok(!kit.row({ href: '/a', title: 't' }).includes(' rel='), 'no rel unless asked');}// ── kit.rail ──@@@ -66,6 +80,14 @@ const sc = require('../showcase');assert.strictEqual((r.match(/<li>/g) || []).length, 2);assert.ok(r.includes('ovk-rail-prev') && r.includes('ovk-rail-next') && r.includes(' hidden>'), 'arrows start hidden (kit-web shows them)');assert.ok(kit.rail({ items: ['x'], size: 'huge' }).includes('ovk-rail--m'), 'an unknown size is medium');++ assert.ok(kit.rail({ items: ['x'], wrap: true }).includes('class="ovk-rail ovk-rail--m ovk-rail--wrap"'), 'wrap: a grid from 760 px');++ assert.ok(!kit.rail({ items: ['x'] }).includes('ovk-rail--wrap'), 'rails swipe unless told to wrap');++ const room = fs.readFileSync(path.join(__dirname, '..', 'room.css'), 'utf8');++ assert.match(room, /@media \(min-width: 760px\) \{\s*\.ovk-rail--wrap \.ovk-rail-track \{ grid-auto-flow: row;/, 'room.css wraps the rail from 760 px');++ assert.match(room, /@media \(pointer: coarse\), \(max-width: 759px\) \{ \.ovk-rail-more \{ min-height: 44px; \} \}/, 'See all is 44 px to the touch');++ const kitCss = fs.readFileSync(path.join(__dirname, '..', 'kit.css'), 'utf8');++ assert.match(kitCss, /\.ovk-tabs a, \.ovk-list-head a \{ min-height: 44px; \}/, 'tab pills and list links are 44 px to the touch');++ assert.match(kitCss, /\.ovk-stats dt a \{ color: inherit; text-decoration: none; \}/, 'a stats label that is a link is not underlined');}// ── sc.room: the front door's order, the accent, nothing invented ──@@@ -166,6 +166,12 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:haassert.ok(h.includes('<p class="sc-apps-more"><a href="/sites">All 48 sites →</a></p>'), 'See all');assert.strictEqual(sc.apps({ items: [] }), '', 'nothing to show, no section');assert.ok(sc.apps({ items: [{ name: 'A', href: '/a' }] }).includes('<ul class="sc-apps">'), 'items without groups is one grid');++ // 4.39.0: no tile alone on a row. Four go four across on a phone and two by two from 600 px; eight like four.++ const n = (k) => sc.apps({ items: Array.from({ length: k }, (_, i) => ({ name: `A${i}`, href: `/a${i}` })) });++ assert.ok(n(4).includes('<ul class="sc-apps sc-apps--c4">') && n(3).includes('sc-apps--c3') && n(2).includes('sc-apps--c2') && n(8).includes('sc-apps--c4'));++ assert.ok(n(7).includes('<ul class="sc-apps">') && n(15).includes('<ul class="sc-apps">'), 'other counts keep the auto grid');++ assert.match(sc.CSS, /@media \(max-width:599px\)\{\.sc-apps--c2\{[^}]*\}\.sc-apps--c3\{[^}]*\}\.sc-apps--c4\{grid-template-columns:repeat\(4,minmax\(0,1fr\)\)\}\}/);++ assert.match(sc.CSS, /\.sc-apps-more a\{display:inline-flex;align-items:center;min-height:44px;/, 'the launcher\'s See all is 44 px');const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'showcase.css'), 'utf8');assert.ok(css.includes('.sc-apps{') && css.includes('grid-template-columns:repeat(auto-fill,minmax(88px,1fr))'), 'phones: icon tiles');}
@@ -6,6 +6,25 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,25 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.39.0 — 2026-10-29
+
+What the front-page passes of 10-29 (Media, Quest, Actor, Rent, Services, Community) each had to add locally:
+
+- **`kit.rail({ wrap: true })`**: phones still swipe; from 760 px every item shows in a grid, with no arrows and no
+ kit-web.js. Media and Quest drew this with their own rules.
+- **`kit.card({ sub, preview, whoHtml })`**: one plain muted line under the title (Rent showed the city as a badge and the
+ kind only on hover); a text thing's first nine lines in the media instead of an icon (Community's code pastes); the
+ name as escaped markup with its decorations. **`kit.row({ rel })`** for links people posted.
+- **No tile alone on a row in `showcase.apps`**: two to four tiles (or eight, twelve…) get a column count that fills
+ their rows. Four are four across on a phone, two by two from 600 px and four from 960 px (Actor had local rules).
+- **44 px to the touch**: the rail's See all (32 px), tab pills (36 px), a list's heading link and the launcher's See
+ all, on touch screens and below 760 px.
+- **A number that is a link is not underlined**: the whole stats tile is the target, with a hover border and a focus ring.
+- **The Markdown twin reads kit markup as a person sees it**: a card's who, count and time keep their spaces
+ ("Maticus12 viewsTue" was what agents read), buttons, icons, players and the ⋯ menu are left out, and a stats strip
+ is `- label: value` lines.
+- `scripts/kit-gallery.js --rooms`: a gallery page of front-page pieces (cards, both rails, stats links, launchers).
+
## 4.38.0 — 2026-10-29
## 4.38.0 — 2026-10-29
- **`showcase.apps`: a launcher.** A set of apps or sites as a phone's home screen shows them: icon tiles three or four
- **`showcase.apps`: a launcher.** A set of apps or sites as a phone's home screen shows them: icon tiles three or four
@@ -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.38.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.39.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.38.0",
+ "version": "4.39.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)",
@@ -43,8 +43,15 @@ function markdownTwin({ html, title, canonical, updated = null }) {
@@ -43,8 +43,15 @@ function markdownTwin({ html, title, canonical, updated = null }) {
if (node.nodeType === 3) return md(node.textContent.replace(/\s+/g, ' '));
if (node.nodeType === 3) return md(node.textContent.replace(/\s+/g, ' '));
if (node.nodeType !== 1) return '';
if (node.nodeType !== 1) return '';
const tag = node.localName;
const tag = node.localName;
- if (['script', 'style', 'template', 'nav', 'footer'].includes(tag) || node.hasAttribute('hidden') || node.getAttribute('aria-hidden') === 'true') return '';
- const inside = () => Array.from(node.childNodes).map(walk).join('');
+ // Controls are not content: buttons, inputs, icons, players, and a card's ⋯ menu (Save, Share) say nothing to a reader.
+ if (['script', 'style', 'template', 'nav', 'footer', 'button', 'svg', 'select', 'input', 'textarea', 'video', 'audio', 'dialog'].includes(tag)
+ || node.hasAttribute('hidden') || node.getAttribute('aria-hidden') === 'true' || (tag === 'details' && /\bovk-more\b/.test(node.getAttribute('class') || ''))) return '';
+ // Pieces the page sets side by side with no space between them (a card's who · count · when, a tile's name and
+ // line) keep a space between them, so "Maticus12 viewsTue" reads "Maticus 12 views Tue".
+ const inside = () => Array.from(node.childNodes).reduce((out, child) => {
+ const piece = walk(child);
+ return out && piece && child.nodeType === 1 && !['sup', 'sub', 'mark', 'wbr', 'abbr'].includes(child.localName) && !/\s$/.test(out) && !/^\s/.test(piece) && !/[([]$/.test(out) ? `${out} ${piece}` : out + piece;
+ }, '');
if (/^h[1-6]$/.test(tag)) return `\n\n${'#'.repeat(Number(tag[1]))} ${inside().trim()}\n\n`;
if (/^h[1-6]$/.test(tag)) return `\n\n${'#'.repeat(Number(tag[1]))} ${inside().trim()}\n\n`;
if (tag === 'a') return node.hasAttribute('href') ? `[${inside()}](${destination(node.getAttribute('href'))})` : inside();
if (tag === 'a') return node.hasAttribute('href') ? `[${inside()}](${destination(node.getAttribute('href'))})` : inside();
if (tag === 'img') return ` || '')})`;
if (tag === 'img') return ` || '')})`;
@@ -67,8 +74,20 @@ function markdownTwin({ html, title, canonical, updated = null }) {
@@ -67,8 +74,20 @@ function markdownTwin({ html, title, canonical, updated = null }) {
const line = row => '| ' + Array.from({ length: width }, (_, i) => row[i] || '').join(' | ') + ' |';
const line = row => '| ' + Array.from({ length: width }, (_, i) => row[i] || '').join(' | ') + ' |';
return '\n\n' + [line(rows[0]), line(Array(width).fill('---')), ...rows.slice(1).map(line)].join('\n') + '\n\n';
return '\n\n' + [line(rows[0]), line(Array(width).fill('---')), ...rows.slice(1).map(line)].join('\n') + '\n\n';
}
}
+ if (tag === 'dl') {
+ // A strip of numbers (kit.stats) or a definition list: one "- label: value" line each.
+ const pairs = [];
+ for (const n of node.querySelectorAll('dt')) {
+ let dd = n.nextElementSibling;
+ while (dd && dd.localName !== 'dd') dd = dd.nextElementSibling;
+ if (!dd) dd = n.previousElementSibling && n.previousElementSibling.localName === 'dd' ? n.previousElementSibling : null;
+ const k = walk(n).trim(), v = dd ? walk(dd).trim() : '';
+ if (k || v) pairs.push(`- ${k}${v ? `: ${v}` : ''}`);
+ }
+ return pairs.length ? `\n\n${pairs.join('\n')}\n\n` : '';
+ }
const content = inside();
const content = inside();
- return /^(p|div|section|article|main|blockquote|dl|dt|dd)$/.test(tag) ? `\n\n${content}\n\n` : content;
+ return /^(p|div|section|article|main|blockquote|dt|dd)$/.test(tag) ? `\n\n${content}\n\n` : content;
};
};
const destination = value => String(value).replace(/[\s()<>]/g, c => encodeURIComponent(c).replace(/\(/g, '%28').replace(/\)/g, '%29'));
const destination = value => String(value).replace(/[\s()<>]/g, c => encodeURIComponent(c).replace(/\(/g, '%28').replace(/\)/g, '%29'));
const content = walk(document.querySelector('main') || document.body).replace(/\n[ \t]+\n/g, '\n\n').replace(/\n{3,}/g, '\n\n').trim();
const content = walk(document.querySelector('main') || document.body).replace(/\n[ \t]+\n/g, '\n\n').replace(/\n{3,}/g, '\n\n').trim();
@@ -61,7 +61,10 @@ adds the tags (only enable where twins are mounted).
@@ -61,7 +61,10 @@ adds the tags (only enable where twins are mounted).
`markdownTwin({ html, title?, canonical, updated? })` uses the existing LinkeDOM parser,
`markdownTwin({ html, title?, canonical, updated? })` uses the existing LinkeDOM parser,
now a runtime dependency. It reads `<main>` (or the body), ignores scripts, styles, templates,
now a runtime dependency. It reads `<main>` (or the body), ignores scripts, styles, templates,
-navigation, footers and explicitly hidden elements, and requires no client JavaScript.
+navigation, footers and explicitly hidden elements, and requires no client JavaScript. Controls are not content:
+buttons, form fields, icons (svg), players, dialogs and a card's ⋯ menu are left out (4.39.0). Pieces a page sets side by
+side with no space between them (a card's who · count · when, a launcher tile's name and line) keep a space, and a
+definition list or a kit.stats strip becomes one `- label: value` line each.
It emits YAML `title`, `canonical`, `updated`, a title heading, and an HTML backlink.
It emits YAML `title`, `canonical`, `updated`, a title heading, and an HTML backlink.
Unknown update times are `null`, never the request time. Real headings remain in order
Unknown update times are `null`, never the request time. Real headings remain in order
(including a page's own h1), links and image alt text come from the rendered page, missing
(including a page's own h1), links and image alt text come from the rendered page, missing
@@ -130,7 +130,7 @@ reset.
@@ -130,7 +130,7 @@ reset.
```js
```js
kit.list(rows, { title: 'Your rooms', more: { href: '/rooms', label: 'All' }, flush, empty: 'No rooms yet.' })
kit.list(rows, { title: 'Your rooms', more: { href: '/rooms', label: 'All' }, flush, empty: 'No rooms yet.' })
kit.row({ href, mark: kit.mark({ name, src, square }), title, badge: kit.badge('live', 'live'),
kit.row({ href, mark: kit.mark({ name, src, square }), title, badge: kit.badge('live', 'live'),
- meta: kit.when(ts).text, metaIso: kit.when(ts).iso, sub, unread, trail })
+ meta: kit.when(ts).text, metaIso: kit.when(ts).iso, sub, unread, trail, rel: 'nofollow ugc' })
```
```
A row has a mark (a picture, or up to two letters on a colour from the name; `square` for places), the title with a
A row has a mark (a picture, or up to two letters on a colour from the name; `square` for places), the title with a
@@ -174,7 +174,7 @@ phone's own Enter, makes a new line), and a round send button.
@@ -174,7 +174,7 @@ phone's own Enter, makes a new line), and a round send button.
### Numbers, empty states, notices, code, sheets
### Numbers, empty states, notices, code, sheets
```js
```js
-kit.stats([[1234, 'messages this week'], [48, 'people chatting']]) // zero and unknown values are left out
+kit.stats([[1234, 'messages this week'], [48, 'people chatting', '/people']]) // zero and unknown values are left out; a third item makes the whole tile a link (never underlined)
kit.empty({ icon: 'chat', title, text, action: { href, label, icon } })
kit.empty({ icon: 'chat', title, text, action: { href, label, icon } })
kit.notice({ kind: 'ok' | 'error' | 'warn' | 'info', text })
kit.notice({ kind: 'ok' | 'error' | 'warn' | 'info', text })
kit.snippet({ code, id }) // a copy button in the corner
kit.snippet({ code, id }) // a copy button in the corner
@@ -199,9 +199,9 @@ kit.badge(text, 'accent' | 'ok' | 'warn' | 'bad' | 'live') · kit.dot(n) · kit.
@@ -199,9 +199,9 @@ kit.badge(text, 'accent' | 'ok' | 'warn' | 'bad' | 'live') · kit.dot(n) · kit.
### Cards and rails (4.28.0)
### Cards and rails (4.28.0)
```js
```js
-kit.card({ href, title, type, ratio, image, alt, video, badge, live, corner, peek, who, avatar,
+kit.card({ href, title, type, ratio, image, alt, video, badge, live, corner, peek, who, whoHtml, avatar, sub, preview,
count: [1832, 'views'], when, level, save: { href }, remind: { href }, share, more: [items] })
count: [1832, 'views'], when, level, save: { href }, remind: { href }, share, more: [items] })
-kit.rail({ id, title, more: { href, label }, items: [kit.card(…), …], size: 's' | 'm' | 'l' })
+kit.rail({ id, title, more: { href, label }, items: [kit.card(…), …], size: 's' | 'm' | 'l', wrap }))
```
```
One card for every kind of thing (plan 2026-10-28 §3.4):
One card for every kind of thing (plan 2026-10-28 §3.4):
@@ -213,8 +213,10 @@ One card for every kind of thing (plan 2026-10-28 §3.4):
@@ -213,8 +213,10 @@ One card for every kind of thing (plan 2026-10-28 §3.4):
| Corner | Bottom right: a length or a price |
| Corner | Bottom right: a length or a price |
| Peek | One line over the media on hover or focus |
| Peek | One line over the media on hover or focus |
| Preview | `video`: a muted loop while the card is hovered. Computers only, never under reduced motion, and the file loads only then |
| Preview | `video`: a muted loop while the card is hovered. Computers only, never under reduced motion, and the file loads only then |
+| Text preview | `preview` (4.39.0): for a thing whose text is the point (a code paste, a note), its first nine lines drawn in the media instead of the icon. A picture wins |
| Title | Two lines at most. The card is one link: the title's, stretched over it |
| Title | Two lines at most. The card is one link: the title's, stretched over it |
-| Meta | Who (with their picture or letters), one count, when. A count of 0 or none is left out, never shown as 0 |
+| Sub | `sub` (4.39.0): one plain muted line under the title: a city and a kind, a language. Not a badge, and not only on hover |
+| Meta | Who (with their picture or letters), one count, when. A count of 0 or none is left out, never shown as 0. `whoHtml` shows the name as markup you escaped (with its Inventory decorations); `who` stays the plain name for the letters |
| ⋯ | Save and Remind when given, and Share always (the system share sheet, else the link copied, said in the item) |
| ⋯ | Save and Remind when given, and Share always (the system share sheet, else the link copied, said in the item) |
A rail scrolls sideways and snaps:
A rail scrolls sideways and snaps:
@@ -224,6 +226,9 @@ A rail scrolls sideways and snaps:
@@ -224,6 +226,9 @@ A rail scrolls sideways and snaps:
| Phone | About 1.2 cards show, and the row bleeds to the screen's edge (the page keeps a 16 px gutter) |
| Phone | About 1.2 cards show, and the row bleeds to the screen's edge (the page keeps a 16 px gutter) |
| Wider | 190 px (`s`), 280 px (`m`) or 400 px (`l`) cards |
| Wider | 190 px (`s`), 280 px (`m`) or 400 px (`l`) cards |
| Pointer | ‹ › buttons, each hidden at its end |
| Pointer | ‹ › buttons, each hidden at its end |
+| `wrap: true` (4.39.0) | Phones still swipe; from 760 px every item shows in a grid (220 px columns, 160 for `s`, 320 for `l`), with no arrows and no kit-web.js. For a front page that shows a handful of things and links to the rest |
+
+The rail's See all, tab pills and a list's heading link are 44 px tall on touch screens and below 760 px.
A card's menu inside a rail is placed on the screen, so the scroller never clips it. An empty rail is `''`.
A card's menu inside a rail is placed on the screen, so the scroller never clips it. An empty rail is `''`.
@@ -477,7 +482,8 @@ still render as their own rows.
@@ -477,7 +482,8 @@ still render as their own rows.
apps as a launcher, the way a phone's home screen does: icon tiles three or four to a row with the name under the icon
apps as a launcher, the way a phone's home screen does: icon tiles three or four to a row with the name under the icon
on a phone, rows with a one-line tagline from 600 px. Use it where a page lists sites or products to jump to (the
on a phone, rows with a one-line tagline from 600 px. Use it where a page lists sites or products to jump to (the
network's front door, /sites, a directory); it replaces a stack of feature cards, which on a phone is one screen per
network's front door, /sites, a directory); it replaces a stack of feature cards, which on a phone is one screen per
-four sites.
+four sites. Since 4.39.0 no tile sits alone on a row: a set of two to four (or eight, twelve…) gets a column count
+that fills its rows; four tiles are four across on a phone, two by two from 600 px and four from 960 px.
`showcase.hero({ eyebrow, title, accent, lede, actions, note })` is the top of most product homes. On a phone (since
`showcase.hero({ eyebrow, title, accent, lede, actions, note })` is the top of most product homes. On a phone (since
4.23.0):
4.23.0):
@@ -94,7 +94,9 @@
@@ -94,7 +94,9 @@
/* ── Lists: rows that read like a phone's (a mark, the name and time on one line, one line under it) ── */
/* ── Lists: rows that read like a phone's (a mark, the name and time on one line, one line under it) ── */
.ovk-list-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 18px 0 8px; }
.ovk-list-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 18px 0 8px; }
.ovk-list-head.ovk-list-head h2 { margin: 0; color: var(--ovk-muted); font-family: inherit; font-size: .76rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
.ovk-list-head.ovk-list-head h2 { margin: 0; color: var(--ovk-muted); font-family: inherit; font-size: .76rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
-.ovk-list-head a { color: var(--ovk-accent); font-size: .84rem; font-weight: 700; text-decoration: none; }
+.ovk-list-head a { display: inline-flex; align-items: center; min-height: 32px; color: var(--ovk-accent); font-size: .84rem; font-weight: 700; text-decoration: none; }
+/* 44 px to the touch: tab pills (36 px) and list links grow on touch screens and narrow windows. */
+@media (pointer: coarse), (max-width: 759px) { .ovk-tabs a, .ovk-list-head a { min-height: 44px; } }
.ovk-list { list-style: none; display: grid; grid-template-columns: minmax(0, 1fr); margin: 0 0 14px; padding: 0; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-panel); overflow: hidden; }
.ovk-list { list-style: none; display: grid; grid-template-columns: minmax(0, 1fr); margin: 0 0 14px; padding: 0; border: 1px solid var(--ovk-line); border-radius: var(--ovk-radius); background: var(--ovk-panel); overflow: hidden; }
.ovk-list--flush { margin: 0; border: 0; border-radius: 0; border-top: 1px solid var(--ovk-line); background: transparent; }
.ovk-list--flush { margin: 0; border: 0; border-radius: 0; border-top: 1px solid var(--ovk-line); background: transparent; }
.ovk-row { margin: 0; color: var(--ovk-text); }
.ovk-row { margin: 0; color: var(--ovk-text); }
@@ -211,7 +213,13 @@ div.ovk-set-row { cursor: default; }
@@ -211,7 +213,13 @@ div.ovk-set-row { cursor: default; }
.ovk-stats::-webkit-scrollbar { display: none; }
.ovk-stats::-webkit-scrollbar { display: none; }
.ovk-stats > div { display: flex; flex-direction: column-reverse; flex: 0 0 auto; min-width: 104px; box-sizing: border-box; padding: 8px 14px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-panel); }
.ovk-stats > div { display: flex; flex-direction: column-reverse; flex: 0 0 auto; min-width: 104px; box-sizing: border-box; padding: 8px 14px; border: 1px solid var(--ovk-line); border-radius: 12px; background: var(--ovk-panel); }
.ovk-stats dt { margin: 0; color: var(--ovk-muted); font-size: .76rem; line-height: 1.25; }
.ovk-stats dt { margin: 0; color: var(--ovk-muted); font-size: .76rem; line-height: 1.25; }
-.ovk-stats dt a { color: inherit; }
+.ovk-stats dt a { color: inherit; text-decoration: none; }
+/* A number that is a link: the whole tile is the target (the label's link stretched over it), never an underline. */
+.ovk-stat--link { position: relative; transition: border-color .15s; }
+.ovk-stat--link dt a::after { content: ""; position: absolute; inset: 0; border-radius: inherit; }
+.ovk-stat--link:hover { border-color: var(--ovk-accent); }
+.ovk-stat--link dt a:focus-visible { outline: none; }
+.ovk-stat--link:focus-within { outline: 2px solid var(--ovk-accent); outline-offset: -2px; }
.ovk-stats dd { margin: 0; color: var(--ovk-text); font-size: 1.2rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.2; }
.ovk-stats dd { margin: 0; color: var(--ovk-text); font-size: 1.2rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.2; }
.ovk-empty { display: grid; justify-items: center; gap: 8px; margin: 0 0 14px; padding: 24px 16px; border: 1px dashed var(--ovk-line); border-radius: var(--ovk-radius); color: var(--ovk-muted); text-align: center; }
.ovk-empty { display: grid; justify-items: center; gap: 8px; margin: 0 0 14px; padding: 24px 16px; border: 1px dashed var(--ovk-line); border-radius: var(--ovk-radius); color: var(--ovk-muted); text-align: center; }
.ovk-empty > .ovk-i { width: 28px; height: 28px; }
.ovk-empty > .ovk-i { width: 28px; height: 28px; }
@@ -202,14 +202,15 @@
@@ -202,14 +202,15 @@
}
}
/**
/**
* One row: its mark, the title (with a badge) and the time on one line, one muted line under it, a trailing control.
* One row: its mark, the title (with a badge) and the time on one line, one muted line under it, a trailing control.
- * href makes the whole row a link; without it the row is static and trail can hold its own forms.
+ * href makes the whole row a link; without it the row is static and trail can hold its own forms. rel: the link's
+ * rel ('nofollow ugc' for links people posted, 'noopener' off-site).
*/
*/
- function row({ href = null, mark: markHtml = '', title = '', titleHtml = null, badge: badgeHtml = '', meta = '', metaIso = '', sub = '', subHtml = null, trail = '', unread = 0, attrs: extra = null } = {}) {
+ function row({ href = null, rel = '', mark: markHtml = '', title = '', titleHtml = null, badge: badgeHtml = '', meta = '', metaIso = '', sub = '', subHtml = null, trail = '', unread = 0, attrs: extra = null } = {}) {
const top = `<span class="ovk-row-top"><strong>${titleHtml != null ? titleHtml : esc(title)}</strong>${badgeHtml || ''}${meta ? (metaIso ? `<time datetime="${esc(metaIso)}">${esc(meta)}</time>` : `<span class="ovk-row-meta">${esc(meta)}</span>`) : ''}</span>`;
const top = `<span class="ovk-row-top"><strong>${titleHtml != null ? titleHtml : esc(title)}</strong>${badgeHtml || ''}${meta ? (metaIso ? `<time datetime="${esc(metaIso)}">${esc(meta)}</time>` : `<span class="ovk-row-meta">${esc(meta)}</span>`) : ''}</span>`;
const subLine = subHtml != null || sub || unread ? `<span class="ovk-row-sub"><span>${subHtml != null ? subHtml : esc(sub)}</span>${dot(unread)}</span>` : '';
const subLine = subHtml != null || sub || unread ? `<span class="ovk-row-sub"><span>${subHtml != null ? subHtml : esc(sub)}</span>${dot(unread)}</span>` : '';
const main = `${markHtml || ''}<span class="ovk-row-main">${top}${subLine}</span>`;
const main = `${markHtml || ''}<span class="ovk-row-main">${top}${subLine}</span>`;
const cls = `ovk-row${unread ? ' ovk-row--unread' : ''}`;
const cls = `ovk-row${unread ? ' ovk-row--unread' : ''}`;
- if (href != null) return `<li class="${cls}"${attrs(extra)}><a href="${esc(url(href))}">${main}${trail ? `<span class="ovk-row-trail">${trail}</span>` : ''}</a></li>`;
+ if (href != null) return `<li class="${cls}"${attrs(extra)}><a href="${esc(url(href))}"${rel ? ` rel="${esc(rel)}"` : ''}>${main}${trail ? `<span class="ovk-row-trail">${trail}</span>` : ''}</a></li>`;
return `<li class="${cls} ovk-row--static"${attrs(extra)}>${main}${trail ? `<span class="ovk-row-trail">${trail}</span>` : ''}</li>`;
return `<li class="${cls} ovk-row--static"${attrs(extra)}>${main}${trail ? `<span class="ovk-row-trail">${trail}</span>` : ''}</li>`;
}
}
@@ -330,25 +331,31 @@
@@ -330,25 +331,31 @@
* - media in the type's shape, a badge top-left (a word: NEW, −24 %, WR; live: true draws LIVE with a pulse) and a
* - media in the type's shape, a badge top-left (a word: NEW, −24 %, WR; live: true draws LIVE with a pulse) and a
* corner bottom-right (a length or a price); peek: one line over the media on hover or focus; video: a muted
* corner bottom-right (a length or a price); peek: one line over the media on hover or focus; video: a muted
* loop played while it is hovered (computers only, never under reduced motion, loaded only then)
* loop played while it is hovered (computers only, never under reduced motion, loaded only then)
- * - the title (two lines at most), then who (with their picture or letters), one count and when; a count of 0 or
- * none is left out, never shown as 0
+ * - the title (two lines at most); sub: one plain muted line under it (a city, a kind, a language); then who (with
+ * their picture or letters), one count and when; a count of 0 or none is left out, never shown as 0. whoHtml: the
+ * name as markup the caller escaped (a name with its decorations); who stays the plain name for the letters
+ * - preview: for a thing without a picture whose text is the point (a code paste, a note), its first lines drawn in
+ * the media instead of the type's icon
* - ⋯ with Save and Remind when given ({ href } or { action, hidden }), Share (always: the system share sheet, else
* - ⋯ with Save and Remind when given ({ href } or { action, hidden }), Share (always: the system share sheet, else
* the link copied) and any `more` items (kit.more's)
* the link copied) and any `more` items (kit.more's)
* The card is one link, the title's, stretched over it; ⋯ sits above it. level: the title's heading level (3).
* The card is one link, the title's, stretched over it; ⋯ sits above it. level: the title's heading level (3).
*/
*/
function card({ href = '#', title = '', type = '', ratio = '', image = '', alt = '', video = '', badge: bd = '', live = false, corner = '',
function card({ href = '#', title = '', type = '', ratio = '', image = '', alt = '', video = '', badge: bd = '', live = false, corner = '',
- peek = '', who = '', avatar = '', count = null, when: at = null, level = 3, save = null, remind = null, share = true, more: extra = null } = {}) {
+ peek = '', who = '', whoHtml = null, avatar = '', count = null, when: at = null, level = 3, save = null, remind = null, share = true,
+ more: extra = null, sub = '', preview = '' } = {}) {
const ar = /^\d{1,2}\/\d{1,2}$/.test(String(ratio)) ? ratio : CARD_RATIO[type] || '16/9';
const ar = /^\d{1,2}\/\d{1,2}$/.test(String(ratio)) ? ratio : CARD_RATIO[type] || '16/9';
const link = url(href);
const link = url(href);
+ const first = String(preview || '').split('\n').slice(0, 9).join('\n').slice(0, 480).trimEnd();
const pic = media(image)
const pic = media(image)
? `<img src="${esc(image)}" alt="${esc(alt)}" loading="lazy" decoding="async">`
? `<img src="${esc(image)}" alt="${esc(alt)}" loading="lazy" decoding="async">`
- : `<span class="ovk-card-noimg">${icon(CARD_ICON[type] || 'image')}</span>`;
+ : first ? `<pre class="ovk-card-pre" aria-hidden="true">${esc(first)}</pre>`
+ : `<span class="ovk-card-noimg">${icon(CARD_ICON[type] || 'image')}</span>`;
const loop = media(video) ? `<video class="ovk-card-video" data-src="${esc(video)}" muted loop playsinline preload="none" aria-hidden="true" tabindex="-1"></video>` : '';
const loop = media(video) ? `<video class="ovk-card-video" data-src="${esc(video)}" muted loop playsinline preload="none" aria-hidden="true" tabindex="-1"></video>` : '';
const tag = live ? '<span class="ovk-card-badge is-live">LIVE</span>' : bd ? `<span class="ovk-card-badge">${esc(bd)}</span>` : '';
const tag = live ? '<span class="ovk-card-badge is-live">LIVE</span>' : bd ? `<span class="ovk-card-badge">${esc(bd)}</span>` : '';
const n = Array.isArray(count) ? Number(count[0]) : Number(count);
const n = Array.isArray(count) ? Number(count[0]) : Number(count);
const counted = Number.isFinite(n) && n > 0 ? `<span>${esc(fmtCount(n))}${Array.isArray(count) && count[1] ? ` ${esc(count[1])}` : ''}</span>` : '';
const counted = Number.isFinite(n) && n > 0 ? `<span>${esc(fmtCount(n))}${Array.isArray(count) && count[1] ? ` ${esc(count[1])}` : ''}</span>` : '';
const t = at != null && at !== '' ? when(at) : null;
const t = at != null && at !== '' ? when(at) : null;
- const meta = [who ? `<span class="ovk-card-who">${esc(who)}</span>` : '', counted, t && t.text ? `<time datetime="${esc(t.iso)}">${esc(t.text)}</time>` : ''].filter(Boolean).join('');
+ const meta = [whoHtml != null && whoHtml !== '' ? `<span class="ovk-card-who">${whoHtml}</span>` : who ? `<span class="ovk-card-who">${esc(who)}</span>` : '', counted, t && t.text ? `<time datetime="${esc(t.iso)}">${esc(t.text)}</time>` : ''].filter(Boolean).join('');
const items = [
const items = [
save ? { label: 'Save', icon: 'star', ...save } : null,
save ? { label: 'Save', icon: 'star', ...save } : null,
remind ? { label: 'Remind me', icon: 'bell', ...remind } : null,
remind ? { label: 'Remind me', icon: 'bell', ...remind } : null,
@@ -358,21 +365,22 @@
@@ -358,21 +365,22 @@
return `<article class="ovk-card${live ? ' is-live' : ''}${type ? ` ovk-card--${esc(type)}` : ''}" style="--ovk-ar:${ar}">`
return `<article class="ovk-card${live ? ' is-live' : ''}${type ? ` ovk-card--${esc(type)}` : ''}" style="--ovk-ar:${ar}">`
+ `<div class="ovk-card-media">${pic}${loop}${tag}${corner ? `<span class="ovk-card-corner">${esc(corner)}</span>` : ''}${peek ? `<span class="ovk-card-peek">${esc(peek)}</span>` : ''}</div>`
+ `<div class="ovk-card-media">${pic}${loop}${tag}${corner ? `<span class="ovk-card-corner">${esc(corner)}</span>` : ''}${peek ? `<span class="ovk-card-peek">${esc(peek)}</span>` : ''}</div>`
+ `<div class="ovk-card-body">${who || avatar ? mark({ name: who || title, src: avatar, size: 34 }) : ''}<div class="ovk-card-text">`
+ `<div class="ovk-card-body">${who || avatar ? mark({ name: who || title, src: avatar, size: 34 }) : ''}<div class="ovk-card-text">`
- + `<h${h} class="ovk-card-title"><a class="ovk-card-link" href="${esc(link)}">${esc(title)}</a></h${h}>${meta ? `<p class="ovk-card-meta">${meta}</p>` : ''}</div>`
+ + `<h${h} class="ovk-card-title"><a class="ovk-card-link" href="${esc(link)}">${esc(title)}</a></h${h}>${sub ? `<p class="ovk-card-sub">${esc(sub)}</p>` : ''}${meta ? `<p class="ovk-card-meta">${meta}</p>` : ''}</div>`
+ `${items.length ? more({ label: `More: ${title}`, items }) : ''}</div></article>`;
+ `${items.length ? more({ label: `More: ${title}`, items }) : ''}</div></article>`;
}
}
/**
/**
* rail({ id, title, more: { href, label }, items: [card html], size: 's'|'m'|'l', label }) — a row that scrolls
* rail({ id, title, more: { href, label }, items: [card html], size: 's'|'m'|'l', label }) — a row that scrolls
* sideways and snaps: about 1.2 cards on a phone, as many as fit on a computer, with ‹ › buttons there (kit.enhance).
* sideways and snaps: about 1.2 cards on a phone, as many as fit on a computer, with ‹ › buttons there (kit.enhance).
- * size: 's' square things (memes, shoes, deals), 'm' (default) pictures 16:9, 'l' wide features. Empty → ''.
+ * size: 's' square things (memes, shoes, deals), 'm' (default) pictures 16:9, 'l' wide features. wrap: from 760 px
+ * the row becomes a grid that shows every item (no arrows, no kit-web.js needed); phones still swipe. Empty → ''.
*/
*/
- function rail({ id = '', title = '', more: m = null, items = [], size = 'm', label = '' } = {}) {
+ function rail({ id = '', title = '', more: m = null, items = [], size = 'm', label = '', wrap = false } = {}) {
const list = (items || []).filter(Boolean);
const list = (items || []).filter(Boolean);
if (!list.length) return '';
if (!list.length) return '';
const hid = id ? `${String(id).replace(/[^a-z0-9_-]/gi, '')}-h` : '';
const hid = id ? `${String(id).replace(/[^a-z0-9_-]/gi, '')}-h` : '';
const head = title || m ? `<div class="ovk-rail-head">${title ? `<h2${hid ? ` id="${hid}"` : ''}>${esc(title)}</h2>` : ''}${m && m.href ? `<a class="ovk-rail-more" href="${esc(url(m.href))}">${esc(m.label || 'See all')}${icon('chevron')}</a>` : ''}</div>` : '';
const head = title || m ? `<div class="ovk-rail-head">${title ? `<h2${hid ? ` id="${hid}"` : ''}>${esc(title)}</h2>` : ''}${m && m.href ? `<a class="ovk-rail-more" href="${esc(url(m.href))}">${esc(m.label || 'See all')}${icon('chevron')}</a>` : ''}</div>` : '';
const named = hid && title ? ` aria-labelledby="${hid}"` : ` aria-label="${esc(label || title || 'More')}"`;
const named = hid && title ? ` aria-labelledby="${hid}"` : ` aria-label="${esc(label || title || 'More')}"`;
- return `<section class="ovk-rail ovk-rail--${size === 's' || size === 'l' ? size : 'm'}"${id ? ` id="${esc(id)}"` : ''}${named}>${head}`
+ return `<section class="ovk-rail ovk-rail--${size === 's' || size === 'l' ? size : 'm'}${wrap ? ' ovk-rail--wrap' : ''}"${id ? ` id="${esc(id)}"` : ''}${named}>${head}`
+ `<div class="ovk-rail-wrap"><ul class="ovk-rail-track" role="list">${list.map((x) => `<li>${x}</li>`).join('')}</ul>`
+ `<div class="ovk-rail-wrap"><ul class="ovk-rail-track" role="list">${list.map((x) => `<li>${x}</li>`).join('')}</ul>`
+ `<button type="button" class="ovk-rail-btn ovk-rail-prev" aria-label="Scroll back" hidden>${icon('back')}</button>`
+ `<button type="button" class="ovk-rail-btn ovk-rail-prev" aria-label="Scroll back" hidden>${icon('back')}</button>`
+ `<button type="button" class="ovk-rail-btn ovk-rail-next" aria-label="Scroll on" hidden>${icon('forward')}</button></div></section>`;
+ `<button type="button" class="ovk-rail-btn ovk-rail-next" aria-label="Scroll on" hidden>${icon('forward')}</button></div></section>`;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.38.0",
+ "version": "4.39.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.38.0",
+ "version": "4.39.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.38.0",
+ "version": "4.39.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": {
@@ -34,6 +34,9 @@
@@ -34,6 +34,9 @@
.ovk-card-link:focus-visible::after { outline: 2px solid var(--room-accent, var(--ovk-accent)); outline-offset: 3px; }
.ovk-card-link:focus-visible::after { outline: 2px solid var(--room-accent, var(--ovk-accent)); outline-offset: 3px; }
.ovk-card-meta { margin: 0; display: flex; flex-wrap: wrap; color: var(--ovk-muted); font-size: 13px; line-height: 1.45; min-width: 0; }
.ovk-card-meta { margin: 0; display: flex; flex-wrap: wrap; color: var(--ovk-muted); font-size: 13px; line-height: 1.45; min-width: 0; }
.ovk-card-meta > * + *::before { content: "·"; margin: 0 6px; }
.ovk-card-meta > * + *::before { content: "·"; margin: 0 6px; }
+.ovk-card-sub { margin: 0; overflow: hidden; color: var(--text-secondary, var(--ovk-muted)); font-size: 13px; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
+/* preview: a text thing's first lines in its media, fading out at the bottom. */
+.ovk-card-pre { position: absolute; inset: 0; margin: 0; padding: 12px 14px; overflow: hidden; color: var(--ovk-text); background: var(--ovk-panel-2); font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre; -webkit-mask-image: linear-gradient(#000 60%, transparent); mask-image: linear-gradient(#000 60%, transparent); }
.ovk-card-who { font-weight: 600; color: var(--text-secondary, var(--ovk-muted)); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovk-card-who { font-weight: 600; color: var(--text-secondary, var(--ovk-muted)); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovk-card .ovk-more { position: relative; z-index: 2; margin: -6px -8px 0 0; }
.ovk-card .ovk-more { position: relative; z-index: 2; margin: -6px -8px 0 0; }
@media (hover: hover) {
@media (hover: hover) {
@@ -51,6 +54,7 @@
@@ -51,6 +54,7 @@
.ovk-rail-head.ovk-rail-head h2 { margin: 0; font-size: 1.15rem; font-weight: 800; letter-spacing: -.01em; }
.ovk-rail-head.ovk-rail-head h2 { margin: 0; font-size: 1.15rem; font-weight: 800; letter-spacing: -.01em; }
.ovk-rail-more { display: inline-flex; align-items: center; gap: 2px; min-height: 32px; color: var(--room-strong, var(--ovk-accent)); font-weight: 700; font-size: .9rem; text-decoration: none; white-space: nowrap; }
.ovk-rail-more { display: inline-flex; align-items: center; gap: 2px; min-height: 32px; color: var(--room-strong, var(--ovk-accent)); font-weight: 700; font-size: .9rem; text-decoration: none; white-space: nowrap; }
.ovk-rail-more:hover { text-decoration: underline; }
.ovk-rail-more:hover { text-decoration: underline; }
+@media (pointer: coarse), (max-width: 759px) { .ovk-rail-more { min-height: 44px; } }
.ovk-rail-wrap { position: relative; }
.ovk-rail-wrap { position: relative; }
.ovk-rail-track { list-style: none; margin: 0 calc(-1 * var(--ovk-rail-bleed)); padding: 2px var(--ovk-rail-bleed) 10px; display: grid; grid-auto-flow: column; grid-auto-columns: var(--ovk-rail-w); gap: 14px; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: var(--ovk-rail-bleed); overscroll-behavior-x: contain; scrollbar-width: none; }
.ovk-rail-track { list-style: none; margin: 0 calc(-1 * var(--ovk-rail-bleed)); padding: 2px var(--ovk-rail-bleed) 10px; display: grid; grid-auto-flow: column; grid-auto-columns: var(--ovk-rail-w); gap: 14px; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: var(--ovk-rail-bleed); overscroll-behavior-x: contain; scrollbar-width: none; }
.ovk-rail-track::-webkit-scrollbar { display: none; }
.ovk-rail-track::-webkit-scrollbar { display: none; }
@@ -73,6 +77,13 @@
@@ -73,6 +77,13 @@
@media (min-width: 600px) {
@media (min-width: 600px) {
.ovk-rail--m { --ovk-rail-w: 280px; } .ovk-rail--s { --ovk-rail-w: 190px; } .ovk-rail--l { --ovk-rail-w: 400px; }
.ovk-rail--m { --ovk-rail-w: 280px; } .ovk-rail--s { --ovk-rail-w: 190px; } .ovk-rail--l { --ovk-rail-w: 400px; }
}
}
+/* rail({ wrap: true }): phones swipe; from 760 px every item shows in a grid, so a mouse needs no arrows. */
+@media (min-width: 760px) {
+ .ovk-rail--wrap .ovk-rail-track { grid-auto-flow: row; grid-auto-columns: auto; grid-template-columns: repeat(auto-fill, minmax(var(--ovk-rail-min, 220px), 1fr)); overflow: visible; margin: 0; padding: 2px 0 10px; }
+ .ovk-rail--wrap.ovk-rail--s { --ovk-rail-min: 160px; }
+ .ovk-rail--wrap.ovk-rail--l { --ovk-rail-min: 320px; }
+ .ovk-rail--wrap .ovk-rail-btn { display: none; }
+}
@media (prefers-reduced-motion: reduce) { .ovk-card-media > img, .ovk-card-peek, .ovk-card-video { transition: none; } .ovk-card-badge.is-live::before { animation: none; } }
@media (prefers-reduced-motion: reduce) { .ovk-card-media > img, .ovk-card-peek, .ovk-card-video { transition: none; } .ovk-card-badge.is-live::before { animation: none; } }
html[data-ov-motion=reduced] .ovk-card-badge.is-live::before { animation: none; }
html[data-ov-motion=reduced] .ovk-card-badge.is-live::before { animation: none; }
@@ -45,13 +45,38 @@ ${k.dock({ action: '#send', placeholder: 'Message #general' })}`;
@@ -45,13 +45,38 @@ ${k.dock({ action: '#send', placeholder: 'Message #general' })}`;
return page(body, 'OpenVibe Kit');
return page(body, 'OpenVibe Kit');
}
}
-function page(body, title) {
+// The pieces of a product's front page (4.39.0): cards with a sub-line, a text preview and a decorated name, a rail that
+// swipes and one that wraps from 760 px, numbers that are links, and the launcher at 4 tiles (no tile alone) and 15.
+function galleryRooms() {
+ const k = kit;
+ const sc = require('../showcase');
+ const code = 'const sdk = require(\'openvibe-sdk\');\nconst ov = sdk.createClient();\n\nconst { results } = await ov.search\n .query(\'go live\');\nconsole.log(results.length);';
+ const cards = [
+ k.card({ href: '#1', title: 'A sunny loft by the river, two rooms', type: 'listing', sub: 'Lisbon · Apartment', corner: '€1,240', who: 'Ana', count: [312, 'views'], when: new Date(Date.now() - 86400e3).toISOString() }),
+ k.card({ href: '#2', title: 'search.js', type: 'post', preview: code, corner: 'JavaScript', who: 'Maticus', whoHtml: '<span style="color:var(--ovk-accent)">Maticus ✦</span>', count: [12, 'views'] }),
+ k.card({ href: '#3', title: 'Release notes for the node, with a much longer title that wraps', type: 'post', sub: 'Markdown', who: 'OpenVibe', count: [48, 'views'] }),
+ k.card({ href: '#4', title: 'Beach-side loading chaos', type: 'clip', corner: '0:24', live: false, who: 'OpenVibe AI', badge: 'AI' }),
+ k.card({ href: '#5', title: 'Garden studio, quiet street', type: 'listing', sub: 'Porto · Room', corner: '€640' }),
+ ];
+ const tiles = (n) => Array.from({ length: n }, (_, i) => ({ icon: ['ov:live', 'ov:chat', 'ov:media', 'ov:tools', 'ov:games'][i % 5], name: ['Live', 'Chat', 'Media', 'Tools', 'Games', 'Search', 'Pay', 'Events'][i % 8], text: 'One line about it', href: `/app/${i}` }));
+ const body = `<h1 class="ovk-title">Front page pieces</h1>
+${k.stats([[26, 'services you can call', '#svc'], [106, 'capabilities an app can request', '#caps'], [239, 'event types'], [70, 'SDK modules']])}
+${k.rail({ id: 'latest', title: 'Latest', more: { href: '#all', label: 'See all' }, items: cards })}
+${k.rail({ id: 'wrapped', title: 'Wraps from 760 px', more: { href: '#all', label: 'See all' }, items: cards, wrap: true })}
+${k.list([k.row({ href: 'https://example.com/', rel: 'nofollow ugc', title: 'A link someone posted', sub: 'example.com', meta: '17:29' })], { title: 'Across OpenVibe', more: { href: '#pulse', label: 'All of Pulse' } })}
+${sc.apps({ id: 'four', title: 'Four tiles', items: tiles(4), more: { href: '/apps', label: 'All apps' } })}
+${sc.apps({ id: 'many', title: 'Fifteen tiles', items: tiles(15) })}`;
+ return page(body, 'OpenVibe Kit: rooms', ['room.css', 'showcase.css']);
+}
+
+function page(body, title, extra = []) {
return `<!doctype html>
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>${title}</title>
<title>${title}</title>
<script src="theme-loader.js"></script>
<script src="theme-loader.js"></script>
<link rel="stylesheet" href="kit.css">
<link rel="stylesheet" href="kit.css">
<link rel="stylesheet" href="kit-data.css">
<link rel="stylesheet" href="kit-data.css">
+${extra.map((x) => `<link rel="stylesheet" href="${x}">`).join('\n')}
<style>body { margin: 0; background: var(--bg-primary, #0b0f17); color: var(--text-primary, #e8ecf3); font-family: 'Inter', system-ui, sans-serif; }</style>
<style>body { margin: 0; background: var(--bg-primary, #0b0f17); color: var(--text-primary, #e8ecf3); font-family: 'Inter', system-ui, sans-serif; }</style>
</head><body>
</head><body>
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
@@ -109,7 +134,7 @@ ${k.section({ title: 'Your quota', body: `<div class="ovk-set-row"><span class="
@@ -109,7 +134,7 @@ ${k.section({ title: 'Your quota', body: `<div class="ovk-set-row"><span class="
async function shots(dir, chromePath) {
async function shots(dir, chromePath) {
const root = path.join(__dirname, '..');
const root = path.join(__dirname, '..');
- const pages = { kit: gallery(), kit2: gallery2() };
+ const pages = { kit: gallery(), kit2: gallery2(), rooms: galleryRooms() };
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
const name = decodeURIComponent(new URL(req.url, 'http://x').pathname.slice(1));
const name = decodeURIComponent(new URL(req.url, 'http://x').pathname.slice(1));
if (!name || pages[name]) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(pages[name || 'kit']); }
if (!name || pages[name]) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(pages[name || 'kit']); }
@@ -123,7 +148,7 @@ async function shots(dir, chromePath) {
@@ -123,7 +148,7 @@ async function shots(dir, chromePath) {
const { chromium } = require(require.resolve('playwright', { paths: [process.cwd(), '/home/workstation/openvibe/work/OpenVibe.Games'] }));
const { chromium } = require(require.resolve('playwright', { paths: [process.cwd(), '/home/workstation/openvibe/work/OpenVibe.Games'] }));
const browser = await chromium.launch(chromePath ? { executablePath: chromePath } : {});
const browser = await chromium.launch(chromePath ? { executablePath: chromePath } : {});
fs.mkdirSync(dir, { recursive: true });
fs.mkdirSync(dir, { recursive: true });
- const only = process.argv.includes('--kit2') ? ['kit2'] : ['kit', 'kit2'];
+ const only = process.argv.includes('--kit2') ? ['kit2'] : process.argv.includes('--rooms') ? ['rooms'] : ['kit', 'kit2', 'rooms'];
for (const name of only) for (const scheme of ['dark', 'light']) for (const [w, h, mobile] of [[320, 700, true], [390, 844, true], [768, 1024, true], [1280, 900, false]]) {
for (const name of only) for (const scheme of ['dark', 'light']) for (const [w, h, mobile] of [[320, 700, true], [390, 844, true], [768, 1024, true], [1280, 900, false]]) {
if (name === 'kit' && (scheme === 'light' || w === 320)) continue;
if (name === 'kit' && (scheme === 'light' || w === 320)) continue;
const ctx = await browser.newContext({ viewport: { width: w, height: h }, isMobile: mobile, hasTouch: mobile, colorScheme: scheme });
const ctx = await browser.newContext({ viewport: { width: w, height: h }, isMobile: mobile, hasTouch: mobile, colorScheme: scheme });
@@ -149,6 +174,6 @@ if (require.main === module) {
@@ -149,6 +174,6 @@ if (require.main === module) {
const a = process.argv.slice(2);
const a = process.argv.slice(2);
const i = a.indexOf('--shots');
const i = a.indexOf('--shots');
if (i >= 0) shots(a[i + 1] || 'kit-shots', a.includes('--chrome') ? a[a.indexOf('--chrome') + 1] : null).catch((e) => { console.error(e); process.exit(1); });
if (i >= 0) shots(a[i + 1] || 'kit-shots', a.includes('--chrome') ? a[a.indexOf('--chrome') + 1] : null).catch((e) => { console.error(e); process.exit(1); });
- else process.stdout.write(a.includes('--kit2') ? gallery2() : gallery());
+ else process.stdout.write(a.includes('--kit2') ? gallery2() : a.includes('--rooms') ? galleryRooms() : gallery());
}
}
-module.exports = { gallery };
+module.exports = { gallery, gallery2, galleryRooms };
@@ -146,14 +146,19 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
@@ -146,14 +146,19 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
.sc-apps b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:700}
.sc-apps b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:700}
.sc-apps small{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.sc-apps small{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.sc-apps-more{margin:14px 0 0;font-weight:700}
.sc-apps-more{margin:14px 0 0;font-weight:700}
-.sc-apps-more a{color:var(--accent-light,#60a5fa);text-decoration:none}
+.sc-apps-more a{display:inline-flex;align-items:center;min-height:44px;color:var(--accent-light,#60a5fa);text-decoration:none}
+.sc-apps-more a:hover{text-decoration:underline}
+@media (max-width:599px){.sc-apps--c2{grid-template-columns:repeat(2,minmax(0,1fr))}.sc-apps--c3{grid-template-columns:repeat(3,minmax(0,1fr))}.sc-apps--c4{grid-template-columns:repeat(4,minmax(0,1fr))}}
@media (min-width:600px){
@media (min-width:600px){
.sc-apps{gap:8px;grid-template-columns:repeat(auto-fill,minmax(230px,1fr))}
.sc-apps{gap:8px;grid-template-columns:repeat(auto-fill,minmax(230px,1fr))}
.sc-apps a{flex-direction:row;align-items:center;gap:12px;padding:10px 12px;text-align:left;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-apps a{flex-direction:row;align-items:center;gap:12px;padding:10px 12px;text-align:left;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-apps a:hover,.sc-apps a:focus-visible{border-color:var(--accent,#3b82f6);background:var(--bg-secondary,#101828)}
.sc-apps a:hover,.sc-apps a:focus-visible{border-color:var(--accent,#3b82f6);background:var(--bg-secondary,#101828)}
.sc-apps b{font-size:15px}
.sc-apps b{font-size:15px}
.sc-apps small{position:static;width:auto;height:auto;clip:auto;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary,#96a7c2);font-size:13px}
.sc-apps small{position:static;width:auto;height:auto;clip:auto;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary,#96a7c2);font-size:13px}
+.sc-apps--c2,.sc-apps--c4{grid-template-columns:repeat(2,minmax(0,1fr))}
+.sc-apps--c3{grid-template-columns:repeat(3,minmax(0,1fr))}
}
}
+@media (min-width:960px){.sc-apps--c4{grid-template-columns:repeat(4,minmax(0,1fr))}}
.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
@@ -332,14 +332,17 @@ function network({ id: sid, service, title = 'Part of OpenVibe', lede = 'One acc
@@ -332,14 +332,17 @@ function network({ id: sid, service, title = 'Part of OpenVibe', lede = 'One acc
* way a phone's home screen shows them: each item { icon ('ov:live' or 'fa-…'), name, text, href }. Phones see icon
* way a phone's home screen shows them: each item { icon ('ov:live' or 'fa-…'), name, text, href }. Phones see icon
* tiles three or four to a row with the name under the icon (the one-line text is for screen readers and wider
* tiles three or four to a row with the name under the icon (the one-line text is for screen readers and wider
* screens); from 600 px each tile is a row: icon, name, the text in one line. groups put a small heading over each
* screens); from 600 px each tile is a row: icon, name, the text in one line. groups put a small heading over each
- * set (the network's neighbourhoods); items without groups is one grid. Empty → ''.
+ * set (the network's neighbourhoods); items without groups is one grid. A set of two to four, or of eight, twelve…,
+ * gets a column count that fills its rows (four tiles: four across on a phone, two by two from 600 px, four from
+ * 960 px), so no tile sits alone on a row. Empty → ''.
*/
*/
function apps({ id: sid, title = '', lede = '', groups = null, items = null, more = null } = {}) {
function apps({ id: sid, title = '', lede = '', groups = null, items = null, more = null } = {}) {
const sets = (groups || [{ name: '', items: items || [] }]).map((g) => ({ name: g.name || '', items: (g.items || []).filter((x) => x && x.name && safeHref(x.href)) })).filter((g) => g.items.length);
const sets = (groups || [{ name: '', items: items || [] }]).map((g) => ({ name: g.name || '', items: (g.items || []).filter((x) => x && x.name && safeHref(x.href)) })).filter((g) => g.items.length);
if (!sets.length) return '';
if (!sets.length) return '';
const h = `sc-${slug(title || 'apps')}`;
const h = `sc-${slug(title || 'apps')}`;
const tile = (x) => `<li><a href="${esc(safeHref(x.href))}"><span class="sc-ic sc-ic-ov">${icon(x.icon || 'ov:spark', 40)}</span><span class="sc-app-t"><b>${esc(x.name)}</b>${x.text ? `<small>${esc(x.text)}</small>` : ''}</span></a></li>`;
const tile = (x) => `<li><a href="${esc(safeHref(x.href))}"><span class="sc-ic sc-ic-ov">${icon(x.icon || 'ov:spark', 40)}</span><span class="sc-app-t"><b>${esc(x.name)}</b>${x.text ? `<small>${esc(x.text)}</small>` : ''}</span></a></li>`;
- const body = sets.map((g) => `${g.name ? `<h3 class="sc-apps-group">${esc(g.name)}</h3>` : ''}<ul class="sc-apps">${g.items.map(tile).join('')}</ul>`).join('');
+ const cols = (n) => (n >= 2 && n <= 4 ? n : n % 4 === 0 ? 4 : 0);
+ const body = sets.map((g) => `${g.name ? `<h3 class="sc-apps-group">${esc(g.name)}</h3>` : ''}<ul class="sc-apps${cols(g.items.length) ? ` sc-apps--c${cols(g.items.length)}` : ''}">${g.items.map(tile).join('')}</ul>`).join('');
const end = more && safeHref(more.href) ? `<p class="sc-apps-more"><a href="${esc(safeHref(more.href))}">${esc(more.label || 'See all')} →</a></p>` : '';
const end = more && safeHref(more.href) ? `<p class="sc-apps-more"><a href="${esc(safeHref(more.href))}">${esc(more.label || 'See all')} →</a></p>` : '';
return `<section class="sc-sec sc-apps-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}${body}${end}</section>`;
return `<section class="sc-sec sc-apps-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}${body}${end}</section>`;
}
}
@@ -550,14 +553,19 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
@@ -550,14 +553,19 @@ a.sc-stat:hover{border-color:var(--accent,#3b82f6)}
.sc-apps b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:700}
.sc-apps b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:700}
.sc-apps small{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.sc-apps small{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.sc-apps-more{margin:14px 0 0;font-weight:700}
.sc-apps-more{margin:14px 0 0;font-weight:700}
-.sc-apps-more a{color:var(--accent-light,#60a5fa);text-decoration:none}
+.sc-apps-more a{display:inline-flex;align-items:center;min-height:44px;color:var(--accent-light,#60a5fa);text-decoration:none}
+.sc-apps-more a:hover{text-decoration:underline}
+@media (max-width:599px){.sc-apps--c2{grid-template-columns:repeat(2,minmax(0,1fr))}.sc-apps--c3{grid-template-columns:repeat(3,minmax(0,1fr))}.sc-apps--c4{grid-template-columns:repeat(4,minmax(0,1fr))}}
@media (min-width:600px){
@media (min-width:600px){
.sc-apps{gap:8px;grid-template-columns:repeat(auto-fill,minmax(230px,1fr))}
.sc-apps{gap:8px;grid-template-columns:repeat(auto-fill,minmax(230px,1fr))}
.sc-apps a{flex-direction:row;align-items:center;gap:12px;padding:10px 12px;text-align:left;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-apps a{flex-direction:row;align-items:center;gap:12px;padding:10px 12px;text-align:left;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828)}
.sc-apps a:hover,.sc-apps a:focus-visible{border-color:var(--accent,#3b82f6);background:var(--bg-secondary,#101828)}
.sc-apps a:hover,.sc-apps a:focus-visible{border-color:var(--accent,#3b82f6);background:var(--bg-secondary,#101828)}
.sc-apps b{font-size:15px}
.sc-apps b{font-size:15px}
.sc-apps small{position:static;width:auto;height:auto;clip:auto;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary,#96a7c2);font-size:13px}
.sc-apps small{position:static;width:auto;height:auto;clip:auto;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary,#96a7c2);font-size:13px}
+.sc-apps--c2,.sc-apps--c4{grid-template-columns:repeat(2,minmax(0,1fr))}
+.sc-apps--c3{grid-template-columns:repeat(3,minmax(0,1fr))}
}
}
+@media (min-width:960px){.sc-apps--c4{grid-template-columns:repeat(4,minmax(0,1fr))}}
.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sc-net{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
.sc-net a{display:flex;gap:12px;align-items:center;padding:14px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#101828);color:inherit;text-decoration:none;transition:border-color .15s,transform .15s}
.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
.sc-net a:hover,.sc-net a:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-2px);outline:0}
@@ -62,6 +62,17 @@ app.use((err, req, res, next) => res.status(500).send(err.message));
@@ -62,6 +62,17 @@ app.use((err, req, res, next) => res.status(500).send(err.message));
assert.equal((await fetch(base + '/guide.md', { method: 'POST' })).status, 404);
assert.equal((await fetch(base + '/guide.md', { method: 'POST' })).status, 404);
assert.match((await fetch(base + '/guide', { headers: { Accept: 'text/html, text/markdown;q=0' } })).headers.get('content-type'), /text\/html/);
assert.match((await fetch(base + '/guide', { headers: { Accept: 'text/html, text/markdown;q=0' } })).headers.get('content-type'), /text\/html/);
assert.match(seo.llmsTxt({ name: 'Example', markdown: true, sections: [{ title: 'Pages', links: [{ title: 'Home', url: descriptor.home }] }], optional: [{ title: 'External', url: 'https://other.example/', html: true }] }), /index.md\)[\s\S]*## Optional[\s\S]*\(HTML\)/);
assert.match(seo.llmsTxt({ name: 'Example', markdown: true, sections: [{ title: 'Pages', links: [{ title: 'Home', url: descriptor.home }] }], optional: [{ title: 'External', url: 'https://other.example/', html: true }] }), /index.md\)[\s\S]*## Optional[\s\S]*\(HTML\)/);
+ // 4.39.0: kit markup reads as a person sees it: a card's who · count · when keep their spaces, its ⋯ menu and
+ // buttons are not content, and a stats strip is "- label: value" lines.
+ {
+ const cards = '<main>' + kit.rail({ title: 'Latest', items: [kit.card({ href: '/p/1', title: 'Release notes', who: 'Maticus', count: [12, 'views'], sub: 'Markdown' })] })
+ + kit.stats([[26, 'services you can call', '/docs/api'], [106, 'capabilities']]) + '</main>';
+ const t = agent.markdownTwin({ html: cards, title: 'Home', canonical: 'https://example.com/' });
+ assert.match(t, /Maticus 12 views/, t);
+ assert.ok(!/Maticus12/.test(t) && !/Share|Scroll back|Scroll on/.test(t), 'no run-together text, no menu or arrow buttons');
+ assert.match(t, /- \[services you can call\]\(\/docs\/api\): 26\n- capabilities: 106/);
+ assert.match(agent.markdownTwin({ html: '<main><p>H<sub>2</sub>O and x<sup>2</sup></p></main>', title: 't', canonical: '/' }), /H2O and x2/, 'sub and sup stay inside the word');
+ }
console.log('agent-layer: manifest, real Frame/Kit twins, discovery, methods, errors and llms checks passed');
console.log('agent-layer: manifest, real Frame/Kit twins, discovery, methods, errors and llms checks passed');
} finally { await new Promise(resolve => server.close(resolve)); }
} finally { await new Promise(resolve => server.close(resolve)); }
})().catch(err => { console.error(err); process.exitCode = 1; });
})().catch(err => { console.error(err); process.exitCode = 1; });
@@ -56,6 +56,20 @@ const sc = require('../showcase');
@@ -56,6 +56,20 @@ const sc = require('../showcase');
assert.ok(kit.card({ title: 'x', live: true }).includes('ovk-card-badge is-live">LIVE'), 'live says LIVE');
assert.ok(kit.card({ title: 'x', live: true }).includes('ovk-card-badge is-live">LIVE'), 'live says LIVE');
assert.ok(kit.card({ title: 'x', video: '/v.mp4' }).includes('data-src="/v.mp4"') && !kit.card({ title: 'x', video: '/v.mp4' }).includes(' src="/v.mp4"'), 'the preview loads only when hovered');
assert.ok(kit.card({ title: 'x', video: '/v.mp4' }).includes('data-src="/v.mp4"') && !kit.card({ title: 'x', video: '/v.mp4' }).includes(' src="/v.mp4"'), 'the preview loads only when hovered');
assert.ok(kit.card({ title: 'x', level: 2 }).includes('<h2 class="ovk-card-title">'));
assert.ok(kit.card({ title: 'x', level: 2 }).includes('<h2 class="ovk-card-title">'));
+ // 4.39.0: a plain sub-line, a text preview in place of the icon, a decorated name.
+ const s = kit.card({ title: 'Loft', sub: 'Lisbon · <2 rooms>', who: 'ann' });
+ assert.ok(s.includes('<p class="ovk-card-sub">Lisbon · <2 rooms></p>') && s.indexOf('ovk-card-sub') < s.indexOf('ovk-card-meta'), 'sub: one escaped line between the title and the meta');
+ assert.ok(!kit.card({ title: 'x' }).includes('ovk-card-sub'), 'no sub, no line');
+ const p = kit.card({ title: 'paste', type: 'post', preview: Array.from({ length: 20 }, (_, i) => `line ${i} <x>`).join('\n') });
+ assert.ok(p.includes('<pre class="ovk-card-pre" aria-hidden="true">line 0 <x>') && !p.includes('line 9 ') && !p.includes('ovk-card-noimg'), 'preview: the first nine lines, escaped, instead of the icon');
+ assert.ok(kit.card({ title: 'x', image: '/a.jpg', preview: 'code' }).includes('<img src="/a.jpg"') && !kit.card({ title: 'x', image: '/a.jpg', preview: 'code' }).includes('ovk-card-pre'), 'a picture wins over a preview');
+ const w = kit.card({ title: 'x', who: 'ann', whoHtml: '<span class="deco">ann</span>' });
+ assert.ok(w.includes('<span class="ovk-card-who"><span class="deco">ann</span></span>') && w.includes('ovk-mark'), 'whoHtml shows the decorated name; who still gives the letters');
+}
+// ── kit.row: rel ──
+{
+ assert.ok(kit.row({ href: 'https://x.example/', title: 't', rel: 'nofollow ugc' }).includes('href="https://x.example/" rel="nofollow ugc"'));
+ assert.ok(!kit.row({ href: '/a', title: 't' }).includes(' rel='), 'no rel unless asked');
}
}
// ── kit.rail ──
// ── kit.rail ──
@@ -66,6 +80,14 @@ const sc = require('../showcase');
@@ -66,6 +80,14 @@ const sc = require('../showcase');
assert.strictEqual((r.match(/<li>/g) || []).length, 2);
assert.strictEqual((r.match(/<li>/g) || []).length, 2);
assert.ok(r.includes('ovk-rail-prev') && r.includes('ovk-rail-next') && r.includes(' hidden>'), 'arrows start hidden (kit-web shows them)');
assert.ok(r.includes('ovk-rail-prev') && r.includes('ovk-rail-next') && r.includes(' hidden>'), 'arrows start hidden (kit-web shows them)');
assert.ok(kit.rail({ items: ['x'], size: 'huge' }).includes('ovk-rail--m'), 'an unknown size is medium');
assert.ok(kit.rail({ items: ['x'], size: 'huge' }).includes('ovk-rail--m'), 'an unknown size is medium');
+ assert.ok(kit.rail({ items: ['x'], wrap: true }).includes('class="ovk-rail ovk-rail--m ovk-rail--wrap"'), 'wrap: a grid from 760 px');
+ assert.ok(!kit.rail({ items: ['x'] }).includes('ovk-rail--wrap'), 'rails swipe unless told to wrap');
+ const room = fs.readFileSync(path.join(__dirname, '..', 'room.css'), 'utf8');
+ assert.match(room, /@media \(min-width: 760px\) \{\s*\.ovk-rail--wrap \.ovk-rail-track \{ grid-auto-flow: row;/, 'room.css wraps the rail from 760 px');
+ assert.match(room, /@media \(pointer: coarse\), \(max-width: 759px\) \{ \.ovk-rail-more \{ min-height: 44px; \} \}/, 'See all is 44 px to the touch');
+ const kitCss = fs.readFileSync(path.join(__dirname, '..', 'kit.css'), 'utf8');
+ assert.match(kitCss, /\.ovk-tabs a, \.ovk-list-head a \{ min-height: 44px; \}/, 'tab pills and list links are 44 px to the touch');
+ assert.match(kitCss, /\.ovk-stats dt a \{ color: inherit; text-decoration: none; \}/, 'a stats label that is a link is not underlined');
}
}
// ── sc.room: the front door's order, the accent, nothing invented ──
// ── sc.room: the front door's order, the accent, nothing invented ──
@@ -166,6 +166,12 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
@@ -166,6 +166,12 @@ assert.ok(/@container \(min-width:520px\) and \(max-width:1035px\)\{\.sc-grid:ha
assert.ok(h.includes('<p class="sc-apps-more"><a href="/sites">All 48 sites →</a></p>'), 'See all');
assert.ok(h.includes('<p class="sc-apps-more"><a href="/sites">All 48 sites →</a></p>'), 'See all');
assert.strictEqual(sc.apps({ items: [] }), '', 'nothing to show, no section');
assert.strictEqual(sc.apps({ items: [] }), '', 'nothing to show, no section');
assert.ok(sc.apps({ items: [{ name: 'A', href: '/a' }] }).includes('<ul class="sc-apps">'), 'items without groups is one grid');
assert.ok(sc.apps({ items: [{ name: 'A', href: '/a' }] }).includes('<ul class="sc-apps">'), 'items without groups is one grid');
+ // 4.39.0: no tile alone on a row. Four go four across on a phone and two by two from 600 px; eight like four.
+ const n = (k) => sc.apps({ items: Array.from({ length: k }, (_, i) => ({ name: `A${i}`, href: `/a${i}` })) });
+ assert.ok(n(4).includes('<ul class="sc-apps sc-apps--c4">') && n(3).includes('sc-apps--c3') && n(2).includes('sc-apps--c2') && n(8).includes('sc-apps--c4'));
+ assert.ok(n(7).includes('<ul class="sc-apps">') && n(15).includes('<ul class="sc-apps">'), 'other counts keep the auto grid');
+ assert.match(sc.CSS, /@media \(max-width:599px\)\{\.sc-apps--c2\{[^}]*\}\.sc-apps--c3\{[^}]*\}\.sc-apps--c4\{grid-template-columns:repeat\(4,minmax\(0,1fr\)\)\}\}/);
+ assert.match(sc.CSS, /\.sc-apps-more a\{display:inline-flex;align-items:center;min-height:44px;/, 'the launcher\'s See all is 44 px');
const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'showcase.css'), 'utf8');
const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'showcase.css'), 'utf8');
assert.ok(css.includes('.sc-apps{') && css.includes('grid-template-columns:repeat(auto-fill,minmax(88px,1fr))'), 'phones: icon tiles');
assert.ok(css.includes('.sc-apps{') && css.includes('grid-template-columns:repeat(auto-fill,minmax(88px,1fr))'), 'phones: icon tiles');
}
}