openvibers/openvibe.network Commits
Pages the browser check flagged (Host scripts/browser-check.js, WS-Q task 3): /login gets a canonical URL, a no-JS note (the form panels need JavaScript), named password toggles ("Show password"/"Hide password", was axe button-name critical), the active tab on --accent-dark (6.7:1, was 3.67:1) and --text-muted #8e8ea0 (5.2:1 on the input background, was 3.5:1); the /updates site filter's current chip uses --accent-strong/--on-accent-strong (was white on --accent, 3.67:1); the home page's JSON-LD site list is named by its visible heading ("Nine sites, one front door", was "OpenVibe sites", in no visible text). test/page-checks.test.js keeps them
Changes
@@@ -4,6 +4,7 @@<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sign In — OpenVibe</title>++<link rel="canonical" href="https://openvibe.network/login"><link rel="icon" type="image/svg+xml" data-ov-icon="network" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%22-32%20-32%2064%2064%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22ring%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2360a5fa%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%232563eb%22%2F%3E%3C%2FlinearGradient%3E%0A%3CradialGradient%20id%3D%22glow%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2360a5fa%22%20stop-opacity%3D%22.34%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%2360a5fa%22%20stop-opacity%3D%220%22%2F%3E%3C%2FradialGradient%3E%0A%3CradialGradient%20id%3D%22bg%22%20cx%3D%22.5%22%20cy%3D%22.38%22%20r%3D%22.8%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2316223a%22%2F%3E%3Cstop%20offset%3D%22.6%22%20stop-color%3D%22%230a0f1c%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23060912%22%2F%3E%3C%2FradialGradient%3E%3C%2Fdefs%3E%3Crect%20x%3D%22-32%22%20y%3D%22-32%22%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2215%22%20fill%3D%22url(%23bg)%22%2F%3E%3Cg%20transform%3D%22scale(1.22)%22%3E%0A%3Ccircle%20r%3D%2221%22%20fill%3D%22url(%23glow)%22%2F%3E%0A%3Ccircle%20r%3D%2218%22%20fill%3D%22none%22%20stroke%3D%22url(%23ring)%22%20stroke-width%3D%224%22%20opacity%3D%22.38%22%2F%3E%0A%3Cpath%20d%3D%22M0%2C-18%20A18%2C18%200%200%2C1%2017.4%2C4.6%22%20fill%3D%22none%22%20stroke%3D%22url(%23ring)%22%20stroke-width%3D%224.4%22%20stroke-linecap%3D%22round%22%2F%3E%0A%3Cpath%20d%3D%22M-9.5%2C-7%20L0%2C10%20L9.5%2C-7%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%224.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%0A%3Ccircle%20cx%3D%2217.4%22%20cy%3D%224.6%22%20r%3D%223.2%22%20fill%3D%22%23ffffff%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E"><meta name="theme-color" content="#0d131d"><meta name="color-scheme" content="dark light">@@@ -17,7 +18,7 @@--bg:#0d0d14;--bg-card:#16161f;--bg-input:#1c1c28;--border:#2a2a3a;--border-focus:#8b5cf6;--accent:#3b82f6;--accent-glow:rgba(139,92,246,0.4);--accent-dark:#1d4ed8;-- --text:#e0e0e0;--text-muted:#707080;--text-dim:#505060;++ --text:#e0e0e0;--text-muted:#8e8ea0;--text-dim:#505060;--live-red:#ef4444;--success:#22c55e;}html{min-height:100%;background:var(--bg);color:var(--text);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;overflow-x:hidden;overflow-y:auto}@@@ -97,7 +98,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Interflex:1;padding:8px;border:none;border-radius:8px;font-size:13px;font-weight:600;background:transparent;color:var(--text-muted);cursor:pointer;transition:all .2s;}--.tab-bar button.active{background:var(--accent);color:#fff;box-shadow:0 2px 8px rgba(139,92,246,0.3)}++.tab-bar button.active{background:var(--accent-dark);color:#fff;box-shadow:0 2px 8px rgba(139,92,246,0.3)}.tab-bar button:hover:not(.active){color:var(--text)}/* Forms */@@@ -254,6 +255,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter</div><!-- Tab bar (hidden when chooser is shown) -->++ <noscript><p class="panel-note">Signing in and creating an account need JavaScript: turn it on for openvibe.network and reload this page. You can use almost everything on OpenVibe as a guest without signing in.</p></noscript><div class="tab-bar" id="auth-tabs"><button class="active" onclick="switchTab('login')">Sign In</button><button onclick="switchTab('register')">Create Account</button>@@@ -288,7 +290,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter<div class="field"><label>Password</label><input type="password" name="password" placeholder="Your password" required autocomplete="current-password">-- <button type="button" class="toggle-password" onclick="togglePw(this)"><i class="fa-solid fa-eye"></i></button>++ <button type="button" class="toggle-password" onclick="togglePw(this)" aria-label="Show password"><i class="fa-solid fa-eye" aria-hidden="true"></i></button></div><button type="submit" class="submit-btn" id="login-submit">Sign In →@@@ -319,7 +321,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter<div class="field"><label>Password</label><input type="password" name="password" placeholder="Min 6 characters" required minlength="6" autocomplete="new-password">-- <button type="button" class="toggle-password" onclick="togglePw(this)"><i class="fa-solid fa-eye"></i></button>++ <button type="button" class="toggle-password" onclick="togglePw(this)" aria-label="Show password"><i class="fa-solid fa-eye" aria-hidden="true"></i></button></div><div class="field"><label>Confirm Password</label>@@@ -357,7 +359,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter<div class="field"><label>New Password</label><input type="password" name="new_password" placeholder="Min 6 characters" required minlength="6" autocomplete="new-password">-- <button type="button" class="toggle-password" onclick="togglePw(this)"><i class="fa-solid fa-eye"></i></button>++ <button type="button" class="toggle-password" onclick="togglePw(this)" aria-label="Show password"><i class="fa-solid fa-eye" aria-hidden="true"></i></button></div><div class="field"><label>Confirm New Password</label>@@@ -711,7 +713,8 @@ async function selectAccount(account) {function togglePw(btn) {const input = btn.previousElementSibling;input.type = input.type === 'password' ? 'text' : 'password';-- btn.innerHTML = input.type === 'password' ? '<i class="fa-solid fa-eye"></i>' : '<i class="fa-solid fa-eye-slash"></i>';++ btn.innerHTML = input.type === 'password' ? '<i class="fa-solid fa-eye" aria-hidden="true"></i>' : '<i class="fa-solid fa-eye-slash" aria-hidden="true"></i>';++ btn.setAttribute('aria-label', input.type === 'password' ? 'Show password' : 'Hide password');}function getRegisterUsernameError(username) {@@@ -50,13 +50,17 @@ const CSS = `@media (prefers-reduced-motion:reduce){.home-card{transition:none}}${icons.CSS}`;++// The sites section's heading; the JSON-LD ItemList of the same sites carries it as its name, so the++// structured data names something a visitor can see (browser check, WS-Q task 3).++const sitesHeading = () => `${inWords(OPEN.length)} sites, one front door`;++function body(catalog) {const byId = new Map(catalog.tools.map(t => [t.id, t]));const popular = POPULAR.map(id => byId.get(id)).filter(Boolean);const fams = catalog.families.filter(f => f.url);const count = (f) => f.count || catalog.tools.filter(t => t.family === f.id).length;return `<style>${CSS}</style>--<section class="home-sec" id="network" aria-labelledby="h-sites"><h2 id="h-sites">${inWords(OPEN.length)} sites, one front door</h2><p class="lede">Stream, chat, build, share, play, write and store. Everything is open to visitors; signing in once carries your name, theme and notifications to all of it.</p>++<section class="home-sec" id="network" aria-labelledby="h-sites"><h2 id="h-sites">${esc(sitesHeading())}</h2><p class="lede">Stream, chat, build, share, play, write and store. Everything is open to visitors; signing in once carries your name, theme and notifications to all of it.</p><div class="home-grid">${OPEN.map((site) => `<a class="home-card" href="https://${esc(site.host)}/">${icon(site.icon, 48)}<span><b>${esc(siteName(site))}</b><em>${esc(site.tagline)}</em><small>${esc(site.what)}</small></span></a>`).join('')}</div></section><section class="home-sec" id="tools" aria-labelledby="h-tools"><h2 id="h-tools">${catalog.tools.length} tools that just open</h2><p class="lede">No installs and no sign-up wall. Every tool has its own short address, so <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a> or <a href="https://dns.openvibe.tools/">dns.openvibe.tools</a> takes you straight there. Browse them all at <a href="https://openvibe.tools/">openvibe.tools</a>.</p><div class="home-fams">${fams.map(f => `<a class="home-fam" href="${esc(f.path ? 'https://openvibe.tools' + f.path : f.url)}">${icon(f.icon, 38)}<span><b>${esc(f.name)}</b><small>${count(f)} tools · ${esc(f.tagline || '')}</small></span></a>`).join('')}</div>@@@ -77,7 +81,7 @@ function render() {const stat = fs.statSync(SHELL);const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}`;if (cache.key === key) return cache;-- const ld = seo.jsonLdTag(seo.jsonLd.itemList('OpenVibe sites', OPEN.map((site) => ({ name: siteName(site), url: `https://${site.host}/`, description: site.what }))));++ const ld = seo.jsonLdTag(seo.jsonLd.itemList(sitesHeading(), OPEN.map((site) => ({ name: siteName(site), url: `https://${site.host}/`, description: site.what }))));const html = fs.readFileSync(SHELL, 'utf8').replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Themes', href: '/themes' }] })).replace('<!--OV:HOME-->', body(catalog)).replace('<!--OV:COUNT-->', String(catalog.tools.length)).replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', `${ld}\n</head>`);cache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };return cache;@@@ -118,7 +118,7 @@ a{color:var(--accent)}.up{max-width:960px;margin:0 auto;padding:24px 16px 40px}.up h1{font-size:clamp(1.5rem,4vw,2rem);margin:.2em 0 .3em}.up .lede{color:var(--text-secondary);margin:0 0 18px;line-height:1.55}.up-sites{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 18px}.up-sites a{font-size:.8rem;padding:4px 12px;border-radius:999px;border:1px solid var(--border);color:var(--text-secondary);text-decoration:none}--.up-sites a[aria-current]{background:var(--accent);border-color:var(--accent);color:#fff}++.up-sites a[aria-current]{background:var(--accent-strong,#1d4ed8);border-color:var(--accent-strong,#1d4ed8);color:var(--on-accent-strong,#fff)}.ov-shipped-day h3{font-size:.95rem;color:var(--text-secondary);border-bottom:1px solid var(--border);padding-bottom:8px;margin:18px 0 4px}.ov-shipped-entry{display:flex;gap:10px;align-items:baseline;padding:8px 12px;flex-wrap:wrap}.ov-shipped-text{flex:1;min-width:0;overflow-wrap:anywhere}.ov-shipped-meta{font-size:.75rem;color:var(--text-secondary)}.ov-shipped-hash{font-family:ui-monospace,monospace;font-size:.8rem}nnew file mode 100644@@@ -0,0 +1,33 @@++'use strict';++// What the browser check (OpenVibe.Host scripts/browser-check.js, WS-Q task 3) found on Network's pages stays++// fixed: /login has a canonical URL, a no-JS note, named password toggles and AA contrast; the /updates site++// filter's current chip reads at AA; the home page's JSON-LD site list is named by its visible heading.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const lum = (hex) => {++ const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.replace('#', '').slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));++ return 0.2126 * r + 0.7152 * g + 0.0722 * b;++};++const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };++++const login = fs.readFileSync(path.join(__dirname, '..', 'public', 'login.html'), 'utf8');++assert.deepStrictEqual(login.match(/<link rel="canonical"[^>]*>/g), ['<link rel="canonical" href="https://openvibe.network/login">']);++assert.match(login, /<noscript><p class="panel-note">Signing in and creating an account need JavaScript/);++const toggles = login.match(/<button[^>]*class="toggle-password"[^>]*>/g);++assert.ok(toggles.length >= 3 && toggles.every((b) => /aria-label="Show password"/.test(b)), 'every password toggle has a name');++assert.match(login, /btn\.setAttribute\('aria-label', input\.type === 'password' \? 'Show password' : 'Hide password'\)/);++const token = (name) => login.match(new RegExp(`--${name}:(#[0-9a-f]{6})`, 'i'))[1];++for (const bg of ['bg', 'bg-card', 'bg-input']) assert.ok(ratio(token('text-muted'), token(bg)) >= 4.5, `muted text on --${bg} reads at 4.5:1`);++assert.match(login, /\.tab-bar button\.active\{background:var\(--accent-dark\);color:#fff;/);++assert.ok(ratio(token('accent-dark'), '#ffffff') >= 4.5, 'the active tab reads at 4.5:1');++++const updates = fs.readFileSync(path.join(__dirname, '..', 'server', 'updates', 'routes.js'), 'utf8');++assert.match(updates, /\.up-sites a\[aria-current\]\{background:var\(--accent-strong,#1d4ed8\);border-color:var\(--accent-strong,#1d4ed8\);color:var\(--on-accent-strong,#fff\)\}/);++++const home = require('../server/home/render').render().html;++const heading = home.match(/<h2 id="h-sites">([^<]*)<\/h2>/)[1];++assert.ok(home.includes(`"@type":"ItemList","name":${JSON.stringify(heading)}`), `the ItemList is named "${heading}"`);++console.log('page checks: login, updates filter and home JSON-LD ok');++process.exit(0);
@@ -4,6 +4,7 @@
@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign In — OpenVibe</title>
<title>Sign In — OpenVibe</title>
+<link rel="canonical" href="https://openvibe.network/login">
<link rel="icon" type="image/svg+xml" data-ov-icon="network" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%22-32%20-32%2064%2064%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22ring%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2360a5fa%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%232563eb%22%2F%3E%3C%2FlinearGradient%3E%0A%3CradialGradient%20id%3D%22glow%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2360a5fa%22%20stop-opacity%3D%22.34%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%2360a5fa%22%20stop-opacity%3D%220%22%2F%3E%3C%2FradialGradient%3E%0A%3CradialGradient%20id%3D%22bg%22%20cx%3D%22.5%22%20cy%3D%22.38%22%20r%3D%22.8%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2316223a%22%2F%3E%3Cstop%20offset%3D%22.6%22%20stop-color%3D%22%230a0f1c%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23060912%22%2F%3E%3C%2FradialGradient%3E%3C%2Fdefs%3E%3Crect%20x%3D%22-32%22%20y%3D%22-32%22%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2215%22%20fill%3D%22url(%23bg)%22%2F%3E%3Cg%20transform%3D%22scale(1.22)%22%3E%0A%3Ccircle%20r%3D%2221%22%20fill%3D%22url(%23glow)%22%2F%3E%0A%3Ccircle%20r%3D%2218%22%20fill%3D%22none%22%20stroke%3D%22url(%23ring)%22%20stroke-width%3D%224%22%20opacity%3D%22.38%22%2F%3E%0A%3Cpath%20d%3D%22M0%2C-18%20A18%2C18%200%200%2C1%2017.4%2C4.6%22%20fill%3D%22none%22%20stroke%3D%22url(%23ring)%22%20stroke-width%3D%224.4%22%20stroke-linecap%3D%22round%22%2F%3E%0A%3Cpath%20d%3D%22M-9.5%2C-7%20L0%2C10%20L9.5%2C-7%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%224.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%0A%3Ccircle%20cx%3D%2217.4%22%20cy%3D%224.6%22%20r%3D%223.2%22%20fill%3D%22%23ffffff%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E">
<link rel="icon" type="image/svg+xml" data-ov-icon="network" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%22-32%20-32%2064%2064%22%3E%3Cdefs%3E%3ClinearGradient%20id%3D%22ring%22%20x1%3D%220%22%20y1%3D%220%22%20x2%3D%221%22%20y2%3D%221%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2360a5fa%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%232563eb%22%2F%3E%3C%2FlinearGradient%3E%0A%3CradialGradient%20id%3D%22glow%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2360a5fa%22%20stop-opacity%3D%22.34%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%2360a5fa%22%20stop-opacity%3D%220%22%2F%3E%3C%2FradialGradient%3E%0A%3CradialGradient%20id%3D%22bg%22%20cx%3D%22.5%22%20cy%3D%22.38%22%20r%3D%22.8%22%3E%3Cstop%20offset%3D%220%22%20stop-color%3D%22%2316223a%22%2F%3E%3Cstop%20offset%3D%22.6%22%20stop-color%3D%22%230a0f1c%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22%23060912%22%2F%3E%3C%2FradialGradient%3E%3C%2Fdefs%3E%3Crect%20x%3D%22-32%22%20y%3D%22-32%22%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2215%22%20fill%3D%22url(%23bg)%22%2F%3E%3Cg%20transform%3D%22scale(1.22)%22%3E%0A%3Ccircle%20r%3D%2221%22%20fill%3D%22url(%23glow)%22%2F%3E%0A%3Ccircle%20r%3D%2218%22%20fill%3D%22none%22%20stroke%3D%22url(%23ring)%22%20stroke-width%3D%224%22%20opacity%3D%22.38%22%2F%3E%0A%3Cpath%20d%3D%22M0%2C-18%20A18%2C18%200%200%2C1%2017.4%2C4.6%22%20fill%3D%22none%22%20stroke%3D%22url(%23ring)%22%20stroke-width%3D%224.4%22%20stroke-linecap%3D%22round%22%2F%3E%0A%3Cpath%20d%3D%22M-9.5%2C-7%20L0%2C10%20L9.5%2C-7%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%224.8%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%0A%3Ccircle%20cx%3D%2217.4%22%20cy%3D%224.6%22%20r%3D%223.2%22%20fill%3D%22%23ffffff%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E">
<meta name="theme-color" content="#0d131d">
<meta name="theme-color" content="#0d131d">
<meta name="color-scheme" content="dark light">
<meta name="color-scheme" content="dark light">
@@ -17,7 +18,7 @@
@@ -17,7 +18,7 @@
--bg:#0d0d14;--bg-card:#16161f;--bg-input:#1c1c28;
--bg:#0d0d14;--bg-card:#16161f;--bg-input:#1c1c28;
--border:#2a2a3a;--border-focus:#8b5cf6;
--border:#2a2a3a;--border-focus:#8b5cf6;
--accent:#3b82f6;--accent-glow:rgba(139,92,246,0.4);--accent-dark:#1d4ed8;
--accent:#3b82f6;--accent-glow:rgba(139,92,246,0.4);--accent-dark:#1d4ed8;
- --text:#e0e0e0;--text-muted:#707080;--text-dim:#505060;
+ --text:#e0e0e0;--text-muted:#8e8ea0;--text-dim:#505060;
--live-red:#ef4444;--success:#22c55e;
--live-red:#ef4444;--success:#22c55e;
}
}
html{min-height:100%;background:var(--bg);color:var(--text);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;overflow-x:hidden;overflow-y:auto}
html{min-height:100%;background:var(--bg);color:var(--text);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;overflow-x:hidden;overflow-y:auto}
@@ -97,7 +98,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
@@ -97,7 +98,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
flex:1;padding:8px;border:none;border-radius:8px;font-size:13px;font-weight:600;
flex:1;padding:8px;border:none;border-radius:8px;font-size:13px;font-weight:600;
background:transparent;color:var(--text-muted);cursor:pointer;transition:all .2s;
background:transparent;color:var(--text-muted);cursor:pointer;transition:all .2s;
}
}
-.tab-bar button.active{background:var(--accent);color:#fff;box-shadow:0 2px 8px rgba(139,92,246,0.3)}
+.tab-bar button.active{background:var(--accent-dark);color:#fff;box-shadow:0 2px 8px rgba(139,92,246,0.3)}
.tab-bar button:hover:not(.active){color:var(--text)}
.tab-bar button:hover:not(.active){color:var(--text)}
/* Forms */
/* Forms */
@@ -254,6 +255,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
@@ -254,6 +255,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
</div>
</div>
<!-- Tab bar (hidden when chooser is shown) -->
<!-- Tab bar (hidden when chooser is shown) -->
+ <noscript><p class="panel-note">Signing in and creating an account need JavaScript: turn it on for openvibe.network and reload this page. You can use almost everything on OpenVibe as a guest without signing in.</p></noscript>
<div class="tab-bar" id="auth-tabs">
<div class="tab-bar" id="auth-tabs">
<button class="active" onclick="switchTab('login')">Sign In</button>
<button class="active" onclick="switchTab('login')">Sign In</button>
<button onclick="switchTab('register')">Create Account</button>
<button onclick="switchTab('register')">Create Account</button>
@@ -288,7 +290,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
@@ -288,7 +290,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
<div class="field">
<div class="field">
<label>Password</label>
<label>Password</label>
<input type="password" name="password" placeholder="Your password" required autocomplete="current-password">
<input type="password" name="password" placeholder="Your password" required autocomplete="current-password">
- <button type="button" class="toggle-password" onclick="togglePw(this)"><i class="fa-solid fa-eye"></i></button>
+ <button type="button" class="toggle-password" onclick="togglePw(this)" aria-label="Show password"><i class="fa-solid fa-eye" aria-hidden="true"></i></button>
</div>
</div>
<button type="submit" class="submit-btn" id="login-submit">
<button type="submit" class="submit-btn" id="login-submit">
Sign In →
Sign In →
@@ -319,7 +321,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
@@ -319,7 +321,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
<div class="field">
<div class="field">
<label>Password</label>
<label>Password</label>
<input type="password" name="password" placeholder="Min 6 characters" required minlength="6" autocomplete="new-password">
<input type="password" name="password" placeholder="Min 6 characters" required minlength="6" autocomplete="new-password">
- <button type="button" class="toggle-password" onclick="togglePw(this)"><i class="fa-solid fa-eye"></i></button>
+ <button type="button" class="toggle-password" onclick="togglePw(this)" aria-label="Show password"><i class="fa-solid fa-eye" aria-hidden="true"></i></button>
</div>
</div>
<div class="field">
<div class="field">
<label>Confirm Password</label>
<label>Confirm Password</label>
@@ -357,7 +359,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
@@ -357,7 +359,7 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
<div class="field">
<div class="field">
<label>New Password</label>
<label>New Password</label>
<input type="password" name="new_password" placeholder="Min 6 characters" required minlength="6" autocomplete="new-password">
<input type="password" name="new_password" placeholder="Min 6 characters" required minlength="6" autocomplete="new-password">
- <button type="button" class="toggle-password" onclick="togglePw(this)"><i class="fa-solid fa-eye"></i></button>
+ <button type="button" class="toggle-password" onclick="togglePw(this)" aria-label="Show password"><i class="fa-solid fa-eye" aria-hidden="true"></i></button>
</div>
</div>
<div class="field">
<div class="field">
<label>Confirm New Password</label>
<label>Confirm New Password</label>
@@ -711,7 +713,8 @@ async function selectAccount(account) {
@@ -711,7 +713,8 @@ async function selectAccount(account) {
function togglePw(btn) {
function togglePw(btn) {
const input = btn.previousElementSibling;
const input = btn.previousElementSibling;
input.type = input.type === 'password' ? 'text' : 'password';
input.type = input.type === 'password' ? 'text' : 'password';
- btn.innerHTML = input.type === 'password' ? '<i class="fa-solid fa-eye"></i>' : '<i class="fa-solid fa-eye-slash"></i>';
+ btn.innerHTML = input.type === 'password' ? '<i class="fa-solid fa-eye" aria-hidden="true"></i>' : '<i class="fa-solid fa-eye-slash" aria-hidden="true"></i>';
+ btn.setAttribute('aria-label', input.type === 'password' ? 'Show password' : 'Hide password');
}
}
function getRegisterUsernameError(username) {
function getRegisterUsernameError(username) {
@@ -50,13 +50,17 @@ const CSS = `
@@ -50,13 +50,17 @@ const CSS = `
@media (prefers-reduced-motion:reduce){.home-card{transition:none}}
@media (prefers-reduced-motion:reduce){.home-card{transition:none}}
${icons.CSS}`;
${icons.CSS}`;
+// The sites section's heading; the JSON-LD ItemList of the same sites carries it as its name, so the
+// structured data names something a visitor can see (browser check, WS-Q task 3).
+const sitesHeading = () => `${inWords(OPEN.length)} sites, one front door`;
+
function body(catalog) {
function body(catalog) {
const byId = new Map(catalog.tools.map(t => [t.id, t]));
const byId = new Map(catalog.tools.map(t => [t.id, t]));
const popular = POPULAR.map(id => byId.get(id)).filter(Boolean);
const popular = POPULAR.map(id => byId.get(id)).filter(Boolean);
const fams = catalog.families.filter(f => f.url);
const fams = catalog.families.filter(f => f.url);
const count = (f) => f.count || catalog.tools.filter(t => t.family === f.id).length;
const count = (f) => f.count || catalog.tools.filter(t => t.family === f.id).length;
return `<style>${CSS}</style>
return `<style>${CSS}</style>
-<section class="home-sec" id="network" aria-labelledby="h-sites"><h2 id="h-sites">${inWords(OPEN.length)} sites, one front door</h2><p class="lede">Stream, chat, build, share, play, write and store. Everything is open to visitors; signing in once carries your name, theme and notifications to all of it.</p>
+<section class="home-sec" id="network" aria-labelledby="h-sites"><h2 id="h-sites">${esc(sitesHeading())}</h2><p class="lede">Stream, chat, build, share, play, write and store. Everything is open to visitors; signing in once carries your name, theme and notifications to all of it.</p>
<div class="home-grid">${OPEN.map((site) => `<a class="home-card" href="https://${esc(site.host)}/">${icon(site.icon, 48)}<span><b>${esc(siteName(site))}</b><em>${esc(site.tagline)}</em><small>${esc(site.what)}</small></span></a>`).join('')}</div></section>
<div class="home-grid">${OPEN.map((site) => `<a class="home-card" href="https://${esc(site.host)}/">${icon(site.icon, 48)}<span><b>${esc(siteName(site))}</b><em>${esc(site.tagline)}</em><small>${esc(site.what)}</small></span></a>`).join('')}</div></section>
<section class="home-sec" id="tools" aria-labelledby="h-tools"><h2 id="h-tools">${catalog.tools.length} tools that just open</h2><p class="lede">No installs and no sign-up wall. Every tool has its own short address, so <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a> or <a href="https://dns.openvibe.tools/">dns.openvibe.tools</a> takes you straight there. Browse them all at <a href="https://openvibe.tools/">openvibe.tools</a>.</p>
<section class="home-sec" id="tools" aria-labelledby="h-tools"><h2 id="h-tools">${catalog.tools.length} tools that just open</h2><p class="lede">No installs and no sign-up wall. Every tool has its own short address, so <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a> or <a href="https://dns.openvibe.tools/">dns.openvibe.tools</a> takes you straight there. Browse them all at <a href="https://openvibe.tools/">openvibe.tools</a>.</p>
<div class="home-fams">${fams.map(f => `<a class="home-fam" href="${esc(f.path ? 'https://openvibe.tools' + f.path : f.url)}">${icon(f.icon, 38)}<span><b>${esc(f.name)}</b><small>${count(f)} tools · ${esc(f.tagline || '')}</small></span></a>`).join('')}</div>
<div class="home-fams">${fams.map(f => `<a class="home-fam" href="${esc(f.path ? 'https://openvibe.tools' + f.path : f.url)}">${icon(f.icon, 38)}<span><b>${esc(f.name)}</b><small>${count(f)} tools · ${esc(f.tagline || '')}</small></span></a>`).join('')}</div>
@@ -77,7 +81,7 @@ function render() {
@@ -77,7 +81,7 @@ function render() {
const stat = fs.statSync(SHELL);
const stat = fs.statSync(SHELL);
const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}`;
const key = `${stat.mtimeMs}:${catalog.updated}:${catalog.tools.length}`;
if (cache.key === key) return cache;
if (cache.key === key) return cache;
- const ld = seo.jsonLdTag(seo.jsonLd.itemList('OpenVibe sites', OPEN.map((site) => ({ name: siteName(site), url: `https://${site.host}/`, description: site.what }))));
+ const ld = seo.jsonLdTag(seo.jsonLd.itemList(sitesHeading(), OPEN.map((site) => ({ name: siteName(site), url: `https://${site.host}/`, description: site.what }))));
const html = fs.readFileSync(SHELL, 'utf8').replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Themes', href: '/themes' }] })).replace('<!--OV:HOME-->', body(catalog)).replace('<!--OV:COUNT-->', String(catalog.tools.length)).replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', `${ld}\n</head>`);
const html = fs.readFileSync(SHELL, 'utf8').replace('<div id="navbar-mount"></div>', '<div id="navbar-mount"></div>' + require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links: [{ label: 'Sign in', href: '/login' }, { label: 'Themes', href: '/themes' }] })).replace('<!--OV:HOME-->', body(catalog)).replace('<!--OV:COUNT-->', String(catalog.tools.length)).replace('<div id="ov-footer"></div>', require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })).replace('</head>', `${ld}\n</head>`);
cache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };
cache = { key, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' };
return cache;
return cache;
@@ -118,7 +118,7 @@ a{color:var(--accent)}
@@ -118,7 +118,7 @@ a{color:var(--accent)}
.up{max-width:960px;margin:0 auto;padding:24px 16px 40px}
.up{max-width:960px;margin:0 auto;padding:24px 16px 40px}
.up h1{font-size:clamp(1.5rem,4vw,2rem);margin:.2em 0 .3em}.up .lede{color:var(--text-secondary);margin:0 0 18px;line-height:1.55}
.up h1{font-size:clamp(1.5rem,4vw,2rem);margin:.2em 0 .3em}.up .lede{color:var(--text-secondary);margin:0 0 18px;line-height:1.55}
.up-sites{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 18px}.up-sites a{font-size:.8rem;padding:4px 12px;border-radius:999px;border:1px solid var(--border);color:var(--text-secondary);text-decoration:none}
.up-sites{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 18px}.up-sites a{font-size:.8rem;padding:4px 12px;border-radius:999px;border:1px solid var(--border);color:var(--text-secondary);text-decoration:none}
-.up-sites a[aria-current]{background:var(--accent);border-color:var(--accent);color:#fff}
+.up-sites a[aria-current]{background:var(--accent-strong,#1d4ed8);border-color:var(--accent-strong,#1d4ed8);color:var(--on-accent-strong,#fff)}
.ov-shipped-day h3{font-size:.95rem;color:var(--text-secondary);border-bottom:1px solid var(--border);padding-bottom:8px;margin:18px 0 4px}
.ov-shipped-day h3{font-size:.95rem;color:var(--text-secondary);border-bottom:1px solid var(--border);padding-bottom:8px;margin:18px 0 4px}
.ov-shipped-entry{display:flex;gap:10px;align-items:baseline;padding:8px 12px;flex-wrap:wrap}.ov-shipped-text{flex:1;min-width:0;overflow-wrap:anywhere}
.ov-shipped-entry{display:flex;gap:10px;align-items:baseline;padding:8px 12px;flex-wrap:wrap}.ov-shipped-text{flex:1;min-width:0;overflow-wrap:anywhere}
.ov-shipped-meta{font-size:.75rem;color:var(--text-secondary)}.ov-shipped-hash{font-family:ui-monospace,monospace;font-size:.8rem}
.ov-shipped-meta{font-size:.75rem;color:var(--text-secondary)}.ov-shipped-hash{font-family:ui-monospace,monospace;font-size:.8rem}
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+'use strict';
+// What the browser check (OpenVibe.Host scripts/browser-check.js, WS-Q task 3) found on Network's pages stays
+// fixed: /login has a canonical URL, a no-JS note, named password toggles and AA contrast; the /updates site
+// filter's current chip reads at AA; the home page's JSON-LD site list is named by its visible heading.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const lum = (hex) => {
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.replace('#', '').slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+};
+const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); };
+
+const login = fs.readFileSync(path.join(__dirname, '..', 'public', 'login.html'), 'utf8');
+assert.deepStrictEqual(login.match(/<link rel="canonical"[^>]*>/g), ['<link rel="canonical" href="https://openvibe.network/login">']);
+assert.match(login, /<noscript><p class="panel-note">Signing in and creating an account need JavaScript/);
+const toggles = login.match(/<button[^>]*class="toggle-password"[^>]*>/g);
+assert.ok(toggles.length >= 3 && toggles.every((b) => /aria-label="Show password"/.test(b)), 'every password toggle has a name');
+assert.match(login, /btn\.setAttribute\('aria-label', input\.type === 'password' \? 'Show password' : 'Hide password'\)/);
+const token = (name) => login.match(new RegExp(`--${name}:(#[0-9a-f]{6})`, 'i'))[1];
+for (const bg of ['bg', 'bg-card', 'bg-input']) assert.ok(ratio(token('text-muted'), token(bg)) >= 4.5, `muted text on --${bg} reads at 4.5:1`);
+assert.match(login, /\.tab-bar button\.active\{background:var\(--accent-dark\);color:#fff;/);
+assert.ok(ratio(token('accent-dark'), '#ffffff') >= 4.5, 'the active tab reads at 4.5:1');
+
+const updates = fs.readFileSync(path.join(__dirname, '..', 'server', 'updates', 'routes.js'), 'utf8');
+assert.match(updates, /\.up-sites a\[aria-current\]\{background:var\(--accent-strong,#1d4ed8\);border-color:var\(--accent-strong,#1d4ed8\);color:var\(--on-accent-strong,#fff\)\}/);
+
+const home = require('../server/home/render').render().html;
+const heading = home.match(/<h2 id="h-sites">([^<]*)<\/h2>/)[1];
+assert.ok(home.includes(`"@type":"ItemList","name":${JSON.stringify(heading)}`), `the ItemList is named "${heading}"`);
+console.log('page checks: login, updates filter and home JSON-LD ok');
+process.exit(0);