openvibers/openvibe.help Commits

Help: the market outcomes on the answer and ticket pages (#147)

OP OpenVibers committed 13 days ago · a6e644b53f · 11 files, +376 −151

* Help: the market outcomes on the answer and ticket pages

Answer pages name the site and the answerer, and state what is known about the last change: the manifest carries no date, so the page says so and shows when Help read it. The ticket flow states what a reader gets back and the median first staff reply measured from help_messages ("not measured yet" when no ticket has one). The site list, search articles and home say there is no promoted or sponsored content. A site that is not serving says so in one line and links to the network status page; HELP_STATUS_URL overrides it.

Tests cover each line and every answer page is measured in Chrome at 390 px (longest at 320, ticket and not-serving pages at both widths). No table, migration or budget change.

* Help: measure the 160 answer pages in four tabs

The phone clipping test is the same check, but four pages share the 160 routes, taking it from ~34s to ~13s so the per-file timeout has room on a loaded CI runner.

* Help: the reviewed answer line, ticket button and phone home

The answer page's facts become one line (site, domain, state and the manifest check date with its tooltip), and the generated-from line moves into a closed Where this answer comes from fold. The ticket submit is the kit's primary button: full width on a phone, at least 44 px, with Your tickets as a plain link below. The promise adds the sample under five answered tickets and shows the median alone from five up.

The phone home is one site list again: no chip row plus picked card, names not hosts (AI, Blog), and the fold summary's disclosure marker is gone. The pick helpers and their CSS are removed; the suite measures every answer page, the primary button and the 320 px cases.

Changes

ddiff --git a/.env.example b/.env.example
iindex 6b34d0c..fd13fa1 100644
---- a/.env.example
++++ b/.env.example
@@@ -19,6 +19,8 @@ VALKEY_PREFIX=ov:help:
# OpenVibe.Network: SSO (OAuth client `help`, PKCE S256) and its JWKS; app tokens for audience openvibe.help.
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++# The network's public status page a site that is not serving links to (defaults to OV_NETWORK_URL/status).
++#HELP_STATUS_URL=https://openvibe.network/status
OV_OAUTH_CLIENT_ID=help
OV_OAUTH_CLIENT_SECRET=
# OV_OAUTH_REDIRECT_URI=https://openvibe.help/auth/callback
ddiff --git a/public/css/app.css b/public/css/app.css
iindex 622e468..780d64e 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -62,6 +62,9 @@ mark { background: var(--warning, #f59e0b); color: #10151f; border-radius: 3px;
.site-links { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
.card { background: var(--bg-card, #131c2e); border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 14px 16px; margin: 14px 0; }
.ticket-form .field, .filters .field { margin: 12px 0; }
++.ticket-submit { margin: 16px 0 4px; }
++.ticket-submit .ovk-btn { width: 100%; }
++@media (min-width: 600px) { .ticket-submit .ovk-btn { width: auto; min-width: 14rem; } }
.field label, .reply-form label { display: block; font-weight: 600; margin-bottom: 4px; }
.field input[type="text"], .field select, .field textarea, .reply-form textarea { width: 100%; padding: 9px 11px; font: inherit; color: inherit; background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); border-radius: 8px; resize: vertical; }
.field select { max-width: 32rem; }
@@@ -125,31 +128,13 @@ mark { background: var(--warning, #f59e0b); color: #10151f; border-radius: 3px;
}
--/* Pick (the owner's short-pages rule, 2026-10-30): on a phone the front page shows one block at a time, picked with a
-- row of chips. Radio inputs and :has(), no script: the chips and the one-block rule live under 640 px and only
-- where :has() is understood, so a wider screen, or an older browser, shows every block and no chips. */
--.pick-head { display: none; }
--.pick-row { display: none; }
--@supports selector(:has(*)) { @media (max-width: 640px) {
-- .pick-bar { margin-top: 0; }
-- .pick-head { display: block; }
-- .pick-head h2 { margin: 0 0 4px; font: 700 clamp(20px,2.4vw,28px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); letter-spacing: -.02em; }
-- .pick-row { display: flex; gap: 8px; overflow-x: auto; margin: 10px -16px 0; padding: 2px 16px 4px; scrollbar-width: none; overscroll-behavior-x: contain; }
-- .pick-row::-webkit-scrollbar { display: none; }
-- .pick-row input { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }
-- .pick-row label { display: inline-flex; align-items: center; flex: none; min-height: 44px; padding: 0 15px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .9rem; font-weight: 650; white-space: nowrap; cursor: pointer; }
-- .pick-row input:checked + label { color: var(--text-primary, #e6edf7); border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
-- .pick-row input:focus-visible + label { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
-- .pick .pick-pane { display: none; }
-- .pick .pick-list > li { display: none; }
-- /* The chip names the block, so the block's own heading gives way to it; the content stays in the page for the
-- crawlers and for the reader on a wide screen. */
-- .pick .sc-sec > h2, .pick section > h2, .pick > h2 { display: none; }
--} }
--
/* The fold (the owner's short-pages rule): closed on a phone, and where there is room it looks like the section it
-- replaces: a heading and its content, with no card around it. */
++ replaces: a heading and its content, with no card around it. The marker is hidden on every screen: the heading is
++ the control, and the phone hint says a tap opens it (a lone disclosure triangle above the heading read as debris). */
.hp-fold-block { margin-top: 32px; }
++.hp-fold-block > summary { display: block; list-style: none; padding: 0; }
++.hp-fold-block > summary::-webkit-details-marker { display: none; }
++.hp-fold-block > summary::marker { content: ''; }
.hp-fold-block > summary h2 { margin: 0; font: 700 clamp(19px,2.2vw,26px)/1.2 var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); }
.hp-fold-hint { display: none; }
/* The fold's summary carries the heading, so the section's own heading gives way to it. */
@@@ -166,14 +151,3 @@ mark { background: var(--warning, #f59e0b); color: #10151f; border-radius: 3px;
.hp-fold-block > summary { cursor: pointer; }
.hp-fold-hint { display: inline; margin-left: 8px; font-size: .78rem; font-weight: 600; color: var(--text-secondary, #96a7c2); }
}
--
--/* A link that points into a pick block opens it: the pane carries the id the link names. */
--@supports selector(:has(*)) { @media (max-width: 640px) {
-- .pick .pick-pane:target { display: block; }
--} }
--
--/* On a wide screen the pick block is invisible: its blocks sit where they were, in the order they were. */
--@media (min-width: 641px) {
-- .pick, .pick-body, .pick-pane { display: contents; }
-- .pick-bar { display: none; }
--}
ddiff --git a/server/catalog.js b/server/catalog.js
iindex ce128ee..dd11450 100644
---- a/server/catalog.js
++++ b/server/catalog.js
@@@ -43,6 +43,18 @@ function firstSentence(text, max = 180) {
return one.length > max ? `${one.slice(0, max - 1).replace(/[\s,;:]$/, '')}…` : one;
}
++/**
++ * The name a site card or chip shows. Most manifests already name the site ("Blog"); a service site whose name is its
++ * host ("ai.openvibe.services") gets its first label as a name ("AI"), never the host. A short first label is
++ * uppercased, so "ai" is "AI"; a longer one is title-cased, so "inventory" is "Inventory".
++ */
++function shortSiteName(name) {
++ const s = String(name || '').replace(/^OpenVibe\./, '');
++ if (!s.includes('.')) return s;
++ const first = s.split('.')[0];
++ return first.length <= 3 ? first.toUpperCase() : first.charAt(0).toUpperCase() + first.slice(1);
++}
++
/** The host a service manifest's site is served on (products.siteDomain keeps one rule for both homes). */
const siteDomain = contracts.products.siteDomain;
@@@ -181,8 +193,8 @@ function buildSites() {
return {
...e,
id: e.domain,
-- // "OpenVibe.Actor" in a list of OpenVibe sites reads as "Actor"; OpenRestream keeps its own name.
-- shortName: String(e.name || e.domain).replace(/^OpenVibe\./, ''),
++ // "OpenVibe.Actor" in a list of OpenVibe sites reads as "Actor"; ai.openvibe.services reads as "AI".
++ shortName: shortSiteName(e.name || e.domain),
state,
live: state === 'live',
// A product manifest has no `what` of its own: its tagline is the one line, or the description's first sentence.
ddiff --git a/server/config.js b/server/config.js
iindex c50577c..1617396 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -48,9 +48,12 @@ function load(env = process.env) {
cacheMs: Math.max(0, int(env.HELP_SEARCH_CACHE_MS, 5 * 60_000)),
},
-- // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
++ // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS. The status page is the network's
++ // own public read of every service's readiness (https://openvibe.network/status); a site page links to it
++ // instead of inventing a state of its own.
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ statusUrl: trim(env.HELP_STATUS_URL || `${networkUrl}/status`),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
// The audience this service's app, agent and service tokens carry.
audience: env.HELP_AUDIENCE || 'openvibe.help',
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 47b9d84..6905f02 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -13,15 +13,16 @@ const express = require('express');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
++const kit = require('openvibe-shared/kit');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
const { html, raw, render, table, notice, time, badge } = require('../render/html');
--// The short home (the owner's rule, 2026-10-30): a pick block for the site list, folds for the parts a reader
--// rarely needs.
--const { pickList, pickListRules, foldBlock } = require('../render/short');
++// The short home (the owner's rule, 2026-10-30): folds for the parts a reader rarely needs. The site list is one
++// list of links (the pick chips and the picked card below them read as the same list twice; removed 2026-10-31).
++const { foldBlock } = require('../render/short');
const { send } = require('../render/layout');
const { searchArticles, markTerms } = require('../search');
const tickets = require('../tickets/tickets');
@@@ -41,6 +42,31 @@ const SITE_STATE_TEXT = { live: 'live', coming: 'coming' };
const textBlock = (t) => html`<p class="txt">${t}</p>`;
const truncate = (s, n) => (String(s == null ? '' : s).length > n ? `${String(s).slice(0, n - 1).trimEnd()}…` : String(s == null ? '' : s));
++// Stated where the answers and the sites are listed: there is no ad or promoted slot in either (outcome 5). It is a
++// fact about how the catalog is built, not a slogan: every entry comes from OpenVibe.Contracts, in the site's words.
++const NO_PROMOTED = 'No promoted or sponsored answers or sites: every entry comes from OpenVibe.Contracts, in its site\'s own words, and no one can pay for a place.';
++
++const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
++/** A date as a reader says it ("30 Oct 2026"), UTC so the page reads the same in every time zone. */
++function shortDate(iso) {
++ const d = new Date(iso);
++ return Number.isFinite(d.getTime()) ? `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}` : '';
++}
++
++/** A measured reply time, in words: under a minute, minutes, hours, days. No number is made up. */
++function replyTime(ms) {
++ if (!Number.isFinite(ms)) return '';
++ const minutes = Math.round(ms / 60_000);
++ if (minutes < 1) return 'under a minute';
++ if (minutes < 60) return `${minutes} min`;
++ const hours = Math.floor(minutes / 60);
++ const rest = minutes % 60;
++ if (hours < 24) return rest ? `${hours} h ${rest} min` : `${hours} h`;
++ const days = Math.floor(hours / 24);
++ const spare = hours % 24;
++ return spare ? `${days} d ${spare} h` : `${days} d`;
++}
++
/**
* The page's JSON-LD in one script: every node in one @graph, so a page carries exactly one JSON-LD script.
* The nodes keep their own fields; only their own @context goes, because the graph carries it once.
@@@ -74,6 +100,23 @@ function createPageRoutes(ctx) {
const signInPrompt = (what, next) => html`<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;
++ /**
++ * The ticket promise: what a reader gets back, and the first-reply time this service has measured. The number is
++ * the median over its own help_messages rows (server/tickets/store.js replyStats); until a ticket has had a staff
++ * reply it says so. Never a target typed in by hand.
++ */
++ const ticketPromise = (stats) => {
++ const sample = stats && stats.answered && stats.answered < 5 ? ` (${stats.answered} ticket${stats.answered === 1 ? '' : 's'} so far)` : '';
++ const measured = stats && stats.answered
++ ? html`<strong>Median first reply: ${replyTime(stats.medianMs)}${sample}.</strong>`
++ : html`<strong>First-reply time: not measured yet${stats && stats.unknown ? '' : ' — no ticket here has had a staff reply'}.</strong>`;
++ return notice(html`A person on the OpenVibe team replies here, not a bot; the conversation stays on this ticket. ${measured}`, 'info');
++ };
++ const measuredReplies = async () => {
++ // A read that failed is not the same as no reply: the page then says "not measured yet" and stops there.
++ try { return await store.replyStats(s); } catch { return { answered: 0, medianMs: null, unknown: true }; }
++ };
++
const searchForm = (q = '', { big = false } = {}) => html`<form class="search-form${big ? ' search-big' : ''}" method="get" action="/search" role="search">
<label class="sr-only" for="q">Search the help centre</label>
<input id="q" name="q" type="search" value="${q}" placeholder="Search every OpenVibe site: how a feature works, what a price is, what is not working" maxlength="200" required>
@@@ -82,12 +125,14 @@ function createPageRoutes(ctx) {
// ── Home ─────────────────────────────────────────────────
/** Contact support: the form itself is /tickets/new, and a visitor who is not signed in is asked to sign in. */
-- function contactBlock(req) {
++ function contactBlock(req, replies) {
if (!signedIn(req)) {
return html`<div class="card">${signInPrompt('To open a ticket', '/tickets/new')}
++${ticketPromise(replies)}
<p class="small muted">A ticket is one conversation with the people who run OpenVibe, kept in your account — not an inbox you lose. Signing in also lets you see and answer it later.</p></div>`;
}
-- return html`<div class="card"><p class="small muted">Signed in as <strong>${req.viewer.displayName || req.viewer.username || 'you'}</strong>.</p>
++ return html`<div class="card">${ticketPromise(replies)}
++<p class="small muted">Signed in as <strong>${req.viewer.displayName || req.viewer.username || 'you'}</strong>.</p>
<p><a class="sc-btn sc-primary" href="/tickets/new">Open a ticket</a> <a class="sc-btn" href="/tickets">Your tickets</a></p></div>`;
}
@@@ -99,6 +144,7 @@ function createPageRoutes(ctx) {
const features = catalog.sites.reduce((n, x) => n + ((x.pillars && x.pillars.length) || 0), 0);
let solved = 0;
try { solved = (await store.staffCounts(s)).solved; } catch { /* the figure is left out, never guessed */ }
++ const replies = await measuredReplies();
const n = (v) => Number(v).toLocaleString('en-US');
const stats = [[`${n(live.length)} sites`, 'live, every one with its own page here'], [`${n(catalog.articles.length)} answers`, 'each in the site\'s own words, linked back']];
if (features) stats.push([`${n(features)} features`, 'described by the sites that run them']);
@@@ -121,22 +167,15 @@ function createPageRoutes(ctx) {
jsonLd: faqLd ? [...homeJsonLd(config), faqLd] : homeJsonLd(config),
description: `Search the answers of every OpenVibe site, browse them one by one, or open a support ticket that stays one conversation.`,
styles: [showcase.STYLESHEET],
-- // The rule that shows the picked site of the pick list: one per site, written for this page.
-- inlineStyles: [`@supports selector(:has(*)){@media (max-width:640px){${pickListRules('sites', catalog.sites.length)}}}`],
body: html`${raw(hero)}
<section class="sc-sec" id="search" aria-labelledby="h-search"><h2 id="h-search">Search the help centre</h2>
<p class="sc-lede">One box over every site's answers, and over the whole OpenVibe network.</p>
${searchForm()}</section>
--${raw(pickList({
-- key: 'sites',
-- label: 'Browse by site',
-- head: 'Browse by site',
-- labels: catalog.sites.map((x) => x.shortName),
-- html: render(html`<section class="sc-sec" aria-labelledby="h-sites"><h2 id="h-sites">Browse by site</h2>
++<section class="sc-sec" id="sites" aria-labelledby="h-sites"><h2 id="h-sites">Browse by site</h2>
<p class="sc-lede">Live sites first; each name opens that site's page, with its own words and its own questions. <a href="/sites">The full list, with what each one is</a>.</p>
--<ul class="site-cards pick-list">${catalog.sites.map((x, i) => html`<li data-i="${i}"><a class="site-card${x.live ? '' : ' coming'}" href="/sites/${x.id}"><span class="site-card-top"><b>${x.shortName}</b><span class="site-state">${x.live ? 'live' : 'coming'}</span></span><span class="site-card-line">${x.oneLine}</span>${answersBySite.get(x.domain) ? html`<span class="site-card-count">${String(answersBySite.get(x.domain))} ${answersBySite.get(x.domain) === 1 ? 'answer' : 'answers'}</span>` : ''}</a></li>`)}</ul>
--<p class="small"><a href="/sites">All ${catalog.sites.length} sites, with what each one is</a></p></section>`),
--}))}
++<p class="small muted">${NO_PROMOTED}</p>
++<ul class="site-cards">${catalog.sites.map((x) => html`<li><a class="site-card${x.live ? '' : ' coming'}" href="/sites/${x.id}"><span class="site-card-top"><b>${x.shortName}</b><span class="site-state">${x.live ? 'live' : 'coming'}</span></span><span class="site-card-line">${x.oneLine}</span>${answersBySite.get(x.domain) ? html`<span class="site-card-count">${String(answersBySite.get(x.domain))} ${answersBySite.get(x.domain) === 1 ? 'answer' : 'answers'}</span>` : ''}</a></li>`)}</ul>
++<p class="small"><a href="/sites">All ${catalog.sites.length} sites, with what each one is</a></p></section>
${raw(foldBlock({
id: 'popular',
head: '<h2 id="h-popular">Popular questions</h2>',
@@@ -147,7 +186,7 @@ ${raw(foldBlock({
}))}
<section class="sc-sec" aria-labelledby="h-contact"><h2 id="h-contact">Contact support</h2>
<p class="sc-lede">Nothing here answered it? Open a ticket: you and the people who run OpenVibe, in one thread.</p>
--${contactBlock(req)}</section>
++${contactBlock(req, replies)}</section>
${raw(foldBlock({
id: 'network',
head: '<h2 id="h-network">Part of OpenVibe</h2>',
@@@ -175,6 +214,7 @@ ${raw(foldBlock({
],
body: html`<h1>Every OpenVibe site</h1>
<p class="sc-lede">${catalog.sites.length} addresses, ${catalog.sites.every((x) => x.live) ? 'every one live' : `${catalog.sites.filter((x) => x.live).length} of them live`}. The state is the service manifest's own <code>exposure.state</code>: a site is live when a service exposes it live. Everything else on this page is that site's manifest, word for word.</p>
++<p class="small muted">${NO_PROMOTED}</p>
${provenance(catalog.source)}
${group('live', 'Live', 'These answer today.')}
${group('coming', 'Coming', 'Registered in the catalog, not serving yet, or serving something else for now.')}`,
@@@ -189,6 +229,9 @@ ${group('coming', 'Coming', 'Registered in the catalog, not serving yet, or serv
}, 404);
const articles = catalog.bySite.get(site.id) || [];
const faqPairs = articles.map((a) => ({ q: a.question, a: a.answer }));
++ // The network's own status page is the public read of whether a service is serving. A site that is not live
++ // links there in one line instead of reporting a health state Help does not own.
++ const statusHref = site.service ? `${config.statusUrl}#svc-${site.service}` : config.statusUrl;
page(req, res, {
index: true, cache: PUBLIC_CACHE,
// "Help with Search", not "Search": the bare name of the OpenVibe.Search page read exactly like Help's own
@@@ -203,10 +246,10 @@ ${group('coming', 'Coming', 'Registered in the catalog, not serving yet, or serv
body: html`<h1>${site.name} ${siteBadge(site.state)}</h1>
${site.tagline ? html`<p class="sc-lede">${site.tagline}</p>` : ''}
${site.description ? textBlock(site.description) : ''}
--<p class="site-links">${site.state === 'live'
-- ? html`<a class="sc-btn sc-primary" href="${site.url}" rel="noopener nofollow">Open ${site.domain}</a>`
-- : html`<span class="muted">Not serving yet — ${site.url} is its address.</span>`}
--${site.state === 'live' ? html`<a class="sc-btn" href="${site.updatesUrl}" rel="noopener nofollow">What shipped on ${site.name}</a>` : ''}</p>
++${site.state === 'live'
++ ? html`<p class="site-links"><a class="sc-btn sc-primary" href="${site.url}" rel="noopener nofollow">Open ${site.domain}</a>
++<a class="sc-btn" href="${site.updatesUrl}" rel="noopener nofollow">What shipped on ${site.name}</a></p>`
++ : notice(html`${site.name} is not serving yet. <a href="${statusHref}" rel="noopener nofollow">See its status on the OpenVibe status page</a>.`, 'warn')}
${provenance(site.provenance)}
${site.pillars.length ? html`<section class="sc-sec" aria-labelledby="h-pillars"><h2 id="h-pillars">${site.state === 'live' ? 'What you can do' : 'What it will do'}</h2>
@@@ -232,8 +275,9 @@ ${textBlock(a.answer)}
body: html`<h1>No such question</h1><p class="muted">No answer here for <code>${truncate(`${req.params.site}/${req.params.slug}`, 90)}</code>. <a href="/sites">See every site</a>.</p>`,
}, 404);
const site = catalog.get(article.site);
++ const domain = site ? html`<a href="${site.url}" rel="noopener nofollow">${article.site}</a>` : article.site;
page(req, res, {
-- index: true, cache: PUBLIC_CACHE,
++ index: true, cache: PUBLIC_CACHE, styles: ['kit.css'],
title: article.question,
description: truncate(article.answer, 160),
crumbs: [{ label: 'Home', href: '/' }, { label: 'Sites', href: '/sites' }, { label: article.siteName, href: `/sites/${article.site}` }, { label: truncate(article.question, 60) }],
@@@ -245,11 +289,11 @@ ${textBlock(a.answer)}
]),
body: html`<article class="answer">
<h1>${article.question}</h1>
--<p class="small muted">Answered by <a href="/sites/${article.site}">${article.siteName}</a> ${siteBadge(article.siteState)}</p>
++<p class="small muted answer-meta"><a href="/sites/${article.site}">${article.siteName}</a> · ${domain} · ${siteBadge(article.siteState)} · <time datetime="${catalog.generatedAt}" title="When Help last read the site's manifest">checked ${shortDate(catalog.generatedAt)}</time></p>
${textBlock(article.answer)}
<p class="site-links"><a class="sc-btn" href="/sites/${article.site}">Everything ${article.siteName} answers</a>
${site ? html`<a class="sc-btn" href="${site.url}" rel="noopener nofollow">${site.domain}</a><a class="sc-btn" href="${site.updatesUrl}" rel="noopener nofollow">What shipped</a>` : ''}</p>
--${provenance(article.provenance)}
++${raw(kit.fold({ id: 'answer-source', title: 'Where this answer comes from', body: render(provenance(article.provenance)) }))}
</article>
<section class="sc-sec" aria-labelledby="h-more"><h2 id="h-more">Other questions from ${article.siteName}</h2>
<ul class="q-list">${(catalog.bySite.get(article.site) || []).filter((a) => a.slug !== article.slug).map((a) => html`<li><a href="${a.path}">${a.question}</a></li>`)}</ul></section>`,
@@@ -272,6 +316,7 @@ ${provenance(article.provenance)}
${searchForm(q, { big: true })}
${!q ? html`<p class="sc-lede">Type something above. The first half of the answer comes from each site's own manifest; the second from <a href="${search.url}" rel="noopener nofollow">OpenVibe.Search</a>, the network's own index.</p>` : ''}
${q ? html`<section class="sc-sec" aria-labelledby="h-help"><h2 id="h-help">Help articles</h2>
++<p class="small muted">${NO_PROMOTED}</p>
${hits.length
? html`<p class="sc-lede">${hits.length} article${hits.length === 1 ? '' : 's'} from the sites' own manifests, best match first.</p>
<ol class="results">${hits.map((h) => html`<li><a href="${h.article.path}">${markTerms(h.article.question, q)}</a>
@@@ -356,7 +401,8 @@ ${problem ? notice(problem, 'warn') : ''}
<p class="small muted">Plain text, up to ${tickets.LIMITS.body} characters.</p></div>
<div class="field"><label for="page_url">Page you were on</label><input id="page_url" name="page_url" type="text" maxlength="${tickets.LIMITS.pageUrl}" value="${values.pageUrl || ''}" placeholder="https://openvibe.example/some/page">
<p class="small muted">Optional, and only useful if you have it: it is stored as text and never opened by us.</p></div>
--<p><button class="sc-btn sc-primary" type="submit">Open the ticket</button> <a class="sc-btn" href="/tickets">Your tickets</a></p>
++<p class="ticket-submit">${raw(kit.button({ label: 'Open the ticket', kind: 'primary' }))}</p>
++<p class="small"><a href="/tickets">Your tickets</a></p>
</form>`;
// "Report a problem" in every site's footer (openvibe-shared footer.js) opens /tickets/new?site=<domain>&page=<url>:
@@@ -367,18 +413,20 @@ ${problem ? notice(problem, 'warn') : ''}
const pageUrl = typeof q.page === 'string' && q.page.length <= tickets.LIMITS.pageUrl ? tickets.pageLink(q.page) : null;
return { site: catalog.byId.has(site) ? site : '', pageUrl: pageUrl || '' };
};
-- r.get('/tickets/new', (req, res) => {
++ r.get('/tickets/new', async (req, res) => {
const pre = prefillOf(req.query);
++ const replies = await measuredReplies();
const back = `/tickets/new${pre.site || pre.pageUrl ? `?${new URLSearchParams({ ...(pre.site ? { site: pre.site } : {}), ...(pre.pageUrl ? { page: pre.pageUrl } : {}) })}` : ''}`;
if (!signedIn(req)) return page(req, res, {
title: 'Open a ticket', crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: 'New' }],
-- body: html`<h1>Open a ticket</h1>${signInPrompt('A ticket belongs to your OpenVibe account, so you can see and answer it later.', back)}`,
++ body: html`<h1>Open a ticket</h1>${ticketPromise(replies)}${signInPrompt('A ticket belongs to your OpenVibe account, so you can see and answer it later.', back)}`,
}, 401);
page(req, res, {
-- title: 'Open a ticket',
++ title: 'Open a ticket', styles: ['kit.css'],
description: 'Tell OpenVibe support what happened; the ticket stays one conversation.',
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: 'New' }],
body: html`<h1>Open a ticket</h1>
++${ticketPromise(replies)}
<p class="sc-lede">One conversation with the people who run OpenVibe. You will see it under <a href="/tickets">your tickets</a> and can reply there.</p>
${ticketForm({ values: pre })}`,
});
@@@ -392,7 +440,7 @@ ${ticketForm({ values: pre })}`,
const b = req.body || {};
const check = tickets.validateNew({ site: b.site, subject: b.subject, message: b.message, pageUrl: b.page_url }, { sites: new Set(catalog.sites.map((x) => x.id)) });
if (!check.ok) return page(req, res, {
-- title: 'Open a ticket', crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: 'New' }],
++ title: 'Open a ticket', styles: ['kit.css'], crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: 'New' }],
body: html`<h1>Open a ticket</h1>${ticketForm({ values: { site: b.site, subject: b.subject, message: b.message, pageUrl: b.page_url }, problem: check.detail })}`,
}, 422);
const row = await store.create(s, {
@@@ -406,9 +454,10 @@ ${ticketForm({ values: pre })}`,
const canSee = (req, row) => Boolean(row) && (row.requester === requesterOf(req) || staffViewer(req));
r.get('/tickets', async (req, res) => {
++ const replies = await measuredReplies();
if (!signedIn(req)) return page(req, res, {
title: 'Your tickets', crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets' }],
-- body: html`<h1>Your tickets</h1>${signInPrompt('Your tickets belong to your OpenVibe account.', '/tickets')}`,
++ body: html`<h1>Your tickets</h1>${ticketPromise(replies)}${signInPrompt('Your tickets belong to your OpenVibe account.', '/tickets')}`,
}, 401);
const out = await store.listForRequester(s, requesterOf(req), { limit: PAGE_LIMIT });
return page(req, res, {
@@@ -416,6 +465,7 @@ ${ticketForm({ values: pre })}`,
description: 'The support tickets you opened, and where each conversation stands.',
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets' }],
body: html`<h1>Your tickets</h1>
++${ticketPromise(replies)}
<p class="sc-lede">Everything you opened, newest first. A ticket stays one conversation — reply on its page and it carries on there.</p>
${staffViewer(req) ? notice(html`You are OpenVibe staff, so you can also <a href="/staff">read the whole support queue</a>.`, 'info') : ''}
<p><a class="sc-btn sc-primary" href="/tickets/new">Open a ticket</a></p>
@@@ -428,9 +478,10 @@ ${out.next ? html`<p><a href="/tickets?before=${out.next}">Older tickets</a></p>
});
r.get('/tickets/:id', async (req, res) => {
++ const replies = await measuredReplies();
if (!signedIn(req)) return page(req, res, {
title: 'Your tickets', crumbs: [{ label: 'Home', href: '/' }, { label: 'Tickets', href: '/tickets' }, { label: 'Ticket' }],
-- body: html`<h1>A ticket</h1>${signInPrompt('A ticket belongs to an OpenVibe account.', `/tickets/${encodeURIComponent(String(req.params.id).slice(0, 40))}`)}`,
++ body: html`<h1>A ticket</h1>${ticketPromise(replies)}${signInPrompt('A ticket belongs to an OpenVibe account.', `/tickets/${encodeURIComponent(String(req.params.id).slice(0, 40))}`)}`,
}, 401);
const row = tickets.isTicketId(req.params.id) ? await store.get(s, req.params.id) : null;
if (!canSee(req, row)) return page(req, res, {
@@@ -459,6 +510,7 @@ ${tickets.STATES.map((st) => html`<label class="radio"><input type="radio" name=
<button class="sc-btn" type="submit">Set the state</button></fieldset>
<p class="small muted">Changing the state does not send a message. Reply below if the person needs to hear something.</p>
</form>` : ''}
++${ticketPromise(replies)}
<form class="card reply-form" method="post" action="/tickets/${row.id}">
<label for="message">${staffViewer(req) ? 'Reply as OpenVibe support' : 'Reply'}</label>
<textarea id="message" name="message" rows="6" maxlength="${tickets.LIMITS.body}" required placeholder="${staffViewer(req) ? 'Answer the question, or ask for what you still need.' : 'Anything else we should know?'}"></textarea>
ddiff --git a/server/render/short.js b/server/render/short.js
iindex ba1b957..afa1d38 100644
---- a/server/render/short.js
++++ b/server/render/short.js
@@@ -2,67 +2,16 @@
/**
* The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
*
-- * pickBlock() several blocks of the same kind, one at a time on a phone, picked with a row of chips. Radio
-- * inputs and :has(), no script: every block stays in the page for crawlers, and a wider screen (or a
-- * browser without :has()) shows them all and no chips.
-- * pickList() a list of things of the same kind, one at a time on a phone, picked with a row of chips.
* foldBlock() a part a reader rarely needs, closed on a phone and open where there is room. The page's own markup
* keeps it open, so a browser without the script shows it as before; the script closes it on a narrow
* screen and remembers the reader's choice.
*
-- * The CSS for both lives in this site's own stylesheet; the rule that shows the picked block is written for the
-- * page, because there is one per block.
++ * The CSS for the fold lives in this site's own stylesheet. The old pick block (a chip row plus the picked card) was
++ * removed on 2026-10-31: the home's site list is one list of links, so the chips and the card read as the same list
++ * twice on a phone.
*/
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
--/**
-- * A pick block. The panes are the page's own blocks; each one keeps its heading on a wide screen, and on a phone the
-- * heading gives way to the chip that names it.
-- *
-- * pickBlock({ key, label, head, panes }) panes: [{ id, label, html }]
-- */
--function pickBlock({ key, label, head = '', panes }) {
-- // A pane with nothing to show is left out, so the chips and the rules that show them match the page.
-- panes = (panes || []).filter((p) => p.html);
-- if (!panes.length) return '';
-- const chips = panes.map((p, i) => '<input type="radio" name="pick-' + esc(key) + '" id="pk-' + esc(key) + '-' + esc(p.id) + '" value="' + esc(p.id) + '"' + (i === 0 ? ' checked' : '') + '><label for="pk-' + esc(key) + '-' + esc(p.id) + '">' + esc(p.label) + '</label>').join('');
-- const body = panes.map((p) => '<div class="pick-pane" id="' + esc(p.id) + '">' + p.html + '</div>').join('');
-- return '<div class="pick" id="pick-' + esc(key) + '"><div class="pick-bar"><div class="pick-head"><h2>' + esc(head || label) + '</h2></div><div class="pick-row" role="radiogroup" aria-label="' + esc(label) + '">' + chips + '</div></div><div class="pick-body">' + body + '</div></div>';
--}
--
--/** The rule that shows the picked block of a pick block: one per block, so the CSS is written for the page. */
--function pickRule(key, id) {
-- return '.pick:has(#pk-' + key + '-' + id + ':checked) #' + id + '{display:block}';
--}
--
--/** The rules for every block of a pick block, in the order they appear. */
--function pickRules(key, ids) {
-- return ids.map((id) => pickRule(key, id)).join('');
--}
--
--/**
-- * A pick list: a list of things of the same kind, one at a time on a phone, picked with a row of chips. The list
-- * keeps its own markup and every item stays in the page; the caller marks each item with the index its chip picks.
-- *
-- * pickList({ key, label, head, labels, html }) labels: the chip labels, in the order of the items
-- */
--function pickList({ key, label, head = '', labels, html }) {
-- labels = labels || [];
-- if (!labels.length) return '';
-- const chips = labels.map((l, i) => '<input type="radio" name="pick-' + esc(key) + '" id="pk-' + esc(key) + '-' + i + '" value="' + i + '"' + (i === 0 ? ' checked' : '') + '><label for="pk-' + esc(key) + '-' + i + '">' + esc(l) + '</label>').join('');
-- return '<div class="pick" id="pick-' + esc(key) + '"><div class="pick-bar"><div class="pick-head"><h2>' + esc(head || label) + '</h2></div><div class="pick-row" role="radiogroup" aria-label="' + esc(label) + '">' + chips + '</div></div>' + html + '</div>';
--}
--
--/** The rule that shows the picked item of a pick list: one per item, so the CSS is written for the page. */
--function pickListRule(key, i) {
-- return '.pick#pick-' + key + ':has(#pk-' + key + '-' + i + ':checked) .pick-list>li[data-i="' + i + '"]{display:block}';
--}
--
--/** The rules for every item of a pick list. */
--function pickListRules(key, n) {
-- return Array.from({ length: n }, (_, i) => pickListRule(key, i)).join('');
--}
--
/**
* A fold. The head is the heading the block had; the html is what it holds.
*
@@@ -77,10 +26,9 @@ function foldScript(id) {
return "<script>(function(){var d=document.getElementById(" + JSON.stringify(id) + ");if(!d)return;var k='ov_fold_'+" + JSON.stringify(id) + ";var s=null;try{s=localStorage.getItem(k)}catch(e){}if(s==='open'){d.open=true;return}if(s==='closed'){d.open=false;return}var t=0;if(window.matchMedia&&window.matchMedia('(max-width:640px)').matches){t=Date.now();d.open=false}d.addEventListener('toggle',function(){if(Date.now()-t<300)return;try{localStorage.setItem(k,d.open?'open':'closed')}catch(e){}})})();</script>";
}
--
/**
-- * A part of a page's own markup, found by the heading it carries: the site can then show it in a pick block or a
-- * fold. { start, end, html } — the section that holds the needle.
++ * A part of a page's own markup, found by the heading it carries: the site can then show it as a fold. { start, end,
++ * html } — the section that holds the needle.
*/
function part(html, needle) {
const at = html.indexOf(needle);
@@@ -106,4 +54,4 @@ function foldAround(html, needle, id, head) {
return html.slice(0, p.start) + foldBlock({ id, head, html: p.html }) + html.slice(p.end);
}
--module.exports = { pickBlock, pickRule, pickRules, pickList, pickListRule, pickListRules, foldBlock, foldScript, part, swap, foldAround };
++module.exports = { foldBlock, foldScript, part, swap, foldAround };
ddiff --git a/server/tickets/store.js b/server/tickets/store.js
iindex 4c7d752..cc71c0c 100644
---- a/server/tickets/store.js
++++ b/server/tickets/store.js
@@@ -67,6 +67,33 @@ async function staffCounts(s) {
return out;
}
++/**
++ * How long a first staff reply has actually taken: the median over the most recent answered tickets, from this
++ * service's own help_messages rows. It is a measurement, not a promise: when no ticket has had a staff reply yet
++ * there is no number, and the page says that instead of guessing. A ticket's first person message is its
++ * creation, so the clock starts there.
++ */
++async function replyStats(s, { sample = 200 } = {}) {
++ const n = Math.min(500, Math.max(1, Number.parseInt(sample, 10) || 200));
++ // The latest answered tickets, not the latest tickets: an old reply still counts even if the queue's newest
++ // tickets are unanswered, so "not measured" can only mean no ticket ever had a staff reply.
++ const rows = await s.db.many(
++ `SELECT (EXTRACT(EPOCH FROM (r.replied_at::timestamptz - a.asked_at::timestamptz)) * 1000) AS ms
++ FROM (SELECT ticket_id, MIN(created_at) AS asked_at
++ FROM help_messages WHERE role = 'person' GROUP BY ticket_id) a
++ JOIN (SELECT ticket_id, MIN(created_at) AS replied_at
++ FROM help_messages WHERE role = 'staff' GROUP BY ticket_id) r
++ ON r.ticket_id = a.ticket_id
++ WHERE r.replied_at >= a.asked_at
++ ORDER BY a.asked_at DESC LIMIT $1`, [n]);
++ const durations = rows.map((row) => Number(row.ms)).filter((ms) => Number.isFinite(ms) && ms >= 0);
++ if (!durations.length) return { answered: 0, medianMs: null };
++ durations.sort((a, b) => a - b);
++ const mid = Math.floor(durations.length / 2);
++ const medianMs = durations.length % 2 ? durations[mid] : Math.round((durations[mid - 1] + durations[mid]) / 2);
++ return { answered: durations.length, medianMs };
++}
++
const messages = (s, ticketId) => s.db.many('SELECT id, author, role, body, created_at FROM help_messages WHERE ticket_id = $1 ORDER BY id', [String(ticketId)]);
/** Append a reply and move updated_at. `role` is person or staff, decided by the caller's session. */
@@@ -88,4 +115,4 @@ async function setState(s, ticketId, state) {
return get(s, ticketId);
}
--module.exports = { create, get, listForRequester, listForStaff, staffCounts, messages, addMessage, setState };
++module.exports = { create, get, listForRequester, listForStaff, staffCounts, replyStats, messages, addMessage, setState };
ddiff --git a/test/help-articles.test.js b/test/help-articles.test.js
iindex 0f1097e..1bbfcf6 100644
---- a/test/help-articles.test.js
++++ b/test/help-articles.test.js
@@@ -71,6 +71,33 @@ const WITH_FAQ = [
}
});
++ await check('a site that is not serving says so in one line and points to the network status page', async () => {
++ // Every catalog site is live today (40/40 with Contracts 0.222.0), so the not-serving branch is exercised with
++ // one constructed entry: the same page code, the state a manifest would give it if it stopped being live.
++ const model = catalog.sites.find((x) => x.live);
++ assert.ok(model);
++ const coming = { ...model, id: 'openvibe.not-serving', name: 'OpenVibe.NotServing', state: 'coming', live: false, service: 'help' };
++ catalog.byId.set(coming.id, coming);
++ const r = await t.get(`/sites/${coming.id}`);
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes(`${esc(coming.name)} is not serving yet.`), 'no one-line state');
++ const status = `${t.config.statusUrl}#svc-${coming.service}`;
++ assert.ok(r.text.includes(`href="${esc(status)}"`), `no status link (${status})`);
++ assert.ok(r.text.includes('See its status on the OpenVibe status page'), 'the status link is not explained');
++ });
++
++ await check('the site list and the answer list say there is no promoted content', async () => {
++ const line = 'No promoted or sponsored answers or sites';
++ for (const path of ['/', '/sites']) {
++ const r = await t.get(path);
++ assert.strictEqual(r.status, 200, path);
++ assert.ok(r.text.includes(line), `${path}: no no-promoted statement`);
++ }
++ const search = await t.get('/search?q=agent');
++ assert.strictEqual(search.status, 200);
++ assert.ok(search.text.includes(line), '/search: no no-promoted statement');
++ });
++
await check('every question in every faq is a page of its own, with the question as the SEO title and the answer verbatim', async () => {
assert.ok(WITH_FAQ.length >= 3, `only ${WITH_FAQ.length} sites answer questions`);
let articles = 0;
@@@ -90,6 +117,16 @@ const WITH_FAQ = [
assert.ok(title.startsWith(esc(entry.question.slice(0, 40))), `${article.path}: the title is not the question (${title})`);
if (entry.question.length <= 60) assert.ok(title.startsWith(esc(entry.question)), `${article.path}: the title is not the question (${title})`);
assert.ok(r.text.includes(esc(entry.answer)), `${article.path}: the answer is not the manifest's`);
++ assert.match(r.text, /class="small muted answer-meta"/, `${article.path}: no one-line site facts`);
++ assert.ok(r.text.includes(`>${esc(article.site)}</a>`), `${article.path}: the site's domain is not linked`);
++ assert.match(r.text, /checked \d{1,2} [A-Z][a-z]{2} \d{4}/, `${article.path}: no checked date`);
++ assert.ok(r.text.includes('title="When Help last read the site\'s manifest"'), `${article.path}: the checked date has no tooltip`);
++ assert.ok(r.text.includes('Where this answer comes from'), `${article.path}: no source fold`);
++ assert.match(r.text, /<details class="ovk-fold" id="answer-source">/, `${article.path}: the source is not a closed fold`);
++ assert.match(r.text, /\/shared\/kit\.css\?v=/, `${article.path}: the fold has no stylesheet`);
++ assert.ok(r.text.includes('Generated from') && r.text.includes('OpenVibe.Contracts'), `${article.path}: no provenance in the fold`);
++ assert.ok(!r.text.includes('not recorded by the site\'s manifest'), `${article.path}: the old debug copy is still there`);
++ assert.ok(!r.text.includes('answer-facts'), `${article.path}: the old facts card is still there`);
assert.ok(r.text.includes(`<link rel="canonical" href="https://openvibe.help${article.path}">`), `${article.path}: canonical`);
// One JSON-LD script per page: every node lives in the page's @graph.
const scripts = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
ddiff --git a/test/home-short.test.js b/test/home-short.test.js
iindex 9e4869b..c513020 100644
---- a/test/home-short.test.js
++++ b/test/home-short.test.js
@@@ -1,41 +1,42 @@
'use strict';
/**
-- * The home on a phone (the owner's short-pages rule, 2026-10-30): the site list is one pick block, a row of chips
-- * picks one site, and the parts a reader rarely needs are folds. Radio inputs and :has(), no script for the chips;
-- * every site stays in the page for crawlers; a wider screen shows them all and no chips.
++ * The home on a phone (the owner's short-pages rule, 2026-10-30; revised 2026-10-31): the site list is one list of
++ * links, not a chip row plus a second copy of the first site. On a phone it is a two-column grid of site names, and
++ * the parts a reader rarely needs are folds whose heading is the control (no lone disclosure triangle).
*/
const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
const { boot, check, done } = require('./helpers/boot');
(async () => {
const t = await boot();
try {
-- await check('the home is one pick block: a chip per site, the first picked, every site in the page', async () => {
++ await check('the home site list is one list: a link per site, with a name not a host', async () => {
const r = await t.get('/');
assert.strictEqual(r.status, 200, r.text);
const page = r.text;
-- const start = page.indexOf('<div class="pick" id="pick-sites">');
-- assert.ok(start > 0, 'the pick block is on the home');
-- const block = page.slice(start, page.indexOf('<details class="hp-fold hp-fold-block" id="popular"'));
-- assert.match(block, /<div class="pick-row" role="radiogroup" aria-label="Browse by site">/);
-- const chips = [...block.matchAll(/<input type="radio" name="pick-sites" id="pk-sites-(\d+)" value="\1"( checked)?><label for="pk-sites-\1">([^<]+)<\/label>/g)];
-- assert.ok(chips.length >= 3, `a chip per site (${chips.length})`);
-- assert.deepStrictEqual(chips.filter((m) => m[2]).map((m) => m[1]), [chips[0][1]], 'the first site is picked');
-- // Every site card stays in the page, with the index its chip picks.
-- const cards = [...block.matchAll(/<li data-i="(\d+)"><a class="site-card/g)];
-- assert.strictEqual(cards.length, chips.length, 'every site is in the page');
-- assert.deepStrictEqual(cards.map((m) => m[1]), chips.map((m) => m[1]), 'each site knows its chip');
++ assert.ok(!page.includes('pick-sites'), 'the old pick block is still on the home');
++ assert.strictEqual((page.match(/id="h-sites"/g) || []).length, 1, 'one Browse by site heading');
++ const start = page.indexOf('<ul class="site-cards">');
++ assert.ok(start > 0, 'the site list is on the home');
++ const block = page.slice(start, page.indexOf('</ul>', start));
++ const cards = [...block.matchAll(/<a class="site-card[^"]*" href="\/sites\/([^"]+)"><span class="site-card-top"><b>([^<]+)<\/b>/g)];
++ assert.strictEqual(cards.length, t.ctx.catalog.sites.length, 'every site is in the one list');
++ const nameOf = (id) => (cards.find((c) => c[1] === id) || [])[2];
++ assert.strictEqual(nameOf('ai.openvibe.services'), 'AI', 'the AI site shows its name, not its host');
++ assert.strictEqual(nameOf('openvibe.blog'), 'Blog');
++ assert.ok(cards.every(([, , name]) => !name.includes('.')), 'a host is shown as a name');
++ assert.ok(!page.includes('name="pick-sites"'), 'a chip row still sits above the list');
});
-- await check('the CSS guard: the chips are hidden and the sites all show until 640 px and where :has() is understood', async () => {
-- const r = await t.get('/');
-- const page = r.text;
-- assert.match(page, /<style>@supports selector\(:has\(\*\)\)\{@media \(max-width:640px\)\{\.pick#pick-sites:has\(#pk-sites-0:checked\) \.pick-list>li\[data-i="0"\]\{display:block\}/, 'the rule that shows the picked site');
-- const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'app.css'), 'utf8');
-- assert.match(css, /\.pick-row \{ display: none; \}/, 'the chips are hidden by default');
-- assert.match(css, /@supports selector\(:has\(\*\)\) \{ @media \(max-width: 640px\) \{/, 'the phone rules are under :has() and 640 px');
-- assert.match(css, /\.pick-row label \{[^}]*min-height: 44px/, 'a chip is a 44 px target');
-- assert.match(css, /\.pick \.pick-list > li \{ display: none; \}/, 'the sites are hidden on a phone');
++ await check('on a phone the one list is two columns of names, and the fold heading hides its marker', async () => {
++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'app.css'), 'utf8');
++ assert.match(css, /\.site-cards \{ grid-template-columns: repeat\(2, minmax\(0, 1fr\)\); gap: 8px; \}/, 'the phone site list is two columns');
++ assert.match(css, /\.site-card-line, \.site-card-count, \.site-card:not\(\.coming\) \.site-state \{ display: none; \}/, 'a phone shows the name only');
++ assert.match(css, /\.hp-fold-block > summary \{ display: block; list-style: none; padding: 0; \}/, 'the summary marker is gone');
++ assert.match(css, /\.hp-fold-block > summary::marker \{ content: ''; \}/, 'the standard marker is empty');
++ assert.match(css, /\.hp-fold-block > summary::-webkit-details-marker \{ display: none; \}/, 'the WebKit marker is hidden');
});
await check('the parts a reader rarely needs are folds, open in the page and closed on a phone', async () => {
ddiff --git a/test/phone-clipping.test.js b/test/phone-clipping.test.js
nnew file mode 100644
iindex 0000000..58c69dc
---- /dev/null
++++ b/test/phone-clipping.test.js
@@@ -0,0 +1,124 @@
++'use strict';
++/**
++ * Outcome 7: every answer page and the ticket pages fit a phone screen. A real Chrome measures the document's
++ * scrollWidth for every generated answer at 390 px (the required check), the longest at 320 px (the narrower
++ * case), the ticket start, list and one signed-in conversation at both widths, and the not-serving site page.
++ * The pages load with JavaScript off, so this is the server-rendered layout and its stylesheet before the shared
++ * Frame's scripts run — the part this service owns and the part a crawler sees. External Frame hosts are blocked
++ * too, so a slow font or navbar cannot hold the check. Chrome is optional: the suite reports the file skipped when
++ * there is none (openvibe-shared/test-runner counts a skip, not a pass).
++ */
++const assert = require('assert');
++const harness = require('openvibe-shared/browser-harness');
++const { boot, check, done } = require('./helpers/boot');
++const store = require('../server/tickets/store');
++
++const BLOCK = ['https://openvibe.network/*', 'https://static.cloudflareinsights.com/*', 'https://fonts.googleapis.com/*',
++ 'https://fonts.gstatic.com/*', 'https://cdnjs.cloudflare.com/*'];
++
++const READ_WIDTH = `(() => {
++ const de = document.documentElement;
++ const vw = de.clientWidth;
++ const scrollWidth = Math.max(de.scrollWidth, document.body ? document.body.scrollWidth : 0);
++ const x0 = window.scrollX; window.scrollTo(100000, window.scrollY); const scrollX = window.scrollX; window.scrollTo(x0, window.scrollY);
++ const offenders = [];
++ if (scrollX > 0 || scrollWidth > vw + 1) {
++ for (const e of document.body ? document.body.getElementsByTagName('*') : []) {
++ if (offenders.length >= 5) break;
++ const r = e.getBoundingClientRect();
++ if (!r.width || r.right <= vw + 1) continue;
++ const cs = getComputedStyle(e);
++ if (cs.position === 'fixed' || cs.visibility === 'hidden') continue;
++ const cls = typeof e.className === 'string' && e.className.trim() ? '.' + e.className.trim().split(/\\s+/)[0] : '';
++ offenders.push(e.tagName.toLowerCase() + (e.id ? '#' + e.id : cls) + ' (right ' + Math.round(r.right) + 'px)');
++ }
++ }
++ return { vw, scrollWidth, scrollX, offenders };
++})()`;
++
++(async () => {
++ if (!harness.findChrome()) {
++ // boot.js captures console.*; the runner reads skips from the test's own stdout.
++ process.stdout.write('phone clipping: skipped (no Chrome)\n');
++ return done();
++ }
++ const t = await boot();
++ let browser = null;
++ try {
++ const articles = t.ctx.catalog.articles;
++ assert.ok(articles.length >= 20, `only ${articles.length} answer pages`);
++
++ // Every catalog site is live today: the not-serving page is exercised with one constructed entry whose only
++ // difference is its state, so its one-line status link is measured at both widths like a real page.
++ const model = t.ctx.catalog.sites.find((s) => s.live);
++ const coming = { ...model, id: 'openvibe.not-serving', name: 'OpenVibe.NotServing', state: 'coming', live: false, service: 'help' };
++ t.ctx.catalog.byId.set(coming.id, coming);
++
++ const writer = t.network.addUser('width-writer');
++ const ticket = await store.create(t.ctx.s, {
++ requester: `user:${writer.subject}`, site: 'openvibe.live', subject: 'A page that must fit a phone',
++ pageUrl: null, body: 'The whole conversation has to stay inside the screen, including a very long word: ' + 'x'.repeat(120),
++ });
++
++ browser = await harness.launch();
++ const context = await browser.newContext();
++ const phone = await harness.openPage(browser, context, { width: 390, height: 900, js: false, block: BLOCK });
++ // The signed-in ticket page is read with the same session cookie the site sets at sign-in (both tabs share
++ // the browser context, so setting it on one covers the other).
++ await phone.send('Network.setCookie', { name: 'help_at', value: t.network.userToken(writer), url: t.base }).catch(() => {});
++ const narrow = await harness.openPage(browser, context, { width: 320, height: 800, js: false, block: BLOCK });
++ // Four tabs share the 160 answer pages: the same browser context, so the cookie and the block list apply,
++ // and the test stays well inside the runner's per-file timeout on a loaded CI machine.
++ const extra = await Promise.all([0, 1, 2].map(() => harness.openPage(browser, context, { width: 390, height: 900, js: false, block: BLOCK })));
++ const answerPages = [phone, ...extra];
++ const failures = [];
++ const visit = async (page, width, path, expectStatus = 200) => {
++ await page.goto(t.base + path, { quietMs: 20, maxMs: 1500, minMs: 30 });
++ const read = await page.evaluate(READ_WIDTH);
++ const status = page.state.doc && page.state.doc.status;
++ if (status !== expectStatus || read.scrollWidth > read.vw + 1 || read.scrollX > 0) {
++ failures.push({ path, width, status, expectStatus, vw: read.vw, scrollWidth: read.scrollWidth, scrollX: read.scrollX, offenders: read.offenders });
++ }
++ };
++
++ // Every answer page at 390 px, and the longest titles and answers again at 320 px.
++ await Promise.all(answerPages.map(async (pg, i) => {
++ for (let j = i; j < articles.length; j += answerPages.length) await visit(pg, 390, articles[j].path);
++ }));
++ const longest = [...articles].sort((a, b) => (b.question.length + b.answer.length) - (a.question.length + a.answer.length)).slice(0, 6);
++ for (const article of longest) await visit(narrow, 320, article.path);
++ await visit(narrow, 320, '/');
++ await visit(narrow, 320, '/sites');
++
++ // The ticket flow: start, list, and one conversation, all signed in through the cookie above.
++ let primary = null;
++ for (const page of [phone, narrow]) {
++ const width = page === phone ? 390 : 320;
++ // Signed in through the cookie above: all four are the real pages a reader sees, not the 401 prompts.
++ await visit(page, width, '/tickets/new', 200);
++ if (width === 390) {
++ primary = await page.evaluate(`(() => {
++ const b = document.querySelector('form.ticket-form button[type="submit"]');
++ if (!b) return null;
++ const r = b.getBoundingClientRect();
++ return { width: r.width, height: r.height };
++ })()`);
++ }
++ await visit(page, width, '/tickets', 200);
++ await visit(page, width, `/tickets/${ticket.id}`, 200);
++ await visit(page, width, `/sites/${coming.id}`, 200);
++ }
++ if (!primary || primary.height < 44 || primary.width < 300) failures.push({ path: '/tickets/new', width: 390, control: 'primary submit', primary });
++
++ await check('every answer page fits 390 px, the ticket submit is 44 px and full width, and the ticket and down-site pages fit 320 px', () => {
++ assert.deepStrictEqual(failures, [], `${failures.length} page(s) do not fit:\n${JSON.stringify(failures.slice(0, 10), null, 2)}`);
++ });
++ for (const pg of extra) await pg.close();
++ await phone.close();
++ await narrow.close();
++ } finally {
++ if (browser) await browser.close();
++ await t.close();
++ }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/tickets.test.js b/test/tickets.test.js
iindex a1873dc..7b8d61f 100644
---- a/test/tickets.test.js
++++ b/test/tickets.test.js
@@@ -9,6 +9,7 @@
const assert = require('assert');
const { signJwt } = require('./helpers/mocks');
const { boot, check, done } = require('./helpers/boot');
++const store = require('../server/tickets/store');
const SAME = { 'sec-fetch-site': 'same-origin' };
const TKT = /^tkt_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -173,6 +174,9 @@ const SUBJECT = 'Nothing happens when I press Go Live';
const form = await t.get('/tickets/new', { as: kim });
assert.strictEqual(form.status, 200);
assert.ok(form.text.includes('<select id="site" name="site"'), 'no site picker');
++ assert.ok(form.text.includes('class="ovk-btn ovk-btn--primary" type="submit"'), 'the submit is not the primary button');
++ assert.ok(form.text.includes('>Open the ticket</span></button>'), 'the submit has no label');
++ assert.match(form.text, /\/shared\/kit\.css\?v=/, 'the primary button has no stylesheet');
for (const site of t.ctx.catalog.sites.slice(0, 5)) assert.ok(form.text.includes(`value="${site.id}"`), `${site.id} is not in the picker`);
const made = await t.get('/tickets', { as: kim, form: { site: 'openvibe.wiki', subject: 'A wiki question', message: 'How do I cite a source?', page_url: '' }, headers: SAME });
assert.strictEqual(made.status, 303, made.text.slice(0, 200));
@@@ -292,6 +296,47 @@ const SUBJECT = 'Nothing happens when I press Go Live';
} finally { await tl.close(); }
});
++ await check('the ticket flow states what a reader gets back and how long a first reply has taken', async () => {
++ let clock = Date.UTC(2026, 9, 30, 12, 0, 0);
++ const tp = await boot({ now: () => clock });
++ try {
++ const empty = await tp.get('/tickets/new');
++ assert.strictEqual(empty.status, 401);
++ assert.ok(empty.text.includes('A person on the OpenVibe team replies here, not a bot'), 'no person, no bot stated');
++ assert.ok(empty.text.includes('First-reply time: not measured yet'), 'an unmeasured time was invented');
++ assert.ok(empty.text.includes('no ticket here has had a staff reply'), 'the reason is not stated');
++
++ const writer = tp.network.addUser('writer');
++ const helper = tp.network.addUser('helper', { role: 'admin' });
++ const made = await store.create(tp.ctx.s, {
++ requester: `user:${writer.subject}`, site: '', subject: 'A question',
++ pageUrl: null, body: 'What happens next?',
++ });
++ clock += 45 * 60_000;
++ await store.addMessage(tp.ctx.s, made.id, { author: `user:${helper.subject}`, role: 'staff', body: 'Here is the answer.' });
++ const measured = await tp.get('/tickets/new');
++ assert.ok(measured.text.includes('Median first reply: 45 min (1 ticket so far).'), 'the small-sample median is not shown');
++ const list = await tp.get('/tickets', { as: writer });
++ assert.strictEqual(list.status, 200);
++ assert.ok(list.text.includes('Median first reply: 45 min'), 'the ticket list does not carry the promise');
++ const one = await tp.get(`/tickets/${made.id}`, { as: writer });
++ assert.ok(one.text.includes('Median first reply: 45 min'), 'the conversation does not carry the promise');
++
++ // Four more answered tickets at 30 min: five answered in all, so the page drops the "(n so far)" rest.
++ for (let i = 0; i < 4; i++) {
++ const more = await store.create(tp.ctx.s, {
++ requester: `user:${writer.subject}`, site: '', subject: `A follow-up ${i + 1}`,
++ pageUrl: null, body: 'What happens next?',
++ });
++ clock += 30 * 60_000;
++ await store.addMessage(tp.ctx.s, more.id, { author: `user:${helper.subject}`, role: 'staff', body: 'Here is the answer.' });
++ }
++ const five = await tp.get('/tickets/new');
++ assert.ok(five.text.includes('Median first reply: 30 min.'), 'the five-ticket median is not shown alone');
++ assert.ok(!five.text.includes('tickets so far'), 'the sample rest is still shown at five tickets');
++ } finally { await tp.close(); }
++ });
++
await t.close();
done();
})().catch((err) => { console.error(err); process.exit(1); });