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)

OP OpenVibers committed 14 days ago · 09babfa95d · 17 files, +189 −38

The 10-29 front-page passes (Media, Quest, Actor, Rent, Services, Community) each added
local rules for the same gaps:

- kit.rail({ wrap: true }): phones swipe; from 760 px a grid shows every item, with no
  arrows and no kit-web.js.
- kit.card({ sub, preview, whoHtml }): one muted line under the title, a text thing's
  first lines in the media instead of an icon, and a decorated name. kit.row({ rel }).
- showcase.apps: two to four tiles (or 8, 12…) get a column count that fills their rows,
  so no tile sits alone.
- 44 px to the touch: the rail's See all, tab pills, list heading links and the
  launcher's See all, on touch screens and below 760 px.
- A stats label that is a link is not underlined. The whole tile is the target.
- The Markdown twin keeps spaces between side-by-side pieces ("Maticus12 viewsTue"), leaves
  out buttons, icons, players and the card's menu, and turns a stats strip into
  "- label: value" lines.
- scripts/kit-gallery.js --rooms draws these pieces at 320–1280 px, dark and light.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex a17a4b6..d12b462 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex f1d20e5..1b90f70 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 3e569b3..e143a67 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/agent-layer.js b/agent-layer.js
iindex ec901be..25d6da6 100644
---- a/agent-layer.js
++++ b/agent-layer.js
@@@ -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 `![${md(node.getAttribute('alt') || '')}](${destination(node.getAttribute('src') || '')})`;
@@@ -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();
ddiff --git a/docs/agent-layer.md b/docs/agent-layer.md
iindex 2fb31cd..db31601 100644
---- a/docs/agent-layer.md
++++ b/docs/agent-layer.md
@@@ -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
ddiff --git a/docs/kit.md b/docs/kit.md
iindex cdc1599..83b3d50 100644
---- a/docs/kit.md
++++ b/docs/kit.md
@@@ -130,7 +130,7 @@ reset.
```js
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'),
-- 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 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
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
4.23.0):
ddiff --git a/kit.css b/kit.css
iindex 05f972e..0f707c0 100644
---- a/kit.css
++++ b/kit.css
@@@ -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; }
ddiff --git a/kit.js b/kit.js
iindex 501ce43..a1f85fe 100644
---- a/kit.js
++++ b/kit.js
@@@ -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>`;
ddiff --git a/package-lock.json b/package-lock.json
iindex e7a01ca..f95f99d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex da3d1ef..6861076 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/room.css b/room.css
iindex a2187b7..6473cbb 100644
---- a/room.css
++++ b/room.css
@@@ -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; }
ddiff --git a/scripts/kit-gallery.js b/scripts/kit-gallery.js
iindex ee78f69..3f453e7 100644
---- a/scripts/kit-gallery.js
++++ b/scripts/kit-gallery.js
@@@ -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 };
ddiff --git a/showcase.css b/showcase.css
iindex b24cde4..0d4d0a7 100644
---- a/showcase.css
++++ b/showcase.css
@@@ -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}
ddiff --git a/showcase.js b/showcase.js
iindex 7adb585..d02a9a2 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -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}
ddiff --git a/test/agent-layer.test.js b/test/agent-layer.test.js
iindex 2ced040..1368d2d 100644
---- a/test/agent-layer.test.js
++++ b/test/agent-layer.test.js
@@@ -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; });
ddiff --git a/test/room-kit.test.js b/test/room-kit.test.js
iindex 1027863..2de36e4 100644
---- a/test/room-kit.test.js
++++ b/test/room-kit.test.js
@@@ -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 · &lt;2 rooms&gt;</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 &lt;x&gt;') && !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 ──
ddiff --git a/test/showcase.test.js b/test/showcase.test.js
iindex 74f2927..bfdf05b 100644
---- a/test/showcase.test.js
++++ b/test/showcase.test.js
@@@ -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.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');
}