openvibers/openvibe.help Commits
Help: the market outcomes on the answer and ticket pages (#147)
Changes
@@@ -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.networkOV_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/statusOV_OAUTH_CLIENT_ID=helpOV_OAUTH_CLIENT_SECRET=# OV_OAUTH_REDIRECT_URI=https://openvibe.help/auth/callback@@@ -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; }--}@@@ -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.@@@ -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',@@@ -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 servbody: 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>@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');--/**-- * 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 };@@@ -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 };@@@ -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]));@@@ -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 () => {nnew file mode 100644@@@ -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); });@@@ -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); });
@@ -19,6 +19,8 @@ VALKEY_PREFIX=ov:help:
@@ -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.
# 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_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
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_ID=help
OV_OAUTH_CLIENT_SECRET=
OV_OAUTH_CLIENT_SECRET=
# OV_OAUTH_REDIRECT_URI=https://openvibe.help/auth/callback
# OV_OAUTH_REDIRECT_URI=https://openvibe.help/auth/callback
@@ -62,6 +62,9 @@ mark { background: var(--warning, #f59e0b); color: #10151f; border-radius: 3px;
@@ -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; }
.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; }
.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-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 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 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; }
.field select { max-width: 32rem; }
@@ -125,31 +128,13 @@ mark { background: var(--warning, #f59e0b); color: #10151f; border-radius: 3px;
@@ -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
/* 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 { 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-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; }
.hp-fold-hint { display: none; }
/* The fold's summary carries the heading, so the section's own heading gives way to it. */
/* 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;
@@ -166,14 +151,3 @@ mark { background: var(--warning, #f59e0b); color: #10151f; border-radius: 3px;
.hp-fold-block > summary { cursor: pointer; }
.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); }
.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; }
-}
@@ -43,6 +43,18 @@ function firstSentence(text, max = 180) {
@@ -43,6 +43,18 @@ function firstSentence(text, max = 180) {
return one.length > max ? `${one.slice(0, max - 1).replace(/[\s,;:]$/, '')}…` : one;
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). */
/** The host a service manifest's site is served on (products.siteDomain keeps one rule for both homes). */
const siteDomain = contracts.products.siteDomain;
const siteDomain = contracts.products.siteDomain;
@@ -181,8 +193,8 @@ function buildSites() {
@@ -181,8 +193,8 @@ function buildSites() {
return {
return {
...e,
...e,
id: e.domain,
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,
state,
live: state === 'live',
live: state === 'live',
// A product manifest has no `what` of its own: its tagline is the one line, or the description's first sentence.
// A product manifest has no `what` of its own: its tagline is the one line, or the description's first sentence.
@@ -48,9 +48,12 @@ function load(env = process.env) {
@@ -48,9 +48,12 @@ function load(env = process.env) {
cacheMs: Math.max(0, int(env.HELP_SEARCH_CACHE_MS, 5 * 60_000)),
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,
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
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),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
// The audience this service's app, agent and service tokens carry.
// The audience this service's app, agent and service tokens carry.
audience: env.HELP_AUDIENCE || 'openvibe.help',
audience: env.HELP_AUDIENCE || 'openvibe.help',
@@ -13,15 +13,16 @@ const express = require('express');
@@ -13,15 +13,16 @@ const express = require('express');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
+const kit = require('openvibe-shared/kit');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
const { html, raw, render, table, notice, time, badge } = require('../render/html');
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 { send } = require('../render/layout');
const { searchArticles, markTerms } = require('../search');
const { searchArticles, markTerms } = require('../search');
const tickets = require('../tickets/tickets');
const tickets = require('../tickets/tickets');
@@ -41,6 +42,31 @@ const SITE_STATE_TEXT = { live: 'live', coming: 'coming' };
@@ -41,6 +42,31 @@ const SITE_STATE_TEXT = { live: 'live', coming: 'coming' };
const textBlock = (t) => html`<p class="txt">${t}</p>`;
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));
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 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.
* The nodes keep their own fields; only their own @context goes, because the graph carries it once.
@@ -74,6 +100,23 @@ function createPageRoutes(ctx) {
@@ -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>`;
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">
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>
<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>
<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) {
@@ -82,12 +125,14 @@ function createPageRoutes(ctx) {
// ── Home ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
/** Contact support: the form itself is /tickets/new, and a visitor who is not signed in is asked to sign in. */
/** 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)) {
if (!signedIn(req)) {
return html`<div class="card">${signInPrompt('To open a ticket', '/tickets/new')}
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>`;
<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>`;
<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) {
@@ -99,6 +144,7 @@ function createPageRoutes(ctx) {
const features = catalog.sites.reduce((n, x) => n + ((x.pillars && x.pillars.length) || 0), 0);
const features = catalog.sites.reduce((n, x) => n + ((x.pillars && x.pillars.length) || 0), 0);
let solved = 0;
let solved = 0;
try { solved = (await store.staffCounts(s)).solved; } catch { /* the figure is left out, never guessed */ }
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 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']];
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']);
if (features) stats.push([`${n(features)} features`, 'described by the sites that run them']);
@@ -121,22 +167,15 @@ function createPageRoutes(ctx) {
@@ -121,22 +167,15 @@ function createPageRoutes(ctx) {
jsonLd: faqLd ? [...homeJsonLd(config), faqLd] : homeJsonLd(config),
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.`,
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],
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)}
body: html`${raw(hero)}
<section class="sc-sec" id="search" aria-labelledby="h-search"><h2 id="h-search">Search the help centre</h2>
<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>
<p class="sc-lede">One box over every site's answers, and over the whole OpenVibe network.</p>
${searchForm()}</section>
${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>
<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({
${raw(foldBlock({
id: 'popular',
id: 'popular',
head: '<h2 id="h-popular">Popular questions</h2>',
head: '<h2 id="h-popular">Popular questions</h2>',
@@ -147,7 +186,7 @@ ${raw(foldBlock({
@@ -147,7 +186,7 @@ ${raw(foldBlock({
}))}
}))}
<section class="sc-sec" aria-labelledby="h-contact"><h2 id="h-contact">Contact support</h2>
<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>
<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({
${raw(foldBlock({
id: 'network',
id: 'network',
head: '<h2 id="h-network">Part of OpenVibe</h2>',
head: '<h2 id="h-network">Part of OpenVibe</h2>',
@@ -175,6 +214,7 @@ ${raw(foldBlock({
@@ -175,6 +214,7 @@ ${raw(foldBlock({
],
],
body: html`<h1>Every OpenVibe site</h1>
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="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)}
${provenance(catalog.source)}
${group('live', 'Live', 'These answer today.')}
${group('live', 'Live', 'These answer today.')}
${group('coming', 'Coming', 'Registered in the catalog, not serving yet, or serving something else for now.')}`,
${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
@@ -189,6 +229,9 @@ ${group('coming', 'Coming', 'Registered in the catalog, not serving yet, or serv
}, 404);
}, 404);
const articles = catalog.bySite.get(site.id) || [];
const articles = catalog.bySite.get(site.id) || [];
const faqPairs = articles.map((a) => ({ q: a.question, a: a.answer }));
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, {
page(req, res, {
index: true, cache: PUBLIC_CACHE,
index: true, cache: PUBLIC_CACHE,
// "Help with Search", not "Search": the bare name of the OpenVibe.Search page read exactly like Help's own
// "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
@@ -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>
body: html`<h1>${site.name} ${siteBadge(site.state)}</h1>
${site.tagline ? html`<p class="sc-lede">${site.tagline}</p>` : ''}
${site.tagline ? html`<p class="sc-lede">${site.tagline}</p>` : ''}
${site.description ? textBlock(site.description) : ''}
${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)}
${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>
${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)}
@@ -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>`,
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);
}, 404);
const site = catalog.get(article.site);
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, {
page(req, res, {
- index: true, cache: PUBLIC_CACHE,
+ index: true, cache: PUBLIC_CACHE, styles: ['kit.css'],
title: article.question,
title: article.question,
description: truncate(article.answer, 160),
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) }],
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)}
@@ -245,11 +289,11 @@ ${textBlock(a.answer)}
]),
]),
body: html`<article class="answer">
body: html`<article class="answer">
<h1>${article.question}</h1>
<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)}
${textBlock(article.answer)}
<p class="site-links"><a class="sc-btn" href="/sites/${article.site}">Everything ${article.siteName} answers</a>
<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>
${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>
</article>
<section class="sc-sec" aria-labelledby="h-more"><h2 id="h-more">Other questions from ${article.siteName}</h2>
<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>`,
<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)}
@@ -272,6 +316,7 @@ ${provenance(article.provenance)}
${searchForm(q, { big: true })}
${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`<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>
${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
${hits.length
? html`<p class="sc-lede">${hits.length} article${hits.length === 1 ? '' : 's'} from the sites' own manifests, best match first.</p>
? 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>
<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') : ''}
@@ -356,7 +401,8 @@ ${problem ? notice(problem, 'warn') : ''}
<p class="small muted">Plain text, up to ${tickets.LIMITS.body} characters.</p></div>
<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">
<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 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>`;
</form>`;
// "Report a problem" in every site's footer (openvibe-shared footer.js) opens /tickets/new?site=<domain>&page=<url>:
// "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') : ''}
@@ -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;
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 || '' };
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 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 } : {}) })}` : ''}`;
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, {
if (!signedIn(req)) return page(req, res, {
title: 'Open a ticket', crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: 'New' }],
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);
}, 401);
page(req, res, {
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.',
description: 'Tell OpenVibe support what happened; the ticket stays one conversation.',
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: 'New' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets', href: '/tickets' }, { label: 'New' }],
body: html`<h1>Open a ticket</h1>
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>
<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 })}`,
${ticketForm({ values: pre })}`,
});
});
@@ -392,7 +440,7 @@ ${ticketForm({ values: pre })}`,
@@ -392,7 +440,7 @@ ${ticketForm({ values: pre })}`,
const b = req.body || {};
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)) });
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, {
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 })}`,
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);
}, 422);
const row = await store.create(s, {
const row = await store.create(s, {
@@ -406,9 +454,10 @@ ${ticketForm({ values: pre })}`,
@@ -406,9 +454,10 @@ ${ticketForm({ values: pre })}`,
const canSee = (req, row) => Boolean(row) && (row.requester === requesterOf(req) || staffViewer(req));
const canSee = (req, row) => Boolean(row) && (row.requester === requesterOf(req) || staffViewer(req));
r.get('/tickets', async (req, res) => {
r.get('/tickets', async (req, res) => {
+ const replies = await measuredReplies();
if (!signedIn(req)) return page(req, res, {
if (!signedIn(req)) return page(req, res, {
title: 'Your tickets', crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets' }],
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);
}, 401);
const out = await store.listForRequester(s, requesterOf(req), { limit: PAGE_LIMIT });
const out = await store.listForRequester(s, requesterOf(req), { limit: PAGE_LIMIT });
return page(req, res, {
return page(req, res, {
@@ -416,6 +465,7 @@ ${ticketForm({ values: pre })}`,
@@ -416,6 +465,7 @@ ${ticketForm({ values: pre })}`,
description: 'The support tickets you opened, and where each conversation stands.',
description: 'The support tickets you opened, and where each conversation stands.',
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your tickets' }],
body: html`<h1>Your tickets</h1>
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>
<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') : ''}
${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>
<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>
@@ -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) => {
r.get('/tickets/:id', async (req, res) => {
+ const replies = await measuredReplies();
if (!signedIn(req)) return page(req, res, {
if (!signedIn(req)) return page(req, res, {
title: 'Your tickets', crumbs: [{ label: 'Home', href: '/' }, { label: 'Tickets', href: '/tickets' }, { label: 'Ticket' }],
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);
}, 401);
const row = tickets.isTicketId(req.params.id) ? await store.get(s, req.params.id) : null;
const row = tickets.isTicketId(req.params.id) ? await store.get(s, req.params.id) : null;
if (!canSee(req, row)) return page(req, res, {
if (!canSee(req, row)) return page(req, res, {
@@ -459,6 +510,7 @@ ${tickets.STATES.map((st) => html`<label class="radio"><input type="radio" name=
@@ -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>
<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>
<p class="small muted">Changing the state does not send a message. Reply below if the person needs to hear something.</p>
</form>` : ''}
</form>` : ''}
+${ticketPromise(replies)}
<form class="card reply-form" method="post" action="/tickets/${row.id}">
<form class="card reply-form" method="post" action="/tickets/${row.id}">
<label for="message">${staffViewer(req) ? 'Reply as OpenVibe support' : 'Reply'}</label>
<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>
<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>
@@ -2,67 +2,16 @@
@@ -2,67 +2,16 @@
/**
/**
* The home on a phone (the owner's short-pages rule, 2026-10-30): one or two screens, app-like.
* 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
* 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
* 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.
* 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
-/**
- * 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.
* A fold. The head is the heading the block had; the html is what it holds.
*
*
@@ -77,10 +26,9 @@ function foldScript(id) {
@@ -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>";
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) {
function part(html, needle) {
const at = html.indexOf(needle);
const at = html.indexOf(needle);
@@ -106,4 +54,4 @@ function foldAround(html, needle, id, head) {
@@ -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);
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 };
@@ -67,6 +67,33 @@ async function staffCounts(s) {
@@ -67,6 +67,33 @@ async function staffCounts(s) {
return out;
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)]);
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. */
/** 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) {
@@ -88,4 +115,4 @@ async function setState(s, ticketId, state) {
return get(s, ticketId);
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 };
@@ -71,6 +71,33 @@ const WITH_FAQ = [
@@ -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 () => {
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`);
assert.ok(WITH_FAQ.length >= 3, `only ${WITH_FAQ.length} sites answer questions`);
let articles = 0;
let articles = 0;
@@ -90,6 +117,16 @@ const WITH_FAQ = [
@@ -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})`);
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})`);
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.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`);
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.
// 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]));
const scripts = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
@@ -1,41 +1,42 @@
@@ -1,41 +1,42 @@
'use strict';
'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 assert = require('assert');
+const fs = require('fs');
+const path = require('path');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
try {
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('/');
const r = await t.get('/');
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
const page = 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 () => {
await check('the parts a reader rarely needs are folds, open in the page and closed on a phone', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -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); });
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
const assert = require('assert');
const assert = require('assert');
const { signJwt } = require('./helpers/mocks');
const { signJwt } = require('./helpers/mocks');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
+const store = require('../server/tickets/store');
const SAME = { 'sec-fetch-site': 'same-origin' };
const SAME = { 'sec-fetch-site': 'same-origin' };
const TKT = /^tkt_[0-9A-HJKMNP-TV-Z]{26}$/;
const TKT = /^tkt_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -173,6 +174,9 @@ const SUBJECT = 'Nothing happens when I press Go Live';
@@ -173,6 +174,9 @@ const SUBJECT = 'Nothing happens when I press Go Live';
const form = await t.get('/tickets/new', { as: kim });
const form = await t.get('/tickets/new', { as: kim });
assert.strictEqual(form.status, 200);
assert.strictEqual(form.status, 200);
assert.ok(form.text.includes('<select id="site" name="site"'), 'no site picker');
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`);
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 });
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));
assert.strictEqual(made.status, 303, made.text.slice(0, 200));
@@ -292,6 +296,47 @@ const SUBJECT = 'Nothing happens when I press Go Live';
@@ -292,6 +296,47 @@ const SUBJECT = 'Nothing happens when I press Go Live';
} finally { await tl.close(); }
} 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();
await t.close();
done();
done();
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });