openvibers/openvibe.mediahub Commits
Pics: draw the home icons and make the upload button visible (#441)
Changes
@@@ -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@@@ -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 heartthat fills in Pics pink, with its count) and Share on the right; under it, quiet buttons to make something with the@@@ -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.' },nnew file mode 100644@@@ -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); });@@@ -9,4 +9,9 @@ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'pics.csassert.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');@@@ -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 "Authorization: Bearer $OPENVIBE_TOKEN" \\\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).
@@ -4,6 +4,14 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
@@ -4,6 +4,14 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## Unreleased
## 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,
- 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 —
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
11 characters of URL-safe base64, minted once by OpenVibe.Media (one id space across VODs and clips) — and its page is
@@ -18,12 +18,15 @@
@@ -18,12 +18,15 @@
.ovp-page h2 { font-size: 1.15rem; margin: 0 0 .75rem; }
.ovp-page h2 { font-size: 1.15rem; margin: 0 0 .75rem; }
/* Buttons */
/* 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 */
/* Home */
.ovp-hero { padding: 12px 0 18px; }
.ovp-hero { padding: 12px 0 18px; }
@@ -112,7 +115,7 @@
@@ -112,7 +115,7 @@
.ovp-copy label { font-size: .8rem; font-weight: 700; color: var(--ovp-dim); }
.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 { 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 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-page > .ovp-copy { max-width: 640px; margin: 0 0 14px; }
.ovp-key { display: inline-block; margin-top: 6px; overflow-wrap: anywhere; }
.ovp-key { display: inline-block; margin-top: 6px; overflow-wrap: anywhere; }
.ovp-confirm-thumb { max-width: 240px; border-radius: 12px; }
.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
@@ -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--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-title { display: none; }
.ovp-drop--signin.ovp-drop--compact .ovp-drop-hint { display: block; flex: 1; }
.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
/* 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
that fills in Pics pink, with its count) and Share on the right; under it, quiet buttons to make something with the
@@ -32,7 +32,7 @@ const { asyncRouter } = require('../http/router');
@@ -32,7 +32,7 @@ const { asyncRouter } = require('../http/router');
const { sameOrigin } = require('../http/principal');
const { sameOrigin } = require('../http/principal');
const { boundaryOf, readFormData } = require('../http/form-upload');
const { boundaryOf, readFormData } = require('../http/form-upload');
const { formatBytes } = require('../drive/rules');
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 labelsUi = require('../render/labels-ui');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { send, assetVersion, SITE_NAME, NAV_LINKS } = require('./layout');
const { send, assetVersion, SITE_NAME, NAV_LINKS } = require('./layout');
@@ -49,6 +49,20 @@ const glyphs = require('openvibe-shared/glyphs');
@@ -49,6 +49,20 @@ const glyphs = require('openvibe-shared/glyphs');
/** A line glyph from Shared's set, decorative (its button says what it does). */
/** 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 glyph = (name, size = 16) => raw(`<span class="ovp-glyph" aria-hidden="true">${glyphs.svg(name, { size })}</span>`);
const showcase = require('openvibe-shared/showcase');
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 { PATHS: POLICY_PATHS, policyPage } = require('./policy');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -233,7 +247,7 @@ function createPicsRoutes(ctx) {
@@ -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>
<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-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>
<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>
<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>` : ''}
${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>
<button class="ovp-btn ovp-btn--primary ovp-nojs-submit" type="submit">Upload</button>
@@ -304,7 +318,7 @@ ${raw(showcase.steps({
@@ -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.' },
{ 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',
title: 'What you get',
items: [
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.' },
{ 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({
@@ -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.' },
{ 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',
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:',
lede: 'Each picture has a short name of eight letters and digits. These are the links it answers at:',
items: [
items: [
@@ -332,7 +346,7 @@ ${raw(showcase.code({
@@ -332,7 +346,7 @@ ${raw(showcase.code({
}))}
}))}
${raw(showcase.limits({ title: 'What a free account gets', columns: limitColumns, rows: limitRows }))}
${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.faq({ title: 'Questions', items: faq.map((f) => [f.q, f.a]) }))}
-${raw(showcase.features({
+${raw(featureCards({
title: 'What we promise, in writing',
title: 'What we promise, in writing',
items: [
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.' },
{ 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.' },
new file mode 100644
new file mode 100644
@@ -0,0 +1,205 @@
@@ -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); });
@@ -9,4 +9,9 @@ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'pics.cs
@@ -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-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.match(css, /\.ovp-size-table thead th \{[^}]*color: var\(--ovp-faint\);/);
assert.ok(!/var\(--text-faint/.test(css), 'no colour names --text-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');
console.log('pics contrast: all checks passed');
@@ -72,6 +72,7 @@ function multipart(fields, file) {
@@ -72,6 +72,7 @@ function multipart(fields, file) {
assert.ok(home.text.includes('Share pictures.') && home.text.includes('Sign in to upload'));
assert.ok(home.text.includes('Share pictures.') && home.text.includes('Sign in to upload'));
const signedInHome = await t.get('/', { headers: PICS, as: kim });
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(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.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.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');
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) {
@@ -91,6 +92,12 @@ function multipart(fields, file) {
assert.deepStrictEqual(sections.filter((h) => !home.includes(`${h}</span></h2>`)), [], 'every section is there');
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(/<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 "Authorization: Bearer $OPENVIBE_TOKEN" \\\n'), 'the curl sample keeps its line breaks');
assert.ok(home.includes('/api/v1/images \\\n -H "Authorization: Bearer $OPENVIBE_TOKEN" \\\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 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');
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).
// The questions section's own disclosures: the shared footer's "More from OpenVibe" is one too (openvibe-shared 4.15).