openvibers/openvibe.mediahub Commits

Pics: draw the home icons and make the upload button visible (#441)

OP OpenVibers committed 3 h ago · 8d9a405eed · 6 files, +254 −13

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 26b3529..e2db655 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,14 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## Unreleased
++- openvibe.pics' home icons and "Choose a picture" (2026-11-13, the owner's phone). The "What you get", "The links a
++ picture gets" and "What we promise, in writing" cards gave the shared showcase bare glyph names, which it drew as
++ Font Awesome placeholders with no font and no mapping: every icon was an empty 40 px square. They are now the glyph
++ registry's SVG, drawn on the server. The upload button lost its pink to the shared presence sheet's later `.ovp-btn`
++ rule, leaving near-black text on the dark card; Pics' buttons are scoped to `.ovp-page`, 44 px tall, and the label
++ carries the upload glyph. Tests: `test/pics.test.js` checks every feature icon is inline SVG, and
++ `test/pics-browser.test.js` opens the home at 390 and 1280 px in dark and light and fails on an unpainted icon, a
++ zero-size glyph, or button text that is invisible against what is behind it.
- YouTube-shaped watch addresses (2026-11-07, the owner: "on openvibe.video we should copy the style of YouTube… /watch,
and then like the actual video ID"). Every recording, clip and upload now has a short, stable, unguessable watch id —
11 characters of URL-safe base64, minted once by OpenVibe.Media (one id space across VODs and clips) — and its page is
ddiff --git a/public/css/pics.css b/public/css/pics.css
iindex be103fa..9df5719 100644
---- a/public/css/pics.css
++++ b/public/css/pics.css
@@@ -18,12 +18,15 @@
.ovp-page h2 { font-size: 1.15rem; margin: 0 0 .75rem; }
/* Buttons */
--.ovp-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 38px; padding: 7px 14px; border-radius: 10px; border: 1px solid var(--ovp-line-strong); background: var(--bg-tertiary, #16223a); color: var(--text-primary, #e6edf7) !important; font: inherit; font-size: .9rem; font-weight: 700; text-decoration: none; cursor: pointer; }
--.ovp-btn:hover, .ovp-btn:focus-visible { border-color: var(--ovp-pink); }
--.ovp-btn--primary { background: var(--ovp-pink); border-color: var(--ovp-pink); color: var(--ovp-on-pink) !important; }
--.ovp-btn--primary:hover, .ovp-btn--primary:focus-visible { background: var(--ovp-pink-soft); border-color: var(--ovp-pink-soft); }
--.ovp-btn--danger { background: transparent; border-color: color-mix(in srgb, var(--error, #ef4444) 55%, transparent); color: var(--error-text, var(--error, #ef4444)) !important; }
--.ovp-btn--danger:hover, .ovp-btn--danger:focus-visible { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: var(--error, #ef4444); }
++/* The shared presence sheet (loaded by the navbar for a signed-in person) also writes `.ovp-btn` — transparent
++ background, colour inherit. Scoped to `.ovp-page` so a later plain `.ovp-btn` can never repaint Pics' primary
++ button: the owner's 11-13 phone shot showed a dark label on the dark card with the pink gone. */
++.ovp-page .ovp-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; padding: 7px 14px; border-radius: 10px; border: 1px solid var(--ovp-line-strong); background: var(--bg-tertiary, #16223a); color: var(--text-primary, #e6edf7) !important; font: inherit; font-size: .9rem; font-weight: 700; text-decoration: none; cursor: pointer; }
++.ovp-page .ovp-btn:hover, .ovp-page .ovp-btn:focus-visible { border-color: var(--ovp-pink); }
++.ovp-page .ovp-btn--primary { background: var(--ovp-pink); border-color: var(--ovp-pink); color: var(--ovp-on-pink) !important; }
++.ovp-page .ovp-btn--primary:hover, .ovp-page .ovp-btn--primary:focus-visible { background: var(--ovp-pink-soft); border-color: var(--ovp-pink-soft); }
++.ovp-page .ovp-btn--danger { background: transparent; border-color: color-mix(in srgb, var(--error, #ef4444) 55%, transparent); color: var(--error-text, var(--error, #ef4444)) !important; }
++.ovp-page .ovp-btn--danger:hover, .ovp-page .ovp-btn--danger:focus-visible { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: var(--error, #ef4444); }
/* Home */
.ovp-hero { padding: 12px 0 18px; }
@@@ -112,7 +115,7 @@
.ovp-copy label { font-size: .8rem; font-weight: 700; color: var(--ovp-dim); }
.ovp-copy-row { display: flex; gap: 6px; min-width: 0; }
.ovp-copy-row input { flex: 1; min-width: 0; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .85rem; padding: 8px 10px; border-radius: 10px; border: 1px solid var(--ovp-line-strong); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); }
--.ovp-copy-row .ovp-btn { min-width: 74px; }
++.ovp-page .ovp-copy-row .ovp-btn { min-width: 74px; }
.ovp-page > .ovp-copy { max-width: 640px; margin: 0 0 14px; }
.ovp-key { display: inline-block; margin-top: 6px; overflow-wrap: anywhere; }
.ovp-confirm-thumb { max-width: 240px; border-radius: 12px; }
@@@ -270,7 +273,6 @@ html[data-ov-motion=off] .ovp-gallery-card:hover .ovp-gc-pic img{transform: none
.ovp-drop--compact .ovp-drop-inner p { margin: 0; }
.ovp-drop--signin.ovp-drop--compact .ovp-drop-title { display: none; }
.ovp-drop--signin.ovp-drop--compact .ovp-drop-hint { display: block; flex: 1; }
--.ovp-btn { min-height: 44px; }
/* The picture's action bar (owner's design pass, 11-09): who published it and how many saw it on the left, Like (a heart
that fills in Pics pink, with its count) and Share on the right; under it, quiet buttons to make something with the
ddiff --git a/server/pics/pages.js b/server/pics/pages.js
iindex fb3fd19..19346bf 100644
---- a/server/pics/pages.js
++++ b/server/pics/pages.js
@@@ -32,7 +32,7 @@ const { asyncRouter } = require('../http/router');
const { sameOrigin } = require('../http/principal');
const { boundaryOf, readFormData } = require('../http/form-upload');
const { formatBytes } = require('../drive/rules');
--const { html, raw, notice, time, shareButton } = require('../render/html');
++const { html, raw, esc, notice, time, shareButton } = require('../render/html');
const labelsUi = require('../render/labels-ui');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { send, assetVersion, SITE_NAME, NAV_LINKS } = require('./layout');
@@@ -49,6 +49,20 @@ const glyphs = require('openvibe-shared/glyphs');
/** A line glyph from Shared's set, decorative (its button says what it does). */
const glyph = (name, size = 16) => raw(`<span class="ovp-glyph" aria-hidden="true">${glyphs.svg(name, { size })}</span>`);
const showcase = require('openvibe-shared/showcase');
++
++/**
++ * What the product does, as the shared showcase's feature grid, with every icon drawn from the glyph registry on the
++ * server. It used to be showcase.features() with bare names ('link', 'shield'), which the shared kit treats as Font
++ * Awesome classes: the placeholder it drew had no font and no glyph name for the browser drop-in to map, so the slot
++ * stayed an empty 40 px square (the owner, 11-13). Names here are registry names, rendered as SVG in the HTML.
++ */
++function featureCards({ title, lede, items }) {
++ const slug = String(title || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'section';
++ return `<section class="sc-sec" aria-labelledby="sc-${slug}"><h2 id="sc-${slug}"><span class="sc-h-ic" aria-hidden="true">${glyphs.svg('grid', { size: 18 })}</span><span>${esc(title)}</span></h2>${lede ? `<p class="sc-lede">${esc(lede)}</p>` : ''}<div class="sc-grid">${items.map((f) => {
++ const inner = `<span class="sc-ic" aria-hidden="true">${glyphs.svg(f.icon, { size: 22 })}</span><span><b>${esc(f.title)}</b>${f.text ? `<small>${esc(f.text)}</small>` : ''}</span>`;
++ return f.href ? `<a class="sc-card" href="${esc(f.href)}">${inner}</a>` : `<div class="sc-card">${inner}</div>`;
++ }).join('')}</div></section>`;
++}
const { PATHS: POLICY_PATHS, policyPage } = require('./policy');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@@ -233,7 +247,7 @@ function createPicsRoutes(ctx) {
<span class="ovp-drop-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="40" height="40"><path fill="currentColor" d="M19 5v14H5V5h14m0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2Zm-4.86 8.86-3 3.87L9 13.14 6 17h12l-3.86-5.14Z"/></svg></span>
<p class="ovp-drop-title">Drop pictures here, paste one, or choose files</p>
<p class="ovp-drop-hint">JPEG, PNG, GIF, WebP or AVIF, up to ${formatBytes(max)} each. Location and camera details are removed.</p>
--<label class="ovp-btn ovp-btn--primary ovp-file" data-ov-guide="pics.upload"><input type="file" name="file" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" required data-pics-input> Choose a picture</label>
++<label class="ovp-btn ovp-btn--primary ovp-file" data-ov-guide="pics.upload"><input type="file" name="file" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" required data-pics-input>${glyph('upload', 18)}<span>Choose a picture</span></label>
<div class="ovp-drop-fields"><label for="up-title">Title (optional)</label><input id="up-title" name="title" type="text" maxlength="120" placeholder="What is it?"></div>
${signedIn(req) ? html`<label>Who can open it <select name="audience">${audienceOptions(defaultAudience)}</select></label>` : ''}
<button class="ovp-btn ovp-btn--primary ovp-nojs-submit" type="submit">Upload</button>
@@@ -304,7 +318,7 @@ ${raw(showcase.steps({
{ title: 'Use it anywhere', text: 'Post it in a forum or a chat, embed it on your site, or put it in an album. Delete it whenever you like.' },
],
}))}
--${raw(showcase.features({
++${raw(featureCards({
title: 'What you get',
items: [
{ icon: 'link', title: 'Links that work everywhere', text: 'A page to share, a direct link for an img tag, and Markdown, BBCode and HTML ready to copy.' },
@@@ -315,7 +329,7 @@ ${raw(showcase.features({
{ icon: 'text', title: 'Alt text', text: 'Say what a picture shows for people who cannot see it, or let AI draft it when you ask. It goes into every embed snippet.' },
],
}))}
--${raw(showcase.features({
++${raw(featureCards({
title: 'The links a picture gets',
lede: 'Each picture has a short name of eight letters and digits. These are the links it answers at:',
items: [
@@@ -332,7 +346,7 @@ ${raw(showcase.code({
}))}
${raw(showcase.limits({ title: 'What a free account gets', columns: limitColumns, rows: limitRows }))}
${raw(showcase.faq({ title: 'Questions', items: faq.map((f) => [f.q, f.a]) }))}
--${raw(showcase.features({
++${raw(featureCards({
title: 'What we promise, in writing',
items: [
{ icon: 'shield', title: 'What happens to your picture', href: '/exif', text: 'The metadata — the GPS position, the camera, the time it was taken — is taken out by us before anyone sees the picture, and the file you uploaded is not kept.' },
ddiff --git a/test/pics-browser.test.js b/test/pics-browser.test.js
nnew file mode 100644
iindex 0000000..288c7e9
---- /dev/null
++++ b/test/pics-browser.test.js
@@@ -0,0 +1,205 @@
++'use strict';
++/**
++ * OpenVibe.Pics' home in a real browser (the owner's 11-13 phone report):
++ * - every icon paints something: a registry glyph with a path in the HTML, never an empty <i> or a zero-size SVG;
++ * - "Choose a picture" shows its words and its upload glyph, is 44 px tall, keeps its pink through a shared sheet that
++ * writes the same `.ovp-btn` class, and takes keyboard focus with a visible ring;
++ * - no button text is the same colour as what is behind it, or invisible through opacity.
++ *
++ * It boots the Pics host over HTTPS (the app sets upgrade-insecure-requests, so an http page loads no stylesheet) with
++ * a throwaway certificate and Chrome's host resolver mapping openvibe.pics to 127.0.0.1. Screenshots of every checked
++ * size and theme go to PICS_SHOT_DIR (default: a directory under the system temp dir) and their paths are printed.
++ *
++ * Skips, with a line saying why, when Playwright, Chrome or openssl is missing.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const https = require('https');
++const os = require('os');
++const path = require('path');
++const { execFileSync } = require('child_process');
++const { boot, check, done } = require('./helpers/boot');
++
++const PLAYWRIGHT = ['playwright', '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright'];
++const CHROME = '/usr/bin/google-chrome';
++const SHOT_DIR = process.env.PICS_SHOT_DIR || path.join(os.tmpdir(), 'openvibe-pics-shots');
++const SIZE_THEMES = [[390, 'dark'], [390, 'light'], [1280, 'dark'], [1280, 'light']];
++
++function playwright() {
++ for (const p of PLAYWRIGHT) { try { return require(p); } catch { /* try the next */ } }
++ return null;
++}
++function canRun() {
++ if (!playwright()) return 'Playwright is not installed';
++ if (!fs.existsSync(CHROME)) return `Chrome is not at ${CHROME}`;
++ try { execFileSync('openssl', ['version'], { stdio: 'ignore' }); } catch { return 'openssl is not installed'; }
++ return '';
++}
++function listen(server) { return new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => resolve(server.address().port)); }); }
++
++/** Start an HTTPS front for the app; Chrome's host resolver maps openvibe.pics to 127.0.0.1. */
++async function httpsFront(base) {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pics-cert-'));
++ const key = path.join(dir, 'key.pem');
++ const cert = path.join(dir, 'cert.pem');
++ execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', key, '-out', cert, '-subj', '/CN=openvibe.pics', '-days', '1'], { stdio: 'ignore' });
++ const target = new URL(base);
++ const server = https.createServer({ key: fs.readFileSync(key), cert: fs.readFileSync(cert) }, (req, res) => {
++ const upstream = http.request({ hostname: target.hostname, port: target.port, path: req.url, method: req.method, headers: { ...req.headers, host: 'openvibe.pics' } }, (r) => {
++ res.writeHead(r.statusCode, r.headers);
++ r.pipe(res);
++ });
++ upstream.on('error', () => { res.writeHead(502, { 'content-type': 'text/plain' }); res.end('proxy'); });
++ req.pipe(upstream);
++ });
++ const port = await listen(server);
++ return { server, port, dir, close: async () => { await new Promise((r) => server.close(r)); fs.rmSync(dir, { recursive: true, force: true }); } };
++}
++
++/** What the page says about its icons and buttons; every problem is a sentence with the element. */
++function audit() {
++ const problems = [];
++ const painted = (el) => {
++ for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
++ const cs = getComputedStyle(n);
++ if (cs.display === 'none' || cs.visibility === 'hidden' || Number(cs.opacity) === 0) return false;
++ }
++ return true;
++ };
++ const geometry = (s) => {
++ const tag = s.tagName.toLowerCase();
++ const num = (v) => Number(v) > 0;
++ if (tag === 'path') return (s.getAttribute('d') || '').trim().length > 1;
++ if (tag === 'circle' || tag === 'ellipse') return num(s.getAttribute('r')) || (num(s.getAttribute('rx')) && num(s.getAttribute('ry')));
++ if (tag === 'rect') return num(s.getAttribute('width')) && num(s.getAttribute('height'));
++ if (tag === 'line') return s.getAttribute('x1') != null && s.getAttribute('x2') != null;
++ if (tag === 'polyline' || tag === 'polygon') return (s.getAttribute('points') || '').trim().length > 3;
++ if (tag === 'use') return Boolean(s.getAttribute('href') || s.getAttribute('xlink:href'));
++ return false;
++ };
++ const describe = (el) => `${el.tagName.toLowerCase()}${el.className && typeof el.className === 'string' ? `.${el.className.trim().split(/\s+/).join('.')}` : ''}`;
++ const parse = (c) => { const m = String(c).match(/rgba?\(([^)]+)\)/); if (!m) return null; const p = m[1].split(',').map(Number); return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 }; };
++ const lum = (c) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b); };
++ const contrast = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };
++ const behind = (el) => {
++ for (let n = el; n && n.nodeType === 1; n = n.parentElement) {
++ const cs = getComputedStyle(n);
++ const c = parse(cs.backgroundColor);
++ if (c && c.a > 0.05) return c;
++ if (cs.backgroundImage && cs.backgroundImage !== 'none') return { r: 0, g: 0, b: 0, a: 1 };
++ }
++ return parse(getComputedStyle(document.body).backgroundColor) || { r: 0, g: 0, b: 0, a: 1 };
++ };
++ const iconSelector = '.sc-h-ic, .sc-card .sc-ic, .ovp-glyph, .ovp-gc-ic, .ovp-drop-icon, .ovnav-ic, body [data-ov-icon], body [data-icon]';
++ for (const el of document.querySelectorAll(iconSelector)) {
++ if (!painted(el)) continue;
++ const box = el.getBoundingClientRect();
++ if (box.width < 1 || box.height < 1) { problems.push(`${describe(el)} has no size (${Math.round(box.width)}x${Math.round(box.height)})`); continue; }
++ const svg = el.tagName.toLowerCase() === 'svg' ? el : el.querySelector('svg');
++ if (svg) {
++ const sbox = svg.getBoundingClientRect();
++ if (sbox.width < 1 || sbox.height < 1) { problems.push(`${describe(el)} draws a zero-size SVG`); continue; }
++ const shapes = [...svg.querySelectorAll('path,circle,rect,line,polyline,polygon,ellipse,use')];
++ if (!shapes.length || !shapes.some(geometry)) { problems.push(`${describe(el)} has an SVG with no drawing`); continue; }
++ const colour = parse(getComputedStyle(svg).color);
++ if (colour && colour.a < 0.1) problems.push(`${describe(el)} draws in a transparent colour`);
++ continue;
++ }
++ const img = el.tagName.toLowerCase() === 'img' ? el : el.querySelector('img');
++ if (img) { if (!img.complete || !img.naturalWidth) problems.push(`${describe(el)} has a picture that did not load`); continue; }
++ problems.push(`${describe(el)} has no SVG or picture`);
++ }
++
++ for (const el of document.querySelectorAll('button, .ovp-page .ovp-btn')) {
++ if (!painted(el)) continue;
++ const text = (el.innerText || '').trim();
++ if (!text) continue;
++ const cs = getComputedStyle(el);
++ const fg = parse(cs.color);
++ const bg = behind(el);
++ if (!fg || fg.a < 0.1) { problems.push(`${describe(el)} "${text.slice(0, 30)}" has no visible text colour`); continue; }
++ const ratio = contrast(fg, bg);
++ if (ratio < 3) problems.push(`${describe(el)} "${text.slice(0, 30)}" has ${ratio.toFixed(2)}:1 text (colour ${cs.color} on ${JSON.stringify(bg)})`);
++ }
++
++ const file = document.querySelector('.ovp-file');
++ if (!file) problems.push('no "Choose a picture" upload trigger on the home');
++ else {
++ if (!/Choose a picture/.test(file.innerText || '')) problems.push('the upload trigger has no words');
++ const box = file.getBoundingClientRect();
++ if (box.height < 44) problems.push(`the upload trigger is ${Math.round(box.height)} px tall, not 44`);
++ const glyph = file.querySelector('.ovp-glyph svg');
++ if (!glyph) problems.push('the upload trigger has no glyph');
++ else if (glyph.getBoundingClientRect().width < 12) problems.push('the upload trigger glyph is too small');
++ }
++ return problems;
++}
++
++(async () => {
++ const why = canRun();
++ if (why) { process.stdout.write(`pics browser: skipped (${why})\n`); return done(); }
++ const { chromium } = playwright();
++ const t = await boot();
++ const kim = t.network.addUser('kim');
++ const shots = [];
++ let front = null;
++ let browser = null;
++ try {
++ front = await httpsFront(t.base);
++ browser = await chromium.launch({ executablePath: CHROME, args: ['--host-resolver-rules=MAP openvibe.pics 127.0.0.1', '--ignore-certificate-errors'] });
++ fs.mkdirSync(SHOT_DIR, { recursive: true });
++ for (const [width, theme] of SIZE_THEMES) {
++ await check(`${width} px, ${theme}: icons paint, button text is visible, the upload trigger is 44 px and keyboard-focusable`, async () => {
++ const context = await browser.newContext({ viewport: { width, height: 900 }, colorScheme: theme, deviceScaleFactor: 1, isMobile: width < 700, hasTouch: width < 700 });
++ try {
++ await context.addCookies([{ name: 'media-hub_at', value: t.network.userToken(kim), domain: 'openvibe.pics', path: '/', secure: true, sameSite: 'Lax' }]);
++ // Paint the chosen theme before the first frame, as a person's saved theme does.
++ await context.addInitScript((id) => { try { if (location.hostname === 'openvibe.pics') localStorage.setItem('ov_theme', JSON.stringify({ id })); } catch { /* no storage */ } }, theme === 'light' ? 'daylight' : 'vibe');
++ const page = await context.newPage();
++ const pageErrors = [];
++ page.on('pageerror', (e) => pageErrors.push(e.message));
++ await page.route('https://openvibe.network/**', (route) => route.fulfill({ status: 204, body: '' }));
++ await page.route('https://openvibe.cam/**', (route) => route.fulfill({ status: 204, body: '' }));
++ await page.goto(`https://openvibe.pics:${front.port}/`, { waitUntil: 'load' });
++ await page.waitForTimeout(600);
++ // The shared presence sheet writes a plain `.ovp-btn` after Pics' own sheet. Inject its real rule so the
++ // check is the same on a page where presence has not arrived yet: Pics' scoped rules must still win.
++ await page.addStyleTag({ content: '.ovp-btn { height: 40px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--border, #333340); background: transparent; color: inherit; font: inherit; font-weight: 650; cursor: pointer; } .ovp-btn:hover { background: color-mix(in srgb, var(--text-primary, #fff) 7%, transparent); }' });
++ await page.waitForTimeout(100);
++ const problems = await page.evaluate(audit);
++ assert.deepStrictEqual(problems, [], `home at ${width} px, ${theme}:\n${problems.join('\n')}`);
++
++ const focus = await page.evaluate(() => {
++ const input = document.querySelector('.ovp-file input[type=file]');
++ if (!input) return { missing: true };
++ input.focus();
++ const label = input.closest('label');
++ const cs = label && getComputedStyle(label);
++ return {
++ active: document.activeElement === input,
++ inTabOrder: input.tabIndex === 0 && !input.disabled,
++ focusWithin: Boolean(label && label.matches(':focus-within')),
++ ring: Boolean(cs && cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) >= 2),
++ name: Boolean(input.labels && [...input.labels].some((l) => /Choose a picture/.test(l.innerText || ''))),
++ };
++ });
++ assert.deepStrictEqual(focus, { active: true, inTabOrder: true, focusWithin: true, ring: true, name: true }, 'the upload trigger takes keyboard focus with its ring and its name');
++
++ const shot = path.join(SHOT_DIR, `pics-home-${width}-${theme}.png`);
++ await page.screenshot({ path: shot, fullPage: true });
++ shots.push(shot);
++ assert.deepStrictEqual(pageErrors, [], `no page errors at ${width} px, ${theme}`);
++ } finally {
++ await context.close();
++ }
++ });
++ }
++ } finally {
++ if (browser) await browser.close();
++ if (front) await front.close();
++ await t.close();
++ }
++ process.stdout.write(`pics browser: screenshots ${shots.join(' ')}\n`);
++ done();
++})().catch((err) => { process.stderr.write(`${err.stack || err}\n`); process.exit(1); });
ddiff --git a/test/pics-contrast.test.js b/test/pics-contrast.test.js
iindex 7d9de0b..1f935bc 100644
---- a/test/pics-contrast.test.js
++++ b/test/pics-contrast.test.js
@@@ -9,4 +9,9 @@ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'pics.cs
assert.match(css, /--ovp-accent-text: color-mix\(in srgb, var\(--ovp-pink\) 60%, var\(--text-primary, #e6edf7\)\);/);
assert.match(css, /\.ovp-size-table thead th \{[^}]*color: var\(--ovp-faint\);/);
assert.ok(!/var\(--text-faint/.test(css), 'no colour names --text-faint');
++// The shared presence sheet also writes `.ovp-btn` (transparent background, colour inherit). Every Pics button rule
++// is scoped to the page, so a later plain `.ovp-btn` cannot repaint the pink primary button (the 11-13 phone shot).
++assert.ok(!/(^|\n)\.ovp-btn(?=[\s,:{])/m.test(css), 'no unscoped .ovp-btn selector competes with the shared sheet');
++assert.match(css, /\.ovp-page \.ovp-btn \{[^}]*min-height: 44px;/);
++assert.match(css, /\.ovp-page \.ovp-btn--primary \{[^}]*background: var\(--ovp-pink\);/);
console.log('pics contrast: all checks passed');
ddiff --git a/test/pics.test.js b/test/pics.test.js
iindex 1054a54..6798d65 100644
---- a/test/pics.test.js
++++ b/test/pics.test.js
@@@ -72,6 +72,7 @@ function multipart(fields, file) {
assert.ok(home.text.includes('Share pictures.') && home.text.includes('Sign in to upload'));
const signedInHome = await t.get('/', { headers: PICS, as: kim });
assert.ok(signedInHome.text.includes('data-ov-guide="pics.upload"'), 'the signed-in upload page has the guide target');
++ assert.ok(/class="[^"]*\bovp-file\b[^"]*"[\s\S]*?class="ovp-glyph"[\s\S]*?<span>Choose a picture<\/span>/.test(signedInHome.text), 'the upload trigger carries its glyph and its words');
assert.ok(/<script type="application\/ld\+json">[^<]*"@type":"WebSite"/.test(home.text), 'the home says what the site is (JSON-LD)');
assert.strictEqual((await t.get('/files', { headers: PICS, as: kim })).status, 404, 'no drive pages on the pics host');
assert.ok((await t.get('/', { headers: { Host: 'openvibe.download' } })).text.indexOf('Share pictures.') === -1, 'the drive host is not Pics');
@@@ -91,6 +92,12 @@ function multipart(fields, file) {
assert.deepStrictEqual(sections.filter((h) => !home.includes(`${h}</span></h2>`)), [], 'every section is there');
assert.ok(/<td[^>]*>20 MB<\/td>/.test(home) && /<td[^>]*>200<\/td>/.test(home) && /<td[^>]*>5 GB<\/td>/.test(home), 'the limits are the configured ones');
assert.ok(home.includes('/api/v1/images \\\n -H &quot;Authorization: Bearer $OPENVIBE_TOKEN&quot; \\\n'), 'the curl sample keeps its line breaks');
++ // Every "What you get" card draws its glyph on the server: a bare name used to become <i class="fa-solid link">,
++ // an empty <i> with no font and no mapping (the owner's 11-13 phone shot).
++ const iconSlots = (home.match(/<span class="sc-ic"/g) || []).length;
++ assert.strictEqual(iconSlots, 13, 'every feature card has its icon slot');
++ assert.strictEqual((home.match(/<span class="sc-ic"[^>]*><svg /g) || []).length, iconSlots, 'every feature icon is drawn in the HTML');
++ assert.ok(!/class="[^"]*fa-/.test(home), 'no Font Awesome placeholder is left in the home');
const ld = [...home.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].flatMap((m) => [].concat(JSON.parse(m[1])));
const faq = ld.find((x) => x['@type'] === 'FAQPage');
// The questions section's own disclosures: the shared footer's "More from OpenVibe" is one too (openvibe-shared 4.15).