openvibers/openvibe.tools Commits

Tools: the shared tool frame on every satellite tool page (#157)

OP OpenVibers committed 11 days ago · 8cbf3a37bc · 50 files, +3493 −2213

* Tools: the shared tool frame on every satellite tool page

The network and code tool pages got the shared frame in #155. The apps that
drew their own pages — img, text, audio, docs, maps, yt, food — now carry the
same frame: the head (the crumb, the name, one sentence, the terms), the box
(a drop zone for the file tools, the field or editor for the rest), the Try
examples, the answer (the file it made, its size before and after, and
Download as the primary button), the family, the folds and "People also use",
in that order.

The terms come from the app's real configuration and from nowhere else: Free,
where the work happens (in the browser, or on our server with the file deleted
after N minutes, from the app's own cleanup config), the size and count limits
from each app's limits config, and the anonymous rate from the guard's quota
table. The one button says the verb — "Convert to PNG", "Compress", "Trim",
"Make a slug" — never "Run". The navbar says "Tools" and the footer
"OpenVibe.Tools", as on the network and code pages.

Each app keeps its own title, description, canonical and JSON-LD, and its own
upload, processing and download: only the page around them changes.

apps/_shared/frame.js is the one source of the markup and of the terms;
apps/_shared/public/tool-frame.js is the browser glue every app serves at
/js/tool-frame.js.

* Merge main into tool-frame-2: the frame's pages and scripts kept; main's reduced-motion guards kept; Font Awesome's stylesheet stays out (#171)

* The hub's "People also use" no longer lists the converter the hub already is

Changes

ddiff --git a/apps/_shared/frame.js b/apps/_shared/frame.js
nnew file mode 100644
iindex 0000000..06298f4
---- /dev/null
++++ b/apps/_shared/frame.js
@@@ -0,0 +1,229 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// The tool frame for the satellites (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
++//
++// The network and code tools on openvibe.tools got the shared frame in #155. The satellites that draw
++// their own pages — img, text, audio, docs, maps, yt, food — get the same frame here: the head (crumb,
++// name, one sentence, terms), the box, the answer, the family, the folds and "People also use".
++//
++// The markup is openvibe-shared/kit's; this module only chooses the copy and the links. It takes the
++// kit and the server from the app that requires it (apps/_shared has no dependencies of its own).
++//
++// The terms come from the app's real configuration and from nowhere else:
++// • where the work happens, and how long the file is kept, from the app's own cleanup config;
++// • the file's size and count limits, from the app's upload limits;
++// • the anonymous rate, from the guard's quota table (apps/_shared/guard/limits.js) and the tool's
++// own cost in it.
++// Nothing here promises a limit the configuration does not have.
++// ═══════════════════════════════════════════════════════════════
++
++const { quotas } = require('./guard/limits');
++
++const SITE = 'https://openvibe.tools';
++
++/**
++ * The frame's builders, for one app.
++ * @param {object} deps { kit, serve } — openvibe-shared/kit and openvibe-shared/serve
++ */
++module.exports = function createFrame({ kit, serve }) {
++ const esc = kit.esc;
++
++ /** A file size in the words a person reads: "50 MB", "1.5 GB". */
++ function size(bytes) {
++ const b = Number(bytes) || 0;
++ if (b >= 1024 * 1024 * 1024) return `${Math.round(b / (1024 * 1024 * 1024))} GB`;
++ if (b >= 1024 * 1024) return `${Math.round(b / (1024 * 1024))} MB`;
++ return `${Math.round(b / 1024)} KB`;
++ }
++
++ /**
++ * The terms of a tool, from its real configuration.
++ * @param {object} s
++ * work 'server' (the file goes to our server) or 'browser' (nothing leaves the page)
++ * ttlMs how long the server keeps the file (the app's cleanup config)
++ * maxBytes the largest file the app takes (the app's upload limits)
++ * maxFiles how many files at once (the tool's own descriptor)
++ * quotaClass the guard's quota class for the tool
++ * cost what one run costs in quota units (the tool's own descriptor)
++ * unit what one run is called: 'conversions', 'downloads', 'lookups'
++ * needsAccount the tool refuses anonymous callers
++ * @returns {Array<{text: string, icon: string}>} the head's terms
++ */
++ function terms(s) {
++ const facts = [{ text: 'Free', icon: 'gift' }];
++ if (s.work === 'server') {
++ if (s.ttlMs) {
++ const minutes = Math.max(1, Math.round((Number(s.ttlMs) || 0) / 60000));
++ facts.push({ text: `Runs on our server; your file is deleted after ${minutes} minutes`, icon: 'server' });
++ } else {
++ // A tool that keeps no file: the app says where its work happens, in its own words.
++ facts.push({ text: s.serverNote || 'Runs on our server', icon: 'server' });
++ }
++ } else {
++ facts.push({ text: 'Runs in your browser: nothing is sent', icon: 'computer' });
++ }
++ if (s.maxBytes) {
++ facts.push({
++ text: s.maxFiles && s.maxFiles > 1
++ ? `Up to ${s.maxFiles} files, ${size(s.maxBytes)} each`
++ : `One file up to ${size(s.maxBytes)}`,
++ icon: 'file',
++ });
++ }
++ const q = quotas()[s.quotaClass];
++ // One run is "1 download", two are "2 downloads": the unit follows the number.
++ const unit = (n) => {
++ const u = s.unit || 'runs';
++ return n === 1 ? u.replace(/s$/, '') : u;
++ };
++ if (s.needsAccount) {
++ // The tool refuses anonymous callers: the page still works, because the browser gets a
++ // session, and that session's rate is the one the guard's table holds.
++ const tier = q && q.session ? q.session : null;
++ if (tier && tier.perMinute) {
++ const n = Math.max(1, Math.floor(tier.perMinute / (s.cost || 1)));
++ facts.push({ text: `${n} ${unit(n)} a minute in a browser session`, icon: 'clock' });
++ }
++ facts.push({ text: 'A browser session: no account needed', icon: 'user' });
++ } else {
++ if (q && q.anonymous && q.anonymous.perMinute) {
++ const n = Math.max(1, Math.floor(q.anonymous.perMinute / (s.cost || 1)));
++ facts.push({ text: `${n} ${unit(n)} a minute without an account`, icon: 'clock' });
++ }
++ facts.push({ text: 'No sign-up', icon: 'user' });
++ }
++ return facts;
++ }
++
++ /** A field's label, from its name: "sampleRate" is "Sample rate". */
++ function labelOf(name, labels = {}) {
++ if (labels[name]) return labels[name];
++ const words = String(name).replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase();
++ return words.charAt(0).toUpperCase() + words.slice(1);
++ }
++
++ /** An enum's value, in the words a person reads: "bass_boost" is "Bass boost". */
++ function humanValue(value) {
++ const s = String(value).replace(/[_-]+/g, ' ').trim();
++ return s.charAt(0).toUpperCase() + s.slice(1);
++ }
++
++ /**
++ * One field of a box, from a tool's own descriptor: its type, its range and its default. A field
++ * the descriptor does not have is not on the page, and the field reads what the tool reads.
++ * @param {string} name the field's name
++ * @param {object} s the descriptor's field
++ * @param {object} [labels] the field's name in the words a person reads
++ * @param {object} [values] the field's values in the words a person reads
++ * @returns {string} HTML
++ */
++ function field(name, s, labels = {}, values = {}) {
++ const label = labelOf(name, labels);
++ if (s.enum) {
++ const value = s.default !== undefined ? s.default : s.enum[0];
++ return kit.selectRow({ name, label, value: String(value), options: s.enum.map((v) => [String(v), values[String(v)] || humanValue(v)]) });
++ }
++ if (s.type === 'boolean') {
++ return kit.selectRow({ name, label, value: String(s.default === true), options: [['true', 'Yes'], ['false', 'No']] });
++ }
++ const min = s.minimum != null ? ` min="${esc(s.minimum)}"` : '';
++ const max = s.maximum != null ? ` max="${esc(s.maximum)}"` : '';
++ const step = s.type === 'integer' ? ' step="1"' : '';
++ if (s.type === 'integer' || s.type === 'number') {
++ const value = s.default !== undefined ? ` value="${esc(s.default)}"` : ' value=""';
++ return `<label class="ovk-set-row"><span class="ovk-set-text">${esc(label)}</span>`
++ + `<input type="number" class="num-input num-input-sm" name="${esc(name)}"${value}${min}${max}${step}></label>`;
++ }
++ return `<label class="ovk-set-row"><span class="ovk-set-text">${esc(label)}</span>`
++ + `<input type="text" class="num-input num-input-sm" name="${esc(name)}" value=""${s.maxLength ? ` maxlength="${esc(s.maxLength)}"` : ''}></label>`;
++ }
++
++ /**
++ * The tool's head: the crumb, the name, the one sentence and the terms.
++ * @param {object} rec { icon, hue, family, crumb: { href, label }, title, task, facts }
++ */
++ function head(rec) {
++ return kit.toolHead({
++ icon: rec.icon || 'grid', hue: rec.hue, family: rec.family || '',
++ crumb: rec.crumb || null, title: rec.title || '', task: rec.task || '', facts: rec.facts || [],
++ });
++ }
++
++ /** The one button of a tool page: it says the verb, never "Run". */
++ function verbButton(label, { id = '', icon = null, disabled = false, extra = '' } = {}) {
++ return `<button type="button"${id ? ` id="${esc(id)}"` : ''} class="ovk-btn ovk-btn--primary ovk-tool-go"${disabled ? ' disabled' : ''}${extra}>`
++ + `${icon ? kit.icon(icon) : ''}<span>${esc(label)}</span></button>`;
++ }
++
++ /**
++ * The folds: About the tool, Questions, and the real call from code.
++ * @param {object} rec { name, about, bullets, faq: [[q, a]], api: { path, fields, needsAccount, capability, input } }
++ */
++ function folds(rec) {
++ const out = [];
++ const name = rec.name || rec.title || 'this tool';
++ if (rec.about) {
++ const bullets = (rec.bullets || []).length ? `<ul>${rec.bullets.map((b) => `<li>${esc(b)}</li>`).join('')}</ul>` : '';
++ out.push(kit.fold({ id: 'about-this-tool', title: `About ${name}`, body: `<p>${esc(rec.about)}</p>${bullets}` }));
++ }
++ if ((rec.faq || []).length) {
++ const body = rec.faq.map(([q, a]) => `<p><strong>${esc(q)}</strong></p><p>${esc(a)}</p>`).join('');
++ out.push(kit.fold({ title: 'Questions', meta: String(rec.faq.length), body }));
++ }
++ if (rec.api) {
++ const a = rec.api;
++ const lines = [`curl -X POST ${SITE}${a.path}`];
++ if (a.needsAccount) lines.push(`-H 'Authorization: Bearer $OPENVIBE_TOKEN'`);
++ lines.push(`-H 'Content-Type: application/json'`, `-d ${shq(JSON.stringify(a.payload || {}))}`);
++ const who = a.needsAccount
++ ? 'Needs a signed-in browser session or a token with <code>tools.tool.run</code>.'
++ : 'No account needed within the free limit; a token with <code>tools.tool.run</code> raises it.';
++ out.push(kit.fold({
++ title: 'Use it from code', meta: 'API',
++ body: `<p>Input: <code>${esc(a.fields || 'none')}</code>. ${who}</p>${kit.snippet({ code: lines.join(' \\\n ') })}`
++ + `<p><a href="${SITE}/api/v1/tools/${esc(a.id)}/schema">JSON Schema</a> · <a href="${SITE}/api/v1/openapi.json">OpenAPI</a> · <a href="${SITE}/developers">API docs</a></p>`,
++ }));
++ }
++ return out.filter(Boolean);
++ }
++
++ // curl quoting for a value with single quotes (the same helper the gateway's frame uses).
++ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
++
++ /**
++ * The whole frame for one tool page.
++ * @param {object} rec {
++ * id, icon, hue, family, crumb, title, task, facts, → the head
++ * box, → the card's markup
++ * familyItems, familyMore, familyTitle, → the family row
++ * about, bullets, faq, api, → the folds
++ * related: [{ href, title, sub }] → "People also use"
++ * }
++ * @returns {string} HTML
++ */
++ function page(rec) {
++ const related = (rec.related || []).filter(Boolean);
++ return `<div class="ovk-page ovk-page--wide" data-ov-tool-page="${esc(rec.id || '')}">${kit.tool({
++ head: head(rec),
++ input: rec.box || '',
++ answer: '',
++ family: kit.family({
++ title: rec.familyTitle || '', more: rec.familyMore || null,
++ items: rec.familyItems || [], label: rec.familyLabel || 'More tools',
++ }),
++ folds: folds(rec),
++ related: related.length ? kit.list(related, { title: 'People also use' }) : '',
++ })}</div>`;
++ }
++
++ /** The browser files a framed page loads (the kit and its behaviour, from this app's own pin). */
++ function assets() {
++ return {
++ css: [serve.url('kit.css'), serve.url('kit-data.css')],
++ js: [serve.url('kit.js'), serve.url('kit-web.js'), serve.url('kit-data-web.js')],
++ };
++ }
++
++ return { terms, field, labelOf, head, verbButton, folds, page, assets, size, SITE };
++};
ddiff --git a/apps/_shared/public/tool-frame.js b/apps/_shared/public/tool-frame.js
nnew file mode 100644
iindex 0000000..7d93547
---- /dev/null
++++ b/apps/_shared/public/tool-frame.js
@@@ -0,0 +1,126 @@
++/* The tool frame's browser glue (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
++ *
++ * The satellite apps keep their own upload, processing and download code; this only fills the frame:
++ * the answer a file tool draws in the frame's answer slot, and the sample a "Try" link drops into the
++ * drop zone. The markup comes from openvibe-shared/kit, the same builders the server used.
++ *
++ * Served by each app at /js/tool-frame.js (the file lives in apps/_shared/public, so the three file
++ * apps share one copy instead of three).
++ */
++(function () {
++ 'use strict';
++
++ var kit = window.OpenVibeKit;
++ if (!kit) return;
++
++ /** A file size in the words a person reads: "1.4 MB", "812 KB". */
++ function size(bytes) {
++ var b = Number(bytes) || 0;
++ if (b >= 1024 * 1024) return (b / (1024 * 1024)).toFixed(1) + ' MB';
++ if (b >= 1024) return Math.round(b / 1024) + ' KB';
++ return b + ' B';
++ }
++
++ /**
++ * The answer of a file tool: the file it made, its size before and after, and Download.
++ * @param {object} o
++ * title the answer's heading
++ * before the size of the file that went in (bytes)
++ * after the size of the file that came out (bytes)
++ * savedPercent how much smaller it is (a number, or nothing)
++ * rows extra rows: [[label, value]]
++ * downloadUrl where the file is
++ * filename the name it downloads under
++ * body markup under the rows
++ * tone 'bad' for a failure
++ */
++ function fileAnswer(o) {
++ var rows = [];
++ if (o.before != null) rows.push(['Before', size(o.before)]);
++ if (o.after != null) rows.push(['After', size(o.after), o.savedPercent ? { text: o.savedPercent + '% smaller', tone: 'ok' } : null]);
++ (o.rows || []).forEach(function (r) { rows.push(r); });
++ var actions = o.downloadUrl
++ ? [kit.button({ href: o.downloadUrl, label: 'Download', icon: 'download', kind: 'primary', attrs: { download: o.filename || '' } })]
++ : [];
++ return kit.answer({
++ title: o.title || '', badge: o.badge || null, rows: rows, actions: actions,
++ body: o.body || '', tone: o.tone || '', live: true,
++ });
++ }
++
++ /** Put an answer in the frame's answer slot. */
++ function show(html) {
++ var out = document.querySelector('.ovk-tool-out');
++ if (out) out.innerHTML = html;
++ return out;
++ }
++
++ /**
++ * Load a sample file into the drop zone, as if the person had chosen it.
++ * @param {string} url the sample's address
++ * @param {function} onFile called with the File
++ */
++ function sample(url, onFile) {
++ if (!url || typeof fetch !== 'function') return;
++ fetch(url, { credentials: 'same-origin' }).then(function (r) {
++ if (!r.ok) throw new Error('sample ' + r.status);
++ return r.blob();
++ }).then(function (blob) {
++ var name = String(url).split('/').pop() || 'sample';
++ var file = new File([blob], name, { type: blob.type || 'application/octet-stream' });
++ var input = document.querySelector('.ovk-drop-input');
++ if (input && window.DataTransfer) {
++ var dt = new DataTransfer();
++ dt.items.add(file);
++ input.files = dt.files;
++ input.dispatchEvent(new Event('change', { bubbles: true }));
++ }
++ if (onFile) onFile(file);
++ }).catch(function () { /* the sample is a convenience, not a promise */ });
++ }
++
++ /**
++ * A text tool's live output belongs in the frame's answer slot. Only an output that sits directly
++ * in the box moves: one inside a two-pane editor (the Markdown preview) keeps its place.
++ */
++ function moveAnswer() {
++ var card = document.querySelector('.ovk-tool-card');
++ var out = document.querySelector('.ovk-tool-out');
++ if (!card || !out) return;
++ var live = card.querySelector('#output, #result, #results, #grid');
++ // A two-pane editor (the Markdown preview) keeps its place: moving it would break the grid.
++ if (live && !live.closest('.md-pane, .md-editor, .pane')) {
++ out.appendChild(live);
++ out.classList.add('ovk-tool-out--live');
++ }
++ }
++
++ /**
++ * The navbar sits over the page, so the frame starts under it: its height is measured and put in
++ * --ovk-nav-h, which the app's own stylesheet uses for the frame's top padding.
++ */
++ function underNavbar() {
++ var nav = document.querySelector('.openvibe-navbar');
++ if (!nav) return;
++ var h = Math.round(nav.getBoundingClientRect().height);
++ if (h > 0) document.documentElement.style.setProperty('--ovk-nav-h', h + 'px');
++ }
++
++ function boot() {
++ moveAnswer();
++ underNavbar();
++ if (typeof ResizeObserver === 'function') {
++ var nav = document.querySelector('.openvibe-navbar');
++ if (nav) new ResizeObserver(underNavbar).observe(nav);
++ }
++ // The navbar renders after the page: measure again once it is there.
++ setTimeout(underNavbar, 400);
++ setTimeout(underNavbar, 1500);
++ window.addEventListener('resize', underNavbar);
++ }
++
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
++ else boot();
++
++ window.OVToolFrame = { size: size, fileAnswer: fileAnswer, show: show, sample: sample, moveAnswer: moveAnswer };
++})();
ddiff --git a/apps/audio/public/css/style.css b/apps/audio/public/css/style.css
iindex 8f6db35..281377f 100644
---- a/apps/audio/public/css/style.css
++++ b/apps/audio/public/css/style.css
@@@ -46,14 +46,17 @@ a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
/* ---------- App Container ---------- */
++/* The page is the shared tool frame: the frame's own .ovk-page sets the width and the padding. */
#app {
width: 100%;
-- max-width: var(--max-width);
-- margin: 0 auto;
-- padding: 0 20px 60px;
flex: 1;
}
++/* The tool frame: the brand is the site ("Tools"), not the subdomain's name. */
++.openvibe-navbar .openvibe-navbar-brand .b-core,
++.openvibe-navbar .openvibe-navbar-brand .b-dot,
++.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }
++
/* ---------- Hero ---------- */
.hero {
text-align: center;
@@@ -311,6 +314,8 @@ a:hover { color: var(--accent-hover); }
transition: var(--transition);
}
.text-input:focus, .num-input:focus { border-color: var(--accent); }
++/* The frame's option rows: the field sits beside its label and keeps its own width. */
++.num-input-sm { width: 120px; flex: none; }
/* Slider */
.slider {
@@@ -583,4 +588,7 @@ a:hover { color: var(--accent-hover); }
.file-details .file-row + .file-row { border-top: 1px solid var(--border); }
.file-details .btn-sm { padding: 4px 10px; font-size: 12px; margin-top: 4px; }
++
++/* The page is the shared tool frame: it starts under the navbar, not behind it. */
++.ovk-page { padding-top: calc(var(--ovk-nav-h, 52px) + 12px); }
@media (prefers-reduced-motion: reduce) { .option-group, .result-panel, .toast { animation: none; } .spinner { animation: none; opacity: .7; } }
ddiff --git a/apps/audio/public/index.html b/apps/audio/public/index.html
iindex eea81eb..bd81960 100644
---- a/apps/audio/public/index.html
++++ b/apps/audio/public/index.html
@@@ -26,324 +26,9 @@
<!-- Navbar injected by OpenVibeNavbar.init() -->
<div id="app">
-- <!-- Hero -->
-- <section class="hero" id="hero">
-- <div class="hero-icon" id="hero-icon"><i class="fa-solid fa-headphones"></i></div>
-- <h1 id="hero-title">Audio.OpenVibe</h1>
-- <p class="hero-subtitle" id="hero-subtitle">Online Audio Converter & Tools</p>
-- </section>
--
-- <!-- Tool Selector (visible on hub domain) -->
-- <nav class="tool-tabs" id="tool-tabs">
-- <button class="tool-tab active" data-tool="convert"><i class="fa-solid fa-arrows-rotate"></i> Convert</button>
-- <button class="tool-tab" data-tool="trim"><i class="fa-solid fa-scissors"></i> Trim</button>
-- <button class="tool-tab" data-tool="merge"><i class="fa-solid fa-object-group"></i> Merge</button>
-- <button class="tool-tab" data-tool="pitch"><i class="fa-solid fa-wave-square"></i> Pitch</button>
-- <button class="tool-tab" data-tool="speed"><i class="fa-solid fa-gauge-high"></i> Speed</button>
-- <button class="tool-tab" data-tool="reverse"><i class="fa-solid fa-backward"></i> Reverse</button>
-- <button class="tool-tab" data-tool="normalize"><i class="fa-solid fa-sliders"></i> Normalize</button>
-- <button class="tool-tab" data-tool="fade"><i class="fa-solid fa-volume-low"></i> Fade</button>
-- <button class="tool-tab" data-tool="bass"><i class="fa-solid fa-volume-high"></i> Bass</button>
-- <button class="tool-tab" data-tool="equalizer"><i class="fa-solid fa-sliders"></i> EQ</button>
-- <button class="tool-tab" data-tool="voice"><i class="fa-solid fa-user-astronaut"></i> Voice FX</button>
-- <button class="tool-tab" data-tool="echo"><i class="fa-solid fa-tower-broadcast"></i> Echo</button>
-- <button class="tool-tab" data-tool="reverb"><i class="fa-solid fa-house-chimney"></i> Reverb</button>
-- <button class="tool-tab" data-tool="extract"><i class="fa-solid fa-music"></i> Extract</button>
-- <button class="tool-tab" data-tool="ringtone"><i class="fa-solid fa-bell"></i> Ringtone</button>
-- </nav>
--
-- <!-- Upload Zone -->
-- <section class="upload-zone" id="upload-zone">
-- <div class="upload-area" id="upload-area">
-- <div class="upload-icon"><i class="fa-solid fa-cloud-arrow-up"></i></div>
-- <p class="upload-text">Drag & drop an audio file here</p>
-- <p class="upload-hint">or click to browse — MP3, WAV, FLAC, OGG, M4A, AAC, OPUS, WMA, AIFF, video files</p>
-- <input type="file" id="file-input" accept="audio/*,video/*" hidden>
-- <button class="btn btn-primary" id="browse-btn"><i class="fa-solid fa-folder-open"></i> Choose File</button>
-- </div>
-- <div class="upload-info" id="upload-info" style="display:none">
-- <div class="file-icon"><i class="fa-solid fa-file-audio"></i></div>
-- <div class="file-details" id="file-details"></div>
-- <button class="btn btn-ghost" id="clear-btn"><i class="fa-solid fa-xmark"></i> Clear</button>
-- </div>
-- </section>
--
-- <!-- Tool Options -->
-- <section class="options-panel" id="options-panel" style="display:none">
-- <!-- Convert Options -->
-- <div class="option-group" id="opt-convert">
-- <label class="option-label">Output Format</label>
-- <div class="format-grid" id="format-grid">
-- <button class="format-btn active" data-format="mp3">MP3</button>
-- <button class="format-btn" data-format="wav">WAV</button>
-- <button class="format-btn" data-format="flac">FLAC</button>
-- <button class="format-btn" data-format="ogg">OGG</button>
-- <button class="format-btn" data-format="m4a">M4A</button>
-- <button class="format-btn" data-format="aac">AAC</button>
-- <button class="format-btn" data-format="opus">OPUS</button>
-- <button class="format-btn" data-format="wma">WMA</button>
-- <button class="format-btn" data-format="aiff">AIFF</button>
-- <button class="format-btn" data-format="ac3">AC3</button>
-- </div>
-- <label class="option-label">Bitrate <span id="bitrate-val">192</span> kbps</label>
-- <input type="range" id="bitrate-slider" min="64" max="320" value="192" step="32" class="slider">
-- <p class="option-hint">Higher = better quality, larger file. Lossless formats (WAV, FLAC, AIFF) ignore bitrate.</p>
-- </div>
--
-- <!-- Merge Options -->
-- <div class="option-group" id="opt-merge" style="display:none">
-- <p class="option-hint">The files are joined in the order listed above (2 to 5 files, up to 3 hours in total). Different formats can be mixed; the result is re-encoded.</p>
-- <label class="option-label">Output Format</label>
-- <div class="format-grid" id="merge-format-grid">
-- <button class="format-btn active" data-format="mp3">MP3</button>
-- <button class="format-btn" data-format="wav">WAV</button>
-- <button class="format-btn" data-format="flac">FLAC</button>
-- <button class="format-btn" data-format="ogg">OGG</button>
-- <button class="format-btn" data-format="m4a">M4A</button>
-- <button class="format-btn" data-format="opus">OPUS</button>
-- </div>
-- <label class="option-label">Bitrate <span id="merge-bitrate-val">192</span> kbps</label>
-- <input type="range" id="merge-bitrate-slider" min="64" max="320" value="192" step="32" class="slider">
-- </div>
--
-- <!-- Trim Options -->
-- <div class="option-group" id="opt-trim" style="display:none">
-- <div class="time-inputs">
-- <div class="time-field">
-- <label class="option-label">Start Time</label>
-- <input type="text" id="trim-start" class="text-input" placeholder="0:00" value="0">
-- </div>
-- <span class="time-arrow"><i class="fa-solid fa-arrow-right"></i></span>
-- <div class="time-field">
-- <label class="option-label">End Time</label>
-- <input type="text" id="trim-end" class="text-input" placeholder="0:30">
-- </div>
-- </div>
-- <p class="option-hint">Accepts seconds (30) or time format (1:30 or 0:01:30)</p>
-- </div>
--
-- <!-- Pitch Options -->
-- <div class="option-group" id="opt-pitch" style="display:none">
-- <label class="option-label">Pitch Shift: <span id="pitch-val">0</span> semitones</label>
-- <input type="range" id="pitch-slider" min="-12" max="12" value="0" class="slider">
-- <div class="pitch-presets">
-- <button class="preset-btn" data-val="-12">-1 Octave</button>
-- <button class="preset-btn" data-val="-5">-5 Semi</button>
-- <button class="preset-btn" data-val="-2">-2 Semi</button>
-- <button class="preset-btn" data-val="2">+2 Semi</button>
-- <button class="preset-btn" data-val="5">+5 Semi</button>
-- <button class="preset-btn" data-val="12">+1 Octave</button>
-- </div>
-- </div>
--
-- <!-- Speed Options -->
-- <div class="option-group" id="opt-speed" style="display:none">
-- <label class="option-label">Speed: <span id="speed-val">1.0</span>x</label>
-- <input type="range" id="speed-slider" min="0.25" max="4.0" value="1.0" step="0.05" class="slider">
-- <div class="speed-presets">
-- <button class="preset-btn" data-val="0.5">0.5x</button>
-- <button class="preset-btn" data-val="0.75">0.75x</button>
-- <button class="preset-btn" data-val="1.25">1.25x</button>
-- <button class="preset-btn" data-val="1.5">1.5x</button>
-- <button class="preset-btn" data-val="2.0">2x</button>
-- <button class="preset-btn" data-val="3.0">3x</button>
-- </div>
-- </div>
--
-- <!-- Reverse Options (no params) -->
-- <div class="option-group" id="opt-reverse" style="display:none">
-- <p class="option-info"><i class="fa-solid fa-info-circle"></i> Reverses the entire audio file. Hit Process to reverse!</p>
-- </div>
--
-- <!-- Normalize Options -->
-- <div class="option-group" id="opt-normalize" style="display:none">
-- <label class="option-label">Normalization Mode</label>
-- <div class="format-grid" id="norm-mode-grid">
-- <button class="format-btn active" data-mode="loudness">Loudness (EBU R128)</button>
-- <button class="format-btn" data-mode="peak">Peak</button>
-- <button class="format-btn" data-mode="rms">RMS</button>
-- </div>
-- </div>
--
-- <!-- Fade Options -->
-- <div class="option-group" id="opt-fade" style="display:none">
-- <div class="time-inputs">
-- <div class="time-field">
-- <label class="option-label">Fade In (seconds)</label>
-- <input type="number" id="fade-in" class="num-input" placeholder="0" min="0" max="30" step="0.5" value="2">
-- </div>
-- <div class="time-field">
-- <label class="option-label">Fade Out (seconds)</label>
-- <input type="number" id="fade-out" class="num-input" placeholder="0" min="0" max="30" step="0.5" value="3">
-- </div>
-- </div>
-- </div>
--
-- <!-- Bass Options -->
-- <div class="option-group" id="opt-bass" style="display:none">
-- <label class="option-label">Bass Gain: <span id="bass-val">+6</span> dB</label>
-- <input type="range" id="bass-slider" min="-20" max="20" value="6" class="slider">
-- <label class="option-label">Frequency: <span id="bass-freq-val">100</span> Hz</label>
-- <input type="range" id="bass-freq-slider" min="20" max="300" value="100" class="slider">
-- </div>
--
-- <!-- Equalizer Options -->
-- <div class="option-group" id="opt-equalizer" style="display:none">
-- <label class="option-label">EQ Preset</label>
-- <div class="format-grid" id="eq-grid">
-- <button class="format-btn active" data-preset="bass_boost">Bass Boost</button>
-- <button class="format-btn" data-preset="treble_boost">Treble Boost</button>
-- <button class="format-btn" data-preset="vocal_boost">Vocal Boost</button>
-- <button class="format-btn" data-preset="rock">Rock</button>
-- <button class="format-btn" data-preset="pop">Pop</button>
-- <button class="format-btn" data-preset="jazz">Jazz</button>
-- <button class="format-btn" data-preset="classical">Classical</button>
-- <button class="format-btn" data-preset="electronic">Electronic</button>
-- <button class="format-btn" data-preset="hip_hop">Hip Hop</button>
-- <button class="format-btn" data-preset="podcast">Podcast</button>
-- <button class="format-btn" data-preset="telephone">Telephone</button>
-- </div>
-- </div>
--
-- <!-- Voice FX Options -->
-- <div class="option-group" id="opt-voice" style="display:none">
-- <label class="option-label">Voice Effect</label>
-- <div class="format-grid" id="voice-grid">
-- <button class="format-btn active" data-preset="chipmunk">🐿️ Chipmunk</button>
-- <button class="format-btn" data-preset="deep">🎸 Deep</button>
-- <button class="format-btn" data-preset="robot">🤖 Robot</button>
-- <button class="format-btn" data-preset="echo">🔊 Echo</button>
-- <button class="format-btn" data-preset="cave">🦇 Cave</button>
-- <button class="format-btn" data-preset="radio">📻 Radio</button>
-- <button class="format-btn" data-preset="underwater">🌊 Underwater</button>
-- <button class="format-btn" data-preset="whisper">🤫 Whisper</button>
-- <button class="format-btn" data-preset="megaphone">📢 Megaphone</button>
-- <button class="format-btn" data-preset="demon">👹 Demon</button>
-- </div>
-- </div>
--
-- <!-- Echo Options -->
-- <div class="option-group" id="opt-echo" style="display:none">
-- <label class="option-label">Delay: <span id="echo-delay-val">250</span> ms</label>
-- <input type="range" id="echo-delay" min="50" max="2000" value="250" step="10" class="slider">
-- <label class="option-label">Decay: <span id="echo-decay-val">0.5</span></label>
-- <input type="range" id="echo-decay" min="0.1" max="0.9" value="0.5" step="0.05" class="slider">
-- <label class="option-label">Repeats: <span id="echo-repeats-val">3</span></label>
-- <input type="range" id="echo-repeats" min="1" max="10" value="3" class="slider">
-- </div>
--
-- <!-- Reverb Options -->
-- <div class="option-group" id="opt-reverb" style="display:none">
-- <label class="option-label">Reverb Type</label>
-- <div class="format-grid" id="reverb-grid">
-- <button class="format-btn" data-preset="room">Room</button>
-- <button class="format-btn active" data-preset="hall">Hall</button>
-- <button class="format-btn" data-preset="cathedral">Cathedral</button>
-- <button class="format-btn" data-preset="plate">Plate</button>
-- </div>
-- <label class="option-label">Wet/Dry Mix: <span id="reverb-mix-val">50</span>%</label>
-- <input type="range" id="reverb-mix" min="0" max="100" value="50" class="slider">
-- </div>
--
-- <!-- Extract Options -->
-- <div class="option-group" id="opt-extract" style="display:none">
-- <label class="option-label">Output Format</label>
-- <div class="format-grid" id="extract-format-grid">
-- <button class="format-btn active" data-format="mp3">MP3</button>
-- <button class="format-btn" data-format="wav">WAV</button>
-- <button class="format-btn" data-format="flac">FLAC</button>
-- <button class="format-btn" data-format="ogg">OGG</button>
-- <button class="format-btn" data-format="m4a">M4A</button>
-- <button class="format-btn" data-format="aac">AAC</button>
-- </div>
-- <p class="option-hint">Upload a video file (MP4, MKV, AVI, WebM, etc.) to extract its audio track.</p>
-- </div>
--
-- <!-- Ringtone Options -->
-- <div class="option-group" id="opt-ringtone" style="display:none">
-- <label class="option-label">Device</label>
-- <div class="format-grid" id="ringtone-device-grid">
-- <button class="format-btn active" data-device="iphone">🍎 iPhone (M4R)</button>
-- <button class="format-btn" data-device="android">🤖 Android (MP3)</button>
-- </div>
-- <div class="time-inputs" style="margin-top:16px">
-- <div class="time-field">
-- <label class="option-label">Start</label>
-- <input type="text" id="ringtone-start" class="text-input" placeholder="0" value="0">
-- </div>
-- <span class="time-arrow"><i class="fa-solid fa-arrow-right"></i></span>
-- <div class="time-field">
-- <label class="option-label">End</label>
-- <input type="text" id="ringtone-end" class="text-input" placeholder="30">
-- </div>
-- </div>
-- <p class="option-hint">iPhone ringtones are max 40 seconds. 30 seconds is ideal.</p>
-- </div>
--
-- <!-- Process Button -->
-- <button class="btn btn-primary btn-lg" id="process-btn" disabled>
-- <i class="fa-solid fa-wand-magic-sparkles"></i> <span id="process-text">Process Audio</span>
-- </button>
-- </section>
--
-- <!-- Processing State -->
-- <section class="processing" id="processing" style="display:none">
-- <div class="spinner"></div>
-- <p>Processing your audio...</p>
-- </section>
--
-- <!-- Result -->
-- <section class="result-panel" id="result-panel" style="display:none">
-- <h2><i class="fa-solid fa-circle-check"></i> Done!</h2>
-- <div class="result-player" id="result-player"></div>
-- <div class="result-info" id="result-info"></div>
-- <div class="result-actions">
-- <a class="btn btn-primary btn-lg" id="download-btn" href="#" download>
-- <i class="fa-solid fa-download"></i> Download
-- </a>
-- <button class="btn btn-secondary" id="another-btn">
-- <i class="fa-solid fa-rotate-left"></i> Process Another
-- </button>
-- </div>
-- </section>
--
-- <!-- Format Subdomains Grid (hub only) -->
-- <section class="subdomains" id="subdomains">
-- <h2>All Audio Tools</h2>
-- <p class="subdomains-subtitle">Each tool has its own dedicated subdomain</p>
-- <div class="subdomain-grid" id="subdomain-grid"></div>
-- </section>
--
-- <!-- Cross-service links -->
-- <section class="cross-links" id="cross-links">
-- <h2>More OpenVibe Tools</h2>
-- <div class="cross-grid">
-- <a class="cross-card" href="https://img.openvibe.tools">
-- <span class="cross-icon"><i class="fa-solid fa-images"></i></span>
-- <span class="cross-info"><span class="cross-name">Img.OpenVibe</span><span class="cross-desc">Image converter & tools</span></span>
-- </a>
-- <a class="cross-card" href="https://yt.openvibe.tools">
-- <span class="cross-icon"><i class="fa-brands fa-youtube"></i></span>
-- <span class="cross-info"><span class="cross-name">YT.OpenVibe</span><span class="cross-desc">YouTube downloader</span></span>
-- </a>
-- <a class="cross-card" href="https://openvibe.live">
-- <span class="cross-icon"><i class="fa-solid fa-video"></i></span>
-- <span class="cross-info"><span class="cross-name">OpenVibe.Live</span><span class="cross-desc">Live streaming platform</span></span>
-- </a>
-- <a class="cross-card" href="https://openvibe.games">
-- <span class="cross-icon"><i class="fa-solid fa-gamepad"></i></span>
-- <span class="cross-info"><span class="cross-name">Scraplandia</span><span class="cross-desc">Multiplayer RPG</span></span>
-- </a>
-- <a class="cross-card" href="https://maps.openvibe.tools">
-- <span class="cross-icon"><i class="fa-solid fa-map-location-dot"></i></span>
-- <span class="cross-info"><span class="cross-name">Maps.OpenVibe</span><span class="cross-desc">Camps, resources & shelters</span></span>
-- </a>
-- <a class="cross-card" href="https://openvibe.food">
-- <span class="cross-icon"><i class="fa-solid fa-utensils"></i></span>
-- <span class="cross-info"><span class="cross-name">OpenVibe.Food</span><span class="cross-desc">Food banks, cheap groceries & meal plans</span></span>
-- </a>
-- </div>
-- </section>
++ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this
++ host — the head, the box, the answer slot, the family, the folds and "People also use". -->
++ <!--OVK:FRAME-->
</div>
<!-- Footer -->
@@@ -354,7 +39,11 @@
<script src="/shared/footer.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.js"></script>
++ <script src="/shared/kit.js" defer></script>
++ <script src="/shared/kit-web.js" defer></script>
++ <script src="/shared/kit-data-web.js" defer></script>
<script src="/js/ov-jobs.js"></script>
++ <script src="/js/tool-frame.js" defer></script>
<script src="/js/app.js"></script>
</body>
</html>
ddiff --git a/apps/audio/public/js/app.js b/apps/audio/public/js/app.js
iindex 8954a1c..62b2f65 100644
---- a/apps/audio/public/js/app.js
++++ b/apps/audio/public/js/app.js
@@@ -1,115 +1,55 @@
--/* ================================================
-- Audio.OpenVibe — Client Application (public/js/app.js)
-- Vanilla JS IIFE — no frameworks, same pattern as openvibe-img
-- ================================================ */
++// ═══════════════════════════════════════════════════════════════
++// Audio.OpenVibe — Client Application
++// The page around the tool is the shared frame (openvibe-shared ≥ 4.57.1): the server draws the head,
++// the box, the answer slot, the family and the folds for this host. This file fills the box (the drop
++// zone, the tool's own options and the one button) and the answer (the file it made, its size before
++// and after, and Download). The upload, the processing and the download are the app's own, unchanged.
++// ═══════════════════════════════════════════════════════════════
++
(function () {
'use strict';
-- /* ---------- State ---------- */
-- let ctx = null; // from /api/context
-- let toolList = []; // from /api/tools
-- let currentTool = null; // active tool ID
-- let uploadedFile = null; // File object
-- let uploadedFiles = []; // merge: the files in the order they are joined
-- let resultId = null; // retention ID from POST /api/v1/tools/:id/run
++ // ── State ────────────────────────────────────────────────
++ let ctx = null; // domain context from /api/context
++ let selectedFiles = []; // the File objects the drop zone holds
-- /* ---------- DOM refs ---------- */
++ // ── DOM refs ─────────────────────────────────────────────
const $ = (sel) => document.querySelector(sel);
-- const $$ = (sel) => [...document.querySelectorAll(sel)];
--
-- const heroIcon = $('#hero-icon');
-- const heroTitle = $('#hero-title');
-- const heroSub = $('#hero-subtitle');
-- const toolTabs = $('#tool-tabs');
-- const uploadZone = $('#upload-zone');
-- const uploadArea = $('#upload-area');
-- const uploadInfo = $('#upload-info');
-- const fileInput = $('#file-input');
-- const browseBtn = $('#browse-btn');
-- const clearBtn = $('#clear-btn');
++ const $$ = (sel) => document.querySelectorAll(sel);
++
++ const fileInput = $('#file-input');
const optionsPanel = $('#options-panel');
-- const processBtn = $('#process-btn');
-- const processText = $('#process-text');
-- const processing = $('#processing');
-- const resultPanel = $('#result-panel');
-- const resultPlayer = $('#result-player');
-- const resultInfo = $('#result-info');
-- const downloadBtn = $('#download-btn');
-- const anotherBtn = $('#another-btn');
-- const subdomains = $('#subdomains');
-- const subdomainGrid = $('#subdomain-grid');
--
-- /* ---------- Init ---------- */
++ const processBtn = $('#process-btn');
++ const clearBtn = $('#clear-btn');
++ const kit = () => window.OpenVibeKit;
++
++ // ── Init ─────────────────────────────────────────────────
async function init() {
try {
-- const [ctxRes, toolsRes] = await Promise.all([
-- fetch('/api/context').then(r => r.json()),
-- fetch('/api/tools').then(r => r.json())
-- ]);
-- ctx = ctxRes;
-- toolList = toolsRes.tools || [];
-- } catch (e) {
-- console.error('Failed to load context:', e);
-- ctx = { toolId: 'hub', brandName: 'Audio.OpenVibe', seoTitle: 'Audio.OpenVibe', seoDescription: 'Online Audio Tools' };
-- toolList = [];
++ const res = await fetch('/api/context');
++ ctx = await res.json();
++ } catch {
++ ctx = { toolId: 'hub', brandName: 'Audio.OpenVibe', defaultOp: 'convert', tools: [] };
}
-- applyBranding();
initNavbar();
initNotifications();
if (typeof OpenVibeAccountSwitcher !== 'undefined') OpenVibeAccountSwitcher.init({ apiBase: 'https://openvibe.network' });
-- initToolTabs();
-- initUpload();
++ initDrop();
initOptions();
-- initSubdomains();
-- initResult();
--
-- // Set initial tool
-- const FORMAT_DOMAINS = ['mp3','wav','flac','ogg','m4a','aac','opus','wma','aiff','ac3'];
-- if (ctx.toolId && ctx.toolId !== 'hub') {
-- // Format-specific domains (mp3.openvibe.tools etc.) use the convert tool
-- if (FORMAT_DOMAINS.includes(ctx.toolId)) {
-- selectTool('convert');
-- } else {
-- selectTool(ctx.toolId);
-- }
-- } else {
-- selectTool('convert');
-- }
reattach();
}
-- /* ---------- Branding ---------- */
-- function applyBranding() {
-- document.title = ctx.seoTitle || ctx.brandName || 'Audio.OpenVibe';
-- const metaDesc = document.querySelector('meta[name="description"]');
-- if (metaDesc && ctx.seoDescription) metaDesc.content = ctx.seoDescription;
-- const metaOg = document.querySelector('meta[property="og:title"]');
-- if (metaOg) metaOg.content = ctx.seoTitle || ctx.brandName;
--
-- if (ctx.faIcon) heroIcon.innerHTML = `<i class="fa-solid ${ctx.faIcon}"></i>`;
-- heroTitle.textContent = ctx.brandName || 'Audio.OpenVibe';
-- heroSub.textContent = ctx.seoDescription || 'Online Audio Tools';
--
-- // On a specific tool domain, hide tool tabs and subdomains
-- if (ctx.toolId && ctx.toolId !== 'hub') {
-- toolTabs.style.display = 'none';
-- subdomains.style.display = 'none';
-- }
-- }
--
-- /* ---------- Navbar (OpenVibe unified bar) ---------- */
++ // ── Navbar ───────────────────────────────────────────────
function initNavbar() {
const token = getCookie('ov_token') || localStorage.getItem('ov_token');
let user = null;
if (token) {
-- try {
-- const payload = JSON.parse(atob(token.split('.')[1]));
-- user = payload;
-- } catch { /* invalid token */ }
++ try { user = JSON.parse(atob(token.split('.')[1])); } catch { /* invalid token */ }
}
-- // Brand name/icon come from the hostname now (png.openvibe.tools → "PNG.OpenVibe.Tools").
++ // The brand is the site ("Tools"), not the subdomain's name: the same brand the network and
++ // code tool pages carry (owner ask 11).
if (typeof OpenVibeNavbar !== 'undefined') {
OpenVibeNavbar.init({
service: 'audio',
@@@ -118,46 +58,41 @@
apiBase: 'https://openvibe.network',
history: { type: 'tool', title: ctx?.brandName || 'Audio.OpenVibe' },
silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',
++ brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' },
});
}
initFooter();
}
/* ---------- Footer (shared OpenVibeFooter) ---------- */
-- // Full footer on the hub host, the compact strip on the single-tool hosts.
function initFooter() {
if (typeof OpenVibeFooter === 'undefined') return;
try {
OpenVibeFooter.init({
service: 'audio',
-- variant: (!ctx || ctx.toolId === 'hub') ? 'full' : 'compact',
++ variant: 'compact',
mount: '#ov-footer',
apiBase: 'https://openvibe.network',
++ brandName: 'OpenVibe.Tools',
links: [{ heading: 'Audio tools', items: [
-- { name: 'Convert', url: 'https://convert.audio.openvibe.tools' },
-- { name: 'Trim', url: 'https://trim.openvibe.tools' },
-- { name: 'Merge', url: 'https://merge.openvibe.tools' },
-- { name: 'Pitch', url: 'https://pitch.openvibe.tools' },
-- { name: 'Speed', url: 'https://speed.openvibe.tools' },
-- { name: 'Normalize', url: 'https://normalize.openvibe.tools' },
{ name: 'MP3', url: 'https://mp3.openvibe.tools' },
{ name: 'WAV', url: 'https://wav.openvibe.tools' },
++ { name: 'FLAC', url: 'https://flac.openvibe.tools' },
++ { name: 'Trim', url: 'https://trim.openvibe.tools' },
++ { name: 'Merge', url: 'https://merge.openvibe.tools' },
++ { name: 'Podcasts', url: 'https://podcast.openvibe.tools' },
] }],
});
} catch { /* optional */ }
}
-- /* ---------- Notifications (OpenVibe bell + toasts) ---------- */
++ // ── Notifications (OpenVibe bell + toasts) ───────────
function initNotifications() {
const token = getCookie('ov_token') || localStorage.getItem('ov_token');
if (typeof OpenVibeNotifications === 'undefined') return;
-- OpenVibeNotifications.init({
-- token: token || null,
-- apiBase: 'https://openvibe.network',
-- });
++ OpenVibeNotifications.init({ token: token || null, apiBase: 'https://openvibe.network' });
-- // Mount bell into navbar if available
if (typeof OpenVibeNavbar !== 'undefined') {
const mount = OpenVibeNavbar.getBellMount();
if (mount) {
@@@ -167,407 +102,122 @@
}
}
-- /* ---------- Tool Tabs ---------- */
-- function initToolTabs() {
-- $$('.tool-tab').forEach(tab => {
-- tab.addEventListener('click', () => selectTool(tab.dataset.tool));
-- });
-- }
--
-- function selectTool(toolId) {
-- currentTool = toolId;
--
-- // Update tab active state
-- $$('.tool-tab').forEach(t => t.classList.toggle('active', t.dataset.tool === toolId));
--
-- // Show matching option group, hide others
-- $$('.option-group').forEach(g => g.style.display = 'none');
-- const group = $(`#opt-${toolId}`);
-- if (group) group.style.display = '';
--
-- // Update process button text
-- const tool = toolList.find(t => t.id === toolId);
-- processText.textContent = tool ? `Process — ${tool.label}` : 'Process Audio';
--
-- // Merge takes several files; the other tools one.
-- fileInput.multiple = isMerge();
-- if (isMerge() && uploadedFile && !uploadedFiles.length) uploadedFiles = [uploadedFile];
-- if (!isMerge() && uploadedFiles.length) { uploadedFile = uploadedFiles[0]; uploadedFiles = []; }
-- if (uploadedFile || uploadedFiles.length) showFiles();
--
-- updateProcessBtn();
-- }
--
-- const MERGE_MAX = 5;
-- function isMerge() { return currentTool === 'merge'; }
--
-- /* ---------- Upload ---------- */
-- function initUpload() {
-- // Click to browse
-- browseBtn.addEventListener('click', () => fileInput.click());
-- uploadArea.addEventListener('click', (e) => {
-- if (e.target === uploadArea || e.target.closest('.upload-icon') || e.target.closest('.upload-text') || e.target.closest('.upload-hint')) {
-- fileInput.click();
-- }
-- });
--
-- // File selected
++ // ── The box: the drop zone ───────────────────────────────
++ function initDrop() {
++ if (!fileInput) return;
fileInput.addEventListener('change', () => {
-- if (isMerge()) addFiles([...fileInput.files]);
-- else if (fileInput.files[0]) handleFile(fileInput.files[0]);
-- fileInput.value = '';
-- });
--
-- // Drag & drop
-- uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('drag-over'); });
-- uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('drag-over'));
-- uploadZone.addEventListener('drop', (e) => {
-- e.preventDefault();
-- uploadZone.classList.remove('drag-over');
-- if (isMerge()) addFiles([...e.dataTransfer.files]);
-- else if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
++ const files = Array.prototype.slice.call(fileInput.files || []);
++ if (files.length) handleFiles(files);
});
++ if (clearBtn) clearBtn.addEventListener('click', resetUpload);
-- // Clear
-- clearBtn.addEventListener('click', clearFile);
-- }
--
-- function handleFile(file) {
-- uploadedFile = file;
-- showFiles();
-- }
--
-- /** Merge: add files to the list (up to MERGE_MAX), in the order chosen. */
-- function addFiles(files) {
-- const room = MERGE_MAX - uploadedFiles.length;
-- if (files.length > room) showToast(`At most ${MERGE_MAX} files can be merged at once`, 'error');
-- uploadedFiles = uploadedFiles.concat(files.slice(0, Math.max(0, room)));
-- uploadedFile = uploadedFiles[0] || null;
-- if (uploadedFiles.length) showFiles(); else clearFile();
-- }
--
-- function showFiles() {
-- const details = $('#file-details');
-- details.textContent = '';
-- const list = isMerge() ? uploadedFiles : (uploadedFile ? [uploadedFile] : []);
-- list.forEach((file, i) => {
-- const row = document.createElement('div');
-- row.className = 'file-row';
-- const name = document.createElement('div');
-- name.className = 'file-name';
-- name.textContent = (isMerge() ? `${i + 1}. ` : '') + file.name;
-- const meta = document.createElement('div');
-- meta.className = 'file-meta';
-- meta.textContent = `${file.name.split('.').pop().toUpperCase()} — ${formatSize(file.size)}`;
-- row.append(name, meta);
-- if (isMerge()) {
-- const rm = document.createElement('button');
-- rm.type = 'button';
-- rm.className = 'btn btn-ghost btn-sm';
-- rm.textContent = 'Remove';
-- rm.addEventListener('click', () => { uploadedFiles.splice(i, 1); addFiles([]); });
-- row.append(rm);
-- }
-- details.append(row);
-- });
-- if (isMerge() && uploadedFiles.length < MERGE_MAX) {
-- const more = document.createElement('button');
-- more.type = 'button';
-- more.className = 'btn btn-ghost btn-sm';
-- more.textContent = uploadedFiles.length < 2 ? 'Add another file' : 'Add a file';
-- more.addEventListener('click', () => fileInput.click());
-- details.append(more);
++ const sampleLink = document.querySelector('[data-ovk-sample]');
++ if (sampleLink && window.OVToolFrame) {
++ sampleLink.addEventListener('click', (e) => {
++ e.preventDefault();
++ window.OVToolFrame.sample(sampleLink.getAttribute('data-ovk-sample'), (file) => handleFiles([file]));
++ });
}
--
-- uploadArea.style.display = 'none';
-- uploadInfo.style.display = 'flex';
-- optionsPanel.style.display = '';
-- resultPanel.style.display = 'none';
-- resultId = null;
-- updateProcessBtn();
}
-- function clearFile() {
-- uploadedFile = null;
-- uploadedFiles = [];
-- fileInput.value = '';
-- uploadArea.style.display = '';
-- uploadInfo.style.display = 'none';
-- optionsPanel.style.display = 'none';
-- updateProcessBtn();
++ function handleFiles(files) {
++ const max = files.length > 1 ? 5 : 1;
++ if (files.length > max) {
++ fail(`That is more than ${max} file${max === 1 ? '' : 's'}.`);
++ return;
++ }
++ const tooBig = files.find((f) => f.size > 100 * 1024 * 1024);
++ if (tooBig) {
++ fail('That file is too large. The maximum is 100 MB.');
++ return;
++ }
++ selectedFiles = files;
++ if (optionsPanel) optionsPanel.hidden = false;
++ if (processBtn) processBtn.disabled = false;
++ hideAnswer();
}
-- function updateProcessBtn() {
-- processBtn.disabled = !currentTool || (isMerge() ? uploadedFiles.length < 2 : !uploadedFile);
++ function resetUpload() {
++ selectedFiles = [];
++ if (fileInput) fileInput.value = '';
++ if (optionsPanel) optionsPanel.hidden = true;
++ if (processBtn) processBtn.disabled = true;
++ hideAnswer();
}
-- /* ---------- Options ---------- */
++ // ── The box: the options and the one button ─────────────
function initOptions() {
-- // Bitrate slider (convert)
-- const bitrateSlider = $('#bitrate-slider');
-- const bitrateVal = $('#bitrate-val');
-- if (bitrateSlider) {
-- bitrateSlider.addEventListener('input', () => { bitrateVal.textContent = bitrateSlider.value; });
-- }
--
-- // Pitch slider
-- const pitchSlider = $('#pitch-slider');
-- const pitchVal = $('#pitch-val');
-- if (pitchSlider) {
-- pitchSlider.addEventListener('input', () => {
-- const v = parseInt(pitchSlider.value);
-- pitchVal.textContent = (v > 0 ? '+' : '') + v;
-- });
-- }
--
-- // Pitch presets
-- $$('#opt-pitch .preset-btn').forEach(btn => {
-- btn.addEventListener('click', () => {
-- pitchSlider.value = btn.dataset.val;
-- pitchSlider.dispatchEvent(new Event('input'));
-- });
-- });
--
-- // Speed slider
-- const speedSlider = $('#speed-slider');
-- const speedVal = $('#speed-val');
-- if (speedSlider) {
-- speedSlider.addEventListener('input', () => { speedVal.textContent = parseFloat(speedSlider.value).toFixed(2); });
-- }
--
-- // Speed presets
-- $$('#opt-speed .preset-btn').forEach(btn => {
-- btn.addEventListener('click', () => {
-- speedSlider.value = btn.dataset.val;
-- speedSlider.dispatchEvent(new Event('input'));
-- });
-- });
--
-- // Bass sliders
-- const bassSlider = $('#bass-slider');
-- const bassVal = $('#bass-val');
-- if (bassSlider) {
-- bassSlider.addEventListener('input', () => {
-- const v = parseInt(bassSlider.value);
-- bassVal.textContent = (v > 0 ? '+' : '') + v;
-- });
-- }
-- const bassFreqSlider = $('#bass-freq-slider');
-- const bassFreqVal = $('#bass-freq-val');
-- if (bassFreqSlider) {
-- bassFreqSlider.addEventListener('input', () => { bassFreqVal.textContent = bassFreqSlider.value; });
-- }
--
-- // Echo sliders
-- const echoDelay = $('#echo-delay');
-- const echoDecay = $('#echo-decay');
-- const echoRepeats = $('#echo-repeats');
-- if (echoDelay) echoDelay.addEventListener('input', () => { $('#echo-delay-val').textContent = echoDelay.value; });
-- if (echoDecay) echoDecay.addEventListener('input', () => { $('#echo-decay-val').textContent = parseFloat(echoDecay.value).toFixed(2); });
-- if (echoRepeats) echoRepeats.addEventListener('input', () => { $('#echo-repeats-val').textContent = echoRepeats.value; });
--
-- // Merge bitrate slider
-- const mergeBitrate = $('#merge-bitrate-slider');
-- if (mergeBitrate) mergeBitrate.addEventListener('input', () => { $('#merge-bitrate-val').textContent = mergeBitrate.value; });
--
-- // Reverb mix slider
-- const reverbMix = $('#reverb-mix');
-- if (reverbMix) reverbMix.addEventListener('input', () => { $('#reverb-mix-val').textContent = reverbMix.value; });
--
-- // Format grid click handlers (convert)
-- initGridSelect('#format-grid .format-btn', 'format');
-- initGridSelect('#merge-format-grid .format-btn', 'format');
-- initGridSelect('#norm-mode-grid .format-btn', 'mode');
-- initGridSelect('#eq-grid .format-btn', 'preset');
-- initGridSelect('#voice-grid .format-btn', 'preset');
-- initGridSelect('#reverb-grid .format-btn', 'preset');
-- initGridSelect('#extract-format-grid .format-btn', 'format');
-- initGridSelect('#ringtone-device-grid .format-btn', 'device');
--
-- // Context defaults — if domain implies a format, pre-select it
-- if (ctx.defaultFormat) {
-- const fmtBtn = $(`#format-grid .format-btn[data-format="${ctx.defaultFormat}"]`);
-- if (fmtBtn) {
-- $$('#format-grid .format-btn').forEach(b => b.classList.remove('active'));
-- fmtBtn.classList.add('active');
-- }
-- }
--
-- // Process button
-- processBtn.addEventListener('click', processAudio);
++ if (processBtn) processBtn.addEventListener('click', processAudio);
}
-- function initGridSelect(selector, attr) {
-- const btns = $$(selector);
-- if (!btns.length) return;
-- const parent = btns[0].parentElement;
-- btns.forEach(btn => {
-- btn.addEventListener('click', () => {
-- parent.querySelectorAll('.format-btn').forEach(b => b.classList.remove('active'));
-- btn.classList.add('active');
-- });
++ /** The tool's own options, as the job's input: the fields the descriptor names, and nothing else. */
++ function collectInput() {
++ const form = document.querySelector('#ovk-audio-form');
++ const input = { tool: (ctx && ctx.defaultOp) || 'convert' };
++ if (!form) return input;
++ form.querySelectorAll('input[name], select[name], textarea[name]').forEach((el) => {
++ // The drop zone's own file field is not an option: the file goes in the job's files.
++ if (el.type === 'file') return;
++ if (el.type === 'radio') { if (el.checked) input[el.name] = el.value; return; }
++ if (el.type === 'checkbox') { input[el.name] = el.checked; return; }
++ if (el.value === '') return;
++ input[el.name] = el.value;
});
++ return input;
}
-- /* ---------- Process ---------- */
++ // ── Process ──────────────────────────────────────────────
async function processAudio() {
-- if (!uploadedFile || !currentTool) return;
-- const files = isMerge() ? uploadedFiles.slice() : [uploadedFile];
-- if (isMerge() && files.length < 2) return;
--
-- const formData = new FormData();
-- for (const f of files) formData.append(isMerge() ? 'files' : 'file', f);
-- formData.append('tool', currentTool);
--
-- // Gather tool-specific options
-- const opts = gatherOptions();
-- for (const [k, v] of Object.entries(opts)) {
-- formData.append(k, v);
++ if (!selectedFiles.length) return;
++
++ const input = collectInput();
++ if (processBtn) {
++ processBtn.disabled = true;
++ const span = processBtn.querySelector('span');
++ // The button keeps its verb while it works: "Compress…", not "Working…".
++ if (span) span.textContent = `${span.textContent.replace(/…$/, '')}…`;
}
++ showWorking();
-- // UI state
-- optionsPanel.style.display = 'none';
-- uploadZone.style.display = 'none';
-- resultPanel.style.display = 'none';
-- processing.style.display = '';
--
-- // Jobs: the work is accepted, followed live (ffmpeg's progress) and survives a reload.
-- const input = {};
-- for (const [k, v] of formData.entries()) if (k !== 'file' && k !== 'files') input[k] = v;
try {
-- const job = await OVJobs.submit({ type: 'audio.process', input, files, fileField: isMerge() ? 'files' : 'file' });
++ const many = selectedFiles.length > 1;
++ const job = await OVJobs.submit({
++ type: 'audio.process', input, files: selectedFiles, fileField: many ? 'files' : 'file',
++ });
OVJobs.remember(job.id);
followJob(job);
} catch (err) {
-- showToast(err.message, 'error');
-- resetUI();
-- }
-- }
--
-- function gatherOptions() {
-- const o = {};
-- switch (currentTool) {
-- case 'convert': {
-- const fmt = $('#format-grid .format-btn.active');
-- if (fmt) o.format = fmt.dataset.format;
-- o.bitrate = $('#bitrate-slider').value;
-- break;
-- }
-- case 'merge': {
-- const fmt = $('#merge-format-grid .format-btn.active');
-- if (fmt) o.format = fmt.dataset.format;
-- o.bitrate = $('#merge-bitrate-slider').value;
-- break;
-- }
-- case 'trim':
-- o.start = $('#trim-start').value || '0';
-- o.end = $('#trim-end').value;
-- break;
-- case 'pitch':
-- o.semitones = $('#pitch-slider').value;
-- break;
-- case 'speed':
-- o.speed = $('#speed-slider').value;
-- break;
-- case 'reverse':
-- break;
-- case 'normalize': {
-- const mode = $('#norm-mode-grid .format-btn.active');
-- if (mode) o.mode = mode.dataset.mode;
-- break;
-- }
-- case 'fade':
-- o.fadeIn = $('#fade-in').value || '0';
-- o.fadeOut = $('#fade-out').value || '0';
-- break;
-- case 'bass':
-- o.gain = $('#bass-slider').value;
-- o.frequency = $('#bass-freq-slider').value;
-- break;
-- case 'equalizer': {
-- const p = $('#eq-grid .format-btn.active');
-- if (p) o.preset = p.dataset.preset;
-- break;
-- }
-- case 'voice': {
-- const p = $('#voice-grid .format-btn.active');
-- if (p) o.preset = p.dataset.preset;
-- break;
-- }
-- case 'echo':
-- o.delay = $('#echo-delay').value;
-- o.decay = $('#echo-decay').value;
-- o.repeats = $('#echo-repeats').value;
-- break;
-- case 'reverb': {
-- const p = $('#reverb-grid .format-btn.active');
-- if (p) o.preset = p.dataset.preset;
-- o.mix = $('#reverb-mix').value;
-- break;
-- }
-- case 'extract': {
-- const fmt = $('#extract-format-grid .format-btn.active');
-- if (fmt) o.format = fmt.dataset.format;
-- break;
-- }
-- case 'ringtone': {
-- const d = $('#ringtone-device-grid .format-btn.active');
-- if (d) o.device = d.dataset.device;
-- o.start = $('#ringtone-start').value || '0';
-- o.end = $('#ringtone-end').value;
-- break;
-- }
++ failProcessing(err.message);
}
-- return o;
}
-- /* ---------- Jobs ---------- */
++ // ── Jobs ─────────────────────────────────────────────────
let watcher = null;
-- let currentJob = null;
function processingText(job) {
const pct = job.progress && job.progress.percent;
-- if (job.cancel_requested) return 'Cancelling...';
-- if (job.state === 'queued') return 'Waiting for a free slot...';
++ if (job.cancel_requested) return 'Cancelling…';
++ if (job.state === 'queued') return 'Waiting for a free slot…';
const label = (job.progress && job.progress.message) || 'Processing your audio';
-- return pct != null ? `${label}... ${Math.round(pct)}%` : `${label}...`;
-- }
--
-- function cancelButton() {
-- let btn = processing.querySelector('.job-cancel');
-- if (!btn) {
-- btn = document.createElement('button');
-- btn.type = 'button';
-- btn.className = 'btn-secondary job-cancel';
-- btn.textContent = 'Cancel';
-- btn.addEventListener('click', () => { if (currentJob) OVJobs.cancel(currentJob).catch(() => {}); });
-- processing.appendChild(btn);
-- }
-- return btn;
++ return pct != null ? `${label}… ${Math.round(pct)}%` : `${label}…`;
}
function followJob(job) {
-- currentJob = job.id;
-- optionsPanel.style.display = 'none';
-- uploadZone.style.display = 'none';
-- resultPanel.style.display = 'none';
-- processing.style.display = '';
-- const label = processing.querySelector('p');
-- const update = (j) => { if (label) label.textContent = processingText(j); };
-- cancelButton().style.display = '';
-- update(job);
++ hideAnswer();
++ showWorking();
if (watcher) watcher.close();
watcher = OVJobs.watch(job.id, {
-- onUpdate: update,
++ onUpdate: (j) => {
++ const out = document.querySelector('.ovk-tool-out');
++ if (out) out.querySelector('[data-ovk-working]')?.setAttribute('data-progress', processingText(j));
++ },
onDone(done) {
watcher = null;
-- cancelButton().style.display = 'none';
-- if (done.state === 'succeeded') { resultId = done.id; return showResult(jobResult(done)); }
++ if (done.state === 'succeeded') return showResult(jobResult(done));
OVJobs.forget();
-- if (done.state !== 'cancelled') showToast((done.error && done.error.detail) || 'Processing failed', 'error');
-- resetUI();
++ failProcessing(done.state === 'cancelled' ? null : (done.error && done.error.detail) || 'Processing failed');
},
-- onGone() { OVJobs.forget(); resetUI(); },
++ onGone() { OVJobs.forget(); failProcessing(null); },
});
}
@@@ -580,6 +230,18 @@
};
}
++ function resetVerb() {
++ const span = processBtn && processBtn.querySelector('span');
++ if (span) span.textContent = span.textContent.replace(/…$/, '');
++ }
++
++ function failProcessing(message) {
++ resetVerb();
++ if (processBtn) processBtn.disabled = !selectedFiles.length;
++ if (message) fail(message);
++ else hideAnswer();
++ }
++
/** After a reload: pick the job up again from ?job=<id> or this tab's session. */
function reattach() {
const id = window.OVJobs && OVJobs.recall();
@@@ -587,122 +249,64 @@
OVJobs.get(id).then(followJob).catch(() => OVJobs.forget());
}
-- /* ---------- Result ---------- */
-- function initResult() {
-- anotherBtn.addEventListener('click', () => {
-- if (window.OVJobs) OVJobs.forget();
-- clearFile();
-- resetUI();
++ // ── The answer ───────────────────────────────────────────
++ function showWorking() {
++ const out = document.querySelector('.ovk-tool-out');
++ if (!out || !kit()) return;
++ out.innerHTML = kit().answer({
++ title: 'Working…',
++ body: '<p data-ovk-working>Your recording is being worked on by ffmpeg on our server. This page stays open until it is done.</p>',
});
}
-- function showResult(data) {
-- processing.style.display = 'none';
-- resultPanel.style.display = '';
--
-- const fileId = data.download.id;
-- const previewUrl = data.download.previewUrl || `/api/preview/${fileId}`;
-- const downloadUrl = data.download.downloadUrl || `/api/download/${fileId}`;
-- const outputMime = data.output?.mime || '';
--
-- if (outputMime.startsWith('image/')) {
-- // Waveform — returns an image
-- resultPlayer.innerHTML = `<img src="${previewUrl}" class="result-waveform" alt="Waveform">`;
-- } else {
-- // Audio player
-- resultPlayer.innerHTML = `<audio controls preload="auto" src="${previewUrl}"></audio>`;
-- }
--
-- // Info: filename, output size, savings %
-- const parts = [];
-- if (data.download.filename) parts.push(data.download.filename);
-- if (data.output?.size) parts.push(formatSize(data.output.size));
-- if (data.input?.size && data.output?.size) {
-- const pct = ((1 - data.output.size / data.input.size) * 100).toFixed(1);
-- if (pct > 0) parts.push(`${pct}% smaller`);
-- }
-- resultInfo.textContent = parts.join(' — ');
--
-- // Download
-- downloadBtn.href = downloadUrl;
-- if (data.download.filename) downloadBtn.download = data.download.filename;
++ function hideAnswer() {
++ const out = document.querySelector('.ovk-tool-out');
++ if (out) out.innerHTML = '';
}
-- function resetUI() {
-- processing.style.display = 'none';
-- resultPanel.style.display = 'none';
-- uploadZone.style.display = '';
-- if (uploadedFile) {
-- optionsPanel.style.display = '';
-- }
++ function fail(message) {
++ const out = document.querySelector('.ovk-tool-out');
++ if (!out || !kit()) return;
++ out.innerHTML = kit().answer({ title: 'That did not work', tone: 'bad', body: `<p>${escapeHtml(message)}</p>` });
}
-- /* ---------- Subdomain Grid ---------- */
-- function initSubdomains() {
-- if (ctx.toolId && ctx.toolId !== 'hub') {
-- subdomains.style.display = 'none';
-- return;
-- }
--
-- const categories = [
-- { label: 'Format Conversion', icon: 'fa-solid fa-arrows-rotate', ids: ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'opus', 'wma', 'aiff', 'ac3'] },
-- { label: 'Audio Editing', icon: 'fa-solid fa-scissors', ids: ['trim', 'merge', 'fade', 'loop', 'silence', 'stereo', 'normalize', 'speed', 'reverse'] },
-- { label: 'Audio Effects', icon: 'fa-solid fa-wand-magic-sparkles', ids: ['pitch', 'bass', 'equalizer', 'echo', 'reverb', 'chorus', 'distortion', 'compressor', 'bitcrusher', 'voice', 'noise'] },
-- { label: 'Extraction & Analysis', icon: 'fa-solid fa-magnifying-glass', ids: ['extract', 'vocal', 'waveform', 'metadata'] },
-- { label: 'Specialized', icon: 'fa-solid fa-star', ids: ['ringtone', 'podcast'] },
-- ];
--
-- // Build a lookup from tools list
-- const toolMap = {};
-- toolList.forEach(t => { toolMap[t.id] = t; });
--
-- let html = '';
-- for (const cat of categories) {
-- html += `<div class="cat-header"><i class="${cat.icon}"></i> ${cat.label}</div>`;
-- for (const id of cat.ids) {
-- const t = toolMap[id];
-- if (!t) continue;
-- const host = `${id}.openvibe.tools`;
-- html += `
-- <a class="subdomain-card" href="https://${host}">
-- <span class="card-icon"><i class="fa-solid ${t.faIcon || 'fa-music'}"></i></span>
-- <span class="card-info">
-- <span class="card-name">${escHtml(t.label)}</span>
-- <span class="card-desc">${escHtml(t.description)}</span>
-- </span>
-- </a>`;
-- }
++ function showResult(data) {
++ resetVerb();
++ if (processBtn) processBtn.disabled = false;
++ const out = data.output || {};
++ const rows = [];
++ if (out.ext) rows.push(['Format', String(out.ext).toUpperCase()]);
++ if (out.duration != null) rows.push(['Length', `${Math.round(out.duration)} s`]);
++ if (data.fileCount) rows.push(['Files joined', String(data.fileCount)]);
++ const expires = data.download && data.download.expiresIn;
++ if (expires) {
++ const mins = Math.max(1, Math.round(expires / 60000));
++ rows.push(['Kept for', mins >= 60 ? `${Math.round(mins / 60)} hour${Math.round(mins / 60) === 1 ? '' : 's'}` : `${mins} minutes`]);
}
-- subdomainGrid.innerHTML = html;
++ const html = window.OVToolFrame.fileAnswer({
++ title: 'Your recording is ready',
++ before: (data.input && data.input.size != null) ? data.input.size : (selectedFiles[0] ? selectedFiles[0].size : null),
++ after: out.size != null ? out.size : null,
++ rows,
++ downloadUrl: data.download && data.download.downloadUrl,
++ filename: data.download && data.download.filename,
++ });
++ window.OVToolFrame.show(html);
}
-- /* ---------- Utilities ---------- */
++ // ── Helpers ──────────────────────────────────────────────
function getCookie(name) {
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
-- function formatSize(bytes) {
-- if (bytes < 1024) return bytes + ' B';
-- if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
-- return (bytes / 1048576).toFixed(1) + ' MB';
-- }
--
-- function escHtml(str) {
-- const d = document.createElement('div');
-- d.textContent = str;
-- return d.innerHTML;
-- }
--
-- function showToast(msg, type = '') {
-- const el = document.createElement('div');
-- el.className = 'toast' + (type ? ` ${type}` : '');
-- el.textContent = msg;
-- document.body.appendChild(el);
-- setTimeout(() => { el.remove(); }, 4500);
++ function escapeHtml(str) {
++ const div = document.createElement('div');
++ div.textContent = str;
++ return div.innerHTML;
}
-- /* ---------- Boot ---------- */
++ // ── Boot ─────────────────────────────────────────────────
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
ddiff --git a/apps/audio/public/sample.mp3 b/apps/audio/public/sample.mp3
nnew file mode 100644
iindex 0000000..6e8b440
BBinary files /dev/null and b/apps/audio/public/sample.mp3 differ
ddiff --git a/apps/audio/server/frame.js b/apps/audio/server/frame.js
nnew file mode 100644
iindex 0000000..358ebcb
---- /dev/null
++++ b/apps/audio/server/frame.js
@@@ -0,0 +1,216 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// The tool frame for the audio pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
++//
++// Every audio host — mp3, trim, merge, reverb and the rest — is a tool page in the shared frame: the
++// head (the family crumb, the tool's name, one sentence, the terms), the box (a drop zone and the
++// tool's own options), the answer (the file it made, its size before and after, and Download), the
++// family, the folds and "People also use".
++//
++// The box's options are the tool's own descriptor (server/descriptors.js): a field the descriptor does
++// not have is not on the page, and its range and its default are the ones the tool reads. The terms
++// come from the app's real configuration: the file's lifetime from config.js's retention, its size
++// from config.js's upload limits, and the anonymous rate from the guard's quota table.
++// ═══════════════════════════════════════════════════════════════
++
++const kit = require('openvibe-shared/kit');
++const serve = require('openvibe-shared/serve');
++const frame = require('../../_shared/frame')({ kit, serve });
++const config = require('./config');
++const { DOMAIN_MAP } = require('./domain-map');
++const { SPECS, FORMAT_MIME, AUDIO_ACCEPT } = require('./descriptors');
++const { TOOLS } = require('./tools');
++
++const HUB = 'https://audio.openvibe.tools/';
++const esc = kit.esc;
++
++// The tools, in the order the family row shows them: the formats first, then the edits and the effects.
++const TOOL_LIST = [
++ ['mp3', 'MP3 Converter', 'MP3', 'Convert any audio file to MP3, the format every player reads.', 'Convert to MP3'],
++ ['wav', 'WAV Converter', 'WAV', 'Convert audio to WAV, the uncompressed format every program reads.', 'Convert to WAV'],
++ ['flac', 'FLAC Converter', 'FLAC', 'Convert audio to FLAC, the lossless format.', 'Convert to FLAC'],
++ ['ogg', 'OGG Converter', 'OGG', 'Convert audio to OGG Vorbis, the open format.', 'Convert to OGG'],
++ ['m4a', 'M4A Converter', 'M4A', 'Convert audio to M4A, the format phones and podcasts use.', 'Convert to M4A'],
++ ['opus', 'Opus Converter', 'Opus', 'Convert audio to Opus, the smallest format for speech.', 'Convert to Opus'],
++ ['aac', 'AAC Converter', 'AAC', 'Convert audio to AAC, the format inside an MP4.', 'Convert to AAC'],
++ ['wma', 'WMA Converter', 'WMA', 'Convert audio to WMA, the format Windows plays.', 'Convert to WMA'],
++ ['aiff', 'AIFF Converter', 'AIFF', 'Convert audio to AIFF, the format audio programs write.', 'Convert to AIFF'],
++ ['ac3', 'AC3 Converter', 'AC3', 'Convert audio to AC3, the surround-sound format.', 'Convert to AC3'],
++ ['extract', 'Extract Audio', 'Extract', 'Take the sound out of a video file.', 'Extract audio'],
++ ['trim', 'Trim Audio', 'Trim', 'Cut a recording down to the part you want to keep.', 'Trim'],
++ ['merge', 'Merge Audio', 'Merge', 'Join up to five recordings into one file.', 'Merge'],
++ ['ringtone', 'Ringtone Maker', 'Ringtone', 'Cut a ringtone for a phone.', 'Make ringtone'],
++ ['speed', 'Change Speed', 'Speed', 'Make a recording play faster or slower, with the pitch kept.', 'Change speed'],
++ ['pitch', 'Change Pitch', 'Pitch', 'Make a recording sound higher or lower without changing its speed.', 'Change pitch'],
++ ['reverse', 'Reverse Audio', 'Reverse', 'Play a recording backwards.', 'Reverse'],
++ ['normalize', 'Normalize Volume', 'Normalize', 'Make a recording as loud as it should be.', 'Normalize'],
++ ['noise', 'Reduce Noise', 'Noise', 'Take the hiss and the hum out of a recording.', 'Reduce noise'],
++ ['silence', 'Remove Silence', 'Silence', 'Cut the quiet parts out of a recording.', 'Remove silence'],
++ ['vocal', 'Remove Vocals', 'Vocals', 'Take the singing out of a recording, or keep only the singing.', 'Remove vocals'],
++ ['fade', 'Add Fade', 'Fade', 'Fade a recording in at the start and out at the end.', 'Add fade'],
++ ['loop', 'Loop Audio', 'Loop', 'Play a recording more than once.', 'Loop'],
++ ['bass', 'Boost Bass', 'Bass', 'Make the low end of a recording louder.', 'Boost bass'],
++ ['equalizer', 'Equalizer', 'Equalizer', 'Change the balance of the frequencies in a recording.', 'Equalize'],
++ ['compressor', 'Compressor', 'Compressor', 'Even out the quiet parts and the loud parts of a recording.', 'Compress'],
++ ['podcast', 'Podcast', 'Podcast', 'Make a recording ready to publish as a podcast.', 'Optimize for podcast'],
++ ['voice', 'Voice Changer', 'Voice', 'Change a voice into chipmunk, robot, giant and more.', 'Change voice'],
++ ['echo', 'Add Echo', 'Echo', 'Add an echo to a recording.', 'Add echo'],
++ ['reverb', 'Add Reverb', 'Reverb', 'Put a recording in a room, a hall or a cathedral.', 'Add reverb'],
++ ['chorus', 'Add Chorus', 'Chorus', 'Double a recording\'s voice with a chorus.', 'Add chorus'],
++ ['distortion', 'Add Distortion', 'Distortion', 'Make a recording sound driven and rough.', 'Add distortion'],
++ ['bitcrusher', 'Bitcrusher', 'Lo-Fi', 'Make a recording sound old and lo-fi.', 'Add lo-fi'],
++ ['stereo', 'Stereo', 'Stereo', 'Turn a recording into stereo or into mono.', 'Change channels'],
++ ['waveform', 'Waveform Image', 'Waveform', 'Draw the shape of a recording as a picture.', 'Draw waveform'],
++ ['metadata', 'Edit Metadata', 'Metadata', 'Read and write the tags inside an audio file.', 'Save metadata'],
++];
++
++const RECS = TOOL_LIST.map(([id, name, short, task, verb]) => ({ id, name, short, task, verb, op: id }));
++const BY_ID = new Map(RECS.map((r) => [r.id, r]));
++const SPEC = new Map(SPECS.map((s) => [s.id, s]));
++
++const HUB_TOOL = {
++ id: 'hub', name: 'Audio.OpenVibe', short: 'All tools', task: 'Convert audio to any format, and change it with the tools below.', verb: 'Convert',
++ op: 'convert',
++ about: 'Every audio tool in one place: convert a recording to another format, cut it, join it, and put an effect on it.',
++};
++
++/** The tool a host is for (the karaoke host is the vocal tool under another name). */
++function toolFor(host) {
++ const ctx = DOMAIN_MAP[host];
++ if (!ctx) return null;
++ if (ctx.toolId === 'hub') return HUB_TOOL;
++ return BY_ID.get(ctx.toolId) || null;
++}
++
++/** The verb of a host: the karaoke host says what it makes. */
++function verbFor(rec, host) {
++ if (host === 'karaoke.openvibe.tools') return 'Make karaoke';
++ if (rec.id === 'hub') return 'Convert';
++ return rec.verb;
++}
++
++/** The terms of a tool, from the app's real configuration and the tool's own descriptor. */
++function termsOf(rec) {
++ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);
++ return frame.terms({
++ work: 'server',
++ ttlMs: config.retention.anonTTL,
++ maxBytes: config.upload.maxFileSize,
++ maxFiles: (spec && spec.files && spec.files.max) || 1,
++ quotaClass: (spec && spec.quotaClass) || 'tools-job',
++ cost: (spec && spec.cost) || 1,
++ unit: 'runs',
++ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),
++ });
++}
++
++/** The labels of the descriptor's fields, in the words a person reads. */
++const FIELD_LABEL = {
++ format: 'Output format', bitrate: 'Bitrate (kbps)', sampleRate: 'Sample rate (Hz)', channels: 'Channels',
++ start: 'Start', end: 'End', speed: 'Speed', semitones: 'Semitones', mode: 'Mode', target: 'Target (dB)',
++ strength: 'Strength', threshold: 'Silence below (dB)', minDuration: 'Shortest gap (s)', device: 'Phone',
++ fadeIn: 'Fade in (s)', fadeOut: 'Fade out (s)', count: 'Times', gain: 'Gain (dB)', frequency: 'Frequency (Hz)',
++ preset: 'Preset', mix: 'Wet mix (%)', delay: 'Delay (ms)', decay: 'Decay', repeats: 'Repeats', bits: 'Bits',
++ width: 'Width (px)', height: 'Height (px)', color: 'Colour', bgColor: 'Background',
++ title: 'Title', artist: 'Artist', album: 'Album', genre: 'Genre', year: 'Year', track: 'Track', comment: 'Comment',
++};
++
++/** The values a person reads better than the code's: the channels are mono and stereo. */
++const VALUE_LABEL = { 1: 'Mono', 2: 'Stereo' };
++
++/** The box's options: the tool's own descriptor fields, with their ranges and their defaults. */
++function optionsFor(rec) {
++ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);
++ const props = (spec && spec.input && spec.input.properties) || {};
++ const fixed = rec.op === 'convert' && (DOMAIN_MAP[`${rec.id}.openvibe.tools`] || {}).defaultFormat;
++ const out = [];
++ for (const [name, s] of Object.entries(props)) {
++ // A format host fixes its own format; the page does not offer another one.
++ if (name === 'format' && fixed) continue;
++ out.push(frame.field(name, s, FIELD_LABEL, VALUE_LABEL));
++ }
++ return out.join('');
++}
++
++/**
++ * The box: a drop zone for the recording, the tool's options and the one button.
++ * @param {object} rec the tool record
++ */
++function boxFor(rec, host) {
++ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);
++ const many = (spec && spec.files && spec.files.max) > 1;
++ const accept = (spec && spec.files && spec.files.accept) || AUDIO_ACCEPT;
++ const drop = kit.drop({
++ name: 'file', accept: accept.join(','), id: 'file-input', multiple: many,
++ label: many ? 'Drop your recordings here' : 'Drop a recording here',
++ hint: many ? 'Up to five files at once' : 'MP3, WAV, FLAC, OGG, M4A, AAC, Opus, WMA, AIFF, MP4, WebM',
++ });
++ return `<form class="ovk-tool-form" id="ovk-audio-form" enctype="multipart/form-data" data-ovk-tool-form>`
++ + drop
++ + `<p class="ovk-try"><span>Try</span><a href="/sample.mp3" data-ovk-sample="/sample.mp3">A sample recording</a></p>`
++ + `<div id="options-panel" hidden>${optionsFor(rec)}</div>`
++ + `<div class="ovk-tool-buttons">${frame.verbButton(verbFor(rec, host), { id: 'process-btn', icon: 'wave' })}`
++ + `<button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" id="clear-btn">${kit.icon('close')}<span>Clear</span></button></div>`
++ + `</form>`;
++}
++
++/** The family: every audio tool, with the one on the page marked. */
++function familyItems(rec) {
++ // The hub is the first of the family: the whole audio suite, and the page the family marks there.
++ const hub = { href: HUB, label: 'Audio.OpenVibe', short: 'All tools', current: rec.id === 'hub' };
++ return [hub, ...RECS.map((t) => ({ href: `https://${t.id}.openvibe.tools/`, label: t.name, short: t.short, current: t.id === rec.id }))];
++}
++
++/** "People also use": the tools next to this one in the family. */
++function relatedFor(rec) {
++ // The hub is the converter, so the converter is not "also" there.
++ const self = rec.id === 'hub' ? 'convert' : rec.id;
++ const at = RECS.findIndex((t) => t.id === self);
++ const out = [];
++ for (let i = 1; out.length < 3 && i < RECS.length + 1; i++) {
++ const other = RECS[(at + i) % RECS.length];
++ if (other.id !== self && other.id !== 'hub') out.push(other);
++ }
++ return out.map((t) => kit.row({ href: `https://${t.id}.openvibe.tools/`, title: t.name, sub: t.task }));
++}
++
++/**
++ * The whole frame for one host.
++ * @param {string} host the satellite's own hostname, e.g. 'mp3.openvibe.tools'
++ * @returns {string} HTML
++ */
++function frameFor(host) {
++ const rec = toolFor(host);
++ if (!rec) return '';
++ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);
++ const fields = spec && spec.input && spec.input.properties ? Object.keys(spec.input.properties).join(', ') : 'none';
++ const apiId = rec.id === 'hub' ? 'convert' : rec.id;
++ const input = spec && spec.input && spec.input.properties ? Object.fromEntries(
++ Object.entries(spec.input.properties).map(([k, v]) => [k, v.default !== undefined ? v.default : '']),
++ ) : {};
++ const about = rec.about || `The ${rec.name.toLowerCase()} tool works on our server: the recording is sent to us, changed there and sent back as a file.`;
++ const faq = [
++ ['Is it free?', 'Yes. Every audio tool here is free, with a fair-use limit per address so the shared servers stay fast for everyone.'],
++ ['How long is my recording kept?', 'An hour for a visitor, a day for a signed-in person. Then the file is gone.'],
++ ['What does it accept?', 'MP3, WAV, FLAC, OGG, M4A, AAC, Opus, WMA, AIFF and the video formats that carry audio.'],
++ ];
++ return frame.page({
++ id: rec.id,
++ icon: 'wave', hue: 265, family: 'audio',
++ crumb: { href: HUB, label: 'Audio tools' },
++ title: rec.name, task: rec.task, facts: termsOf(rec),
++ box: boxFor(rec, host),
++ familyTitle: 'More audio tools',
++ familyMore: { href: HUB, label: `All ${RECS.length + 1}` },
++ familyItems: familyItems(rec),
++ about, faq,
++ api: {
++ id: apiId, path: `/api/v1/tools/${apiId}/run`, fields, payload: { input },
++ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),
++ },
++ related: relatedFor(rec),
++ });
++}
++
++module.exports = { frameFor, toolFor, termsOf, boxFor, verbFor, optionsFor, RECS, HUB_TOOL };
ddiff --git a/apps/audio/server/index.js b/apps/audio/server/index.js
iindex c0163e8..7a9d5de 100644
---- a/apps/audio/server/index.js
++++ b/apps/audio/server/index.js
@@@ -304,6 +304,12 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
},
}));
++// The tool frame's browser glue (apps/_shared/public/tool-frame.js): one copy for every file app.
++app.get('/js/tool-frame.js', (_req, res) => {
++ res.set('Cache-Control', 'no-cache');
++ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));
++});
++
// SPA fallback — every host gets index.html stamped with its own title, canonical, social
// tags and structured data (server/seo.js); the raw file carries only the hub's.
const { sendIndex } = require('./seo');
ddiff --git a/apps/audio/server/seo.js b/apps/audio/server/seo.js
iindex d83cd43..13429a5 100644
---- a/apps/audio/server/seo.js
++++ b/apps/audio/server/seo.js
@@@ -15,8 +15,10 @@
const fs = require('fs');
const path = require('path');
++const serve = require('openvibe-shared/serve');
const { canonicalHostFor, ownHost } = require('../../_shared/host-role');
const { DOMAIN_MAP } = require('./domain-map');
++const { frameFor } = require('./frame');
const PUBLIC = path.join(__dirname, '..', 'public');
const HUB_HOST = 'audio.openvibe.tools';
@@@ -89,10 +91,15 @@ function renderIndex(ctx, host, canonicalHost) {
`<meta name="twitter:title" content="${esc(title)}">`,
`<meta name="twitter:description" content="${esc(desc)}">`,
`<meta name="twitter:image" content="${image}">`,
++ // The shared tool frame's look (openvibe-shared >= 4.57.1), from this app's own pin.
++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
...ld.map(b => `<script type="application/ld+json">${JSON.stringify(b).replace(/</g, '\\u003c')}</script>`),
].join('\n');
-- const out = html.replace(/<\/head>/i, `${head}\n</head>`);
++ const out = html.replace(/<\/head>/i, `${head}\n</head>`)
++ // The frame itself: the head, the box, the answer slot, the family, the folds and "People also use".
++ .replace('<!--OVK:FRAME-->', frameFor(host) || '');
if (_cache.size > 100) _cache.clear();
_cache.set(key, out);
return out;
ddiff --git a/apps/audio/test/frame.test.js b/apps/audio/test/frame.test.js
nnew file mode 100644
iindex 0000000..06f87b7
---- /dev/null
++++ b/apps/audio/test/frame.test.js
@@@ -0,0 +1,137 @@
++'use strict';
++// The tool frame on the audio pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):
++// • every audio host is a tool page in the same frame: the head, the box, the answer slot, the
++// family, the folds and "People also use", in that order;
++// • the terms come from the app's real configuration — the file's lifetime from the cleanup config, its
++// size from the upload limits, the rate from the guard's quota table — and they follow it when it
++// changes;
++// • the box's options are the tool's own descriptor: a field the descriptor does not have is not on
++// the page;
++// • the one button says the verb, never "Run".
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const os = require('os');
++const path = require('path');
++const { frameFor, verbFor, RECS } = require('../server/frame');
++const { DOMAIN_MAP } = require('../server/domain-map');
++const { SPECS } = require('../server/descriptors');
++const config = require('../server/config');
++const { startApp } = require('../../_shared/test/spawn');
++
++const HOSTS = Object.keys(DOMAIN_MAP);
++
++/** The frame's order: each marker appears once, and in this order. */
++function inOrder(html, parts) {
++ let at = -1;
++ for (const p of parts) {
++ const i = html.indexOf(p, at + 1);
++ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);
++ at = i;
++ }
++}
++
++/** The head's terms, as text. */
++function factsOf(html) {
++ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);
++ if (!m) return [];
++ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);
++}
++
++// ── Every audio host is a framed tool page, in the same order ──
++{
++ for (const host of HOSTS) {
++ const html = frameFor(host);
++ assert.ok(html, `${host}: a frame`);
++ inOrder(html, [
++ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',
++ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
++ ]);
++ assert.ok(html.includes('https://audio.openvibe.tools/">Audio tools'), `${host}: the crumb is the family`);
++ assert.ok(html.includes('class="ovk-drop"'), `${host}: a drop zone`);
++ assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);
++ assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);
++ assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);
++ }
++ assert.strictEqual(RECS.length, 36, 'the family has thirty-six audio tools');
++}
++
++// ── The terms come from the app's own configuration ──
++{
++ const facts = factsOf(frameFor('mp3.openvibe.tools'));
++ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server; your file is deleted after 60 minutes'], 'mp3: free, on our server, and the file\'s lifetime');
++ assert.ok(facts.includes('One file up to 100 MB'), 'mp3: the upload limit');
++ assert.ok(facts.includes('A browser session: no account needed'), 'mp3: the tool refuses anonymous callers, so the page says what it needs');
++
++ const saved = config.retention.anonTTL;
++ try {
++ config.retention.anonTTL = 15 * 60 * 1000;
++ assert.ok(factsOf(frameFor('mp3.openvibe.tools')).includes('Runs on our server; your file is deleted after 15 minutes'), 'the lifetime follows the config');
++ config.upload.maxFileSize = 20 * 1024 * 1024;
++ assert.ok(factsOf(frameFor('mp3.openvibe.tools')).includes('One file up to 20 MB'), 'the size follows the config');
++ } finally {
++ config.retention.anonTTL = saved;
++ config.upload.maxFileSize = 100 * 1024 * 1024;
++ }
++
++ // A tool that takes several files says so.
++ assert.ok(factsOf(frameFor('merge.openvibe.tools')).includes('Up to 5 files, 100 MB each'), 'merge: the file count from its descriptor');
++}
++
++// ── The box's options are the tool's own descriptor ──
++{
++ const spec = SPECS.find((s) => s.id === 'trim');
++ const html = frameFor('trim.openvibe.tools');
++ for (const name of Object.keys(spec.input.properties)) {
++ assert.ok(html.includes(`name="${name}"`), `trim: the descriptor's field ${name} is on the page`);
++ }
++ assert.ok(!html.includes('name="bitrate"'), 'trim: a field the descriptor does not have is not on the page');
++
++ // The range and the default are the tool's own.
++ assert.ok(html.includes('name="start"'), 'trim: the start field');
++ assert.ok(/name="bitrate"[^>]*min="32"[^>]*max="320"/.test(frameFor('mp3.openvibe.tools')), 'mp3: the bitrate range is the descriptor\'s');
++}
++
++// ── The one button says the verb ──
++{
++ assert.strictEqual(verbFor({ id: 'trim', verb: 'Trim' }, 'trim.openvibe.tools'), 'Trim');
++ assert.strictEqual(verbFor({ id: 'vocal', verb: 'Remove vocals' }, 'karaoke.openvibe.tools'), 'Make karaoke');
++ for (const host of HOSTS) {
++ const html = frameFor(host);
++ const m = /<button[^>]*id="process-btn"[^>]*>\s*(?:<svg[\s\S]*?<\/svg>)?\s*<span>([^<]+)<\/span>/.exec(html);
++ assert.ok(m, `${host}: a verb button`);
++ assert.notStrictEqual(m[1], 'Run', `${host}: never Run`);
++ }
++ assert.ok(/<span>Convert to MP3<\/span>/.test(frameFor('mp3.openvibe.tools')), 'mp3: the button says the verb');
++}
++
++// ── The page the server sends: the frame, the title, the canonical and the structured data ──
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-audio-frame-'));
++ const app = await startApp('audio', { DATA_DIR: tmp });
++ const get = (host) => new Promise((resolve) => {
++ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {
++ let body = '';
++ res.setEncoding('utf8');
++ res.on('data', (d) => { body += d; });
++ res.on('end', () => resolve({ status: res.statusCode, body }));
++ });
++ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });
++ req.on('error', () => resolve({ status: 0, body: '' }));
++ });
++ try {
++ for (const host of HOSTS) {
++ const r = await get(host);
++ assert.strictEqual(r.status, 200, `${host}: answers 200`);
++ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), `${host}: the frame's title`);
++ assert.ok(r.body.includes('<link rel="canonical"'), `${host}: a canonical`);
++ assert.ok(r.body.includes('"@type":"BreadcrumbList"'), `${host}: the structured data`);
++ assert.ok(r.body.includes('/shared/kit.css?'), `${host}: the kit's look`);
++ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, `${host}: one h1`);
++ }
++ } finally {
++ await app.kill();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ console.log(`tool frame (audio): ${HOSTS.length} audio pages framed, the terms from the config, the options from the descriptors: all checks passed`);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/docs/public/css/style.css b/apps/docs/public/css/style.css
iindex 9c85d88..549f09b 100644
---- a/apps/docs/public/css/style.css
++++ b/apps/docs/public/css/style.css
@@@ -38,14 +38,17 @@ body {
flex-direction: column;
}
++/* The page is the shared tool frame: the frame's own .ovk-page sets the width and the padding. */
#app {
flex: 1;
-- max-width: 900px;
width: 100%;
-- margin: 0 auto;
-- padding: 0 20px 40px;
}
++/* The tool frame: the brand is the site ("Tools"), not the subdomain's name. */
++.openvibe-navbar .openvibe-navbar-brand .b-core,
++.openvibe-navbar .openvibe-navbar-brand .b-dot,
++.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }
++
/* ── Hero ───────────────────────────────────────────────────── */
.hero {
text-align: center;
@@@ -475,4 +478,7 @@ body {
}
.check-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 14px; color: var(--text-secondary); cursor: pointer; }
++
++/* The page is the shared tool frame: it starts under the navbar, not behind it. */
++.ovk-page { padding-top: calc(var(--ovk-nav-h, 52px) + 12px); }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; opacity: .7; } }
ddiff --git a/apps/docs/public/index.html b/apps/docs/public/index.html
iindex 8522c91..ea24057 100644
---- a/apps/docs/public/index.html
++++ b/apps/docs/public/index.html
@@@ -26,197 +26,9 @@
<!-- Navbar injected by OpenVibeNavbar.init() -->
<div id="app">
-- <!-- Hero -->
-- <section class="hero" id="hero">
-- <div class="hero-icon" id="hero-icon"><i class="fa-solid fa-file-pdf"></i></div>
-- <h1 id="hero-title">Docs.OpenVibe</h1>
-- <p class="hero-subtitle" id="hero-subtitle">Online PDF & Document Tools</p>
-- </section>
--
-- <!-- Tool Selector (visible on hub domain) -->
-- <nav class="tool-tabs" id="tool-tabs">
-- <button class="tool-tab active" data-tool="merge"><i class="fa-solid fa-object-group"></i> Merge</button>
-- <button class="tool-tab" data-tool="split"><i class="fa-solid fa-scissors"></i> Split</button>
-- <button class="tool-tab" data-tool="compress"><i class="fa-solid fa-compress"></i> Compress</button>
-- <button class="tool-tab" data-tool="rotate"><i class="fa-solid fa-rotate"></i> Rotate</button>
-- <button class="tool-tab" data-tool="reorder"><i class="fa-solid fa-arrow-down-wide-short"></i> Reorder</button>
-- <button class="tool-tab" data-tool="watermark"><i class="fa-solid fa-signature"></i> Watermark</button>
-- <button class="tool-tab" data-tool="img2pdf"><i class="fa-solid fa-file-image"></i> IMG→PDF</button>
-- <button class="tool-tab" data-tool="pdf2img"><i class="fa-solid fa-image"></i> PDF→IMG</button>
-- </nav>
--
-- <!-- Upload Zone -->
-- <section class="upload-zone" id="upload-zone">
-- <div class="upload-area" id="upload-area">
-- <div class="upload-icon"><i class="fa-solid fa-cloud-arrow-up"></i></div>
-- <p class="upload-text">Drag & drop files here</p>
-- <p class="upload-hint" id="upload-hint">or click to browse — PDF, PNG, JPG, WebP, TIFF, BMP, GIF</p>
-- <input type="file" id="file-input" accept=".pdf,image/*" hidden>
-- <button class="btn btn-primary" id="browse-btn"><i class="fa-solid fa-folder-open"></i> Choose Files</button>
-- </div>
--
-- <!-- Single file info -->
-- <div class="upload-info" id="upload-info" style="display:none">
-- <div class="file-preview" id="file-preview"><i class="fa-solid fa-file-pdf"></i></div>
-- <div class="file-details" id="file-details"></div>
-- <button class="btn btn-ghost" id="clear-btn"><i class="fa-solid fa-xmark"></i> Clear</button>
-- </div>
--
-- <!-- Multi-file list -->
-- <div id="multi-file-zone" style="display:none">
-- <ul class="file-list" id="file-list"></ul>
-- <div style="display:flex;gap:8px;justify-content:center;margin-top:8px">
-- <button class="btn btn-ghost" id="add-more-btn"><i class="fa-solid fa-plus"></i> Add More</button>
-- <button class="btn btn-ghost" id="clear-all-btn"><i class="fa-solid fa-xmark"></i> Clear All</button>
-- </div>
-- <input type="file" id="multi-file-input" accept=".pdf,image/*" multiple hidden>
-- </div>
-- </section>
--
-- <!-- Tool Options -->
-- <section class="options-panel" id="options-panel" style="display:none">
-- <!-- Split Options -->
-- <div class="option-group" id="opt-split" style="display:none">
-- <label class="option-label">Split Mode</label>
-- <select id="split-mode" class="select-input">
-- <option value="ranges">By Page Ranges</option>
-- <option value="all">Every Page Separate</option>
-- <option value="half">Split in Half</option>
-- </select>
-- <div id="split-ranges-input" style="margin-top:12px">
-- <label class="option-label">Page Ranges</label>
-- <input type="text" id="split-ranges" class="text-input" placeholder="e.g. 1-3, 5, 7-9">
-- <p class="option-hint">Comma-separated page numbers or ranges</p>
-- </div>
-- <p class="option-hint">Several parts download as one ZIP of PDF files.</p>
-- </div>
--
-- <!-- Compress Options -->
-- <div class="option-group" id="opt-compress" style="display:none">
-- <label class="option-label">Compression Level</label>
-- <select id="compress-level" class="select-input">
-- <option value="low">Low (best quality)</option>
-- <option value="medium" selected>Medium (balanced)</option>
-- <option value="high">High (smallest file)</option>
-- </select>
-- </div>
--
-- <!-- Rotate Options -->
-- <div class="option-group" id="opt-rotate" style="display:none">
-- <label class="option-label">Rotation Angle</label>
-- <div class="tool-tabs" style="margin-bottom:12px">
-- <button class="tool-tab active" data-angle="90">90° CW</button>
-- <button class="tool-tab" data-angle="180">180°</button>
-- <button class="tool-tab" data-angle="270">90° CCW</button>
-- </div>
-- <label class="option-label">Pages</label>
-- <input type="text" id="rotate-pages" class="text-input" placeholder="all (or e.g. 1,3,5-7)">
-- </div>
--
-- <!-- Reorder Options -->
-- <div class="option-group" id="opt-reorder" style="display:none">
-- <label class="option-label">New Page Order</label>
-- <input type="text" id="reorder-order" class="text-input" placeholder="e.g. 3,1,2,5,4">
-- <p class="option-hint">Comma-separated page numbers in the desired order. Omit pages to remove them.</p>
-- </div>
--
-- <!-- Watermark Options -->
-- <div class="option-group" id="opt-watermark" style="display:none">
-- <label class="option-label">Watermark Text</label>
-- <input type="text" id="wm-text" class="text-input" placeholder="CONFIDENTIAL" value="CONFIDENTIAL">
-- <div class="page-inputs" style="margin-top:12px">
-- <div class="dim-field">
-- <label class="option-label">Font Size</label>
-- <input type="number" id="wm-fontsize" class="num-input" value="48" min="8" max="200">
-- </div>
-- <div class="dim-field">
-- <label class="option-label">Opacity <span id="wm-opacity-val">15</span>%</label>
-- <input type="range" id="wm-opacity" class="slider" min="1" max="100" value="15">
-- </div>
-- <div class="dim-field">
-- <label class="option-label">Rotation</label>
-- <input type="number" id="wm-rotation" class="num-input" value="-45" min="-180" max="180">
-- </div>
-- <div class="dim-field">
-- <label class="option-label">Color</label>
-- <input type="color" id="wm-color" value="#888888" style="width:100%;height:38px;padding:2px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);">
-- </div>
-- </div>
-- <label class="option-label" style="margin-top:12px">Pages</label>
-- <input type="text" id="wm-pages" class="text-input" placeholder="all (or e.g. 1,3,5-7)">
-- </div>
--
-- <!-- Image to PDF Options -->
-- <div class="option-group" id="opt-img2pdf" style="display:none">
-- <label class="option-label">Page Size</label>
-- <select id="img2pdf-pagesize" class="select-input">
-- <option value="a4" selected>A4</option>
-- <option value="letter">US Letter</option>
-- <option value="legal">US Legal</option>
-- <option value="fit">Fit to Image</option>
-- </select>
-- </div>
--
-- <!-- PDF to Image Options -->
-- <div class="option-group" id="opt-pdf2img" style="display:none">
-- <label class="option-label">Output Format</label>
-- <div class="tool-tabs" style="margin-bottom:12px">
-- <button class="tool-tab active" data-imgfmt="png">PNG</button>
-- <button class="tool-tab" data-imgfmt="jpg">JPG</button>
-- </div>
-- <label class="option-label">DPI</label>
-- <input type="number" id="pdf2img-dpi" class="num-input num-input-sm" value="150" min="72" max="600">
-- <label class="option-label" style="margin-top:12px">Pages</label>
-- <input type="text" id="pdf2img-pages" class="text-input" placeholder="all (or e.g. 1,3,5-7)">
-- <p class="option-hint">Up to 50 pages at 150 DPI (20 at 300 DPI, 5 above). One page downloads as an image, several as a ZIP.</p>
-- </div>
--
-- <!-- Protect Options -->
-- <div class="option-group" id="opt-protect" style="display:none">
-- <label class="option-label">Password</label>
-- <input type="password" id="protect-password" class="text-input" placeholder="Enter a password" autocomplete="new-password" maxlength="127">
-- <label class="check-row"><input type="checkbox" id="protect-print" checked> Allow printing</label>
-- <label class="check-row"><input type="checkbox" id="protect-copy" checked> Allow copying text</label>
-- <p class="option-hint">Encrypted with AES-256. The password cannot be recovered, so keep it safe.</p>
-- </div>
--
-- <!-- Unlock Options -->
-- <div class="option-group" id="opt-unlock" style="display:none">
-- <label class="option-label">Current Password</label>
-- <input type="password" id="unlock-password" class="text-input" placeholder="Enter the PDF password">
-- </div>
--
-- <!-- Process Button -->
-- <button class="btn btn-primary btn-lg" id="process-btn" disabled style="margin-top:20px;width:100%">
-- <i class="fa-solid fa-wand-magic-sparkles"></i> <span id="process-text">Process Document</span>
-- </button>
-- </section>
--
-- <!-- Processing State -->
-- <section class="processing" id="processing" style="display:none">
-- <div class="spinner"></div>
-- <p>Processing your document...</p>
-- </section>
--
-- <!-- Result -->
-- <section class="result-panel" id="result-panel" style="display:none">
-- <h2><i class="fa-solid fa-circle-check"></i> Done!</h2>
-- <div class="result-info" id="result-info"></div>
-- <div class="result-actions">
-- <a class="btn btn-primary btn-lg" id="download-btn" href="#" download>
-- <i class="fa-solid fa-download"></i> Download
-- </a>
-- <button class="btn btn-secondary" id="another-btn">
-- <i class="fa-solid fa-rotate-left"></i> Process Another
-- </button>
-- </div>
-- </section>
--
-- <!-- Subdomains Grid (hub only) -->
-- <section class="subdomains" id="subdomains">
-- <h2>All Document Tools</h2>
-- <div class="subdomain-grid" id="subdomain-grid"></div>
-- </section>
++ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this
++ host — the head, the box, the answer slot, the family, the folds and "People also use". -->
++ <!--OVK:FRAME-->
</div>
<!-- Footer -->
@@@ -227,7 +39,11 @@
<script src="/shared/footer.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.js"></script>
++ <script src="/shared/kit.js" defer></script>
++ <script src="/shared/kit-web.js" defer></script>
++ <script src="/shared/kit-data-web.js" defer></script>
<script src="/js/ov-jobs.js"></script>
++ <script src="/js/tool-frame.js" defer></script>
<script src="/js/app.js"></script>
</body>
</html>
ddiff --git a/apps/docs/public/js/app.js b/apps/docs/public/js/app.js
iindex ef56731..0fe272c 100644
---- a/apps/docs/public/js/app.js
++++ b/apps/docs/public/js/app.js
@@@ -1,51 +1,27 @@
// ═══════════════════════════════════════════════════════════════
// Docs.OpenVibe — Client Application
--// Domain-aware document processing SPA.
--// Detects hostname → fetches context → adapts branding + tools.
++// The page around the tool is the shared frame (openvibe-shared ≥ 4.57.1): the server draws the head,
++// the box, the answer slot, the family and the folds for this host. This file fills the box (the drop
++// zone, the tool's own options and the one button) and the answer (the file it made, its size before
++// and after, and Download). The upload, the processing and the download are the app's own, unchanged.
// ═══════════════════════════════════════════════════════════════
(function () {
'use strict';
// ── State ────────────────────────────────────────────────
-- let ctx = null; // domain context from /api/context
-- let selectedFiles = []; // File objects (single or multi)
-- let selectedTool = 'merge';
-- let selectedAngle = 90;
-- let selectedImgFmt = 'png';
++ let ctx = null; // domain context from /api/context
++ let selectedFiles = []; // the File objects the drop zone holds
// ── DOM refs ─────────────────────────────────────────────
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
-- const heroIcon = $('#hero-icon');
-- const heroTitle = $('#hero-title');
-- const heroSubtitle = $('#hero-subtitle');
-- const toolTabs = $('#tool-tabs');
-- const uploadArea = $('#upload-area');
-- const uploadHint = $('#upload-hint');
-- const uploadInfo = $('#upload-info');
-- const multiFileZone = $('#multi-file-zone');
const fileInput = $('#file-input');
-- const multiFileInput = $('#multi-file-input');
-- const browseBtn = $('#browse-btn');
-- const clearBtn = $('#clear-btn');
-- const addMoreBtn = $('#add-more-btn');
-- const clearAllBtn = $('#clear-all-btn');
-- const fileList = $('#file-list');
const optionsPanel = $('#options-panel');
const processBtn = $('#process-btn');
-- const processText = $('#process-text');
-- const processingEl = $('#processing');
-- const resultPanel = $('#result-panel');
-- const resultInfo = $('#result-info');
-- const downloadBtn = $('#download-btn');
-- const anotherBtn = $('#another-btn');
-- const subdomainsSection = $('#subdomains');
-- const subdomainGrid = $('#subdomain-grid');
--
-- // Multi-file tools
-- const MULTI_FILE_TOOLS = new Set(['merge', 'img2pdf']);
++ const clearBtn = $('#clear-btn');
++ const kit = () => window.OpenVibeKit;
// ── Init ─────────────────────────────────────────────────
async function init() {
@@@ -53,104 +29,43 @@
const res = await fetch('/api/context');
ctx = await res.json();
} catch {
-- ctx = { toolId: 'hub', brandName: 'Docs.OpenVibe', defaultOp: null, faIcon: 'fa-file-pdf', tools: [] };
++ ctx = { toolId: 'hub', brandName: 'Docs.OpenVibe', tools: [] };
}
-- applyBranding();
-- showAvailability();
initNavbar();
initNotifications();
if (typeof OpenVibeAccountSwitcher !== 'undefined') OpenVibeAccountSwitcher.init({ apiBase: 'https://openvibe.network' });
-- initToolTabs();
-- initUpload();
++ showAvailability();
++ initDrop();
initOptions();
-- initSubdomains();
-- initResult();
reattach();
}
-- // ── Branding ─────────────────────────────────────────────
-- function applyBranding() {
-- document.title = ctx.seoTitle || `${ctx.brandName} — Document Tools`;
-- heroIcon.innerHTML = `<i class="fa-solid ${ctx.faIcon || 'fa-file-pdf'}"></i>`;
-- heroTitle.textContent = ctx.brandName || 'Docs.OpenVibe';
--
-- const subtitles = {
-- hub: 'Online PDF & Document Tools',
-- merge: 'Combine PDF Files Online',
-- split: 'Split PDF Files Online',
-- compress: 'Compress PDF Files Online',
-- rotate: 'Rotate PDF Pages Online',
-- reorder: 'Rearrange PDF Pages Online',
-- watermark: 'Add Watermarks to PDFs Online',
-- protect: 'Password Protect PDFs Online',
-- unlock: 'Remove PDF Password Online',
-- img2pdf: 'Convert Images to PDF Online',
-- pdf2img: 'Convert PDF to Images Online',
-- };
-- heroSubtitle.textContent = subtitles[ctx.toolId] || ctx.seoDescription || subtitles.hub;
--
-- if (ctx.defaultOp) selectedTool = ctx.defaultOp;
--
-- // Hide tool tabs on single-tool domains
-- if (ctx.toolId !== 'hub') {
-- toolTabs.style.display = 'none';
-- }
--
-- // Hide subdomains section on non-hub domains
-- if (ctx.toolId !== 'hub') {
-- subdomainsSection.style.display = 'none';
-- }
--
-- // Update upload hint based on tool
-- updateUploadHint();
--
-- // Update meta description
-- const metaDesc = document.querySelector('meta[name="description"]');
-- if (metaDesc && ctx.seoDescription) metaDesc.content = ctx.seoDescription;
-- }
--
-- // A tool whose server-side program is not installed yet (Protect/Unlock need qpdf, PDF to image
-- // needs poppler): the page says so instead of letting an upload fail.
++ // ── A tool whose command-line tools are not on the server yet ──
++ // Protect and Unlock need qpdf, PDF to images needs poppler: while one is missing the tool answers
++ // 503, so the page says so instead of letting the upload fail.
function showAvailability() {
-- const tool = (ctx.tools || []).find(t => t.id === selectedTool);
++ const op = (ctx && ctx.defaultOp) || null;
++ const tool = (ctx.tools || []).find((t) => t.id === op);
const off = !!(tool && tool.available === false);
++ const card = document.querySelector('.ovk-tool-card');
++ if (!card) return;
let note = document.getElementById('tool-notice');
-- const zone = document.getElementById('upload-zone');
-- if (off && !note && zone) {
++ if (off && !note) {
note = document.createElement('div');
note.id = 'tool-notice';
note.className = 'tool-notice';
note.setAttribute('role', 'status');
-- zone.parentNode.insertBefore(note, zone);
++ card.appendChild(note);
}
if (note) {
note.textContent = off ? `${tool.label} is being set up on the server and is not available yet. The other PDF tools work now.` : '';
note.style.display = off ? '' : 'none';
}
-- if (zone) zone.style.display = off ? 'none' : '';
-- if (off) { optionsPanel.style.display = 'none'; processBtn.disabled = true; }
-- }
--
-- function updateUploadHint() {
-- const isImageTool = selectedTool === 'img2pdf';
-- const isPdfTool = !isImageTool;
-- if (isImageTool) {
-- uploadHint.textContent = 'or click to browse — PNG, JPG, WebP, TIFF, BMP, GIF';
-- fileInput.accept = 'image/*';
-- multiFileInput.accept = 'image/*';
-- } else {
-- uploadHint.textContent = 'or click to browse — PDF files';
-- fileInput.accept = '.pdf,application/pdf';
-- multiFileInput.accept = '.pdf,application/pdf';
-- }
--
-- // Multi-file tools allow multiple selection
-- if (MULTI_FILE_TOOLS.has(selectedTool)) {
-- fileInput.multiple = true;
-- } else {
-- fileInput.multiple = false;
-- }
++ const drop = document.querySelector('.ovk-drop');
++ if (drop) drop.hidden = off;
++ if (optionsPanel) optionsPanel.hidden = off || optionsPanel.hidden;
++ if (off && processBtn) processBtn.disabled = true;
}
// ── Navbar ───────────────────────────────────────────────
@@@ -158,13 +73,11 @@
const token = getCookie('ov_token') || localStorage.getItem('ov_token');
let user = null;
if (token) {
-- try {
-- const payload = JSON.parse(atob(token.split('.')[1]));
-- user = payload;
-- } catch { /* invalid token */ }
++ try { user = JSON.parse(atob(token.split('.')[1])); } catch { /* invalid token */ }
}
-- // Brand name/icon come from the hostname now (png.openvibe.tools → "PNG.OpenVibe.Tools").
++ // The brand is the site ("Tools"), not the subdomain's name: the same brand the network and
++ // code tool pages carry (owner ask 11).
if (typeof OpenVibeNavbar !== 'undefined') {
OpenVibeNavbar.init({
service: 'docs',
@@@ -173,39 +86,41 @@
apiBase: 'https://openvibe.network',
history: { type: 'tool', title: ctx?.brandName || 'Docs.OpenVibe' },
silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',
++ brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' },
});
}
initFooter();
}
/* ---------- Footer (shared OpenVibeFooter) ---------- */
-- // Full footer on the hub host, the compact strip on the single-tool hosts.
function initFooter() {
if (typeof OpenVibeFooter === 'undefined') return;
try {
OpenVibeFooter.init({
service: 'docs',
-- variant: (!ctx || ctx.toolId === 'hub') ? 'full' : 'compact',
++ variant: 'compact',
mount: '#ov-footer',
apiBase: 'https://openvibe.network',
-- links: [{ heading: 'Docs tools', items: [
-- { name: 'Merge PDF', url: 'https://mergepdf.openvibe.tools' },
-- { name: 'Split PDF', url: 'https://splitpdf.openvibe.tools' },
-- { name: 'Compress PDF', url: 'https://compresspdf.openvibe.tools' },
-- { name: 'Rotate PDF', url: 'https://rotatepdf.openvibe.tools' },
-- { name: 'Watermark PDF', url: 'https://watermarkpdf.openvibe.tools' },
-- { name: 'Protect PDF', url: 'https://protectpdf.openvibe.tools' },
-- { name: 'Images to PDF', url: 'https://image2pdf.openvibe.tools' },
-- { name: 'PDF to images', url: 'https://pdf2jpg.openvibe.tools' },
++ brandName: 'OpenVibe.Tools',
++ links: [{ heading: 'PDF tools', items: [
++ { name: 'Merge', url: 'https://mergepdf.openvibe.tools' },
++ { name: 'Split', url: 'https://splitpdf.openvibe.tools' },
++ { name: 'Compress', url: 'https://compresspdf.openvibe.tools' },
++ { name: 'Rotate', url: 'https://rotatepdf.openvibe.tools' },
++ { name: 'Watermark', url: 'https://watermarkpdf.openvibe.tools' },
++ { name: 'Protect', url: 'https://protectpdf.openvibe.tools' },
] }],
});
} catch { /* optional */ }
}
++ // ── Notifications (OpenVibe bell + toasts) ───────────
function initNotifications() {
const token = getCookie('ov_token') || localStorage.getItem('ov_token');
if (typeof OpenVibeNotifications === 'undefined') return;
++
OpenVibeNotifications.init({ token: token || null, apiBase: 'https://openvibe.network' });
++
if (typeof OpenVibeNavbar !== 'undefined') {
const mount = OpenVibeNavbar.getBellMount();
if (mount) {
@@@ -215,255 +130,90 @@
}
}
-- // ── Tool Tabs ────────────────────────────────────────────
-- function initToolTabs() {
-- $$('.tool-tab[data-tool]').forEach(tab => {
-- if (tab.dataset.tool === selectedTool) tab.classList.add('active');
-- else tab.classList.remove('active');
--
-- tab.addEventListener('click', () => {
-- selectedTool = tab.dataset.tool;
-- $$('.tool-tab[data-tool]').forEach(t => t.classList.toggle('active', t.dataset.tool === selectedTool));
-- resetUpload();
-- updateUploadHint();
-- updateProcessButton();
-- showAvailability();
-- });
-- });
-- }
--
-- // ── Upload ───────────────────────────────────────────────
-- function initUpload() {
-- uploadArea.addEventListener('dragover', (e) => {
-- e.preventDefault();
-- uploadArea.classList.add('dragover');
-- });
-- uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover'));
-- uploadArea.addEventListener('drop', (e) => {
-- e.preventDefault();
-- uploadArea.classList.remove('dragover');
-- if (e.dataTransfer.files.length) handleFiles(Array.from(e.dataTransfer.files));
-- });
--
-- uploadArea.addEventListener('click', () => fileInput.click());
-- browseBtn.addEventListener('click', (e) => { e.stopPropagation(); fileInput.click(); });
++ // ── The box: the drop zone ───────────────────────────────
++ function initDrop() {
++ if (!fileInput) return;
fileInput.addEventListener('change', () => {
-- if (fileInput.files.length) handleFiles(Array.from(fileInput.files));
++ const files = Array.prototype.slice.call(fileInput.files || []);
++ if (files.length) handleFiles(files);
});
++ if (clearBtn) clearBtn.addEventListener('click', resetUpload);
-- // Multi-file controls
-- addMoreBtn.addEventListener('click', () => multiFileInput.click());
-- multiFileInput.addEventListener('change', () => {
-- if (multiFileInput.files.length) handleFiles(Array.from(multiFileInput.files), true);
-- });
--
-- clearBtn.addEventListener('click', resetUpload);
-- clearAllBtn.addEventListener('click', resetUpload);
-- }
--
-- function handleFiles(files, append = false) {
-- const isMulti = MULTI_FILE_TOOLS.has(selectedTool);
--
-- // Validate file types
-- for (const file of files) {
-- if (selectedTool === 'img2pdf') {
-- if (!file.type.startsWith('image/')) {
-- alert(`"${file.name}" is not an image file.`);
-- return;
-- }
-- } else {
-- if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
-- alert(`"${file.name}" is not a PDF file.`);
-- return;
-- }
-- }
-- if (file.size > 100 * 1024 * 1024) {
-- alert(`"${file.name}" is too large. Maximum 100MB per file.`);
-- return;
-- }
-- }
--
-- if (isMulti) {
-- if (append) {
-- selectedFiles = [...selectedFiles, ...files];
-- } else {
-- selectedFiles = [...files];
-- }
-- showMultiFileList();
-- } else {
-- selectedFiles = [files[0]];
-- showSingleFile(files[0]);
++ const sampleLink = document.querySelector('[data-ovk-sample]');
++ if (sampleLink && window.OVToolFrame) {
++ sampleLink.addEventListener('click', (e) => {
++ e.preventDefault();
++ window.OVToolFrame.sample(sampleLink.getAttribute('data-ovk-sample'), (file) => handleFiles([file]));
++ });
}
--
-- optionsPanel.style.display = '';
-- showToolOptions();
-- updateProcessButton();
}
-- function showSingleFile(file) {
-- uploadArea.style.display = 'none';
-- multiFileZone.style.display = 'none';
-- uploadInfo.style.display = 'flex';
--
-- const preview = $('#file-preview');
-- const details = $('#file-details');
--
-- if (file.type === 'application/pdf') {
-- preview.innerHTML = '<i class="fa-solid fa-file-pdf"></i>';
-- } else {
-- const url = URL.createObjectURL(file);
-- preview.innerHTML = `<img src="${url}" alt="Preview">`;
++ function handleFiles(files) {
++ const many = fileInput.hasAttribute('multiple');
++ const max = many ? 50 : 1;
++ if (files.length > max) {
++ fail(`That is more than ${max} file${max === 1 ? '' : 's'}.`);
++ return;
}
--
-- details.innerHTML = `
-- <div class="fname">${escapeHtml(file.name)}</div>
-- <div class="fmeta">${file.type || 'application/pdf'} &bull; ${formatBytes(file.size)}</div>
-- `;
-- }
--
-- function showMultiFileList() {
-- uploadArea.style.display = 'none';
-- uploadInfo.style.display = 'none';
-- multiFileZone.style.display = '';
--
-- fileList.innerHTML = selectedFiles.map((f, i) => `
-- <li>
-- <span class="fname"><i class="fa-solid ${f.type === 'application/pdf' ? 'fa-file-pdf' : 'fa-file-image'}" style="color:var(--accent);margin-right:8px"></i>${escapeHtml(f.name)}</span>
-- <span class="fsize">${formatBytes(f.size)}</span>
-- <button class="remove-file" data-idx="${i}" title="Remove"><i class="fa-solid fa-xmark"></i></button>
-- </li>
-- `).join('');
--
-- // Bind remove buttons
-- fileList.querySelectorAll('.remove-file').forEach(btn => {
-- btn.addEventListener('click', () => {
-- const idx = parseInt(btn.dataset.idx);
-- selectedFiles.splice(idx, 1);
-- if (selectedFiles.length === 0) {
-- resetUpload();
-- } else {
-- showMultiFileList();
-- updateProcessButton();
-- }
-- });
-- });
++ const tooBig = files.find((f) => f.size > 100 * 1024 * 1024);
++ if (tooBig) {
++ fail('That file is too large. The maximum is 100 MB.');
++ return;
++ }
++ selectedFiles = files;
++ if (optionsPanel) optionsPanel.hidden = false;
++ if (processBtn) processBtn.disabled = false;
++ hideAnswer();
}
function resetUpload() {
selectedFiles = [];
-- fileInput.value = '';
-- multiFileInput.value = '';
-- uploadArea.style.display = '';
-- uploadInfo.style.display = 'none';
-- multiFileZone.style.display = 'none';
-- optionsPanel.style.display = 'none';
-- resultPanel.style.display = 'none';
-- processingEl.style.display = 'none';
-- processBtn.disabled = true;
++ if (fileInput) fileInput.value = '';
++ if (optionsPanel) optionsPanel.hidden = true;
++ if (processBtn) processBtn.disabled = true;
++ hideAnswer();
}
-- // ── Options ──────────────────────────────────────────────
++ // ── The box: the options and the one button ─────────────
function initOptions() {
-- // Split mode toggle
-- const splitMode = $('#split-mode');
-- if (splitMode) {
-- splitMode.addEventListener('change', () => {
-- const rangesInput = $('#split-ranges-input');
-- rangesInput.style.display = splitMode.value === 'ranges' ? '' : 'none';
-- });
-- }
--
-- // Watermark opacity slider
-- const wmOpacity = $('#wm-opacity');
-- const wmOpacityVal = $('#wm-opacity-val');
-- if (wmOpacity) {
-- wmOpacity.addEventListener('input', () => { wmOpacityVal.textContent = wmOpacity.value; });
-- }
--
-- // Angle buttons
-- $$('[data-angle]').forEach(btn => {
-- btn.addEventListener('click', () => {
-- selectedAngle = parseInt(btn.dataset.angle);
-- $$('[data-angle]').forEach(b => b.classList.toggle('active', b.dataset.angle === btn.dataset.angle));
-- });
-- });
--
-- // Image format buttons
-- $$('[data-imgfmt]').forEach(btn => {
-- btn.addEventListener('click', () => {
-- selectedImgFmt = btn.dataset.imgfmt;
-- $$('[data-imgfmt]').forEach(b => b.classList.toggle('active', b.dataset.imgfmt === btn.dataset.imgfmt));
-- });
-- });
--
-- processBtn.addEventListener('click', processDocument);
++ if (processBtn) processBtn.addEventListener('click', processDocument);
}
-- function showToolOptions() {
-- // Hide all option groups
-- const groups = ['opt-split', 'opt-compress', 'opt-rotate', 'opt-reorder',
-- 'opt-watermark', 'opt-img2pdf', 'opt-pdf2img', 'opt-protect', 'opt-unlock'];
-- groups.forEach(id => {
-- const el = $(`#${id}`);
-- if (el) el.style.display = 'none';
++ /** The tool's own options, as the job's input: the fields the descriptor names, and nothing else. */
++ function collectInput() {
++ const form = document.querySelector('#ovk-docs-form');
++ const input = {};
++ if (!form) return input;
++ form.querySelectorAll('input[name], select[name], textarea[name]').forEach((el) => {
++ if (el.type === 'hidden') { input[el.name] = el.value; return; }
++ // The drop zone's own file field is not an option: the file goes in the job's files.
++ if (el.type === 'file') return;
++ if (el.type === 'radio') { if (el.checked) input[el.name] = el.value; return; }
++ if (el.type === 'checkbox') { input[el.name] = el.checked; return; }
++ if (el.value === '') return;
++ input[el.name] = el.value;
});
--
-- // Show active tool options
-- const optEl = $(`#opt-${selectedTool}`);
-- if (optEl) optEl.style.display = '';
-- }
--
-- function updateProcessButton() {
-- const isMulti = MULTI_FILE_TOOLS.has(selectedTool);
-- const hasFiles = selectedFiles.length > 0;
-- const hasEnoughFiles = isMulti ? selectedFiles.length >= (selectedTool === 'merge' ? 2 : 1) : hasFiles;
-- processBtn.disabled = !hasEnoughFiles;
--
-- const labels = {
-- merge: 'Merge PDFs',
-- split: 'Split PDF',
-- compress: 'Compress PDF',
-- rotate: 'Rotate Pages',
-- reorder: 'Reorder Pages',
-- watermark: 'Add Watermark',
-- protect: 'Protect PDF',
-- unlock: 'Unlock PDF',
-- img2pdf: 'Convert to PDF',
-- pdf2img: 'Convert to Images',
-- metadata: 'View Info',
-- };
-- processText.textContent = labels[selectedTool] || 'Process Document';
++ return input;
}
// ── Process ──────────────────────────────────────────────
async function processDocument() {
-- if (selectedFiles.length === 0) return;
--
-- optionsPanel.style.display = 'none';
-- resultPanel.style.display = 'none';
-- processingEl.style.display = '';
-- processBtn.disabled = true;
--
-- const isMulti = MULTI_FILE_TOOLS.has(selectedTool);
-- const formData = new FormData();
-- formData.append('tool', selectedTool);
--
-- if (isMulti) {
-- for (const file of selectedFiles) {
-- formData.append('files', file);
-- }
-- } else {
-- formData.append('file', selectedFiles[0]);
++ if (!selectedFiles.length) return;
++
++ const input = collectInput();
++ if (processBtn) {
++ processBtn.disabled = true;
++ const span = processBtn.querySelector('span');
++ // The button keeps its verb while it works: "Compress…", not "Working…".
++ if (span) span.textContent = `${span.textContent.replace(/…$/, '')}…`;
}
++ showWorking();
-- // Add tool-specific options
-- addToolOptions(formData);
--
-- // Jobs: the work is accepted, followed live and survives a reload (?job=<id> in the address).
-- const input = {};
-- for (const [k, v] of formData.entries()) if (k !== 'file' && k !== 'files') input[k] = v;
try {
-- const job = await OVJobs.submit({ type: 'docs.process', input, files: isMulti ? selectedFiles : [selectedFiles[0]], fileField: isMulti ? 'files' : 'file' });
++ const many = selectedFiles.length > 1;
++ const job = await OVJobs.submit({
++ type: 'docs.process', input, files: selectedFiles, fileField: many ? 'files' : 'file',
++ });
OVJobs.remember(job.id);
followJob(job);
} catch (err) {
@@@ -471,99 +221,29 @@
}
}
-- function addToolOptions(formData) {
-- switch (selectedTool) {
-- case 'split': {
-- const mode = $('#split-mode').value;
-- if (mode === 'ranges') {
-- formData.append('ranges', $('#split-ranges').value);
-- } else {
-- formData.append('mode', mode);
-- }
-- break;
-- }
-- case 'compress':
-- formData.append('level', $('#compress-level').value);
-- break;
-- case 'rotate':
-- formData.append('angle', selectedAngle);
-- formData.append('pages', $('#rotate-pages').value || 'all');
-- break;
-- case 'reorder':
-- formData.append('order', $('#reorder-order').value);
-- break;
-- case 'watermark':
-- formData.append('text', $('#wm-text').value);
-- formData.append('fontSize', $('#wm-fontsize').value);
-- formData.append('opacity', ($('#wm-opacity').value / 100).toString());
-- formData.append('rotation', $('#wm-rotation').value);
-- formData.append('color', $('#wm-color').value);
-- formData.append('pages', $('#wm-pages').value || 'all');
-- break;
-- case 'img2pdf':
-- formData.append('pageSize', $('#img2pdf-pagesize').value);
-- break;
-- case 'pdf2img':
-- formData.append('format', selectedImgFmt);
-- formData.append('dpi', $('#pdf2img-dpi').value);
-- formData.append('pages', $('#pdf2img-pages').value || 'all');
-- break;
-- case 'protect':
-- formData.append('password', $('#protect-password').value);
-- formData.append('allowPrint', $('#protect-print').checked ? 'true' : 'false');
-- formData.append('allowCopy', $('#protect-copy').checked ? 'true' : 'false');
-- break;
-- case 'unlock':
-- formData.append('password', $('#unlock-password').value);
-- break;
-- }
-- }
--
// ── Jobs ─────────────────────────────────────────────────
let watcher = null;
-- let currentJob = null;
function processingText(job) {
const pct = job.progress && job.progress.percent;
-- if (job.cancel_requested) return 'Cancelling...';
-- if (job.state === 'queued') return 'Waiting for a free slot...';
-- const label = (job.progress && job.progress.message) || 'Processing your document';
-- return pct != null ? `${label}... ${Math.round(pct)}%` : `${label}...`;
-- }
--
-- function cancelButton() {
-- let btn = processingEl.querySelector('.job-cancel');
-- if (!btn) {
-- btn = document.createElement('button');
-- btn.type = 'button';
-- btn.className = 'btn-secondary job-cancel';
-- btn.textContent = 'Cancel';
-- btn.addEventListener('click', () => { if (currentJob) OVJobs.cancel(currentJob).catch(() => {}); });
-- processingEl.appendChild(btn);
-- }
-- return btn;
++ if (job.cancel_requested) return 'Cancelling…';
++ if (job.state === 'queued') return 'Waiting for a free slot…';
++ const label = (job.progress && job.progress.message) || 'Working on your document';
++ return pct != null ? `${label}… ${Math.round(pct)}%` : `${label}…`;
}
function followJob(job) {
-- currentJob = job.id;
-- optionsPanel.style.display = 'none';
-- resultPanel.style.display = 'none';
-- processingEl.style.display = '';
-- processBtn.disabled = true;
-- const label = processingEl.querySelector('p');
-- const update = (j) => { if (label) label.textContent = processingText(j); };
-- cancelButton().style.display = '';
-- update(job);
++ hideAnswer();
++ showWorking();
if (watcher) watcher.close();
watcher = OVJobs.watch(job.id, {
-- onUpdate: update,
++ onUpdate: (j) => {
++ const out = document.querySelector('.ovk-tool-out');
++ if (out) out.querySelector('[data-ovk-working]')?.setAttribute('data-progress', processingText(j));
++ },
onDone(done) {
watcher = null;
-- cancelButton().style.display = 'none';
-- if (done.state === 'succeeded') {
-- const data = jobResult(done);
-- return data.viewOnly ? showMetadataResult(data) : showResult(data);
-- }
++ if (done.state === 'succeeded') return showResult(jobResult(done));
OVJobs.forget();
failProcessing(done.state === 'cancelled' ? null : (done.error && done.error.detail) || 'Processing failed');
},
@@@ -571,20 +251,25 @@
});
}
-- /** A finished job in the shape the synchronous endpoints answer with. */
++ /** A finished job in the shape the synchronous endpoint answers with. */
function jobResult(job) {
-- const f = job.result && job.result.files && job.result.files[0];
++ const f = (job.result && job.result.files && job.result.files[0]) || {};
return {
...(job.result && job.result.data),
-- download: f ? { id: job.id, downloadUrl: f.url, filename: f.name, expiresIn: OVJobs.expiresIn(job) } : undefined,
++ download: { id: job.id, downloadUrl: f.url, previewUrl: f.url ? `${f.url}?inline=1` : null, filename: f.name, expiresIn: OVJobs.expiresIn(job) },
};
}
++ function resetVerb() {
++ const span = processBtn && processBtn.querySelector('span');
++ if (span) span.textContent = span.textContent.replace(/…$/, '');
++ }
++
function failProcessing(message) {
-- processingEl.style.display = 'none';
-- optionsPanel.style.display = selectedFiles.length ? '' : 'none';
-- processBtn.disabled = !selectedFiles.length;
-- if (message) alert(`Error: ${message}`);
++ resetVerb();
++ if (processBtn) processBtn.disabled = !selectedFiles.length;
++ if (message) fail(message);
++ else hideAnswer();
}
/** After a reload: pick the job up again from ?job=<id> or this tab's session. */
@@@ -594,104 +279,50 @@
OVJobs.get(id).then(followJob).catch(() => OVJobs.forget());
}
-- // ── Result ───────────────────────────────────────────────
-- function initResult() {
-- anotherBtn.addEventListener('click', () => {
-- if (window.OVJobs) OVJobs.forget();
-- resetUpload();
-- (toolTabs.style.display !== 'none' ? toolTabs : uploadArea).scrollIntoView({ behavior: 'smooth' });
++ // ── The answer ───────────────────────────────────────────
++ function showWorking() {
++ const out = document.querySelector('.ovk-tool-out');
++ if (!out || !kit()) return;
++ out.innerHTML = kit().answer({
++ title: 'Working…',
++ body: '<p data-ovk-working>Your document is being worked on by our server. This page stays open until it is done.</p>',
});
}
-- function showResult(data) {
-- processingEl.style.display = 'none';
-- resultPanel.style.display = '';
--
-- let infoHtml = '';
--
-- if (data.output) {
-- infoHtml += `<p>Format: <strong>${data.output.ext.toUpperCase()}</strong> &bull; Size: <strong>${data.output.sizeKB} KB</strong></p>`;
-- }
-- if (data.pageCount !== undefined) {
-- infoHtml += `<p>Pages: <strong>${data.pageCount}</strong></p>`;
-- }
-- if (data.fileCount) {
-- infoHtml += `<p>Files processed: <strong>${data.fileCount}</strong></p>`;
-- }
-- if (data.savings) {
-- const pct = data.savings.savedPercent;
-- if (pct > 0) {
-- infoHtml += `<p class="savings">Saved ${pct}% (${formatBytes(data.savings.savedBytes)} smaller)</p>`;
-- } else {
-- infoHtml += `<p>File size unchanged or slightly larger.</p>`;
-- }
-- }
-- if (data.note) {
-- infoHtml += `<p style="color:var(--text-muted);font-size:12px">${escapeHtml(data.note)}</p>`;
-- }
--
-- const expires = data.download?.expiresIn;
-- if (expires) {
-- const expiresStr = expires >= 3600000 ? `${Math.round(expires / 3600000)}h` : `${Math.round(expires / 60000)}m`;
-- infoHtml += `<p style="color:var(--text-muted);font-size:12px;margin-top:8px">Download expires in ${expiresStr}</p>`;
-- }
--
-- resultInfo.innerHTML = infoHtml;
-- downloadBtn.href = data.download?.downloadUrl || '#';
-- downloadBtn.download = '';
-- downloadBtn.style.display = data.download ? '' : 'none';
-- resultPanel.scrollIntoView({ behavior: 'smooth' });
++ function hideAnswer() {
++ const out = document.querySelector('.ovk-tool-out');
++ if (out) out.innerHTML = '';
}
-- function showMetadataResult(data) {
-- processingEl.style.display = 'none';
-- resultPanel.style.display = '';
--
-- const m = data.metadata || {};
-- let infoHtml = '<div style="text-align:left;max-width:500px;margin:0 auto">';
-- infoHtml += `<p><strong>Title:</strong> ${escapeHtml(m.title || '(none)')}</p>`;
-- infoHtml += `<p><strong>Author:</strong> ${escapeHtml(m.author || '(none)')}</p>`;
-- infoHtml += `<p><strong>Subject:</strong> ${escapeHtml(m.subject || '(none)')}</p>`;
-- infoHtml += `<p><strong>Creator:</strong> ${escapeHtml(m.creator || '(none)')}</p>`;
-- infoHtml += `<p><strong>Producer:</strong> ${escapeHtml(m.producer || '(none)')}</p>`;
-- infoHtml += `<p><strong>Pages:</strong> ${m.pageCount || '?'}</p>`;
-- if (m.creationDate) infoHtml += `<p><strong>Created:</strong> ${m.creationDate}</p>`;
-- if (m.modificationDate) infoHtml += `<p><strong>Modified:</strong> ${m.modificationDate}</p>`;
-- infoHtml += '</div>';
--
-- resultInfo.innerHTML = infoHtml;
-- downloadBtn.style.display = 'none';
-- resultPanel.scrollIntoView({ behavior: 'smooth' });
++ function fail(message) {
++ const out = document.querySelector('.ovk-tool-out');
++ if (!out || !kit()) return;
++ out.innerHTML = kit().answer({ title: 'That did not work', tone: 'bad', body: `<p>${escapeHtml(message)}</p>` });
}
-- // ── Subdomains Grid ──────────────────────────────────────
-- function initSubdomains() {
-- const tools = [
-- { host: 'mergepdf.openvibe.tools', name: 'MergePDF', icon: 'fa-object-group', desc: 'Combine PDF files' },
-- { host: 'splitpdf.openvibe.tools', name: 'SplitPDF', icon: 'fa-scissors', desc: 'Split by page range' },
-- { host: 'compresspdf.openvibe.tools', name: 'CompressPDF', icon: 'fa-compress', desc: 'Reduce file size' },
-- { host: 'rotatepdf.openvibe.tools', name: 'RotatePDF', icon: 'fa-rotate', desc: 'Rotate PDF pages' },
-- { host: 'reorderpdf.openvibe.tools', name: 'ReorderPDF', icon: 'fa-arrow-down-wide-short', desc: 'Rearrange pages' },
-- { host: 'watermarkpdf.openvibe.tools', name: 'WatermarkPDF', icon: 'fa-signature', desc: 'Add watermarks' },
-- { host: 'protectpdf.openvibe.tools', name: 'ProtectPDF', icon: 'fa-lock', desc: 'Password protect' },
-- { host: 'unlockpdf.openvibe.tools', name: 'UnlockPDF', icon: 'fa-unlock', desc: 'Remove password' },
-- { host: 'image2pdf.openvibe.tools', name: 'Image2PDF', icon: 'fa-file-image', desc: 'Images to PDF' },
-- { host: 'jpg2pdf.openvibe.tools', name: 'JPG2PDF', icon: 'fa-file-image', desc: 'JPG to PDF' },
-- { host: 'png2pdf.openvibe.tools', name: 'PNG2PDF', icon: 'fa-file-image', desc: 'PNG to PDF' },
-- { host: 'pdf2jpg.openvibe.tools', name: 'PDF2JPG', icon: 'fa-image', desc: 'PDF to JPG' },
-- { host: 'pdf2png.openvibe.tools', name: 'PDF2PNG', icon: 'fa-image', desc: 'PDF to PNG' },
-- { host: 'pdf.openvibe.tools', name: 'OpenVibePDF', icon: 'fa-file-pdf', desc: 'All PDF tools' },
-- ];
--
-- subdomainGrid.innerHTML = tools.map(t => `
-- <a class="subdomain-card" href="https://${t.host}">
-- <span class="sd-icon"><i class="fa-solid ${t.icon}"></i></span>
-- <span class="sd-info">
-- <span class="sd-name">${t.name}</span>
-- <span class="sd-desc">${t.desc}</span>
-- </span>
-- </a>
-- `).join('');
++ function showResult(data) {
++ resetVerb();
++ if (processBtn) processBtn.disabled = false;
++ const out = data.output || {};
++ const rows = [];
++ if (out.ext) rows.push(['Format', String(out.ext).toUpperCase()]);
++ if (data.fileCount) rows.push(['Files', String(data.fileCount)]);
++ if (data.pageCount) rows.push(['Pages', String(data.pageCount)]);
++ if (data.savings && data.savings.savedPercent) rows.push(['Smaller by', `${data.savings.savedPercent}%`, { text: 'good', tone: 'ok' }]);
++ const expires = data.download && data.download.expiresIn;
++ if (expires) {
++ const mins = Math.max(1, Math.round(expires / 60000));
++ rows.push(['Kept for', mins >= 60 ? `${Math.round(mins / 60)} hour${Math.round(mins / 60) === 1 ? '' : 's'}` : `${mins} minutes`]);
++ }
++ const html = window.OVToolFrame.fileAnswer({
++ title: 'Your document is ready',
++ before: selectedFiles.reduce((sum, f) => sum + f.size, 0) || null,
++ after: out.size != null ? out.size : null,
++ rows,
++ downloadUrl: data.download && data.download.downloadUrl,
++ filename: data.download && data.download.filename,
++ });
++ window.OVToolFrame.show(html);
}
// ── Helpers ──────────────────────────────────────────────
@@@ -700,12 +331,6 @@
return match ? decodeURIComponent(match[1]) : null;
}
-- function formatBytes(bytes) {
-- if (bytes < 1024) return bytes + ' B';
-- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
-- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
-- }
--
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
ddiff --git a/apps/docs/public/sample.pdf b/apps/docs/public/sample.pdf
nnew file mode 100644
iindex 0000000..62bdd3d
BBinary files /dev/null and b/apps/docs/public/sample.pdf differ
ddiff --git a/apps/docs/server/frame.js b/apps/docs/server/frame.js
nnew file mode 100644
iindex 0000000..0227037
---- /dev/null
++++ b/apps/docs/server/frame.js
@@@ -0,0 +1,194 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// The tool frame for the PDF pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
++//
++// Every PDF host — merge, split, compress, rotate, protect and the rest — is a tool page in the shared
++// frame: the head (the family crumb, the tool's name, one sentence, the terms), the box (a drop zone
++// and the tool's own options), the answer (the file it made, its size before and after, and Download),
++// the family, the folds and "People also use".
++//
++// The box's options are the tool's own descriptor (server/descriptors.js): a field the descriptor does
++// not have is not on the page, and its range and its default are the ones the tool reads. The terms come
++// from the app's real configuration: the file's lifetime from config.js's retention, its size from
++// config.js's upload limits, and the rate from the guard's quota table.
++// ═══════════════════════════════════════════════════════════════
++
++const kit = require('openvibe-shared/kit');
++const serve = require('openvibe-shared/serve');
++const frame = require('../../_shared/frame')({ kit, serve });
++const config = require('./config');
++const { DOMAIN_MAP } = require('./domain-map');
++const { SPECS } = require('./descriptors');
++
++const HUB = 'https://docs.openvibe.tools/';
++const esc = kit.esc;
++
++// The tools, in the order the family row shows them.
++const TOOL_LIST = [
++ ['merge', 'mergepdf', 'Merge PDFs', 'Merge', 'Combine several PDF files into one.', 'Merge'],
++ ['split', 'splitpdf', 'Split PDF', 'Split', 'Cut a PDF into its pages, or into the ranges you name.', 'Split'],
++ ['compress', 'compresspdf', 'Compress PDF', 'Compress', 'Make a PDF smaller without losing what is in it.', 'Compress'],
++ ['rotate', 'rotatepdf', 'Rotate PDF', 'Rotate', 'Turn the pages of a PDF around.', 'Rotate'],
++ ['reorder', 'reorderpdf', 'Reorder Pages', 'Reorder', 'Put the pages of a PDF in the order you want.', 'Reorder'],
++ ['watermark', 'watermarkpdf', 'Watermark PDF', 'Watermark', 'Write a word or a name over every page of a PDF.', 'Add watermark'],
++ ['protect', 'protectpdf', 'Protect PDF', 'Protect', 'Put a password on a PDF.', 'Protect'],
++ ['unlock', 'unlockpdf', 'Unlock PDF', 'Unlock', 'Take the password off a PDF you know.', 'Unlock'],
++ ['img2pdf', 'image2pdf', 'Images to PDF', 'Images to PDF', 'Turn pictures into a PDF.', 'Make PDF'],
++ ['pdf2img', 'pdf2jpg', 'PDF to Images', 'PDF to images', 'Turn the pages of a PDF into pictures.', 'Make pictures'],
++];
++
++const RECS = TOOL_LIST.map(([id, spec, name, short, task, verb]) => ({ id, spec, name, short, task, verb }));
++const BY_ID = new Map(RECS.map((r) => [r.id, r]));
++const SPEC = new Map(SPECS.map((s) => [s.id, s]));
++
++const HUB_TOOL = {
++ id: 'hub', name: 'Docs.OpenVibe', short: 'All tools', task: 'Every PDF tool in one place — the merge page is below, the rest are one click away.', verb: 'Merge',
++ op: 'merge', spec: 'mergepdf',
++ about: 'Every PDF tool in one place: combine files, cut one up, make it smaller, turn the pages, write on them, protect them and turn pictures into a PDF.',
++};
++
++/** The tool a host is for (the JPG and PNG hosts are the same tool as the images one). */
++function toolFor(host) {
++ const ctx = DOMAIN_MAP[host];
++ if (!ctx) return null;
++ if (ctx.toolId === 'hub') return HUB_TOOL;
++ return BY_ID.get(ctx.toolId) || null;
++}
++
++/** The terms of a tool, from the app's real configuration and the tool's own descriptor. */
++function termsOf(rec) {
++ const spec = SPEC.get(rec.spec);
++ return frame.terms({
++ work: 'server',
++ ttlMs: config.retention.anonTTL,
++ maxBytes: config.upload.maxFileSize,
++ maxFiles: (spec && spec.files && spec.files.max) || 1,
++ quotaClass: (spec && spec.quotaClass) || 'tools-job',
++ cost: (spec && spec.cost) || 1,
++ unit: 'runs',
++ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),
++ });
++}
++
++/** The labels of the descriptor's fields, in the words a person reads. */
++const FIELD_LABEL = {
++ ranges: 'Pages', angle: 'Turn by', pages: 'Pages', order: 'New order', text: 'Text', fontSize: 'Text size',
++ opacity: 'Opacity', rotation: 'Tilt', color: 'Colour', pageSize: 'Page size', format: 'Picture format',
++ dpi: 'Resolution (dpi)', password: 'Password', ownerPassword: 'Owner password', allowPrint: 'Allow printing',
++ allowCopy: 'Allow copying', level: 'Level',
++};
++
++/** The values a person reads better than the code's. */
++const VALUE_LABEL = {
++ a4: 'A4', letter: 'Letter', legal: 'Legal', fit: 'Fit the picture',
++ low: 'Light', medium: 'Medium', high: 'Heavy',
++ '90': '90°', '180': '180°', '270': '270°',
++ jpg: 'JPG', png: 'PNG',
++};
++
++/** The box's options: the tool's own descriptor fields, with their ranges and their defaults. */
++function optionsFor(rec) {
++ const spec = SPEC.get(rec.spec);
++ const props = (spec && spec.input && spec.input.properties) || {};
++ const preset = (DOMAIN_MAP[`${rec.id}.openvibe.tools`] || {}).defaultFormat;
++ const out = [];
++ for (const [name, s] of Object.entries(props)) {
++ // A list of page numbers is not a field a person types; the tool reads it from the pages it has.
++ if (s.type === 'array') continue;
++ const field = { ...s };
++ if (name === 'format' && preset) field.default = preset;
++ out.push(frame.field(name, field, FIELD_LABEL, VALUE_LABEL));
++ }
++ return out.join('');
++}
++
++/**
++ * The operation the box runs: the tool's own id (the registry's key: merge, compress, pdf2img…),
++ * or the hub's own default.
++ * @param {object} rec the tool record
++ * @returns {string}
++ */
++function opOf(rec) {
++ return rec.op || rec.id;
++}
++
++/**
++ * The box: a drop zone for the document, the tool's options and the one button.
++ * @param {object} rec the tool record
++ */
++function boxFor(rec) {
++ const spec = SPEC.get(rec.spec);
++ const many = (spec && spec.files && spec.files.max) > 1;
++ const accept = (spec && spec.files && spec.files.accept) || ['application/pdf'];
++ const drop = kit.drop({
++ name: 'file', accept: accept.join(','), id: 'file-input', multiple: many,
++ label: many ? 'Drop your documents here' : 'Drop a document here',
++ hint: many ? 'Up to 50 files at once' : 'A PDF, a picture, or both',
++ });
++ return `<form class="ovk-tool-form" id="ovk-docs-form" enctype="multipart/form-data" data-ovk-tool-form>`
++ + `<input type="hidden" name="tool" value="${esc(opOf(rec))}">`
++ + drop
++ + `<p class="ovk-try"><span>Try</span><a href="/sample.pdf" data-ovk-sample="/sample.pdf">A sample document</a></p>`
++ + `<div id="options-panel" hidden>${optionsFor(rec)}</div>`
++ + `<div class="ovk-tool-buttons">${frame.verbButton(rec.verb, { id: 'process-btn', icon: 'file' })}`
++ + `<button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" id="clear-btn">${kit.icon('close')}<span>Clear</span></button></div>`
++ + `</form>`;
++}
++
++/** The family: every PDF tool, with the one on the page marked. */
++function familyItems(rec) {
++ const hub = { href: HUB, label: 'Docs.OpenVibe', short: 'All tools', current: rec.id === 'hub' };
++ const current = rec.id === 'hub' ? 'merge' : rec.id;
++ return [hub, ...RECS.map((t) => ({ href: `https://${t.spec}.openvibe.tools/`, label: t.name, short: t.short, current: t.id === current }))];
++}
++
++/** "People also use": the tools next to this one in the family. */
++function relatedFor(rec) {
++ const at = RECS.findIndex((t) => t.id === rec.id);
++ const out = [];
++ for (let i = 1; out.length < 3 && i < RECS.length + 1; i++) {
++ const other = RECS[(at + i) % RECS.length];
++ if (other.id !== rec.id) out.push(other);
++ }
++ return out.map((t) => kit.row({ href: `https://${t.spec}.openvibe.tools/`, title: t.name, sub: t.task }));
++}
++
++/**
++ * The whole frame for one host.
++ * @param {string} host the satellite's own hostname, e.g. 'mergepdf.openvibe.tools'
++ * @returns {string} HTML
++ */
++function frameFor(host) {
++ const rec = toolFor(host);
++ if (!rec) return '';
++ const spec = SPEC.get(rec.spec);
++ const fields = spec && spec.input && spec.input.properties ? Object.keys(spec.input.properties).join(', ') : 'none';
++ const input = spec && spec.input && spec.input.properties ? Object.fromEntries(
++ Object.entries(spec.input.properties).map(([k, v]) => [k, v.default !== undefined ? v.default : '']),
++ ) : {};
++ const faq = [
++ ['Is it free?', 'Yes. Every PDF tool here is free, with a fair-use limit per address so the shared servers stay fast for everyone.'],
++ ['How long is my document kept?', 'An hour for a visitor, a day for a signed-in person. Then the file is gone.'],
++ ['Are my files safe?', 'They are worked on by our own server and deleted afterwards. Nothing is kept and nothing is shared.'],
++ ];
++ return frame.page({
++ id: rec.id,
++ icon: 'file', hue: 25, family: 'pdf',
++ crumb: { href: HUB, label: 'PDF tools' },
++ title: rec.name, task: rec.task, facts: termsOf(rec),
++ box: boxFor(rec),
++ familyTitle: 'More PDF tools',
++ familyMore: { href: HUB, label: `All ${RECS.length + 1}` },
++ familyItems: familyItems(rec),
++ about: rec.about || `The ${rec.name.toLowerCase()} tool works on our server: the document is sent to us, changed there and sent back as a file.`,
++ faq,
++ api: {
++ id: rec.spec, path: `/api/v1/tools/${rec.spec}/run`, fields, payload: { input },
++ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),
++ },
++ related: relatedFor(rec),
++ });
++}
++
++module.exports = { frameFor, toolFor, termsOf, boxFor, optionsFor, RECS, HUB_TOOL };
ddiff --git a/apps/docs/server/index.js b/apps/docs/server/index.js
iindex 7d43522..db7f073 100644
---- a/apps/docs/server/index.js
++++ b/apps/docs/server/index.js
@@@ -300,6 +300,12 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
},
}));
++// The tool frame's browser glue (apps/_shared/public/tool-frame.js): one copy for every file app.
++app.get('/js/tool-frame.js', (_req, res) => {
++ res.set('Cache-Control', 'no-cache');
++ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));
++});
++
// SPA fallback — every host gets index.html stamped with its own title, canonical, social
// tags and structured data (server/seo.js); the raw file carries only the hub's.
const { sendIndex } = require('./seo');
ddiff --git a/apps/docs/server/seo.js b/apps/docs/server/seo.js
iindex b12b454..928310b 100644
---- a/apps/docs/server/seo.js
++++ b/apps/docs/server/seo.js
@@@ -15,8 +15,10 @@
const fs = require('fs');
const path = require('path');
++const serve = require('openvibe-shared/serve');
const { canonicalHostFor, ownHost } = require('../../_shared/host-role');
const { DOMAIN_MAP } = require('./domain-map');
++const { frameFor } = require('./frame');
const PUBLIC = path.join(__dirname, '..', 'public');
const HUB_HOST = 'docs.openvibe.tools';
@@@ -89,10 +91,15 @@ function renderIndex(ctx, host, canonicalHost) {
`<meta name="twitter:title" content="${esc(title)}">`,
`<meta name="twitter:description" content="${esc(desc)}">`,
`<meta name="twitter:image" content="${image}">`,
++ // The shared tool frame's look (openvibe-shared >= 4.57.1), from this app's own pin.
++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
...ld.map(b => `<script type="application/ld+json">${JSON.stringify(b).replace(/</g, '\\u003c')}</script>`),
].join('\n');
-- const out = html.replace(/<\/head>/i, `${head}\n</head>`);
++ const out = html.replace(/<\/head>/i, `${head}\n</head>`)
++ // The frame itself: the head, the box, the answer slot, the family, the folds and "People also use".
++ .replace('<!--OVK:FRAME-->', frameFor(host) || '');
if (_cache.size > 100) _cache.clear();
_cache.set(key, out);
return out;
ddiff --git a/apps/docs/test/frame.test.js b/apps/docs/test/frame.test.js
nnew file mode 100644
iindex 0000000..ac1b449
---- /dev/null
++++ b/apps/docs/test/frame.test.js
@@@ -0,0 +1,139 @@
++'use strict';
++// The tool frame on the PDF pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):
++// • every PDF host is a tool page in the same frame: the head, the box, the answer slot, the family,
++// the folds and "People also use", in that order;
++// • the terms come from the app's real configuration — the file's lifetime from the cleanup config, its
++// size from the upload limits, the rate from the guard's quota table — and they follow it when it
++// changes;
++// • the box's options are the tool's own descriptor;
++// • the one button says the verb, never "Run".
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const os = require('os');
++const path = require('path');
++const { frameFor, RECS } = require('../server/frame');
++const { DOMAIN_MAP } = require('../server/domain-map');
++const { SPECS } = require('../server/descriptors');
++const config = require('../server/config');
++const { startApp } = require('../../_shared/test/spawn');
++
++const HOSTS = Object.keys(DOMAIN_MAP);
++
++/** The frame's order: each marker appears once, and in this order. */
++function inOrder(html, parts) {
++ let at = -1;
++ for (const p of parts) {
++ const i = html.indexOf(p, at + 1);
++ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);
++ at = i;
++ }
++}
++
++/** The head's terms, as text. */
++function factsOf(html) {
++ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);
++ if (!m) return [];
++ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);
++}
++
++// ── Every PDF host is a framed tool page, in the same order ──
++{
++ for (const host of HOSTS) {
++ const html = frameFor(host);
++ assert.ok(html, `${host}: a frame`);
++ inOrder(html, [
++ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',
++ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
++ ]);
++ assert.ok(html.includes('https://docs.openvibe.tools/">PDF tools'), `${host}: the crumb is the family`);
++ assert.ok(html.includes('class="ovk-drop"'), `${host}: a drop zone`);
++ assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);
++ assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);
++ assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);
++ }
++ assert.strictEqual(RECS.length, 10, 'the family has ten PDF tools');
++}
++
++// ── The terms come from the app's own configuration ──
++{
++ const facts = factsOf(frameFor('mergepdf.openvibe.tools'));
++ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server; your file is deleted after 60 minutes'], 'merge: free, on our server, and the file\'s lifetime');
++ assert.ok(facts.includes('Up to 50 files, 100 MB each'), 'merge: the file count and size from its descriptor and the upload limits');
++
++ const saved = config.retention.anonTTL;
++ try {
++ config.retention.anonTTL = 45 * 60 * 1000;
++ assert.ok(factsOf(frameFor('mergepdf.openvibe.tools')).includes('Runs on our server; your file is deleted after 45 minutes'), 'the lifetime follows the config');
++ config.upload.maxFileSize = 25 * 1024 * 1024;
++ assert.ok(factsOf(frameFor('splitpdf.openvibe.tools')).includes('One file up to 25 MB'), 'the size follows the config');
++ } finally {
++ config.retention.anonTTL = saved;
++ config.upload.maxFileSize = 100 * 1024 * 1024;
++ }
++}
++
++// ── The box's options are the tool's own descriptor ──
++{
++ const spec = SPECS.find((s) => s.id === 'rotatepdf');
++ const html = frameFor('rotatepdf.openvibe.tools');
++ for (const name of Object.keys(spec.input.properties)) {
++ assert.ok(html.includes(`name="${name}"`), `rotate: the descriptor's field ${name} is on the page`);
++ }
++ assert.ok(!html.includes('name="level"'), 'rotate: a field the descriptor does not have is not on the page');
++ assert.ok(frameFor('compresspdf.openvibe.tools').includes('name="level"'), 'compress: the level field');
++}
++
++// ── The box runs the tool the page is for ──
++{
++ for (const host of HOSTS) {
++ const html = frameFor(host);
++ const m = /<input type="hidden" name="tool" value="([^"]+)">/.exec(html);
++ assert.ok(m, `${host}: the box says which tool it runs`);
++ const op = (DOMAIN_MAP[host] || {}).defaultOp;
++ assert.strictEqual(m[1], op || 'merge', `${host}: the box runs ${op || 'merge'}`);
++ }
++}
++
++// ── The one button says the verb ──
++{
++ for (const host of HOSTS) {
++ const html = frameFor(host);
++ const m = /<button[^>]*id="process-btn"[^>]*>\s*(?:<svg[\s\S]*?<\/svg>)?\s*<span>([^<]+)<\/span>/.exec(html);
++ assert.ok(m, `${host}: a verb button`);
++ assert.notStrictEqual(m[1], 'Run', `${host}: never Run`);
++ }
++ assert.ok(/<span>Split<\/span>/.test(frameFor('splitpdf.openvibe.tools')), 'split: the button says the verb');
++ assert.ok(/<span>Add watermark<\/span>/.test(frameFor('watermarkpdf.openvibe.tools')), 'watermark: the button says the verb');
++}
++
++// ── The page the server sends: the frame, the title, the canonical and the structured data ──
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-docs-frame-'));
++ const app = await startApp('docs', { DATA_DIR: tmp });
++ const get = (host) => new Promise((resolve) => {
++ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {
++ let body = '';
++ res.setEncoding('utf8');
++ res.on('data', (d) => { body += d; });
++ res.on('end', () => resolve({ status: res.statusCode, body }));
++ });
++ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });
++ req.on('error', () => resolve({ status: 0, body: '' }));
++ });
++ try {
++ for (const host of HOSTS) {
++ const r = await get(host);
++ assert.strictEqual(r.status, 200, `${host}: answers 200`);
++ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), `${host}: the frame's title`);
++ assert.ok(r.body.includes('<link rel="canonical"'), `${host}: a canonical`);
++ assert.ok(r.body.includes('"@type":"BreadcrumbList"'), `${host}: the structured data`);
++ assert.ok(r.body.includes('/shared/kit.css?'), `${host}: the kit's look`);
++ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, `${host}: one h1`);
++ }
++ } finally {
++ await app.kill();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ console.log(`tool frame (docs): ${HOSTS.length} PDF pages framed, the terms from the config, the options from the descriptors: all checks passed`);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/food/public/css/style.css b/apps/food/public/css/style.css
iindex 8957abd..aa6cfbc 100644
---- a/apps/food/public/css/style.css
++++ b/apps/food/public/css/style.css
@@@ -236,4 +236,15 @@ body { min-height:100%; font-family:var(--font); background:var(--bg); color:var
.food-table th, .food-table td { padding:6px; }
}
++
++/* ── The tool frame ─────────────────────────────────────────────── */
++.openvibe-navbar .openvibe-navbar-brand .b-core,
++.openvibe-navbar .openvibe-navbar-brand .b-dot,
++.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }
++.ovk-food-box .loc-search { max-width: 720px; margin: 0 auto; }
++.ovk-food-box .loc-search input { min-width: 0; flex: 1; }
++
++
++/* The page is the shared tool frame: it starts under the navbar, not behind it. */
++.ovk-page { padding-top: calc(var(--ovk-nav-h, 52px) + 12px); }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; opacity: .7; } }
ddiff --git a/apps/food/public/index.html b/apps/food/public/index.html
iindex 439a8bb..adeaabd 100644
---- a/apps/food/public/index.html
++++ b/apps/food/public/index.html
@@@ -39,6 +39,10 @@
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
++ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this
++ host — the head, the box, the answer slot, the family, the folds and "People also use". -->
++ <!--OVK:FRAME-->
++ <!--OVK:BOX-->
<!-- Location Bar -->
<div id="loc-bar">
<div class="loc-search">
@@@ -49,6 +53,8 @@
</div>
</div>
++ <!--/OVK:BOX-->
++ <!--OVK:ANSWER-->
<!-- Main Content -->
<main id="main">
<!-- Tabs -->
@@@ -161,6 +167,7 @@
</section>
</div>
</main>
++ <!--/OVK:ANSWER-->
<p class="data-credit"><i class="fa-solid fa-database"></i> Data from USDA, OSM and store pricing research</p>
<div id="ov-footer"></div>
@@@ -170,7 +177,11 @@
<script src="/shared/footer.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.js"></script>
-- <script src="/js/app.js"></script>
++ <script src="/shared/kit.js" defer></script>
++<script src="/shared/kit-web.js" defer></script>
++<script src="/shared/kit-data-web.js" defer></script>
++<script src="/js/tool-frame.js" defer></script>
++<script src="/js/app.js"></script>
<script>
(function () {
function getCookie(name) {
@@@ -181,12 +192,15 @@
let user = null;
if (token) { try { user = JSON.parse(atob(token.split('.')[1])); } catch {} }
if (typeof OpenVibeNavbar !== 'undefined') {
-- OpenVibeNavbar.init({ service: 'food', token, user, apiBase: 'https://openvibe.network', history: { type: 'tool', title: 'Food.OpenVibe' }, silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}' });
++ // The brand is the site ("Tools"), not the subdomain's name: the same brand every framed tool
++ // page carries (owner ask 11).
++ OpenVibeNavbar.init({ service: 'food', token, user, apiBase: 'https://openvibe.network', history: { type: 'tool', title: 'Food.OpenVibe' }, silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}', brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' } });
}
if (typeof OpenVibeFooter !== 'undefined') {
try {
OpenVibeFooter.init({
service: 'food', variant: 'compact', mount: '#ov-footer', apiBase: 'https://openvibe.network',
++ brandName: 'OpenVibe.Tools',
links: [{ heading: 'Food.OpenVibe', items: [
{ name: 'Find food', url: 'https://food.openvibe.tools' },
{ name: 'Maps.OpenVibe', url: 'https://maps.openvibe.tools' },
ddiff --git a/apps/food/server/frame.js b/apps/food/server/frame.js
nnew file mode 100644
iindex 0000000..868d624
---- /dev/null
++++ b/apps/food/server/frame.js
@@@ -0,0 +1,181 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// The tool frame for the food finder (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
++//
++// The finder's page is one tool on one host: the head (the crumb, the name, one sentence, the terms),
++// the box (the place search), the answer (the food banks, stores, meal plan, food list and map), the
++// family, the folds and "People also use".
++//
++// The page's own parts are marked in public/index.html and moved into the frame's box and answer slot
++// here, so the page keeps its own script, its own ids and its own behaviour.
++//
++// The terms come from the app's real configuration and from the guard's quota table: the lookups
++// happen on our server (the food app proxies them to the maps satellite), and the rate is the one
++// the guard holds for the tools-map class.
++// ═══════════════════════════════════════════════════════════════
++
++const fs = require('fs');
++const path = require('path');
++const kit = require('openvibe-shared/kit');
++const serve = require('openvibe-shared/serve');
++const frame = require('../../_shared/frame')({ kit, serve });
++const { canonicalHostFor, restampHead } = require('../../_shared/host-role');
++const { SPECS } = require('../../maps/server/descriptors');
++
++const esc = kit.esc;
++const HOST = 'food.openvibe.tools';
++const SITE = 'https://openvibe.tools';
++const FILE = path.join(__dirname, '..', 'public', 'index.html');
++const SPEC = SPECS.find((s) => s.id === 'food') || {};
++
++// The hubs this tool sits next to.
++const HUBS = [
++ { href: `${SITE}/maps-and-food`, label: 'Maps & Food', short: 'Maps' },
++ { href: 'https://maps.openvibe.tools', label: 'Survival map', short: 'Map' },
++ { href: 'https://openvibe.food', label: 'OpenVibe.Food', short: 'Food' },
++ { href: `${SITE}/`, label: 'All tools', short: 'All' },
++];
++
++/**
++ * The terms of the food finder, from the app's real configuration and the guard's quota table.
++ */
++function termsOf() {
++ return frame.terms({
++ work: 'server',
++ serverNote: 'Runs on our server: your search is looked up there',
++ quotaClass: SPEC.quotaClass || 'tools-map',
++ cost: SPEC.cost || 2,
++ unit: 'lookups',
++ });
++}
++
++/** The family: the hubs this tool sits next to. The page's own hub is the one marked. */
++function familyItems() {
++ return HUBS.map((h) => ({ ...h, current: h.href === 'https://openvibe.food' }));
++}
++
++/** "People also use": the other hubs, as rows. */
++function relatedItems() {
++ return HUBS.filter((h) => h.href !== 'https://openvibe.food')
++ .map((h) => kit.row({ href: h.href, title: h.label, sub: '' }));
++}
++
++/**
++ * The whole frame for the finder's page.
++ * @param {string} box the page's own place search
++ * @param {string} answer the page's own tabs and panels
++ * @returns {string} HTML
++ */
++function frameFor(box, answer) {
++ const title = 'Food finder';
++ const task = 'Find free food near you: food banks, pantries, budget grocery stores and a meal plan.';
++ const faq = [
++ ['Is it free?', 'Yes. Thirty lookups a minute without an account.'],
++ ['Where does the data come from?', 'Public sources: food banks, the national food bank directory, and grocery stores that accept food assistance.'],
++ ['Is my search sent anywhere?', 'Only to our server, which does the lookups. Nothing is kept about you afterwards.'],
++ ['Can I use it offline?', 'No. The finder needs a connection: it looks places up as you search.'],
++ ];
++ return `<div class="ovk-page ovk-page--wide" data-ov-tool-page="food">${kit.tool({
++ head: frame.head({
++ icon: 'cart', hue: 25, family: 'places',
++ crumb: { href: SITE, label: 'Tools' },
++ title, task, facts: termsOf(),
++ }),
++ input: `<div class="ovk-food-box">${box}</div>`,
++ answer: `<div class="ovk-food-answer">${answer}</div>`,
++ family: kit.family({
++ title: 'More OpenVibe tools', more: { href: SITE, label: 'All tools' },
++ items: familyItems(), label: 'More tools',
++ }),
++ folds: frame.folds({
++ name: title,
++ about: 'The food finder puts food banks, community pantries, budget grocery stores, a meal planner and the food list on one page, so you can find what is free near you and plan with what you have.',
++ faq,
++ api: null,
++ }),
++ related: kit.list(relatedItems(), { title: 'People also use' }),
++ })}</div>`;
++}
++
++/** The page's own part between two markers. */
++function pick(html, re) {
++ const m = re.exec(html);
++ return m ? m[1] : '';
++}
++
++let _parts = null;
++
++/** The page's own parts, read once: the box and the answer. */
++function parts() {
++ if (_parts) return _parts;
++ const html = fs.readFileSync(FILE, 'utf8');
++ _parts = {
++ box: pick(html, /<!--OVK:BOX-->([\s\S]*?)<!--\/OVK:BOX-->/),
++ answer: pick(html, /<!--OVK:ANSWER-->([\s\S]*?)<!--\/OVK:ANSWER-->/),
++ page: html
++ .replace(/<!--OVK:BOX-->[\s\S]*?<!--\/OVK:BOX-->/, '')
++ .replace(/<!--OVK:ANSWER-->[\s\S]*?<!--\/OVK:ANSWER-->/, ''),
++ };
++ return _parts;
++}
++
++let _cache = null;
++
++/**
++ * The finder's page with the frame around it, stamped for this request's canonical host.
++ * @param {string} canonicalHost the host the page is about
++ * @returns {string} HTML
++ */
++function renderIndex(canonicalHost) {
++ if (_cache === null) {
++ const { box, answer, page: stripped } = parts();
++ _cache = stripped
++ .replace('<!--OVK:FRAME-->', frameFor(box, answer))
++ .replace(/<\/head>/i, `${[
++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
++ ].join('\n')}\n</head>`)
++ .replace(/<\/body>/i, `${[
++ '<script src="/shared/kit.js" defer></script>',
++ '<script src="/shared/kit-web.js" defer></script>',
++ '<script src="/shared/kit-data-web.js" defer></script>',
++ '<script src="/js/tool-frame.js"></script>',
++ ].join('\n')}\n</body>`);
++ }
++ return _cache;
++}
++
++/**
++ * The page with the frame, built fresh (no cache): the tests change the configuration and read the
++ * page again.
++ * @param {string} canonicalHost the host the page is about
++ * @returns {string} HTML
++ */
++function renderFrame(canonicalHost) {
++ const { box, answer, page } = parts();
++ const out = page
++ .replace('<!--OVK:FRAME-->', frameFor(box, answer))
++ .replace(/<\/head>/i, `${[
++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
++ ].join('\n')}\n</head>`)
++ .replace(/<\/body>/i, `${[
++ '<script src="/shared/kit.js" defer></script>',
++ '<script src="/shared/kit-web.js" defer></script>',
++ '<script src="/shared/kit-data-web.js" defer></script>',
++ '<script src="/js/tool-frame.js"></script>',
++ ].join('\n')}\n</body>`);
++ return canonicalHost ? restampHead(out, HOST, canonicalHost) : out;
++}
++
++/** Express handler: the page with the frame, stamped for this request's host. */
++function sendIndex(req, res) {
++ const canonical = canonicalHostFor(req, HOST);
++ res.setHeader('Content-Type', 'text/html; charset=utf-8');
++ res.setHeader('Cache-Control', 'no-cache');
++ res.setHeader('Vary', 'X-OV-Canonical-Host');
++ return res.send(renderIndex(canonical));
++}
++
++module.exports = { frameFor, termsOf, familyItems, relatedItems, renderIndex, renderFrame, sendIndex, HUBS, HOST };
ddiff --git a/apps/food/server/index.js b/apps/food/server/index.js
iindex ed0bf37..54dfc15 100644
---- a/apps/food/server/index.js
++++ b/apps/food/server/index.js
@@@ -120,8 +120,11 @@ app.use(analytics.middleware());
const HOST = 'food.openvibe.tools';
app.use(hostGuard({ knows: (h) => h === HOST }));
// The page is OpenVibe.Food's now; the JSON routes below stay for whoever calls them.
--const FOOD_URL = (process.env.OV_FOOD_PUBLIC_URL || 'https://openvibe.food').replace(/\/+$/, '');
--const toFood = (req, res) => res.redirect(301, `${FOOD_URL}/`);
++// Where the page went: OpenVibe.Food (openvibe.food). Set OV_FOOD_PUBLIC_URL to an empty value to keep
++// the food finder's own page here (the frame around it, the same frame every other tool page has).
++const FOOD_URL = (process.env.OV_FOOD_PUBLIC_URL === '' ? '' : (process.env.OV_FOOD_PUBLIC_URL || 'https://openvibe.food')).replace(/\/+$/, '');
++const { sendIndex: sendFoodPage } = require('./frame');
++const toFood = (req, res) => (FOOD_URL ? res.redirect(301, `${FOOD_URL}/`) : sendFoodPage(req, res));
app.get(['/', '/index.html'], toFood);
// Static files
@@@ -168,6 +171,12 @@ app.get('/api/internal/analytics/bots', internalAccess, async (req, res) => {
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
++// The tool frame's browser glue (apps/_shared/public/tool-frame.js).
++app.get('/js/tool-frame.js', (_req, res) => {
++ res.set('Cache-Control', 'no-cache');
++ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));
++});
++
// What used to be the page's own paths go to OpenVibe.Food as well (the API answers JSON 404s).
app.get('*', (req, res) => (req.path.startsWith('/api/') ? res.status(404).json({ error: 'Not found' }) : toFood(req, res)));
ddiff --git a/apps/food/test/frame.test.js b/apps/food/test/frame.test.js
nnew file mode 100644
iindex 0000000..38902b1
---- /dev/null
++++ b/apps/food/test/frame.test.js
@@@ -0,0 +1,94 @@
++'use strict';
++// The tool frame on the food finder's page (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):
++// • the page is a tool page in the same frame: the head, the box, the answer slot, the family, the
++// folds and "People also use", in that order;
++// • the terms come from the app's real configuration — the lookups happen on our server and the rate
++// is the guard's quota table — and they follow it when it changes;
++// • the one button says the verb, never "Run".
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const os = require('os');
++const path = require('path');
++const { renderFrame } = require('../server/frame');
++const { startApp } = require('../../_shared/test/spawn');
++
++/** The frame's order: each marker appears once, and in this order. */
++function inOrder(html, parts) {
++ let at = -1;
++ for (const p of parts) {
++ const i = html.indexOf(p, at + 1);
++ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);
++ at = i;
++ }
++}
++
++/** The head's terms, as text. */
++function factsOf(html) {
++ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);
++ if (!m) return [];
++ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);
++}
++
++// ── The page is a framed tool page, in the same order ──
++{
++ const html = renderFrame('food.openvibe.tools');
++ inOrder(html, [
++ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',
++ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
++ ]);
++ assert.ok(html.includes('href="https://openvibe.tools">Tools'), 'the crumb is the tools home');
++ assert.ok(html.includes('id="loc-input"'), 'the box has the place search');
++ assert.ok(html.includes('id="food-banks"'), 'the answer holds the food banks');
++ assert.ok(html.includes('aria-current="page"'), 'the family marks this tool');
++ assert.ok(/id="btn-loc-search"[^>]*>[\s\S]*?Find Food/.test(html), 'the one button says the verb');
++ assert.ok(!/>\s*Run\s*</.test(html), 'never Run');
++ assert.strictEqual((html.match(/<h1/g) || []).length, 1, 'one h1');
++}
++
++// ── The terms come from the app's own configuration ──
++{
++ const facts = factsOf(renderFrame('food.openvibe.tools'));
++ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server: your search is looked up there'], 'free, and the lookups happen on our server');
++ assert.ok(facts.includes('30 lookups a minute without an account'), 'the rate from the guard\'s table and the tool\'s own cost');
++ assert.ok(facts.includes('No sign-up'), 'no account needed');
++
++ const savedEnv = process.env.TOOLS_GUARD_LIMITS;
++ try {
++ process.env.TOOLS_GUARD_LIMITS = JSON.stringify({ 'tools-map': { anonymous: { perMinute: 20, burst: 10, perDay: 0 } } });
++ assert.ok(factsOf(renderFrame('food.openvibe.tools')).includes('10 lookups a minute without an account'), 'the rate follows the guard\'s table');
++ } finally {
++ if (savedEnv === undefined) delete process.env.TOOLS_GUARD_LIMITS;
++ else process.env.TOOLS_GUARD_LIMITS = savedEnv;
++ }
++}
++
++// ── The page the server sends: the frame, the title and the canonical ──
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-food-frame-'));
++ // The food app has no /api/health (its readiness needs the maps satellite): the page answers.
++ const app = await startApp('food', { DATA_DIR: tmp, OV_FOOD_PUBLIC_URL: '' }, undefined, { readyPath: '/' });
++ const get = (host) => new Promise((resolve) => {
++ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {
++ let body = '';
++ res.setEncoding('utf8');
++ res.on('data', (d) => { body += d; });
++ res.on('end', () => resolve({ status: res.statusCode, body }));
++ });
++ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });
++ req.on('error', () => resolve({ status: 0, body: '' }));
++ });
++ try {
++ const r = await get('food.openvibe.tools');
++ assert.strictEqual(r.status, 200, 'answers 200');
++ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), 'the frame\'s title');
++ assert.ok(r.body.includes('<link rel="canonical"'), 'a canonical');
++ assert.ok(r.body.includes('/shared/kit.css?'), 'the kit\'s look');
++ assert.ok(r.body.includes('/js/tool-frame.js'), 'the frame\'s behaviour');
++ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, 'one h1');
++ } finally {
++ await app.kill();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ console.log('tool frame (food): the food finder framed, the terms from the config, the button says the verb: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/gateway/test/security-ssrf.test.js b/apps/gateway/test/security-ssrf.test.js
iindex c929a14..dc602c9 100644
---- a/apps/gateway/test/security-ssrf.test.js
++++ b/apps/gateway/test/security-ssrf.test.js
@@@ -103,6 +103,7 @@ function rawGet(port, p, headers = {}) {
'_shared/tools/run.js': 'the satellites on loopback', '_shared/usage.js': 'Network user modules (configured)',
'_shared/egress.js': 'the egress guard itself',
'_shared/billing.js': 'OpenVibe.Billing and Network token endpoint (configured)',
++ '_shared/public/tool-frame.js': 'the app\'s own sample file, on the app\'s own origin (same origin; no visitor-chosen host)',
};
const found = [];
const walk = (dir) => {
ddiff --git a/apps/img/public/css/style.css b/apps/img/public/css/style.css
iindex d047209..4554c31 100644
---- a/apps/img/public/css/style.css
++++ b/apps/img/public/css/style.css
@@@ -38,14 +38,17 @@ body {
flex-direction: column;
}
++/* The page is the shared tool frame: the frame's own .ovk-page sets the width and the padding. */
#app {
flex: 1;
-- max-width: 900px;
width: 100%;
-- margin: 0 auto;
-- padding: 0 20px 40px;
}
++/* The tool frame: the brand is the site ("Tools"), not the subdomain's name. */
++.openvibe-navbar .openvibe-navbar-brand .b-core,
++.openvibe-navbar .openvibe-navbar-brand .b-dot,
++.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }
++
/* ── Hero ───────────────────────────────────────────────────── */
.hero {
text-align: center;
@@@ -478,4 +481,7 @@ body {
line-height: 1.5;
}
++
++/* The page is the shared tool frame: it starts under the navbar, not behind it. */
++.ovk-page { padding-top: calc(var(--ovk-nav-h, 52px) + 12px); }
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; opacity: .7; } }
ddiff --git a/apps/img/public/index.html b/apps/img/public/index.html
iindex 7ef84d5..8c198c5 100644
---- a/apps/img/public/index.html
++++ b/apps/img/public/index.html
@@@ -26,141 +26,9 @@
<!-- Navbar injected by OpenVibeNavbar.init() -->
<div id="app">
-- <!-- Hero -->
-- <section class="hero" id="hero">
-- <div class="hero-icon" id="hero-icon"><i class="fa-solid fa-images"></i></div>
-- <h1 id="hero-title">Img.OpenVibe</h1>
-- <p class="hero-subtitle" id="hero-subtitle">Online Image Converter & Tools</p>
-- </section>
--
-- <!-- Tool Selector (visible on hub domain) -->
-- <nav class="tool-tabs" id="tool-tabs">
-- <button class="tool-tab active" data-tool="convert"><i class="fa-solid fa-arrows-rotate"></i> Convert</button>
-- <button class="tool-tab" data-tool="compress"><i class="fa-solid fa-compress"></i> Compress</button>
-- <button class="tool-tab" data-tool="resize"><i class="fa-solid fa-up-right-and-down-left-from-center"></i> Resize</button>
-- <button class="tool-tab" data-tool="crop"><i class="fa-solid fa-scissors"></i> Crop</button>
-- </nav>
--
-- <!-- Upload Zone -->
-- <section class="upload-zone" id="upload-zone">
-- <div class="upload-area" id="upload-area">
-- <div class="upload-icon"><i class="fa-solid fa-cloud-arrow-up"></i></div>
-- <p class="upload-text">Drag & drop an image here</p>
-- <p class="upload-hint">or click to browse — PNG, JPG, WebP, AVIF, HEIC, SVG, GIF, ICO, TIFF, BMP</p>
-- <input type="file" id="file-input" accept="image/*" hidden>
-- <button class="btn btn-primary" id="browse-btn"><i class="fa-solid fa-folder-open"></i> Choose File</button>
-- </div>
-- <div class="upload-info" id="upload-info" style="display:none">
-- <div class="file-preview" id="file-preview"></div>
-- <div class="file-details" id="file-details"></div>
-- <button class="btn btn-ghost" id="clear-btn"><i class="fa-solid fa-xmark"></i> Clear</button>
-- </div>
-- </section>
--
-- <!-- Tool Options -->
-- <section class="options-panel" id="options-panel" style="display:none">
-- <!-- Convert Options -->
-- <div class="option-group" id="opt-convert">
-- <label class="option-label">Output Format</label>
-- <div class="format-grid" id="format-grid">
-- <button class="format-btn" data-format="png">PNG</button>
-- <button class="format-btn" data-format="jpg">JPG</button>
-- <button class="format-btn" data-format="webp">WebP</button>
-- <button class="format-btn" data-format="avif">AVIF</button>
-- <button class="format-btn" data-format="gif">GIF</button>
-- <button class="format-btn" data-format="tiff">TIFF</button>
-- <button class="format-btn" data-format="bmp">BMP</button>
-- <button class="format-btn" data-format="ico">ICO</button>
-- </div>
-- <label class="option-label">Quality <span id="quality-val">80</span>%</label>
-- <input type="range" id="quality-slider" min="1" max="100" value="80" class="slider">
-- </div>
--
-- <!-- Compress Options -->
-- <div class="option-group" id="opt-compress" style="display:none">
-- <label class="option-label">Compression Level <span id="compress-val">75</span>%</label>
-- <input type="range" id="compress-slider" min="1" max="100" value="75" class="slider">
-- <p class="option-hint">Lower = smaller file size, higher = better quality</p>
-- </div>
--
-- <!-- Resize Options -->
-- <div class="option-group" id="opt-resize" style="display:none">
-- <div class="dimension-inputs">
-- <div class="dim-field">
-- <label class="option-label">Width (px)</label>
-- <input type="number" id="resize-w" class="num-input" placeholder="auto" min="1" max="16384">
-- </div>
-- <span class="dim-x"><i class="fa-solid fa-xmark"></i></span>
-- <div class="dim-field">
-- <label class="option-label">Height (px)</label>
-- <input type="number" id="resize-h" class="num-input" placeholder="auto" min="1" max="16384">
-- </div>
-- </div>
-- <label class="option-label">Or scale by percentage</label>
-- <input type="number" id="resize-pct" class="num-input num-input-sm" placeholder="e.g. 50" min="1" max="1000"> %
-- <label class="option-label">Fit Mode</label>
-- <select id="resize-fit" class="select-input">
-- <option value="inside" selected>Inside (fit within bounds)</option>
-- <option value="cover">Cover (fill & crop)</option>
-- <option value="contain">Contain (fit with padding)</option>
-- <option value="fill">Fill (stretch)</option>
-- <option value="outside">Outside (cover minimum)</option>
-- </select>
-- </div>
--
-- <!-- Crop Options -->
-- <div class="option-group" id="opt-crop" style="display:none">
-- <label class="option-label">Aspect Ratio Preset</label>
-- <div class="aspect-grid" id="aspect-grid">
-- <button class="aspect-btn" data-aspect="1:1">1:1</button>
-- <button class="aspect-btn" data-aspect="16:9">16:9</button>
-- <button class="aspect-btn" data-aspect="4:3">4:3</button>
-- <button class="aspect-btn" data-aspect="3:2">3:2</button>
-- <button class="aspect-btn" data-aspect="21:9">21:9</button>
-- <button class="aspect-btn" data-aspect="9:16">9:16</button>
-- <button class="aspect-btn" data-aspect="3:4">3:4</button>
-- <button class="aspect-btn active" data-aspect="">Custom</button>
-- </div>
-- <div class="crop-coords" id="crop-coords">
-- <div class="dim-field"><label class="option-label">Left</label><input type="number" id="crop-left" class="num-input" placeholder="0" min="0"></div>
-- <div class="dim-field"><label class="option-label">Top</label><input type="number" id="crop-top" class="num-input" placeholder="0" min="0"></div>
-- <div class="dim-field"><label class="option-label">Width</label><input type="number" id="crop-w" class="num-input" placeholder="auto" min="1"></div>
-- <div class="dim-field"><label class="option-label">Height</label><input type="number" id="crop-h" class="num-input" placeholder="auto" min="1"></div>
-- </div>
-- </div>
--
-- <!-- Process Button -->
-- <button class="btn btn-primary btn-lg" id="process-btn" disabled>
-- <i class="fa-solid fa-wand-magic-sparkles"></i> <span id="process-text">Process Image</span>
-- </button>
-- </section>
--
-- <!-- Processing State -->
-- <section class="processing" id="processing" style="display:none">
-- <div class="spinner"></div>
-- <p>Processing your image...</p>
-- </section>
--
-- <!-- Result -->
-- <section class="result-panel" id="result-panel" style="display:none">
-- <h2><i class="fa-solid fa-circle-check"></i> Done!</h2>
-- <div class="result-preview" id="result-preview"></div>
-- <div class="result-info" id="result-info"></div>
-- <div class="result-actions">
-- <a class="btn btn-primary btn-lg" id="download-btn" href="#" download>
-- <i class="fa-solid fa-download"></i> Download
-- </a>
-- <button class="btn btn-secondary" id="another-btn">
-- <i class="fa-solid fa-rotate-left"></i> Process Another
-- </button>
-- </div>
-- </section>
--
-- <!-- Format Subdomains Grid (hub only) -->
-- <section class="subdomains" id="subdomains">
-- <h2>All Image Tools</h2>
-- <div class="subdomain-grid" id="subdomain-grid"></div>
-- </section>
++ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this
++ host — the head, the box, the answer slot, the family, the folds and "People also use". -->
++ <!--OVK:FRAME-->
</div>
<!-- Footer -->
@@@ -171,7 +39,11 @@
<script src="/shared/footer.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.js"></script>
++ <script src="/shared/kit.js" defer></script>
++ <script src="/shared/kit-web.js" defer></script>
++ <script src="/shared/kit-data-web.js" defer></script>
<script src="/js/ov-jobs.js"></script>
++ <script src="/js/tool-frame.js" defer></script>
<script src="/js/app.js"></script>
</body>
</html>
ddiff --git a/apps/img/public/js/app.js b/apps/img/public/js/app.js
iindex b6d76e8..24698f9 100644
---- a/apps/img/public/js/app.js
++++ b/apps/img/public/js/app.js
@@@ -1,7 +1,9 @@
// ═══════════════════════════════════════════════════════════════
// Img.OpenVibe — Client Application
--// Domain-aware image processing SPA.
--// Detects hostname → fetches context → adapts branding + tools.
++// The page around the tool is the shared frame (openvibe-shared ≥ 4.57.1): the server draws the head,
++// the box, the answer slot, the family and the folds for this host. This file fills the box (the drop
++// zone, the options and the one button) and the answer (the file it made, its size before and after,
++// and Download). The upload, the processing and the download are the app's own, unchanged.
// ═══════════════════════════════════════════════════════════════
(function () {
@@@ -11,43 +13,19 @@
let ctx = null; // domain context from /api/context
let selectedFile = null; // File object
let selectedTool = 'convert';
-- let selectedFormat = 'png';
-- let selectedAspect = '';
// ── DOM refs ─────────────────────────────────────────────
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
-- const heroIcon = $('#hero-icon');
-- const heroTitle = $('#hero-title');
-- const heroSubtitle = $('#hero-subtitle');
-- const toolTabs = $('#tool-tabs');
-- const uploadArea = $('#upload-area');
-- const uploadInfo = $('#upload-info');
const fileInput = $('#file-input');
-- const browseBtn = $('#browse-btn');
-- const clearBtn = $('#clear-btn');
const optionsPanel = $('#options-panel');
const processBtn = $('#process-btn');
-- const processText = $('#process-text');
-- const processingEl = $('#processing');
-- const resultPanel = $('#result-panel');
-- const resultPreview = $('#result-preview');
-- const resultInfo = $('#result-info');
-- const downloadBtn = $('#download-btn');
-- const anotherBtn = $('#another-btn');
-- const subdomainsSection = $('#subdomains');
-- const subdomainGrid = $('#subdomain-grid');
--
-- // Option elements
-- const qualitySlider = $('#quality-slider');
-- const qualityVal = $('#quality-val');
-- const compressSlider = $('#compress-slider');
-- const compressVal = $('#compress-val');
++ const clearBtn = $('#clear-btn');
++ const kit = () => window.OpenVibeKit;
// ── Init ─────────────────────────────────────────────────
async function init() {
-- // Fetch domain context
try {
const res = await fetch('/api/context');
ctx = await res.json();
@@@ -55,88 +33,26 @@
ctx = { toolId: 'hub', brandName: 'Img.OpenVibe', defaultOp: 'convert', faIcon: 'fa-images', tools: [] };
}
-- applyBranding();
-- showUnavailableInputs();
initNavbar();
initNotifications();
if (typeof OpenVibeAccountSwitcher !== 'undefined') OpenVibeAccountSwitcher.init({ apiBase: 'https://openvibe.network' });
-- initToolTabs();
-- initUpload();
++ showUnavailableInputs();
++ initDrop();
initOptions();
-- initSubdomains();
initResult();
reattach();
}
-- // ── Branding ─────────────────────────────────────────────
-- function applyBranding() {
-- document.title = ctx.seoTitle || `${ctx.brandName} — Image Tools`;
-- heroIcon.innerHTML = `<i class="fa-solid ${ctx.faIcon || 'fa-images'}"></i>`;
-- heroTitle.textContent = ctx.brandName || 'Img.OpenVibe';
--
-- // Set subtitle based on tool
-- const subtitles = {
-- hub: 'Online Image Converter & Tools',
-- convert: 'Online Image Format Converter',
-- compress: 'Online Image Compressor',
-- resize: 'Online Image Resizer',
-- crop: 'Online Image Cropper',
-- favicon: 'Online Favicon Generator',
-- };
-- heroSubtitle.textContent = subtitles[ctx.toolId] || ctx.seoDescription || subtitles.hub;
--
-- // If format-specific domain, set default format
-- if (ctx.defaultFormat) {
-- selectedFormat = ctx.defaultFormat;
-- }
--
-- // Set default tool from context
-- selectedTool = ctx.defaultOp || 'convert';
--
-- // Hide tool tabs on single-tool domains (format-specific ones show convert)
-- const isFormatDomain = ctx.toolId && !['hub', 'convert', 'compress', 'resize', 'crop', 'favicon'].includes(ctx.toolId);
-- if (isFormatDomain) {
-- toolTabs.style.display = 'none';
-- selectedTool = 'convert';
-- }
--
-- // Hide subdomains section on non-hub domains
-- if (ctx.toolId !== 'hub') {
-- subdomainsSection.style.display = 'none';
-- }
--
-- // Update meta description
-- const metaDesc = document.querySelector('meta[name="description"]');
-- if (metaDesc && ctx.seoDescription) metaDesc.content = ctx.seoDescription;
-- }
--
-- // A format this server cannot read yet (HEIC before libheif's decoder is installed): say so up
-- // front on the page that is about it, instead of letting the upload fail.
-- function showUnavailableInputs() {
-- const heicPage = ctx.toolId === 'heic';
-- if (!ctx.inputs || ctx.inputs.heic !== false || !heicPage) return;
-- const zone = document.getElementById('upload-zone');
-- if (!zone || document.getElementById('input-notice')) return;
-- const note = document.createElement('div');
-- note.id = 'input-notice';
-- note.className = 'tool-notice';
-- note.setAttribute('role', 'status');
-- note.textContent = 'HEIC (iPhone) photos cannot be converted yet: the decoder is being set up on the server. JPG, PNG, WebP, AVIF and the other formats work now.';
-- zone.parentNode.insertBefore(note, zone);
-- }
--
// ── Navbar ───────────────────────────────────────────────
function initNavbar() {
const token = getCookie('ov_token') || localStorage.getItem('ov_token');
let user = null;
if (token) {
-- try {
-- const payload = JSON.parse(atob(token.split('.')[1]));
-- user = payload;
-- } catch { /* invalid token */ }
++ try { user = JSON.parse(atob(token.split('.')[1])); } catch { /* invalid token */ }
}
-- // Brand name/icon come from the hostname now (png.openvibe.tools → "PNG.OpenVibe.Tools").
++ // The brand is the site ("Tools"), not the subdomain's name: the same brand the network and
++ // code tool pages carry (owner ask 11).
if (typeof OpenVibeNavbar !== 'undefined') {
OpenVibeNavbar.init({
service: 'img',
@@@ -145,21 +61,22 @@
apiBase: 'https://openvibe.network',
history: { type: 'tool', title: ctx?.brandName || 'Img.OpenVibe' },
silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',
++ brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' },
});
}
initFooter();
}
/* ---------- Footer (shared OpenVibeFooter) ---------- */
-- // Full footer on the hub host, the compact strip on the single-tool hosts.
function initFooter() {
if (typeof OpenVibeFooter === 'undefined') return;
try {
OpenVibeFooter.init({
service: 'img',
-- variant: (!ctx || ctx.toolId === 'hub') ? 'full' : 'compact',
++ variant: 'compact',
mount: '#ov-footer',
apiBase: 'https://openvibe.network',
++ brandName: 'OpenVibe.Tools',
links: [{ heading: 'Img tools', items: [
{ name: 'Convert', url: 'https://convert.openvibe.tools' },
{ name: 'Compress', url: 'https://compress.openvibe.tools' },
@@@ -184,7 +101,6 @@
apiBase: 'https://openvibe.network',
});
-- // Mount bell into navbar if available
if (typeof OpenVibeNavbar !== 'undefined') {
const mount = OpenVibeNavbar.getBellMount();
if (mount) {
@@@ -194,188 +110,143 @@
}
}
-- // ── Tool Tabs ────────────────────────────────────────────
-- function initToolTabs() {
-- // Highlight active tab
-- $$('.tool-tab').forEach(tab => {
-- if (tab.dataset.tool === selectedTool) tab.classList.add('active');
-- else tab.classList.remove('active');
--
-- tab.addEventListener('click', () => {
-- selectedTool = tab.dataset.tool;
-- $$('.tool-tab').forEach(t => t.classList.toggle('active', t.dataset.tool === selectedTool));
-- showToolOptions();
-- updateProcessButton();
-- });
++ // A format this server cannot read yet (HEIC before libheif's decoder is installed): say so up
++ // front on the page that is about it, instead of letting the upload fail.
++ function showUnavailableInputs() {
++ if (!ctx || ctx.toolId !== 'heic') return;
++ if (!ctx.inputs || ctx.inputs.heic !== false) return;
++ const card = document.querySelector('.ovk-tool-card');
++ if (!card || document.getElementById('input-notice') || !kit()) return;
++ const note = document.createElement('div');
++ note.id = 'input-notice';
++ note.innerHTML = kit().notice({
++ kind: 'warn',
++ text: 'HEIC (iPhone) photos cannot be converted yet: the decoder is being set up on the server. JPG, PNG, WebP, AVIF and the other formats work now.',
});
++ card.insertBefore(note, card.firstChild);
}
-- // ── Upload ───────────────────────────────────────────────
-- function initUpload() {
-- // Drag & drop
-- uploadArea.addEventListener('dragover', (e) => {
-- e.preventDefault();
-- uploadArea.classList.add('dragover');
-- });
-- uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('dragover'));
-- uploadArea.addEventListener('drop', (e) => {
-- e.preventDefault();
-- uploadArea.classList.remove('dragover');
-- if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
-- });
--
-- // Click to browse
-- uploadArea.addEventListener('click', () => fileInput.click());
-- browseBtn.addEventListener('click', (e) => { e.stopPropagation(); fileInput.click(); });
++ // ── The box: the drop zone ───────────────────────────────
++ function initDrop() {
++ if (!fileInput) return;
fileInput.addEventListener('change', () => {
-- if (fileInput.files.length) handleFile(fileInput.files[0]);
++ if (fileInput.files && fileInput.files.length) handleFile(fileInput.files[0]);
});
--
-- // Clear
-- clearBtn.addEventListener('click', resetUpload);
++ if (clearBtn) clearBtn.addEventListener('click', resetUpload);
++
++ // "Try": the sample picture goes into the drop zone as if it had been chosen.
++ const sampleLink = document.querySelector('[data-ovk-sample]');
++ if (sampleLink && window.OVToolFrame) {
++ sampleLink.addEventListener('click', (e) => {
++ e.preventDefault();
++ window.OVToolFrame.sample(sampleLink.getAttribute('data-ovk-sample'), handleFile);
++ });
++ }
}
function handleFile(file) {
++ if (!file) return;
if (!file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
-- alert('Please select an image file.');
++ fail('Please choose an image file.');
return;
}
if (file.size > 50 * 1024 * 1024) {
-- alert('File too large. Maximum 50MB.');
++ fail('That file is too large. The maximum is 50 MB.');
return;
}
selectedFile = file;
-- uploadArea.style.display = 'none';
-- uploadInfo.style.display = 'flex';
--
-- // Preview
-- const preview = $('#file-preview');
-- const details = $('#file-details');
-- const objectUrl = URL.createObjectURL(file);
-- preview.innerHTML = `<img src="${objectUrl}" alt="Preview">`;
-- details.innerHTML = `
-- <div class="fname">${escapeHtml(file.name)}</div>
-- <div class="fmeta">${file.type} &bull; ${formatBytes(file.size)}</div>
-- `;
--
-- optionsPanel.style.display = '';
-- showToolOptions();
-- updateProcessButton();
++ selectedTool = (ctx && ctx.defaultOp) || 'convert';
++ if (optionsPanel) optionsPanel.hidden = false;
++ if (processBtn) processBtn.disabled = false;
++ hideAnswer();
}
function resetUpload() {
selectedFile = null;
-- fileInput.value = '';
-- uploadArea.style.display = '';
-- uploadInfo.style.display = 'none';
-- optionsPanel.style.display = 'none';
-- resultPanel.style.display = 'none';
-- processingEl.style.display = 'none';
-- processBtn.disabled = true;
++ if (fileInput) fileInput.value = '';
++ if (optionsPanel) optionsPanel.hidden = true;
++ if (processBtn) processBtn.disabled = true;
++ hideAnswer();
}
-- // ── Options ──────────────────────────────────────────────
++ // ── The box: the options and the one button ─────────────
function initOptions() {
-- // Quality slider
-- qualitySlider.addEventListener('input', () => { qualityVal.textContent = qualitySlider.value; });
-- compressSlider.addEventListener('input', () => { compressVal.textContent = compressSlider.value; });
--
-- // Format buttons
-- $$('.format-btn').forEach(btn => {
-- btn.addEventListener('click', () => {
-- selectedFormat = btn.dataset.format;
-- $$('.format-btn').forEach(b => b.classList.toggle('active', b.dataset.format === selectedFormat));
-- });
-- });
--
-- // Aspect ratio buttons
-- $$('.aspect-btn').forEach(btn => {
-- btn.addEventListener('click', () => {
-- selectedAspect = btn.dataset.aspect;
-- $$('.aspect-btn').forEach(b => b.classList.toggle('active', b.dataset.aspect === selectedAspect));
-- // Show/hide custom coords
-- $('#crop-coords').style.display = selectedAspect ? 'none' : 'grid';
-- });
-- });
++ // The format chips: the button follows the format that is picked ("Convert to PNG").
++ $$('input[name=format]').forEach((radio) => radio.addEventListener('change', updateVerb));
-- // Process button
-- processBtn.addEventListener('click', processImage);
++ // The quality slider: the kit shows the value; the app only reads it.
++ if (processBtn) processBtn.addEventListener('click', processImage);
}
-- function showToolOptions() {
-- // Hide all option groups
-- ['opt-convert', 'opt-compress', 'opt-resize', 'opt-crop'].forEach(id => {
-- const el = $(`#${id}`);
-- if (el) el.style.display = 'none';
-- });
++ function selectedFormat() {
++ const checked = document.querySelector('input[name=format]:checked');
++ return checked ? checked.value : ((ctx && ctx.defaultFormat) || 'png');
++ }
-- // Show active tool options
-- const optEl = $(`#opt-${selectedTool}`);
-- if (optEl) optEl.style.display = '';
++ function updateVerb() {
++ if (!processBtn) return;
++ const span = processBtn.querySelector('span');
++ if (!span) return;
++ if (selectedTool === 'compress') span.textContent = 'Compress';
++ else if (selectedTool === 'resize') span.textContent = 'Resize';
++ else if (selectedTool === 'crop') span.textContent = 'Crop';
++ else if (ctx && ctx.toolId === 'favicon') span.textContent = 'Make favicon';
++ else span.textContent = `Convert to ${selectedFormat().toUpperCase()}`;
++ }
-- // Pre-select format if domain-specific
-- if (ctx.defaultFormat) {
-- selectedFormat = ctx.defaultFormat;
-- $$('.format-btn').forEach(b => b.classList.toggle('active', b.dataset.format === selectedFormat));
-- }
++ function numberValue(name) {
++ const el = document.querySelector(`[name="${name}"]`);
++ return el && el.value !== '' ? Number(el.value) : null;
}
-- function updateProcessButton() {
-- processBtn.disabled = !selectedFile;
-- const labels = { convert: 'Convert Image', compress: 'Compress Image', resize: 'Resize Image', crop: 'Crop Image' };
-- processText.textContent = labels[selectedTool] || 'Process Image';
++ function radioValue(name) {
++ const el = document.querySelector(`input[name="${name}"]:checked`);
++ return el ? el.value : '';
}
// ── Process ──────────────────────────────────────────────
async function processImage() {
if (!selectedFile) return;
-- // Show processing state
-- optionsPanel.style.display = 'none';
-- resultPanel.style.display = 'none';
-- processingEl.style.display = '';
-- processBtn.disabled = true;
--
-- const formData = new FormData();
-- formData.append('file', selectedFile);
-- formData.append('tool', selectedTool);
--
-- // Add tool-specific options
++ const input = { tool: selectedTool };
if (selectedTool === 'convert') {
-- formData.append('format', selectedFormat);
-- formData.append('quality', qualitySlider.value);
++ input.format = selectedFormat();
++ const q = numberValue('quality');
++ if (q != null) input.quality = q;
} else if (selectedTool === 'compress') {
-- formData.append('quality', compressSlider.value);
++ const q = numberValue('quality');
++ if (q != null) input.quality = q;
} else if (selectedTool === 'resize') {
-- const w = $('#resize-w').value;
-- const h = $('#resize-h').value;
-- const pct = $('#resize-pct').value;
-- const fit = $('#resize-fit').value;
-- if (pct) formData.append('percentage', pct);
++ const pct = numberValue('percentage');
++ if (pct != null) input.percentage = pct;
else {
-- if (w) formData.append('width', w);
-- if (h) formData.append('height', h);
++ const w = numberValue('width');
++ const h = numberValue('height');
++ if (w != null) input.width = w;
++ if (h != null) input.height = h;
}
-- formData.append('fit', fit);
++ const fit = document.querySelector('[name="fit"]');
++ if (fit && fit.value) input.fit = fit.value;
} else if (selectedTool === 'crop') {
-- if (selectedAspect) {
-- formData.append('aspect', selectedAspect);
-- } else {
-- const l = $('#crop-left').value;
-- const t = $('#crop-top').value;
-- const w = $('#crop-w').value;
-- const h = $('#crop-h').value;
-- if (l) formData.append('left', l);
-- if (t) formData.append('top', t);
-- if (w) formData.append('width', w);
-- if (h) formData.append('height', h);
++ const aspect = radioValue('aspect');
++ if (aspect) input.aspect = aspect;
++ else {
++ for (const k of ['left', 'top', 'width', 'height']) {
++ const v = numberValue(k);
++ if (v != null) input[k] = v;
++ }
}
}
-- // Jobs: the work is accepted, followed live and survives a reload (?job=<id> in the address).
-- const input = {};
-- for (const [k, v] of formData.entries()) if (k !== 'file') input[k] = v;
++ if (processBtn) {
++ processBtn.disabled = true;
++ const span = processBtn.querySelector('span');
++ // The button keeps its verb while it works: "Compress…", not "Working…".
++ if (span) span.textContent = `${span.textContent.replace(/…$/, '')}…`;
++ }
++ showWorking();
++
try {
const job = await OVJobs.submit({ type: 'img.process', input, files: [selectedFile] });
OVJobs.remember(job.id);
@@@ -391,41 +262,24 @@
function processingText(job) {
const pct = job.progress && job.progress.percent;
-- if (job.cancel_requested) return 'Cancelling...';
-- if (job.state === 'queued') return 'Waiting for a free slot...';
++ if (job.cancel_requested) return 'Cancelling…';
++ if (job.state === 'queued') return 'Waiting for a free slot…';
const label = (job.progress && job.progress.message) || 'Processing your image';
-- return pct != null ? `${label}... ${Math.round(pct)}%` : `${label}...`;
-- }
--
-- function cancelButton() {
-- let btn = processingEl.querySelector('.job-cancel');
-- if (!btn) {
-- btn = document.createElement('button');
-- btn.type = 'button';
-- btn.className = 'btn-secondary job-cancel';
-- btn.textContent = 'Cancel';
-- btn.addEventListener('click', () => { if (currentJob) OVJobs.cancel(currentJob).catch(() => {}); });
-- processingEl.appendChild(btn);
-- }
-- return btn;
++ return pct != null ? `${label}… ${Math.round(pct)}%` : `${label}…`;
}
function followJob(job) {
currentJob = job.id;
-- optionsPanel.style.display = 'none';
-- resultPanel.style.display = 'none';
-- processingEl.style.display = '';
-- processBtn.disabled = true;
-- const label = processingEl.querySelector('p');
-- const update = (j) => { if (label) label.textContent = processingText(j); };
-- cancelButton().style.display = '';
-- update(job);
++ hideAnswer();
++ showWorking();
if (watcher) watcher.close();
watcher = OVJobs.watch(job.id, {
-- onUpdate: update,
++ onUpdate: (j) => {
++ const out = document.querySelector('.ovk-tool-out');
++ if (out) out.querySelector('[data-ovk-working]')?.setAttribute('data-progress', processingText(j));
++ },
onDone(done) {
watcher = null;
-- cancelButton().style.display = 'none';
if (done.state === 'succeeded') return showResult(jobResult(done));
OVJobs.forget();
failProcessing(done.state === 'cancelled' ? null : (done.error && done.error.detail) || 'Processing failed');
@@@ -443,11 +297,19 @@
};
}
++ function resetVerb() {
++ const span = processBtn && processBtn.querySelector('span');
++ if (span) span.textContent = span.textContent.replace(/…$/, '');
++ }
++
function failProcessing(message) {
-- processingEl.style.display = 'none';
-- optionsPanel.style.display = selectedFile ? '' : 'none';
-- processBtn.disabled = !selectedFile;
-- if (message) alert(`Error: ${message}`);
++ resetVerb();
++ if (processBtn) {
++ processBtn.disabled = !selectedFile;
++ updateVerb();
++ }
++ if (message) fail(message);
++ else hideAnswer();
}
/** After a reload: pick the job up again from ?job=<id> or this tab's session. */
@@@ -457,89 +319,58 @@
OVJobs.get(id).then(followJob).catch(() => OVJobs.forget());
}
-- // ── Result ───────────────────────────────────────────────
-- function initResult() {
-- anotherBtn.addEventListener('click', () => {
-- if (window.OVJobs) OVJobs.forget();
-- resetUpload();
-- toolTabs.scrollIntoView({ behavior: 'smooth' });
++ // ── The answer ───────────────────────────────────────────
++ function showWorking() {
++ const out = document.querySelector('.ovk-tool-out');
++ if (!out || !kit()) return;
++ out.innerHTML = kit().answer({
++ title: 'Working…',
++ body: '<p data-ovk-working>Your picture is being processed on our server. This page stays open until it is done.</p>',
});
}
-- function showResult(data) {
-- processingEl.style.display = 'none';
-- resultPanel.style.display = '';
--
-- // Preview image (if it's a previewable type)
-- const previewable = ['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/avif'];
-- if (previewable.includes(data.output.mime)) {
-- const img = document.createElement('img');
-- img.src = data.download.previewUrl || data.download.downloadUrl;
-- img.alt = 'Result';
-- resultPreview.replaceChildren(img);
-- } else {
-- resultPreview.innerHTML = `<div style="font-size:48px;color:var(--accent);padding:24px"><i class="fa-solid fa-file-image"></i></div>`;
-- }
++ function hideAnswer() {
++ const out = document.querySelector('.ovk-tool-out');
++ if (out) out.innerHTML = '';
++ }
-- // Info
-- let infoHtml = `<p>Format: <strong>${data.output.ext.toUpperCase()}</strong> &bull; Size: <strong>${data.output.sizeKB} KB</strong></p>`;
-- if (data.savings) {
-- const pct = data.savings.savedPercent;
-- if (pct > 0) {
-- infoHtml += `<p class="savings">Saved ${pct}% (${formatBytes(data.savings.savedBytes)} smaller)</p>`;
-- } else {
-- infoHtml += `<p>File size unchanged or slightly larger — try a lower quality.</p>`;
-- }
-- }
-- if (data.dimensions) {
-- infoHtml += `<p>${data.dimensions.original.width}x${data.dimensions.original.height} → ${data.dimensions.resized.width}x${data.dimensions.resized.height}</p>`;
++ function fail(message) {
++ const out = document.querySelector('.ovk-tool-out');
++ if (!out || !kit()) return;
++ out.innerHTML = kit().answer({ title: 'That did not work', tone: 'bad', body: `<p>${escapeHtml(message)}</p>` });
++ }
++
++ function showResult(data) {
++ resetVerb();
++ if (processBtn) {
++ processBtn.disabled = false;
++ updateVerb();
}
-- if (data.crop) {
-- infoHtml += `<p>Cropped to ${data.crop.width}x${data.crop.height}</p>`;
++ const out = data.output || {};
++ const rows = [];
++ if (out.ext) rows.push(['Format', String(out.ext).toUpperCase()]);
++ if (data.dimensions) rows.push(['Size', `${data.dimensions.original.width}×${data.dimensions.original.height} → ${data.dimensions.resized.width}×${data.dimensions.resized.height}`]);
++ if (data.crop) rows.push(['Kept', `${data.crop.width}×${data.crop.height}`]);
++ const expires = data.download && data.download.expiresIn;
++ if (expires) {
++ const mins = Math.max(1, Math.round(expires / 60000));
++ rows.push(['Kept for', mins >= 60 ? `${Math.round(mins / 60)} hour${Math.round(mins / 60) === 1 ? '' : 's'}` : `${mins} minutes`]);
}
--
-- const expires = data.download.expiresIn;
-- const expiresStr = expires >= 3600000 ? `${Math.round(expires / 3600000)}h` : `${Math.round(expires / 60000)}m`;
-- infoHtml += `<p style="color:var(--text-muted);font-size:12px;margin-top:8px">Download expires in ${expiresStr}</p>`;
--
-- resultInfo.innerHTML = infoHtml;
--
-- // Download link
-- downloadBtn.href = data.download.downloadUrl;
-- downloadBtn.download = '';
--
-- resultPanel.scrollIntoView({ behavior: 'smooth' });
++ const html = window.OVToolFrame.fileAnswer({
++ title: 'Your picture is ready',
++ before: selectedFile ? selectedFile.size : null,
++ after: out.size != null ? out.size : null,
++ savedPercent: data.savings && data.savings.savedPercent,
++ rows,
++ downloadUrl: data.download && data.download.downloadUrl,
++ filename: data.download && data.download.filename,
++ });
++ window.OVToolFrame.show(html);
}
-- // ── Subdomains Grid ──────────────────────────────────────
-- function initSubdomains() {
-- const tools = [
-- { host: 'png.openvibe.tools', name: 'OpenVibePNG', icon: 'fa-file-image', desc: 'Convert to PNG' },
-- { host: 'jpg.openvibe.tools', name: 'OpenVibeJPG', icon: 'fa-file-image', desc: 'Convert to JPG' },
-- { host: 'webp.openvibe.tools', name: 'OpenVibeWebP', icon: 'fa-file-image', desc: 'Convert to WebP' },
-- { host: 'avif.openvibe.tools', name: 'OpenVibeAVIF', icon: 'fa-file-image', desc: 'Convert to AVIF' },
-- { host: 'heic.openvibe.tools', name: 'OpenVibeHEIC', icon: 'fa-file-image', desc: 'HEIC Converter' },
-- { host: 'svg.openvibe.tools', name: 'OpenVibeSVG', icon: 'fa-shapes', desc: 'SVG Converter' },
-- { host: 'gif.openvibe.tools', name: 'OpenVibeGIF', icon: 'fa-film', desc: 'GIF Converter' },
-- { host: 'ico.openvibe.tools', name: 'OpenVibeICO', icon: 'fa-icons', desc: 'ICO Favicon Maker' },
-- { host: 'tiff.openvibe.tools', name: 'OpenVibeTIFF', icon: 'fa-file-image', desc: 'Convert to TIFF' },
-- { host: 'bmp.openvibe.tools', name: 'OpenVibeBMP', icon: 'fa-file-image', desc: 'Convert to BMP' },
-- { host: 'compress.openvibe.tools', name: 'OpenVibeCompress', icon: 'fa-compress', desc: 'Compress Images' },
-- { host: 'resize.openvibe.tools', name: 'OpenVibeResize', icon: 'fa-up-right-and-down-left-from-center', desc: 'Resize Images' },
-- { host: 'crop.openvibe.tools', name: 'OpenVibeCrop', icon: 'fa-scissors', desc: 'Crop Images' },
-- { host: 'convert.openvibe.tools', name: 'OpenVibeConvert', icon: 'fa-arrows-rotate', desc: 'Format Converter' },
-- { host: 'favicon.openvibe.tools', name: 'OpenVibeFavicon', icon: 'fa-icons', desc: 'Favicon Generator' },
-- ];
--
-- subdomainGrid.innerHTML = tools.map(t => `
-- <a class="subdomain-card" href="https://${t.host}">
-- <span class="sd-icon"><i class="fa-solid ${t.icon}"></i></span>
-- <span class="sd-info">
-- <span class="sd-name">${t.name}</span>
-- <span class="sd-desc">${t.desc}</span>
-- </span>
-- </a>
-- `).join('');
++ // ── Result ───────────────────────────────────────────────
++ function initResult() {
++ // Nothing to wire: the answer is drawn in the frame's answer slot.
}
// ── Helpers ──────────────────────────────────────────────
@@@ -548,12 +379,6 @@
return match ? decodeURIComponent(match[1]) : null;
}
-- function formatBytes(bytes) {
-- if (bytes < 1024) return bytes + ' B';
-- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
-- return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
-- }
--
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
ddiff --git a/apps/img/public/sample.png b/apps/img/public/sample.png
nnew file mode 100644
iindex 0000000..dfdaade
BBinary files /dev/null and b/apps/img/public/sample.png differ
ddiff --git a/apps/img/server/frame.js b/apps/img/server/frame.js
nnew file mode 100644
iindex 0000000..6a79713
---- /dev/null
++++ b/apps/img/server/frame.js
@@@ -0,0 +1,314 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// The tool frame for the image pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
++//
++// Every image host — png, jpg, compress, resize, crop and the rest — is a tool page in the shared
++// frame: the head (the family crumb, the tool's name, one sentence, the terms), the box (a drop zone
++// and the tool's own options), the answer (the file it made, its size before and after, and Download),
++// the family, the folds and "People also use".
++//
++// The terms come from the app's real configuration: the file's lifetime from config.js's retention,
++// its size from config.js's upload limits, and the anonymous rate from the guard's quota table and the
++// tool's own cost in it (server/descriptors.js).
++// ═══════════════════════════════════════════════════════════════
++
++const kit = require('openvibe-shared/kit');
++const serve = require('openvibe-shared/serve');
++const frame = require('../../_shared/frame')({ kit, serve });
++const config = require('./config');
++const { DOMAIN_MAP } = require('./domain-map');
++const { SPECS } = require('./descriptors');
++
++const HUB = 'https://img.openvibe.tools/';
++const esc = kit.esc;
++
++// The tools, in the order the family row shows them. `op` is the operation the host runs
++// (domain-map.js); `format` the one it writes by default.
++const TOOLS = [
++ {
++ id: 'convert', host: 'convert.openvibe.tools', name: 'Image Converter', short: 'Convert', icon: 'swap', hue: 190, op: 'convert',
++ task: 'Convert an image to PNG, JPG, WebP, AVIF, GIF, TIFF, BMP or ICO.',
++ about: 'The image converter writes the format you pick and nothing else: the picture is decoded, re-encoded and sent back as a file you can download.',
++ bullets: ['PNG keeps every pixel, JPG is small and universal, WebP and AVIF are smaller still.', 'A quality slider is there for the formats that lose a little to save bytes.'],
++ faq: [['Which format should I pick?', 'PNG for a picture with text or flat colours, JPG for a photograph you want small, WebP or AVIF when the file travels far.'],
++ ['Is my picture uploaded?', 'Yes, to our server, and it is deleted an hour later. Nothing is kept.'],
++ ['Can I convert a HEIC photo?', 'Yes, on a server with the HEIC decoder installed. The page says so when it is not.']],
++ },
++ {
++ id: 'compress', host: 'compress.openvibe.tools', name: 'Image Compressor', short: 'Compress', icon: 'zap', hue: 190, op: 'compress',
++ task: 'Make an image smaller without changing how it looks.',
++ about: 'The compressor re-encodes your picture at the quality you choose and shows the size before and after, so you can see what it saved.',
++ bullets: ['A lower level means a smaller file and a softer picture.', 'The original is never touched; you get a new file.'],
++ faq: [['How much smaller can it get?', 'A photograph often loses half its size; a flat picture with sharp edges loses less.'],
++ ['Does it change the resolution?', 'No. The picture keeps its dimensions; only the way it is encoded changes.']],
++ },
++ {
++ id: 'resize', host: 'resize.openvibe.tools', name: 'Image Resizer', short: 'Resize', icon: 'grid', hue: 190, op: 'resize',
++ task: 'Make an image a new size, in pixels or by a percentage.',
++ about: 'The resizer scales a picture to the width and height you give it, or to a percentage of what it is now.',
++ bullets: ['Five ways to fit: inside, cover, contain, fill or outside.', 'A picture is never enlarged past its own size unless you ask for it.'],
++ faq: [['What does "inside" mean?', 'The picture fits inside the box you typed; the other side keeps its ratio.'],
++ ['Can I make it bigger?', 'Yes, but a picture scaled up is softer than the original.']],
++ },
++ {
++ id: 'crop', host: 'crop.openvibe.tools', name: 'Image Cropper', short: 'Crop', icon: 'grid', hue: 190, op: 'crop',
++ task: 'Cut an image down to the part you want to keep.',
++ about: 'The cropper keeps a rectangle of your picture, or the largest centred piece with the ratio you choose.',
++ bullets: ['Nine ratios, from 1:1 to 21:9.', 'Type the rectangle yourself when the ratio is not the one you want.'],
++ faq: [['Which part is kept when I pick a ratio?', 'The largest centred piece with that ratio.'],
++ ['Can I crop to an exact rectangle?', 'Yes. Leave the ratio on "Custom" and type the four numbers.']],
++ },
++ {
++ id: 'favicon', host: 'favicon.openvibe.tools', name: 'Favicon Generator', short: 'Favicon', icon: 'star', hue: 190, op: 'convert', format: 'ico',
++ task: 'Turn a picture into the .ico file a website asks for.',
++ about: 'The favicon generator writes a multi-size ICO file from your picture, the file a browser tab and a bookmark ask a site for.',
++ bullets: ['One .ico file with the sizes browsers look for.', 'A PNG of the same picture comes with it for social cards.'],
++ faq: [['Where do I put the file?', 'In your site\'s root folder, next to your index.html, and you link it in the head.'],
++ ['What size should the picture be?', 'A square of at least 512 pixels; the generator resizes it for you.']],
++ },
++ {
++ id: 'png', host: 'png.openvibe.tools', name: 'PNG Converter', short: 'PNG', icon: 'image', hue: 190, op: 'convert', format: 'png',
++ task: 'Convert an image to PNG, the format that keeps every pixel.',
++ about: 'The PNG converter writes a lossless PNG from any picture you drop: JPG, WebP, AVIF, HEIC, GIF, BMP, TIFF or SVG.',
++ bullets: ['Lossless: the pixels that come out are the pixels that went in.', 'The background is filled with white when the picture had none.'],
++ faq: [['Is a PNG bigger than a JPG?', 'Usually. PNG keeps every pixel, so it is bigger for a photograph and smaller for a logo.'],
++ ['Does it keep the transparency?', 'Yes, when the format you drop it in has transparency.']],
++ },
++ {
++ id: 'jpg', host: 'jpg.openvibe.tools', name: 'JPG Converter', short: 'JPG', icon: 'image', hue: 190, op: 'convert', format: 'jpg',
++ task: 'Convert an image to JPG, the format every program reads.',
++ about: 'The JPG converter writes a JPEG from any picture you drop, at the quality you pick.',
++ bullets: ['The format for photographs and for anything that has to be small.', 'Transparency is filled with white.'],
++ faq: [['What quality should I use?', '80 to 85 is invisible to the eye and small enough for the web.'],
++ ['Can it write a progressive JPG?', 'Yes, the file loads line by line while it downloads.']],
++ },
++ {
++ id: 'webp', host: 'webp.openvibe.tools', name: 'WebP Converter', short: 'WebP', icon: 'image', hue: 190, op: 'convert', format: 'webp',
++ task: 'Convert an image to WebP, the web format that is smaller than JPG.',
++ about: 'The WebP converter writes a modern web image format: smaller than JPG at the same look, and it keeps transparency.',
++ bullets: ['Every current browser reads WebP.', 'Usually a third smaller than the same picture as a JPG.'],
++ faq: [['Does every browser read WebP?', 'Every browser released since 2020 does.'],
++ ['Is WebP lossy?', 'You choose: a quality slider, like the JPG converter.']],
++ },
++ {
++ id: 'avif', host: 'avif.openvibe.tools', name: 'AVIF Converter', short: 'AVIF', icon: 'image', hue: 190, op: 'convert', format: 'avif',
++ task: 'Convert an image to AVIF, the smallest format the web has.',
++ about: 'The AVIF converter writes the modern image format browsers support: smaller than WebP at the same quality.',
++ bullets: ['The smallest files of the lot, and the slowest to write.', 'Transparency, like PNG.'],
++ faq: [['Is AVIF slow?', 'Yes. A big picture takes a while to encode, and the page says so while it works.'],
++ ['Which browsers read AVIF?', 'Chrome, Firefox, Safari 16 and every browser since.']],
++ },
++ {
++ id: 'heic', host: 'heic.openvibe.tools', name: 'HEIC Converter', short: 'HEIC', icon: 'image', hue: 190, op: 'convert', format: 'png',
++ task: 'Convert an iPhone HEIC photo to a picture every program can open.',
++ about: 'The HEIC converter reads the photos an iPhone saves and writes them as PNG or JPG.',
++ bullets: ['HEIC and HEIF, the formats iPhones and some cameras use.', 'Needs a HEIC decoder on the server; the page says so when there is none.'],
++ faq: [['Why can I not convert my photo?', 'The server needs the HEIC decoder. The page says so when it is missing.'],
++ ['What does it write?', 'PNG by default; the format grid is there if you want JPG instead.']],
++ },
++ {
++ id: 'svg', host: 'svg.openvibe.tools', name: 'SVG Converter', short: 'SVG', icon: 'image', hue: 190, op: 'convert', format: 'png',
++ task: 'Convert an SVG drawing to a picture you can use anywhere.',
++ about: 'The SVG converter rasterises a vector drawing, so you can use it as a PNG or a JPG in a program that cannot read vectors.',
++ bullets: ['Vector in, picture out.', 'The width and height of the drawing decide the size of the picture.'],
++ faq: [['Will the picture be sharp?', 'It is drawn at the size you ask for, so ask for the size you need.'],
++ ['Can I go the other way?', 'Not here: a picture turned into a vector is a different tool.']],
++ },
++ {
++ id: 'gif', host: 'gif.openvibe.tools', name: 'GIF Converter', short: 'GIF', icon: 'image', hue: 190, op: 'convert', format: 'gif',
++ task: 'Convert an image to GIF, the format every program still reads.',
++ about: 'The GIF converter writes an animated or still GIF from any picture you drop.',
++ bullets: ['Good for pictures with few colours and for short animations.', 'A picture of many colours becomes bigger, not smaller.'],
++ faq: [['Does it keep the animation?', 'Yes, when the file you drop is an animated picture.'],
++ ['Why is my GIF so big?', 'GIF has no compression for photographs. Use JPG or WebP for those.']],
++ },
++ {
++ id: 'ico', host: 'ico.openvibe.tools', name: 'ICO Converter', short: 'ICO', icon: 'image', hue: 190, op: 'convert', format: 'ico',
++ task: 'Convert an image to ICO, the icon file a website and a program use.',
++ about: 'The ICO converter writes a multi-size icon file from your picture.',
++ bullets: ['The sizes Windows and browsers look for, in one file.', 'A PNG of the same picture comes with it.'],
++ faq: [['What size should the picture be?', 'A square of 512 pixels or more; the sizes below it are made for you.'],
++ ['Can I make a favicon from a photo?', 'Yes, but a picture with one shape in it reads better as an icon.']],
++ },
++ {
++ id: 'tiff', host: 'tiff.openvibe.tools', name: 'TIFF Converter', short: 'TIFF', icon: 'image', hue: 190, op: 'convert', format: 'tiff',
++ task: 'Convert an image to TIFF, the format printers and scanners use.',
++ about: 'The TIFF converter writes an uncompressed, lossless picture that a printer or a scanner program reads.',
++ bullets: ['Lossless and unshrunk.', 'Big files: a picture is not compressed here.'],
++ faq: [['Why is the file so big?', 'TIFF stores the picture as it is, without compression.'],
++ ['Who needs TIFF?', 'Printers, scanners and publishers.']],
++ },
++ {
++ id: 'bmp', host: 'bmp.openvibe.tools', name: 'BMP Converter', short: 'BMP', icon: 'image', hue: 190, op: 'convert', format: 'bmp',
++ task: 'Convert an image to BMP, the oldest picture format there is.',
++ about: 'The BMP converter writes the uncompressed bitmap format that old programs and some tools still ask for.',
++ bullets: ['No compression: the file is as big as the picture.', 'Nothing is lost on the way.'],
++ faq: [['Why would I want a BMP?', 'Some old programs and device tools only read BMP.'],
++ ['Is the file big?', 'Yes. It is the picture in its own bytes, with no compression.']],
++ },
++];
++
++const BY_ID = new Map(TOOLS.map((t) => [t.id, t]));
++const BY_HOST = new Map(TOOLS.map((t) => [t.host, t]));
++const SPEC = new Map(SPECS.map((s) => [s.id, s]));
++
++// The hub is a tool page too: the converter, with the whole family under it.
++const HUB_TOOL = {
++ id: 'hub', name: 'Img.OpenVibe', short: 'All tools', icon: 'image', hue: 190, op: 'convert', format: 'png',
++ task: 'Convert an image to PNG, JPG, WebP, AVIF, GIF, TIFF, BMP or ICO — the other image tools are below.',
++ about: 'Every image tool in one place: convert a picture to another format, make it smaller, change its size or cut it down.',
++ faq: [['Which formats do you read?', 'PNG, JPG, WebP, AVIF, HEIC, HEIF, SVG, GIF, ICO, TIFF and BMP.'],
++ ['How long are my files kept?', 'An hour for a visitor, a day for a signed-in person. Then they are gone.'],
++ ['Is it free?', 'Yes. Every image tool here is free, with a fair-use limit per address so the server stays fast.']],
++};
++
++/** The tool a host is for (the aliases jpeg and heif point at jpg and heic). */
++function toolFor(host) {
++ const t = BY_HOST.get(host);
++ if (t) return t;
++ const ctx = DOMAIN_MAP[host];
++ if (!ctx) return null;
++ if (ctx.toolId === 'hub') return HUB_TOOL;
++ return BY_ID.get(ctx.toolId) || null;
++}
++
++/** The format the host writes by default. */
++const formatOf = (rec) => rec.format || (DOMAIN_MAP[`${rec.id}.openvibe.tools`] || {}).defaultFormat || 'png';
++
++/** The terms of a tool, from the app's real configuration and the tool's own descriptor. */
++function termsOf(rec) {
++ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);
++ // The size is the app's own upload limit (config.js); the count is the tool's own descriptor.
++ const maxBytes = config.upload.maxFileSize;
++ const maxFiles = (spec && spec.files && spec.files.max) || 1;
++ return frame.terms({
++ work: 'server',
++ ttlMs: config.retention.anonTTL,
++ maxBytes, maxFiles,
++ quotaClass: (spec && spec.quotaClass) || 'tools-job',
++ cost: (spec && spec.cost) || 1,
++ unit: rec.op === 'convert' ? 'conversions' : 'runs',
++ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),
++ });
++}
++
++/** The format buttons of the convert box. */
++function formatChips(current) {
++ const formats = [['png', 'PNG'], ['jpg', 'JPG'], ['webp', 'WebP'], ['avif', 'AVIF'], ['gif', 'GIF'], ['tiff', 'TIFF'], ['bmp', 'BMP'], ['ico', 'ICO']];
++ return kit.chips({
++ name: 'format', single: true, label: 'Output format',
++ options: formats.map(([v, l]) => [v, l, v === current]),
++ });
++}
++
++/** A number field of the box, in the app's own field style. */
++function numberField(name, label, { min = 0, max = null, placeholder = '' } = {}) {
++ return `<label class="ovk-set-row"><span class="ovk-set-text">${esc(label)}</span>`
++ + `<input type="number" class="num-input num-input-sm" name="${esc(name)}" value="" placeholder="${esc(placeholder)}"${min != null ? ` min="${min}"` : ''}${max != null ? ` max="${max}"` : ''}></label>`;
++}
++
++/** The options of the box, per operation. */
++function optionsFor(rec) {
++ if (rec.op === 'compress') return kit.rangeRow({ name: 'quality', label: 'Compression level', value: 75, min: 1, max: 100, step: 1 });
++ if (rec.op === 'resize') {
++ return numberField('width', 'Width (px)', { min: 1, max: 16384, placeholder: 'auto' })
++ + numberField('height', 'Height (px)', { min: 1, max: 16384, placeholder: 'auto' })
++ + numberField('percentage', 'Or scale by percentage', { min: 1, max: 1000, placeholder: 'e.g. 50' })
++ + kit.selectRow({ name: 'fit', label: 'Fit mode', value: 'inside', options: [['inside', 'Inside (fit within bounds)'], ['cover', 'Cover (fill and crop)'], ['contain', 'Contain (fit with padding)'], ['fill', 'Fill (stretch)'], ['outside', 'Outside (cover minimum)']] });
++ }
++ if (rec.op === 'crop') {
++ const ratios = [['1:1', '1:1'], ['16:9', '16:9'], ['4:3', '4:3'], ['3:2', '3:2'], ['21:9', '21:9'], ['9:16', '9:16'], ['3:4', '3:4'], ['', 'Custom']];
++ return kit.chips({ name: 'aspect', single: true, label: 'Aspect ratio', options: ratios.map(([v, l]) => [v, l, v === '']) })
++ + numberField('left', 'Left (px)', { min: 0, placeholder: '0' })
++ + numberField('top', 'Top (px)', { min: 0, placeholder: '0' })
++ + numberField('width', 'Width (px)', { min: 1, placeholder: 'auto' })
++ + numberField('height', 'Height (px)', { min: 1, placeholder: 'auto' });
++ }
++ return formatChips(formatOf(rec)) + kit.rangeRow({ name: 'quality', label: 'Quality', value: 80, min: 1, max: 100, step: 1 });
++}
++
++/** The one button of the page: it says the verb. */
++function verbFor(rec) {
++ if (rec.op === 'compress') return 'Compress';
++ if (rec.op === 'resize') return 'Resize';
++ if (rec.op === 'crop') return 'Crop';
++ if (rec.id === 'favicon') return 'Make favicon';
++ return `Convert to ${formatOf(rec).toUpperCase()}`;
++}
++
++/**
++ * The box: a drop zone for the picture, the tool's options and the one button.
++ * @param {object} rec the tool record
++ */
++function boxFor(rec) {
++ const accept = 'image/png,image/jpeg,image/webp,image/avif,image/tiff,image/bmp,image/gif,image/heic,image/heif,image/svg+xml,image/x-icon,image/vnd.microsoft.icon';
++ const drop = kit.drop({
++ name: 'file', accept, id: 'file-input',
++ label: 'Drop an image here',
++ hint: 'PNG, JPG, WebP, AVIF, HEIC, SVG, GIF, ICO, TIFF, BMP',
++ });
++ return `<form class="ovk-tool-form" id="ovk-img-form" enctype="multipart/form-data" data-ovk-tool-form>`
++ + drop
++ + `<p class="ovk-try"><span>Try</span><a href="/sample.png" data-ovk-sample="/sample.png">A sample image</a></p>`
++ + `<div id="options-panel" hidden>${optionsFor(rec)}</div>`
++ + `<div class="ovk-tool-buttons">${frame.verbButton(verbFor(rec), { id: 'process-btn', icon: 'zap' })}`
++ + `<button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" id="clear-btn">${kit.icon('close')}<span>Clear</span></button></div>`
++ + `</form>`;
++}
++
++/** The family: every image tool, with the one on the page marked. */
++function familyItems(rec) {
++ // The hub is the converter itself, so the converter is the tool the family marks there.
++ const current = rec.id === 'hub' ? 'convert' : rec.id;
++ return TOOLS.map((t) => ({
++ href: `https://${t.host}/`, label: t.name, short: t.short, current: t.id === current,
++ }));
++}
++
++/** "People also use": the tools next to this one in the family. */
++function relatedFor(rec) {
++ // The hub is the converter, so the converter is not "also" there.
++ const self = rec.id === 'hub' ? 'convert' : rec.id;
++ const at = TOOLS.findIndex((t) => t.id === self);
++ const out = [];
++ for (let i = 1; out.length < 3 && i < TOOLS.length + 1; i++) {
++ const other = TOOLS[(at + i) % TOOLS.length];
++ if (other.id !== self && other.id !== 'hub') out.push(other);
++ }
++ return out.map((t) => kit.row({ href: `https://${t.host}/`, title: t.name, sub: t.task }));
++}
++
++/**
++ * The whole frame for one host.
++ * @param {string} host the satellite's own hostname, e.g. 'png.openvibe.tools'
++ * @returns {string} HTML
++ */
++function frameFor(host) {
++ const rec = toolFor(host);
++ if (!rec) return '';
++ const spec = SPEC.get(rec.id === 'hub' ? 'convert' : rec.id);
++ const fields = spec && spec.input && spec.input.properties ? Object.keys(spec.input.properties).join(', ') : 'none';
++ const apiId = rec.id === 'hub' ? 'convert' : rec.id;
++ const input = spec && spec.input && spec.input.properties ? Object.fromEntries(
++ Object.entries(spec.input.properties).map(([k, v]) => [k, v.default !== undefined ? v.default : (k === 'format' ? formatOf(rec) : '')]),
++ ) : {};
++ return frame.page({
++ id: rec.id,
++ icon: rec.icon, hue: rec.hue, family: 'image',
++ crumb: { href: HUB, label: 'Image tools' },
++ title: rec.name, task: rec.task, facts: termsOf(rec),
++ box: boxFor(rec),
++ familyTitle: 'More image tools',
++ familyMore: { href: HUB, label: `All ${TOOLS.length}` },
++ familyItems: familyItems(rec),
++ about: rec.about, bullets: rec.bullets, faq: rec.faq,
++ api: {
++ id: apiId, path: `/api/v1/tools/${apiId}/run`, fields, payload: { input },
++ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),
++ },
++ related: relatedFor(rec),
++ });
++}
++
++module.exports = { frameFor, toolFor, termsOf, boxFor, verbFor, TOOLS, HUB_TOOL, optionsFor, formatChips };
ddiff --git a/apps/img/server/index.js b/apps/img/server/index.js
iindex 4c17c0c..d9a4ebb 100644
---- a/apps/img/server/index.js
++++ b/apps/img/server/index.js
@@@ -267,6 +267,12 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
},
}));
++// The tool frame's browser glue (apps/_shared/public/tool-frame.js): one copy for every file app.
++app.get('/js/tool-frame.js', (_req, res) => {
++ res.set('Cache-Control', 'no-cache');
++ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));
++});
++
// SPA fallback — every host gets index.html stamped with its own title, canonical, social
// tags and structured data (server/seo.js); the raw file carries only the hub's.
const { sendIndex } = require('./seo');
ddiff --git a/apps/img/server/seo.js b/apps/img/server/seo.js
iindex df1031b..1ecca47 100644
---- a/apps/img/server/seo.js
++++ b/apps/img/server/seo.js
@@@ -15,8 +15,10 @@
const fs = require('fs');
const path = require('path');
++const serve = require('openvibe-shared/serve');
const { canonicalHostFor, ownHost } = require('../../_shared/host-role');
const { DOMAIN_MAP } = require('./domain-map');
++const { frameFor } = require('./frame');
const PUBLIC = path.join(__dirname, '..', 'public');
const HUB_HOST = 'img.openvibe.tools';
@@@ -89,10 +91,15 @@ function renderIndex(ctx, host, canonicalHost) {
`<meta name="twitter:title" content="${esc(title)}">`,
`<meta name="twitter:description" content="${esc(desc)}">`,
`<meta name="twitter:image" content="${image}">`,
++ // The shared tool frame's look (openvibe-shared ≥ 4.57.1), from this app's own pin.
++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
...ld.map(b => `<script type="application/ld+json">${JSON.stringify(b).replace(/</g, '\\u003c')}</script>`),
].join('\n');
-- const out = html.replace(/<\/head>/i, `${head}\n</head>`);
++ const out = html.replace(/<\/head>/i, `${head}\n</head>`)
++ // The frame itself: the head, the box, the answer slot, the family, the folds and "People also use".
++ .replace('<!--OVK:FRAME-->', frameFor(host) || '');
if (_cache.size > 100) _cache.clear();
_cache.set(key, out);
return out;
ddiff --git a/apps/img/test/frame.test.js b/apps/img/test/frame.test.js
nnew file mode 100644
iindex 0000000..dcd8507
---- /dev/null
++++ b/apps/img/test/frame.test.js
@@@ -0,0 +1,135 @@
++'use strict';
++// The tool frame on the image pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):
++// • every image host is a tool page in the same frame: the head, the box, the answer slot, the family,
++// the folds and "People also use", in that order;
++// • the terms come from the app's real configuration — the file's lifetime from the cleanup config, its
++// size from the upload limits, the anonymous rate from the guard's quota table — and they follow it
++// when it changes;
++// • the one button says the verb, never "Run";
++// • the title, the canonical and the structured data stay.
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const os = require('os');
++const path = require('path');
++const { frameFor, verbFor, TOOLS } = require('../server/frame');
++const { DOMAIN_MAP } = require('../server/domain-map');
++const config = require('../server/config');
++const { startApp } = require('../../_shared/test/spawn');
++
++const HOSTS = Object.keys(DOMAIN_MAP);
++
++/** The frame's order: each marker appears once, and in this order. */
++function inOrder(html, parts) {
++ let at = -1;
++ for (const p of parts) {
++ const i = html.indexOf(p, at + 1);
++ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);
++ at = i;
++ }
++}
++
++/** The head's terms, as text. */
++function factsOf(html) {
++ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);
++ if (!m) return [];
++ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);
++}
++
++// ── Every image host is a framed tool page, in the same order ──
++{
++ for (const host of HOSTS) {
++ const html = frameFor(host);
++ assert.ok(html, `${host}: a frame`);
++ inOrder(html, [
++ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',
++ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
++ ]);
++ assert.ok(html.includes('https://img.openvibe.tools/">Image tools'), `${host}: the crumb is the family`);
++ assert.ok(html.includes('class="ovk-drop"'), `${host}: a drop zone`);
++ assert.ok(html.includes('data-ovk-sample'), `${host}: a sample to try`);
++ assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);
++ assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);
++ assert.ok(html.includes('About '), `${host}: the about fold`);
++ assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);
++ }
++ assert.strictEqual(TOOLS.length, 15, 'the family has fifteen image tools');
++}
++
++// ── The terms come from the app's own configuration ──
++{
++ const html = frameFor('png.openvibe.tools');
++ const facts = factsOf(html);
++ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server; your file is deleted after 60 minutes'], 'png: free, on our server, and the file\'s lifetime');
++ assert.ok(facts.includes('One file up to 50 MB'), 'png: the upload limit');
++ assert.ok(facts.includes('12 conversions a minute without an account'), 'png: the guard\'s anonymous rate');
++ assert.ok(facts.includes('No sign-up'), 'png: anonymous');
++
++ // The lifetime follows the cleanup config.
++ const saved = config.retention.anonTTL;
++ try {
++ config.retention.anonTTL = 30 * 60 * 1000;
++ assert.ok(factsOf(frameFor('png.openvibe.tools')).includes('Runs on our server; your file is deleted after 30 minutes'), 'the lifetime follows the config');
++ config.upload.maxFileSize = 10 * 1024 * 1024;
++ assert.ok(factsOf(frameFor('png.openvibe.tools')).includes('One file up to 10 MB'), 'the size follows the config');
++ } finally {
++ config.retention.anonTTL = saved;
++ config.upload.maxFileSize = 50 * 1024 * 1024;
++ }
++
++ // A tool that costs more in the quota table gets a lower rate.
++ const avif = factsOf(frameFor('avif.openvibe.tools'));
++ assert.ok(avif.includes('7 conversions a minute without an account'), 'avif: its own cost in the quota table');
++
++ // A tool that does not convert says runs, not conversions.
++ const compress = factsOf(frameFor('compress.openvibe.tools'));
++ assert.ok(compress.includes('12 runs a minute without an account'), 'compress: the rate is counted in runs');
++ assert.ok(!compress.some((f) => /conversion/.test(f)), 'compress: never a conversion');
++}
++
++// ── The one button says the verb ──
++{
++ assert.strictEqual(verbFor({ op: 'convert', id: 'png', format: 'png' }), 'Convert to PNG');
++ assert.strictEqual(verbFor({ op: 'compress', id: 'compress' }), 'Compress');
++ assert.strictEqual(verbFor({ op: 'resize', id: 'resize' }), 'Resize');
++ assert.strictEqual(verbFor({ op: 'crop', id: 'crop' }), 'Crop');
++ for (const host of HOSTS) {
++ const html = frameFor(host);
++ const m = /<button[^>]*id="process-btn"[^>]*>\s*(?:<svg[\s\S]*?<\/svg>)?\s*<span>([^<]+)<\/span>/.exec(html);
++ assert.ok(m, `${host}: a verb button`);
++ assert.notStrictEqual(m[1], 'Run', `${host}: never Run`);
++ assert.ok(/^[A-Z]/.test(m[1]), `${host}: the button says the verb (${m[1]})`);
++ }
++}
++
++// ── The page the server sends: the frame, the title, the canonical and the structured data ──
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-img-frame-'));
++ const app = await startApp('img', { DATA_DIR: tmp });
++ const get = (host) => new Promise((resolve) => {
++ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {
++ let body = '';
++ res.setEncoding('utf8');
++ res.on('data', (d) => { body += d; });
++ res.on('end', () => resolve({ status: res.statusCode, body }));
++ });
++ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });
++ req.on('error', () => resolve({ status: 0, body: '' }));
++ });
++ try {
++ for (const host of HOSTS) {
++ const r = await get(host);
++ assert.strictEqual(r.status, 200, `${host}: answers 200`);
++ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), `${host}: the frame's title`);
++ assert.ok(r.body.includes('<link rel="canonical"'), `${host}: a canonical`);
++ assert.ok(r.body.includes('"@type":"BreadcrumbList"'), `${host}: the structured data`);
++ assert.ok(r.body.includes('/shared/kit.css?'), `${host}: the kit's look`);
++ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, `${host}: one h1`);
++ }
++ } finally {
++ await app.kill();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ console.log(`tool frame (img): ${HOSTS.length} image pages framed, the terms from the config, the verb buttons: all checks passed`);
++})().catch((err) => { console.error(err); process.exit(1); });
++
ddiff --git a/apps/maps/public/css/style.css b/apps/maps/public/css/style.css
iindex 1d08bc3..cad813b 100644
---- a/apps/maps/public/css/style.css
++++ b/apps/maps/public/css/style.css
@@@ -545,4 +545,33 @@ body {
.header-btn { width: 30px; height: 30px; font-size: 12px; }
}
++
++/* ── The tool frame ──────────────────────────────────────────────
++ The map is in the frame's answer slot now, so the page is a normal scrolling page and the map
++ takes a fixed height of the screen instead of the whole of it. */
++html, body { height: auto; min-height: 100%; overflow: auto; }
++.ovk-page #main-content { height: 68vh; }
++.ovk-page #header { position: static; }
++.ovk-maps-box .search-container { max-width: 720px; margin: 0 auto; }
++.ovk-maps-box .search-box { max-width: none; }
++
++
++/* The tool frame: the brand is the site ("Tools"), not the subdomain's name. */
++.openvibe-navbar .openvibe-navbar-brand .b-core,
++.openvibe-navbar .openvibe-navbar-brand .b-dot,
++.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }
++
++
++/* The search box sits in the frame's box now: on a phone it wraps instead of running off the screen. */
++.ovk-maps-box .search-box input { min-width: 0; flex: 1 1 120px; }
++.ovk-maps-box .search-box select { max-width: 78px; }
++@media (max-width: 560px) {
++ .ovk-maps-box .search-box { flex-wrap: wrap; }
++ .ovk-maps-box .search-box input { flex: 1 1 100%; order: -1; }
++}
++
++
++/* The page is the shared tool frame: it starts under the navbar, not behind it. */
++#app { padding-top: calc(var(--ovk-nav-h, 52px) + 8px); }
++.ovk-page { padding-top: 12px; }
@media (prefers-reduced-motion: reduce) { .splash-content, .splash-icon, #splash-screen.fade-out, .welcome-icon, .user-marker, #detail-panel, .modal-content, .sp-item:not(.done):not(.error) { animation: none; } .search-progress .spinner { animation: none; opacity: .7; } }
ddiff --git a/apps/maps/public/index.html b/apps/maps/public/index.html
iindex 427e1a2..60ee9ca 100644
---- a/apps/maps/public/index.html
++++ b/apps/maps/public/index.html
@@@ -35,6 +35,7 @@
</script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;600&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
++ <!-- Font Awesome 6 -->
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css">
@@@ -63,7 +64,7 @@
<div id="splash-screen">
<div class="splash-content">
<div class="splash-icon"><i class="fa-solid fa-person-shelter"></i></div>
-- <h1 class="splash-title">OPENVIBE<span class="green">MAPS</span></h1>
++ <p class="splash-title">OPENVIBE<span class="green">MAPS</span></p>
<p class="splash-subtitle"><i class="fa-solid fa-map-location-dot"></i> Survival Map for North America</p>
<div class="splash-loader">
<div class="loader-bar"><div class="loader-fill" id="loader-fill"></div></div>
@@@ -71,7 +72,7 @@
</div>
<div class="splash-sources">
<span><i class="fa-solid fa-database"></i> RIDB</span>
-- <span><i class="fa-solid fa-map"></i> OSM</span>
++ <span><i class="fa-brands fa-openstreetmap"></i> OSM</span>
<span><i class="fa-solid fa-campground"></i> FreeCampsites</span>
<span><i class="fa-solid fa-globe"></i> iOverlander</span>
<span><i class="fa-solid fa-bridge"></i> Bridges</span>
@@@ -90,7 +91,7 @@
<div id="disclaimer-overlay" class="hidden">
<div class="disclaimer-box">
<div class="disclaimer-icon"><i class="fa-solid fa-triangle-exclamation"></i></div>
-- <h1 class="disclaimer-title">IMPORTANT DISCLAIMER</h1>
++ <h2 class="disclaimer-title">IMPORTANT DISCLAIMER</h2>
<div class="disclaimer-body">
<p><strong>READ CAREFULLY BEFORE USING MAPS.OPENVIBE</strong></p>
<p>Maps.OpenVibe is provided <strong>strictly for informational and educational purposes only</strong>.</p>
@@@ -129,11 +130,27 @@
<span class="header-tagline"><i class="fa-solid fa-map-location-dot"></i> Survival Map</span>
</div>
-- <div class="search-container">
++
++ <div class="header-right">
++ <button id="btn-weather" class="header-btn" title="Weather"><i class="fa-solid fa-cloud-sun"></i></button>
++ <button id="btn-food" class="header-btn" title="Food Banks & Grocery"><i class="fa-solid fa-utensils"></i></button>
++ <button id="btn-favorites" class="header-btn" title="Favorites"><i class="fa-solid fa-heart"></i></button>
++ <button id="btn-add-spot" class="header-btn" title="Add Custom Spot"><i class="fa-solid fa-plus"></i></button>
++ <button id="btn-guide" class="header-btn" title="Survival Guide"><i class="fa-solid fa-book-skull"></i></button>
++ <button id="btn-stats" class="header-btn" title="Statistics"><i class="fa-solid fa-chart-pie"></i></button>
++ <button id="btn-theme" class="header-btn" title="Toggle theme"><i class="fa-solid fa-moon"></i></button>
++ </div>
++ </header>
++
++ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this
++ host — the head, the box, the answer slot, the family, the folds and "People also use". -->
++ <!--OVK:FRAME-->
++ <!--OVK:BOX-->
++ <div class="search-container">
<div class="search-box">
<i class="fa-solid fa-magnifying-glass search-icon"></i>
<input type="text" id="search-input" placeholder="Address, city, or coordinates..." autocomplete="off">
-- <button id="btn-my-location" title="Use GPS"><i class="fa-solid fa-location-dot"></i></button>
++ <button id="btn-my-location" title="Use GPS"><i class="fa-solid fa-crosshairs"></i></button>
<select id="search-radius" title="Search radius">
<option value="5">5 mi</option>
<option value="10">10 mi</option>
@@@ -144,18 +161,10 @@
<button id="btn-search" class="btn-primary"><i class="fa-solid fa-person-shelter"></i><span>Search</span></button>
</div>
</div>
++ <!--/OVK:BOX-->
-- <div class="header-right">
-- <button id="btn-weather" class="header-btn" title="Weather"><i class="fa-solid fa-cloud-sun"></i></button>
-- <button id="btn-food" class="header-btn" title="Food Banks & Grocery"><i class="fa-solid fa-utensils"></i></button>
-- <button id="btn-favorites" class="header-btn" title="Favorites"><i class="fa-solid fa-heart"></i></button>
-- <button id="btn-add-spot" class="header-btn" title="Add Custom Spot"><i class="fa-solid fa-plus"></i></button>
-- <button id="btn-guide" class="header-btn" title="Survival Guide"><i class="fa-solid fa-book"></i></button>
-- <button id="btn-stats" class="header-btn" title="Statistics"><i class="fa-solid fa-chart-column"></i></button>
-- <button id="btn-theme" class="header-btn" title="Toggle theme"><i class="fa-solid fa-moon"></i></button>
-- </div>
-- </header>
++ <!--OVK:ANSWER-->
<!-- Filter Bar -->
<div id="filter-bar">
<div class="filter-section">
@@@ -185,7 +194,7 @@
</select>
<button id="btn-heatmap" class="filter-tool-btn" title="Stealth heatmap"><i class="fa-solid fa-fire"></i></button>
<button id="btn-crime-heatmap" class="filter-tool-btn" title="Crime/sketch heatmap"><i class="fa-solid fa-skull-crossbones"></i></button>
-- <button id="btn-satellite" class="filter-tool-btn" title="Satellite view"><i class="fa-solid fa-satellite-dish"></i></button>
++ <button id="btn-satellite" class="filter-tool-btn" title="Satellite view"><i class="fa-solid fa-satellite"></i></button>
</div>
</div>
@@@ -212,7 +221,7 @@
<div class="mini-stat"><i class="fa-solid fa-database"></i> 17+ Sources</div>
<div class="mini-stat"><i class="fa-solid fa-map-pin"></i> 100+ Spots</div>
<div class="mini-stat"><i class="fa-solid fa-cloud-sun"></i> Live Weather</div>
-- <div class="mini-stat"><i class="fa-solid fa-earth-americas"></i> North America</div>
++ <div class="mini-stat"><i class="fa-solid fa-globe-americas"></i> North America</div>
</div>
</div>
<div id="results-list"></div>
@@@ -222,14 +231,15 @@
<div id="map-container">
<div id="map"></div>
<div class="map-ctrl-group">
-- <button id="btn-map-locate" class="map-ctrl-btn" title="My location"><i class="fa-solid fa-map-location-dot"></i></button>
++ <button id="btn-map-locate" class="map-ctrl-btn" title="My location"><i class="fa-solid fa-street-view"></i></button>
<button id="btn-map-fullscreen" class="map-ctrl-btn" title="Fullscreen"><i class="fa-solid fa-expand"></i></button>
</div>
<button id="btn-search-area" class="search-area-btn hidden">
-- <i class="fa-solid fa-magnifying-glass"></i> Search This Area
++ <i class="fa-solid fa-magnifying-glass-location"></i> Search This Area
</button>
</div>
</main>
++ <!--/OVK:ANSWER-->
<!-- Footer -->
<footer id="footer">
@@@ -276,7 +286,7 @@
<button class="food-tab active" data-tab="food-banks"><i class="fa-solid fa-hand-holding-heart"></i> Food Banks</button>
<button class="food-tab" data-tab="stores"><i class="fa-solid fa-cart-shopping"></i> Stores</button>
<button class="food-tab" data-tab="meal-plan"><i class="fa-solid fa-calendar-days"></i> Meal Plan</button>
-- <button class="food-tab" data-tab="food-db"><i class="fa-solid fa-utensils"></i> Food Database</button>
++ <button class="food-tab" data-tab="food-db"><i class="fa-solid fa-apple-whole"></i> Food Database</button>
</div>
<div id="food-banks" class="food-panel active"></div>
<div id="stores" class="food-panel hidden"></div>
@@@ -351,15 +361,15 @@
<div id="guide-modal" class="modal hidden">
<div class="modal-content modal-lg">
<div class="modal-header">
-- <h2><i class="fa-solid fa-book"></i> Survival Guide</h2>
++ <h2><i class="fa-solid fa-book-skull"></i> Survival Guide</h2>
<button class="modal-close"><i class="fa-solid fa-xmark"></i></button>
</div>
<div class="modal-body">
<div class="guide-tabs">
<button class="guide-tab active" data-guide="stealth"><i class="fa-solid fa-eye-slash"></i> Stealth</button>
<button class="guide-tab" data-guide="rules"><i class="fa-solid fa-gavel"></i> Rules</button>
-- <button class="guide-tab" data-guide="gear"><i class="fa-solid fa-briefcase"></i> Gear</button>
-- <button class="guide-tab" data-guide="safety"><i class="fa-solid fa-shield"></i> Safety</button>
++ <button class="guide-tab" data-guide="gear"><i class="fa-solid fa-backpack"></i> Gear</button>
++ <button class="guide-tab" data-guide="safety"><i class="fa-solid fa-shield-heart"></i> Safety</button>
<button class="guide-tab" data-guide="fire"><i class="fa-solid fa-fire"></i> Fire</button>
<button class="guide-tab" data-guide="firstaid"><i class="fa-solid fa-kit-medical"></i> First Aid</button>
</div>
@@@ -372,7 +382,7 @@
<div id="stats-modal" class="modal hidden">
<div class="modal-content modal-md">
<div class="modal-header">
-- <h2><i class="fa-solid fa-chart-column"></i> Statistics</h2>
++ <h2><i class="fa-solid fa-chart-pie"></i> Statistics</h2>
<button class="modal-close"><i class="fa-solid fa-xmark"></i></button>
</div>
<div class="modal-body">
@@@ -395,6 +405,10 @@
<script src="/shared/footer.js"></script>
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.js"></script>
++<script src="/shared/kit.js" defer></script>
++<script src="/shared/kit-web.js" defer></script>
++<script src="/shared/kit-data-web.js" defer></script>
++<script src="/js/tool-frame.js" defer></script>
<script src="/js/app.js"></script>
<script>
(function () {
@@@ -406,12 +420,15 @@
let user = null;
if (token) { try { user = JSON.parse(atob(token.split('.')[1])); } catch {} }
if (typeof OpenVibeNavbar !== 'undefined') {
-- OpenVibeNavbar.init({ service: 'maps', token, user, apiBase: 'https://openvibe.network', history: { type: 'tool', title: 'Maps.OpenVibe' }, silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}' });
++ // The brand is the site ("Tools"), not the subdomain's name: the same brand every framed tool
++ // page carries (owner ask 11).
++ OpenVibeNavbar.init({ service: 'maps', token, user, apiBase: 'https://openvibe.network', history: { type: 'tool', title: 'Maps.OpenVibe' }, silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}', brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' } });
}
if (typeof OpenVibeFooter !== 'undefined') {
try {
OpenVibeFooter.init({
service: 'maps', variant: 'compact', mount: '#ov-footer', apiBase: 'https://openvibe.network',
++ brandName: 'OpenVibe.Tools',
links: [{ heading: 'Maps.OpenVibe', items: [
{ name: 'Survival map', url: 'https://maps.openvibe.tools' },
{ name: 'OpenVibe.Food', url: 'https://openvibe.food' },
ddiff --git a/apps/maps/server/frame.js b/apps/maps/server/frame.js
nnew file mode 100644
iindex 0000000..d5b1beb
---- /dev/null
++++ b/apps/maps/server/frame.js
@@@ -0,0 +1,184 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// The tool frame for the survival map (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
++//
++// The map is one tool on one host, and it is a map first: a sticky bar, a filter bar, a sidebar of
++// results and the map itself. The frame goes around it without changing what it does — the head
++// (the crumb, the name, one sentence, the terms), the box (the place search), the answer (the map
++// and the results), the family, the folds and "People also use".
++//
++// The map's own parts are marked in public/index.html and moved into the frame's box and answer
++// slot here, so the page keeps its own script, its own ids and its own behaviour.
++//
++// The terms come from the app's real configuration and from the guard's quota table: the lookups
++// happen on our server, and the rate is the one the guard holds for the tools-map class.
++// ═══════════════════════════════════════════════════════════════
++
++const fs = require('fs');
++const path = require('path');
++const kit = require('openvibe-shared/kit');
++const serve = require('openvibe-shared/serve');
++const frame = require('../../_shared/frame')({ kit, serve });
++const { restampHead, canonicalHostFor } = require('../../_shared/host-role');
++const { SPECS } = require('./descriptors');
++
++const esc = kit.esc;
++const HOST = 'maps.openvibe.tools';
++const SITE = 'https://openvibe.tools';
++const FILE = path.join(__dirname, '..', 'public', 'index.html');
++const SPEC = SPECS.find((s) => s.id === 'maps') || {};
++
++// The hubs this tool sits next to (the gateway's own families).
++const HUBS = [
++ { href: `${SITE}/maps-and-food`, label: 'Maps & Food', short: 'Maps' },
++ { href: 'https://food.openvibe.tools', label: 'Food finder', short: 'Food' },
++ { href: `${SITE}/image-tools`, label: 'Image Tools', short: 'Image' },
++ { href: `${SITE}/pdf-tools`, label: 'PDF & Document Tools', short: 'PDF' },
++ { href: `${SITE}/`, label: 'All tools', short: 'All' },
++];
++
++/**
++ * The terms of the survival map, from the app's real configuration and the guard's quota table:
++ * the lookups happen on our server, and the rate is the one the guard holds for this class.
++ */
++function termsOf() {
++ return frame.terms({
++ work: 'server',
++ serverNote: 'Runs on our server: your search is looked up there',
++ quotaClass: SPEC.quotaClass || 'tools-map',
++ cost: SPEC.cost || 2,
++ unit: 'lookups',
++ });
++}
++
++/** The family: the hubs this tool sits next to. The page's own hub is the one marked. */
++function familyItems() {
++ // The family is the hubs this tool sits in: the maps and food hub holds it.
++ return HUBS.map((h, i) => ({ ...h, current: i === 0 }));
++}
++
++/** "People also use": the other hubs, as rows. */
++function relatedItems() {
++ return HUBS.filter((h) => h.href !== 'https://maps.openvibe.tools').slice(0, 3)
++ .map((h) => kit.row({ href: h.href, title: h.label, sub: '' }));
++}
++
++/**
++ * The whole frame for the map's page.
++ * @param {string} box the page's own search box
++ * @param {string} answer the page's own filter bar and map
++ * @returns {string} HTML
++ */
++function frameFor(box, answer) {
++ const title = 'Maps.OpenVibe';
++ const task = 'Find free camping, dispersed campsites, shelters and other resources across North America.';
++ const faq = [
++ ['Is it free?', 'Yes. Thirty lookups a minute without an account.'],
++ ['What does the map look up?', 'Camping, shelters, water, food banks and the weather, from public sources: OpenStreetMap, RIDB, the US Forest Service, the National Park Service and more.'],
++ ['Is my search sent anywhere?', 'Only to our server, which does the lookups. Nothing is kept about you afterwards.'],
++ ['Is the map safe to rely on?', 'It is informational. Places may be closed, changed or wrong, and being in the wild carries real risk.'],
++ ];
++ return `<div class="ovk-page ovk-page--wide" data-ov-tool-page="maps">${kit.tool({
++ head: frame.head({
++ icon: 'map', hue: 150, family: 'places',
++ crumb: { href: SITE, label: 'Tools' },
++ title, task, facts: termsOf(),
++ }),
++ input: `<div class="ovk-maps-box">${box}</div>`,
++ answer: `<div class="ovk-maps-answer">${answer}</div>`,
++ family: kit.family({
++ title: 'More OpenVibe tools', more: { href: SITE, label: 'All tools' },
++ items: familyItems(), label: 'More tools',
++ }),
++ folds: frame.folds({
++ name: title,
++ about: 'The survival map puts free camping, dispersed campsites, shelters, bridges, rain cover, water, restrooms and food banks on one map of North America, with the weather where you are.',
++ faq,
++ api: null,
++ }),
++ related: kit.list(relatedItems(), { title: 'People also use' }),
++ })}</div>`;
++}
++
++/** The page's own part between two markers. */
++function pick(html, re) {
++ const m = re.exec(html);
++ return m ? m[1] : '';
++}
++
++let _parts = null;
++
++/** The page's own parts, read once: the box and the answer. */
++function parts() {
++ if (_parts) return _parts;
++ const html = fs.readFileSync(FILE, 'utf8');
++ _parts = {
++ box: pick(html, /<!--OVK:BOX-->([\s\S]*?)<!--\/OVK:BOX-->/),
++ answer: pick(html, /<!--OVK:ANSWER-->([\s\S]*?)<!--\/OVK:ANSWER-->/),
++ page: html
++ .replace(/<!--OVK:BOX-->[\s\S]*?<!--\/OVK:BOX-->/, '')
++ .replace(/<!--OVK:ANSWER-->[\s\S]*?<!--\/OVK:ANSWER-->/, ''),
++ };
++ return _parts;
++}
++
++let _cache = null;
++
++/**
++ * The map's page with the frame around it, stamped for this request's canonical host.
++ * @param {string} canonicalHost the host the page is about
++ * @returns {string} HTML
++ */
++function renderIndex(canonicalHost) {
++ if (_cache === null) {
++ const { box, answer, page: stripped } = parts();
++ _cache = stripped
++ .replace('<!--OVK:FRAME-->', frameFor(box, answer))
++ .replace(/<\/head>/i, `${[
++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
++ ].join('\n')}\n</head>`)
++ .replace(/<\/body>/i, `${[
++ '<script src="/shared/kit.js" defer></script>',
++ '<script src="/shared/kit-web.js" defer></script>',
++ '<script src="/shared/kit-data-web.js" defer></script>',
++ '<script src="/js/tool-frame.js"></script>',
++ ].join('\n')}\n</body>`);
++ }
++ return restampHead(_cache, HOST, canonicalHost);
++}
++
++/**
++ * The page with the frame, built fresh (no cache): the tests change the configuration and read the
++ * page again.
++ * @param {string} canonicalHost the host the page is about
++ * @returns {string} HTML
++ */
++function renderFrame(canonicalHost) {
++ const { box, answer, page } = parts();
++ const out = page
++ .replace('<!--OVK:FRAME-->', frameFor(box, answer))
++ .replace(/<\/head>/i, `${[
++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
++ ].join('\n')}\n</head>`)
++ .replace(/<\/body>/i, `${[
++ '<script src="/shared/kit.js" defer></script>',
++ '<script src="/shared/kit-web.js" defer></script>',
++ '<script src="/shared/kit-data-web.js" defer></script>',
++ '<script src="/js/tool-frame.js"></script>',
++ ].join('\n')}\n</body>`);
++ return canonicalHost ? restampHead(out, HOST, canonicalHost) : out;
++}
++
++/** Express handler: the page with the frame, stamped for this request's host. */
++function sendIndex(req, res) {
++ const canonical = canonicalHostFor(req, HOST);
++ res.setHeader('Content-Type', 'text/html; charset=utf-8');
++ res.setHeader('Cache-Control', 'no-cache');
++ res.setHeader('Vary', 'X-OV-Canonical-Host');
++ return res.send(renderIndex(canonical));
++}
++
++module.exports = { frameFor, termsOf, familyItems, relatedItems, renderIndex, renderFrame, sendIndex, HUBS, HOST };
ddiff --git a/apps/maps/server/index.js b/apps/maps/server/index.js
iindex 103d3dc..9544135 100644
---- a/apps/maps/server/index.js
++++ b/apps/maps/server/index.js
@@@ -20,7 +20,7 @@ const http = require('./egress');
// ── Analytics ────────────────────────────────────────────────
const { AnalyticsTrackerPg } = require('openvibe-shared/analytics/pg'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
const { requireInternalAccess } = require('../../_shared/internal-token');
--const { hostGuard, stampedPage } = require('../../_shared/host-role');
++const { hostGuard } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
@@@ -119,9 +119,17 @@ app.use(analytics.middleware());
// page's canonical / og:url / JSON-LD follow it; aliases are redirected; other hosts go to the tools index.
const HOST = 'maps.openvibe.tools';
app.use(hostGuard({ knows: (h) => h === HOST }));
--const sendIndex = stampedPage(path.join(__dirname, '..', 'public', 'index.html'), HOST);
++// The page with the shared tool frame around it (openvibe-shared >= 4.57.1): the head, the box, the
++// answer slot, the family, the folds and "People also use".
++const { sendIndex } = require('./frame');
app.get(['/', '/index.html'], sendIndex);
++// The tool frame's browser glue (apps/_shared/public/tool-frame.js).
++app.get('/js/tool-frame.js', (_req, res) => {
++ res.set('Cache-Control', 'no-cache');
++ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));
++});
++
// ── Static files ───────────────────────────────────────────
app.use(express.static(path.join(__dirname, '..', 'public'), {
maxAge: '1h',
ddiff --git a/apps/maps/test/frame.test.js b/apps/maps/test/frame.test.js
nnew file mode 100644
iindex 0000000..03fbf99
---- /dev/null
++++ b/apps/maps/test/frame.test.js
@@@ -0,0 +1,95 @@
++'use strict';
++// The tool frame on the survival map's page (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):
++// • the page is a tool page in the same frame: the head, the box, the answer slot, the family, the
++// folds and "People also use", in that order;
++// • the terms come from the app's real configuration — the lookups happen on our server and the rate
++// is the guard's quota table — and they follow it when it changes;
++// • the one button says the verb, never "Run".
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const os = require('os');
++const path = require('path');
++const { renderFrame, termsOf } = require('../server/frame');
++const { SPECS } = require('../server/descriptors');
++const { startApp } = require('../../_shared/test/spawn');
++
++/** The frame's order: each marker appears once, and in this order. */
++function inOrder(html, parts) {
++ let at = -1;
++ for (const p of parts) {
++ const i = html.indexOf(p, at + 1);
++ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);
++ at = i;
++ }
++}
++
++/** The head's terms, as text. */
++function factsOf(html) {
++ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);
++ if (!m) return [];
++ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);
++}
++
++// ── The page is a framed tool page, in the same order ──
++{
++ const html = renderFrame('maps.openvibe.tools');
++ inOrder(html, [
++ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',
++ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
++ ]);
++ assert.ok(html.includes('href="https://openvibe.tools">Tools'), 'the crumb is the tools home');
++ assert.ok(html.includes('id="search-input"'), 'the box has the place search');
++ assert.ok(html.includes('id="map-container"'), 'the answer holds the map');
++ assert.ok(html.includes('aria-current="page"'), 'the family marks this tool');
++ assert.ok(html.includes('id="btn-search"') && /<span>Search<\/span>/.test(html), 'the one button says the verb');
++ assert.strictEqual((html.match(/<h1/g) || []).length, 1, 'one h1');
++}
++
++// ── The terms come from the app's own configuration ──
++{
++ const facts = factsOf(renderFrame('maps.openvibe.tools'));
++ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server: your search is looked up there'], 'free, and the lookups happen on our server');
++ assert.ok(facts.includes('30 lookups a minute without an account'), 'the rate from the guard\'s table and the tool\'s own cost');
++ assert.ok(facts.includes('No sign-up'), 'no account needed');
++
++ // Change the configuration: the line follows it.
++ const savedEnv = process.env.TOOLS_GUARD_LIMITS;
++ try {
++ process.env.TOOLS_GUARD_LIMITS = JSON.stringify({ 'tools-map': { anonymous: { perMinute: 20, burst: 10, perDay: 0 } } });
++ assert.ok(factsOf(renderFrame('maps.openvibe.tools')).includes('10 lookups a minute without an account'), 'the rate follows the guard\'s table');
++ } finally {
++ if (savedEnv === undefined) delete process.env.TOOLS_GUARD_LIMITS;
++ else process.env.TOOLS_GUARD_LIMITS = savedEnv;
++ }
++ assert.ok(SPECS.find((s) => s.id === 'maps'), 'the map has a descriptor');
++}
++
++// ── The page the server sends: the frame, the title, the canonical and the structured data ──
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-maps-frame-'));
++ const app = await startApp('maps', { DATA_DIR: tmp });
++ const get = (host) => new Promise((resolve) => {
++ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {
++ let body = '';
++ res.setEncoding('utf8');
++ res.on('data', (d) => { body += d; });
++ res.on('end', () => resolve({ status: res.statusCode, body }));
++ });
++ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });
++ req.on('error', () => resolve({ status: 0, body: '' }));
++ });
++ try {
++ const r = await get('maps.openvibe.tools');
++ assert.strictEqual(r.status, 200, 'answers 200');
++ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), 'the frame\'s title');
++ assert.ok(r.body.includes('<link rel="canonical"'), 'a canonical');
++ assert.ok(r.body.includes('/shared/kit.css?'), 'the kit\'s look');
++ assert.ok(r.body.includes('/js/tool-frame.js'), 'the frame\'s behaviour');
++ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, 'one h1');
++ } finally {
++ await app.kill();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ console.log('tool frame (maps): the survival map framed, the terms from the config, the button says the verb: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/text/public/css/shared.css b/apps/text/public/css/shared.css
iindex 9334ad4..6cf8e57 100644
---- a/apps/text/public/css/shared.css
++++ b/apps/text/public/css/shared.css
@@@ -896,4 +896,17 @@ textarea.large {
.interconnect-banner .ic-links { margin-left: 0; }
}
++/* ── The shared tool frame (openvibe-shared >= 4.57.1) ── */
++/* The page's own section is the box now: it does not draw a card inside the frame's card. */
++.ovk-tool-card .tool-section { background: none; border: 0; padding: 0; margin: 0 0 12px; backdrop-filter: none; }
++.ovk-tool-card .tool-section:last-child { margin-bottom: 0; }
++.ovk-tool-out--live > #output, .ovk-tool-out--live > #result, .ovk-tool-out--live > #results { margin: 0; }
++/* The brand is the site ("Tools"), not the subdomain's name. */
++.openvibe-navbar .openvibe-navbar-brand .b-core,
++.openvibe-navbar .openvibe-navbar-brand .b-dot,
++.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }
++
++
++/* The page is the shared tool frame: its head starts under the navbar, not behind it. */
++.ovk-tool-head { margin-top: calc(var(--ovk-nav-h, 52px) + 12px); }
@media (prefers-reduced-motion: reduce) { .live-dot, .output-flash, .toast { animation: none; } }
ddiff --git a/apps/text/public/js/shared.js b/apps/text/public/js/shared.js
iindex 1dd5bcc..b3981ed 100644
---- a/apps/text/public/js/shared.js
++++ b/apps/text/public/js/shared.js
@@@ -185,6 +185,9 @@ function initNavbar() {
token,
history: { type: 'tool', title: pageTitle() },
silentLogin: SILENT_LOGIN,
++ // The brand is the site ("Tools"), not the tool's own name: the same brand every framed
++ // tool page carries (owner ask 11).
++ brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' },
});
} catch (e) { /* the page works without the navbar */ }
if (typeof OpenVibeAccountSwitcher !== 'undefined') {
@@@ -219,7 +222,7 @@ function initFooter(opts = {}) {
links.push({ heading: 'Logo tools', items: LOGO_TOOLS });
}
try {
-- OpenVibeFooter.init({ service, variant: opts.variant || 'compact', links, apiBase: NETWORK, mount: '#ov-footer' });
++ OpenVibeFooter.init({ service, variant: opts.variant || 'compact', links, apiBase: NETWORK, mount: '#ov-footer', brandName: 'OpenVibe.Tools' });
} catch (e) { /* optional */ }
}
ddiff --git a/apps/text/server/frame.js b/apps/text/server/frame.js
nnew file mode 100644
iindex 0000000..074bcf0
---- /dev/null
++++ b/apps/text/server/frame.js
@@@ -0,0 +1,242 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// The tool frame for the text and logo pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
++//
++// The text app's pages are their own HTML files, one per tool (several hosts share one file). The page
++// keeps its own editor and its own live output; the frame goes around them: the head (the family crumb,
++// the tool's name, one sentence, the terms), the box (the page's own editor), the answer (the page's
++// live output, moved into the frame's answer slot by the shared browser glue), the family, the folds
++// and "People also use".
++//
++// The head's copy is the page's own: its title is the tool's name and its description is the one
++// sentence. The terms come from the app's real configuration: these tools run in the browser, so
++// nothing is sent, and the rate is the guard's own for the class they are in.
++// ═══════════════════════════════════════════════════════════════
++
++const fs = require('fs');
++const path = require('path');
++const kit = require('openvibe-shared/kit');
++const serve = require('openvibe-shared/serve');
++const frame = require('../../_shared/frame')({ kit, serve });
++const { SPECS } = require('./descriptors');
++
++const HUB = 'https://text.openvibe.tools/';
++const esc = kit.esc;
++
++// The page each tool lives in, and the family row's short names.
++const PAGE_OF = {
++ fancy: 'fancy.html', zalgo: 'zalgo.html', bubble: 'bubble.html', ascii: 'ascii.html', symbols: 'symbols.html',
++ kaomoji: 'kaomoji.html', unicode: 'unicode.html', braille: 'braille.html', morse: 'morse.html', binary: 'binary.html',
++ case: 'case.html', count: 'count.html', reversetext: 'reverse.html', clean: 'clean.html', sort: 'sort.html',
++ compare: 'compare.html', json: 'json.html', markdown: 'markdown.html', escape: 'escape.html', slug: 'slug.html',
++ bio: 'bio.html', nickname: 'nickname.html', logo: 'logo-hub.html', wordmark: 'wordmark.html', title: 'title.html',
++ thumbnail: 'thumbnail.html', badge: 'badge.html', watermark: 'watermark.html', transparent: 'transparent.html',
++ peso: 'peso.html', spanish: 'spanish.html', slang: 'slang.html', ice: 'ice.html',
++};
++
++const SHORT = {
++ fancy: 'Fancy', zalgo: 'Zalgo', bubble: 'Bubble', ascii: 'ASCII', symbols: 'Symbols', kaomoji: 'Kaomoji',
++ unicode: 'Unicode', braille: 'Braille', morse: 'Morse', binary: 'Binary', case: 'Case', count: 'Count',
++ reversetext: 'Reverse', clean: 'Clean', sort: 'Sort', compare: 'Compare', json: 'JSON', markdown: 'Markdown',
++ escape: 'Escape', slug: 'Slug', bio: 'Bio', nickname: 'Nickname', logo: 'Logo', wordmark: 'Wordmark',
++ title: 'Title', thumbnail: 'Thumbnail', badge: 'Badge', watermark: 'Watermark', transparent: 'Transparent',
++ peso: 'Peso', spanish: 'Spanish', slang: 'Slang', ice: 'ICE',
++};
++
++const ICON = {
++ fancy: 'spark', zalgo: 'wave', bubble: 'spark', ascii: 'grid', symbols: 'list', kaomoji: 'star',
++ unicode: 'code', braille: 'grid', morse: 'zap', binary: 'code', case: 'code', count: 'chart',
++ reversetext: 'swap', clean: 'spark', sort: 'list', compare: 'swap', json: 'code', markdown: 'book',
++ escape: 'lock', slug: 'link', bio: 'user', nickname: 'tag', logo: 'palette', wordmark: 'palette',
++ title: 'pencil', thumbnail: 'image', badge: 'star', watermark: 'image', transparent: 'image',
++ peso: 'coin', spanish: 'book', slang: 'chat', ice: 'shield',
++};
++
++const SPEC = new Map(SPECS.map((s) => [s.id, s]));
++
++// The verb of each tool: the one button says what it does, never "Run".
++const VERB = {'fancy': 'Make fancy text', 'zalgo': 'Add Zalgo', 'bubble': 'Make bubble text', 'ascii': 'Make ASCII art', 'symbols': 'Make symbols', 'kaomoji': 'Make kaomoji', 'unicode': 'Make Unicode', 'braille': 'Make braille', 'morse': 'Make Morse code', 'binary': 'Make binary', 'case': 'Change the case', 'count': 'Count the words', 'reversetext': 'Reverse the text', 'clean': 'Clean the text', 'sort': 'Sort the lines', 'compare': 'Compare the text', 'json': 'Format the JSON', 'markdown': 'Make Markdown', 'escape': 'Escape the text', 'slug': 'Make a slug', 'bio': 'Make a bio', 'nickname': 'Make a nickname', 'logo': 'Make a logo', 'wordmark': 'Make a wordmark', 'title': 'Make a title', 'thumbnail': 'Make a thumbnail', 'badge': 'Make a badge', 'watermark': 'Add a watermark', 'transparent': 'Make it transparent', 'peso': 'Make peso art', 'spanish': 'Add Spanish accents', 'slang': 'Make slang', 'ice': 'Make an ICE card'};
++
++// The plain text of a page's element, without its tags.
++const textOf = (html) => String(html || '')
++ .replace(/<[^>]+>/g, ' ')
++ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
++ .replace(/\s+/g, ' ').trim();
++
++/** The first group of a match, as plain text. */
++const pick = (html, re) => {
++ const m = re.exec(html);
++ return m ? textOf(m[1]) : '';
++};
++
++/**
++ * The terms of a text tool, from the app's real configuration: these tools run in the browser, so
++ * nothing is sent; the size of the text is the tool's own limit; and the rate is the guard's own for
++ * the class the tool is in (apps/_shared/guard/limits.js).
++ */
++function termsOf(toolId) {
++ const spec = SPEC.get(toolId);
++ const facts = frame.terms({
++ work: 'browser',
++ quotaClass: (spec && spec.quotaClass) || 'tools-run',
++ cost: (spec && spec.cost) || 1,
++ unit: 'runs',
++ needsAccount: !!(spec && spec.auth && spec.auth.anonymous === false),
++ });
++ // A tool with no server engine is not rate limited at all: it runs on the device. A tool with an
++ // engine is limited by the guard's own table, and says so that the rate is not read as the page's.
++ const at = facts.findIndex((f) => f.text.includes('a minute'));
++ if (at >= 0) {
++ facts[at] = spec && spec.api
++ ? { ...facts[at], text: `API: ${facts[at].text}` }
++ : { text: 'No limit: it runs on your device', icon: 'zap' };
++ }
++ // The size of the text the tool takes, from the tool's own limit.
++ const maxBytes = spec && spec.limits && spec.limits.maxInputBytes;
++ if (maxBytes) facts.push({ text: `Up to ${Math.round(maxBytes / 1024)} KB of text`, icon: 'file' });
++ return facts;
++}
++
++/** The family: every text tool, with the one on the page marked. */
++function familyItems(current, hostnameMap, nameOf) {
++ const items = [];
++ const seen = new Set();
++ for (const [host, file] of Object.entries(hostnameMap)) {
++ const toolId = TOOL_OF_FILE[file];
++ if (!toolId || seen.has(toolId)) continue;
++ seen.add(toolId);
++ items.push({ href: `https://${host}/`, label: nameOf(toolId), short: SHORT[toolId] || nameOf(toolId), current: toolId === current });
++ }
++ return items;
++}
++
++/** The tool a page file holds. */
++const TOOL_OF_FILE = Object.fromEntries(Object.entries(PAGE_OF).map(([id, file]) => [file, id]));
++
++const PUBLIC = path.join(__dirname, '..', 'public');
++let _names = null;
++
++/** Every tool's name and its one sentence, read from the page's own head. */
++function namesOf() {
++ if (_names) return _names;
++ _names = new Map();
++ for (const [id, file] of Object.entries(PAGE_OF)) {
++ let html = '';
++ try { html = fs.readFileSync(path.join(PUBLIC, file), 'utf8'); } catch { continue; }
++ _names.set(id, {
++ name: pick(html, /<h1[^>]*>([\s\S]*?)<\/h1>/) || id,
++ desc: pick(html, /<meta\s+name=["']description["']\s+content=["']([^"']*)["']/i),
++ });
++ }
++ return _names;
++}
++
++/** The "People also use" of a page: its own related links (the footer's data-related). */
++function relatedOf(html) {
++ const m = /data-related="([^"]*)"/.exec(html);
++ if (!m) return [];
++ return m[1].split(';').map((pair) => {
++ const [title, href] = pair.split('|');
++ if (!title || !href) return null;
++ return kit.row({ href: href.trim(), title: title.trim(), sub: '' });
++ }).filter(Boolean).slice(0, 4);
++}
++
++/**
++ * The frame around a text page: the head, the box, the answer slot, the family, the folds and
++ * "People also use". The page's own editor and buttons stay inside the box; its live output moves
++ * into the answer slot (apps/_shared/public/tool-frame.js).
++ *
++ * @param {string} html the page's own HTML
++ * @param {string} host the host that serves it
++ * @param {object} [ctx] { hostnameMap, nameOf }
++ * @returns {string} the page with the frame around it
++ */
++function frameAround(html, host, ctx = {}) {
++ const file = ctx.file;
++ const toolId = TOOL_OF_FILE[file];
++ if (!toolId) return html;
++
++ const own = namesOf().get(toolId) || { name: toolId, desc: '' };
++ const name = (ctx.nameOf && ctx.nameOf(toolId)) || own.name;
++ const task = (ctx.descOf && ctx.descOf(toolId)) || pick(html, /<p class="subtitle"[^>]*>([\s\S]*?)<\/p>/) || own.desc;
++ const about = own.desc;
++
++ const head = frame.head({
++ icon: ICON[toolId] || 'code', hue: 140, family: 'text',
++ crumb: { href: HUB, label: 'Text tools' },
++ title: name, task, facts: termsOf(toolId),
++ });
++
++ const items = familyItems(toolId, ctx.hostnameMap || {}, ctx.nameOf || ((id) => (namesOf().get(id) || {}).name || id));
++ const family = kit.family({
++ title: 'More text tools', more: { href: HUB, label: 'All text tools' },
++ items,
++ });
++ const folds = frame.folds({
++ name, about,
++ faq: [
++ ['Is it free?', 'Yes. Every text tool here is free, and it runs in your browser.'],
++ ['Does my text go anywhere?', 'No. The work happens in your browser; nothing is sent to a server.'],
++ ['Can I call it from code?', 'Some of these tools have an API: POST /api/v1/tools/<tool>/run. The <a href="https://openvibe.tools/developers">API docs</a> has the list.'],
++ ],
++ api: {
++ id: toolId, path: `/api/v1/tools/${toolId}/run`, fields: 'text',
++ payload: { input: { text: 'Hello, World!' } }, needsAccount: false,
++ },
++ });
++ // "People also use": the page's own related links, or the tools next to this one in the family.
++ let related = relatedOf(html);
++ if (!related.length) {
++ related = items.filter((it) => !it.current).slice(0, 3)
++ .map((it) => kit.row({ href: it.href, title: it.label, sub: '' }));
++ }
++
++ // The page's own main button says the verb, the way the other tools' buttons do.
++ const verb = VERB[toolId] || 'Make it';
++ const withVerb = html.replace(/(<button[^>]*class="[^"]*generate-btn[^"]*"[^>]*>)([\s\S]*?)(<\/button>)/i,
++ // The button's own text is the last text in it: the icon, then the word.
++ (m, open, mid, close) => `${open}${mid.replace(/>([^<>]*)$/, (_, text) => `>${esc(verb)}`)}${close}`);
++
++ // The head replaces the page's own header (the page keeps one h1: the frame's), the box holds the
++ // page's own editor and buttons, and the answer slot waits for the page's live output.
++ let out = withVerb;
++ // The page's own header and crumb go: the frame's head takes their place, and the page keeps one h1.
++ out = out.replace(/<nav class="crumbs"[^>]*>[\s\S]*?<\/nav>\s*/i, '');
++ out = out.replace(/<header class="tool-head"[^>]*>[\s\S]*?<\/header>\s*/i, '');
++ out = out.replace(/<div class="page-header"[^>]*>[\s\S]*?<\/div>\s*/i, '');
++ out = out.replace(/<div class="hub-hero"[^>]*>[\s\S]*?<\/div>\s*/i, '');
++ // A text tool's example: a word to try it on. The page's own field takes it (the kit's try
++ // fills the field and the page answers).
++ const tryRow = /id="input"/.test(html)
++ ? kit.tries({ name: 'text', target: '#input', items: [{ label: 'OpenVibe', value: 'OpenVibe' }] })
++ : '';
++ const open = `${head}\n<div class="ovk-tool-card">\n${tryRow}`;
++ if (/<div class="tool-wrap"[^>]*>/i.test(out)) {
++ out = out.replace(/<div class="tool-wrap"[^>]*>/i, (m) => `${m}\n${open}`);
++ } else {
++ out = out.replace(/<body[^>]*>/i, (m) => `${m}\n${open}`);
++ }
++
++ const tail = `</div>\n<div class="ovk-tool-out"></div>\n${family}\n${kit.tool ? `<div class="ovk-tool-folds">${folds.join('')}</div>` : ''}\n`
++ + (related.length ? `<div class="ovk-tool-related">${kit.list(related, { title: 'People also use' })}</div>` : '')
++ + `\n`;
++ out = out.replace(/<div id="ov-footer"/i, `${tail}<div id="ov-footer"`);
++
++ // The kit's look and behaviour, from this app's own pin.
++ out = out.replace(/<\/head>/i, `${[
++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
++ ].join('\n')}\n</head>`);
++ out = out.replace(/<\/body>/i, `${[
++ '<script src="/shared/kit.js" defer></script>',
++ '<script src="/shared/kit-web.js" defer></script>',
++ '<script src="/shared/kit-data-web.js" defer></script>',
++ '<script src="/js/tool-frame.js" defer></script>',
++ ].join('\n')}\n</body>`);
++
++ return out;
++}
++
++module.exports = { frameAround, termsOf, familyItems, relatedOf, namesOf, PAGE_OF, TOOL_OF_FILE, SHORT, VERB, HUB };
ddiff --git a/apps/text/server/index.js b/apps/text/server/index.js
iindex d767823..d356f75 100644
---- a/apps/text/server/index.js
++++ b/apps/text/server/index.js
@@@ -144,6 +144,12 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
},
}));
++// The tool frame's browser glue (apps/_shared/public/tool-frame.js): one copy for every app.
++app.get('/js/tool-frame.js', (_req, res) => {
++ res.set('Cache-Control', 'no-cache');
++ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));
++});
++
// ── Hostname-based routing ───────────────────────────────────
// Pages are stamped with a canonical + structured data on the way out (server/seo.js). Several
// hosts deliberately share one file (smallcaps/fancy, glitch/zalgo); without a canonical those
ddiff --git a/apps/text/server/seo.js b/apps/text/server/seo.js
iindex bd3d1b2..bbdd1b2 100644
---- a/apps/text/server/seo.js
++++ b/apps/text/server/seo.js
@@@ -19,8 +19,10 @@
const fs = require('fs');
const path = require('path');
const { canonicalHostFor, ownHost } = require('../../_shared/host-role');
++const { frameAround } = require('./frame');
const PUBLIC = path.join(__dirname, '..', 'public');
++let HOSTNAME_MAP = {};
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
/** file → the first hostname that serves it; that host is the canonical one. */
@@@ -38,7 +40,7 @@ const _cache = new Map();
* Read a page and stamp in canonical, social tags and JSON-LD.
* Falls back to the untouched file if anything goes wrong — SEO extras must never break serving.
*/
--function renderPage(file, primaryHost) {
++function renderPage(file, primaryHost, hostnameMap) {
const key = `${file}::${primaryHost}`;
if (_cache.has(key)) return _cache.get(key);
@@@ -89,7 +91,13 @@ function renderPage(file, primaryHost) {
return !new RegExp(`(?:rel|property|name)=["']${m[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`, 'i').test(html);
}).join('\n');
-- const out = html.replace(/<\/head>/i, `${wanted}\n</head>`);
++ let out = html.replace(/<\/head>/i, `${wanted}\n</head>`);
++
++ // The shared tool frame around the page (openvibe-shared >= 4.57.1): the head, the box, the
++ // answer slot, the family, the folds and "People also use". The page's own editor and its live
++ // output stay where they are.
++ out = frameAround(out, primaryHost, { file, hostnameMap: hostnameMap || HOSTNAME_MAP });
++
if (_cache.size > 100) _cache.clear();
_cache.set(key, out);
return out;
@@@ -97,6 +105,7 @@ function renderPage(file, primaryHost) {
/** Express handler factory: serves the host's page with its SEO tags applied. */
function pageSender(hostnameMap) {
++ HOSTNAME_MAP = hostnameMap;
const primary = buildPrimaryHosts(hostnameMap);
const has = (h) => Object.prototype.hasOwnProperty.call(hostnameMap, h);
return function sendPage(req, res) {
@@@ -104,7 +113,7 @@ function pageSender(hostnameMap) {
const file = (has(hostname) && hostnameMap[hostname]) || 'index.html';
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Vary', 'X-OV-Canonical-Host');
-- const rendered = renderPage(file, canonicalHostFor(req, primary.get(file) || hostname));
++ const rendered = renderPage(file, canonicalHostFor(req, primary.get(file) || hostname), hostnameMap);
if (!rendered) return res.sendFile(path.join(PUBLIC, file));
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(rendered);
ddiff --git a/apps/text/test/frame.test.js b/apps/text/test/frame.test.js
nnew file mode 100644
iindex 0000000..cd9519c
---- /dev/null
++++ b/apps/text/test/frame.test.js
@@@ -0,0 +1,140 @@
++'use strict';
++// The tool frame on the text pages (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):
++// • every text tool's page is a tool page in the same frame: the head, the box, the answer slot, the
++// family, the folds and "People also use", in that order;
++// • the terms come from the app's real configuration — the work happens in the browser, the size of
++// the text is the tool's own limit, the rate is the guard's quota table — and they follow it when
++// it changes;
++// • the one button says the verb, never "Run".
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const os = require('os');
++const path = require('path');
++const { frameAround, termsOf, PAGE_OF, TOOL_OF_FILE, VERB } = require('../server/frame');
++const { HOSTNAME_MAP } = require('../server/hosts');
++const { SPECS } = require('../server/descriptors');
++const { startApp } = require('../../_shared/test/spawn');
++
++/** The hosts that are a tool's own page (the hubs are their own thing). */
++const TOOL_HOSTS = Object.entries(HOSTNAME_MAP).filter(([host, file]) => TOOL_OF_FILE[file]);
++
++/** The frame's order: each marker appears once, and in this order. */
++function inOrder(html, parts) {
++ let at = -1;
++ for (const p of parts) {
++ const i = html.indexOf(p, at + 1);
++ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);
++ at = i;
++ }
++}
++
++/** The head's terms, as text. */
++function factsOf(html) {
++ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);
++ if (!m) return [];
++ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);
++}
++
++/** The page a host serves, with the frame around it. */
++function pageOf(host, file) {
++ const html = fs.readFileSync(path.join(__dirname, '..', 'public', file), 'utf8');
++ return frameAround(html, host, { file, hostnameMap: HOSTNAME_MAP });
++}
++
++// ── Every text tool's page is a framed tool page, in the same order ──
++{
++ for (const [host, file] of TOOL_HOSTS) {
++ const html = pageOf(host, file);
++ inOrder(html, [
++ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',
++ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
++ ]);
++ assert.ok(html.includes('https://text.openvibe.tools/">Text tools'), `${host}: the crumb is the family`);
++ assert.ok(html.includes('aria-current="page"'), `${host}: the family marks this tool`);
++ assert.ok(html.includes('Use it from code') && html.includes('https://openvibe.tools/api/v1/tools/'), `${host}: the API fold`);
++ assert.strictEqual((html.match(/<h1/g) || []).length, 1, `${host}: one h1`);
++ }
++ assert.ok(TOOL_HOSTS.length >= 60, `the text app has ${TOOL_HOSTS.length} tool pages`);
++}
++
++// ── The terms come from the app's own configuration ──
++{
++ const spec = SPECS.find((s) => s.id === 'fancy');
++ const facts = factsOf(pageOf('fancy.openvibe.tools', 'fancy.html'));
++ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs in your browser: nothing is sent'], 'fancy: free, and it runs in the browser');
++ assert.ok(facts.includes('API: 120 runs a minute without an account'), 'fancy: the rate from the guard\'s table');
++ assert.ok(facts.includes(`Up to ${Math.round(spec.limits.maxInputBytes / 1024)} KB of text`), 'fancy: the size from the tool\'s own limit');
++
++ // Change the configuration: the line follows it.
++ const saved = spec.limits.maxInputBytes;
++ try {
++ spec.limits.maxInputBytes = 5 * 1024;
++ assert.ok(factsOf(pageOf('fancy.openvibe.tools', 'fancy.html')).includes('Up to 5 KB of text'), 'the size follows the config');
++ } finally {
++ spec.limits.maxInputBytes = saved;
++ }
++ const savedEnv = process.env.TOOLS_GUARD_LIMITS;
++ try {
++ process.env.TOOLS_GUARD_LIMITS = JSON.stringify({ 'tools-run': { anonymous: { perMinute: 40, burst: 20, perDay: 0 } } });
++ assert.ok(factsOf(pageOf('fancy.openvibe.tools', 'fancy.html')).includes('API: 40 runs a minute without an account'), 'the rate follows the guard\'s table');
++ } finally {
++ if (savedEnv === undefined) delete process.env.TOOLS_GUARD_LIMITS;
++ else process.env.TOOLS_GUARD_LIMITS = savedEnv;
++ }
++
++ // A tool that runs on the device only is not rate limited at all.
++ const logo = factsOf(pageOf('logo.openvibe.tools', 'logo-hub.html'));
++ assert.ok(logo.includes('No limit: it runs on your device'), 'logo: no server, so no limit to claim');
++}
++
++// ── The one button says the verb ──
++{
++ let withButton = 0;
++ for (const [host, file] of TOOL_HOSTS) {
++ const html = pageOf(host, file);
++ const toolId = TOOL_OF_FILE[file];
++ assert.ok(!/>\s*Run\s*</.test(html), `${host}: never Run`);
++ const m = /<button[^>]*class="[^"]*generate-btn[^"]*"[^>]*>[\s\S]*?<i[^>]*><\/i>\s*([^<]+?)\s*<\/button>/.exec(html);
++ if (!m) continue; // a live or gallery page: it has no action button to name
++ withButton += 1;
++ assert.strictEqual(m[1], VERB[toolId], `${host}: the button says the verb`);
++ assert.notStrictEqual(m[1], 'Generate', `${host}: not a generic "Generate"`);
++ }
++ assert.ok(withButton >= 10, `only ${withButton} pages have an action button`);
++ assert.strictEqual(VERB.escape, 'Escape the text', 'escape: the verb');
++ assert.ok(/<i class="fas fa-bolt"><\/i>\s*Escape the text\s*<\/button>/.test(pageOf('escape.openvibe.tools', 'escape.html')), 'escape: the button says the verb');
++ assert.ok(/<i class="fas fa-bolt"><\/i>\s*Make a slug\s*<\/button>/.test(pageOf('slug.openvibe.tools', 'slug.html')), 'slug: the button says the verb');
++}
++
++// ── The page the server sends: the frame, the title, the canonical and the structured data ──
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-text-frame-'));
++ const app = await startApp('text', { DATA_DIR: tmp });
++ const get = (host) => new Promise((resolve) => {
++ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {
++ let body = '';
++ res.setEncoding('utf8');
++ res.on('data', (d) => { body += d; });
++ res.on('end', () => resolve({ status: res.statusCode, body }));
++ });
++ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });
++ req.on('error', () => resolve({ status: 0, body: '' }));
++ });
++ try {
++ for (const host of ['escape.openvibe.tools', 'fancy.openvibe.tools', 'slug.openvibe.tools', 'logo.openvibe.tools']) {
++ const r = await get(host);
++ assert.strictEqual(r.status, 200, `${host}: answers 200`);
++ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), `${host}: the frame's title`);
++ assert.ok(r.body.includes('<link rel="canonical"'), `${host}: a canonical`);
++ assert.ok(r.body.includes('"@type":"BreadcrumbList"'), `${host}: the structured data`);
++ assert.ok(r.body.includes('/shared/kit.css?'), `${host}: the kit's look`);
++ assert.ok(r.body.includes('/js/tool-frame.js'), `${host}: the frame's behaviour`);
++ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, `${host}: one h1`);
++ }
++ } finally {
++ await app.kill();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ console.log(`tool frame (text): ${TOOL_HOSTS.length} text pages framed, the terms from the config, the button says the verb: all checks passed`);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/apps/yt/public/css/style.css b/apps/yt/public/css/style.css
iindex 342f293..c7bf903 100644
---- a/apps/yt/public/css/style.css
++++ b/apps/yt/public/css/style.css
@@@ -413,4 +413,13 @@ a:hover { text-decoration: underline; }
.footer-inner { flex-direction: column; text-align: center; }
}
++
++/* The tool frame: the brand is the site ("Tools"), not the subdomain's name. */
++.openvibe-navbar .openvibe-navbar-brand .b-core,
++.openvibe-navbar .openvibe-navbar-brand .b-dot,
++.openvibe-navbar .openvibe-navbar-brand .b-tld { display: none; }
++
++
++/* The page is the shared tool frame: it starts under the navbar, not behind it. */
++.ovk-page { padding-top: calc(var(--ovk-nav-h, 52px) + 12px); }
@media (prefers-reduced-motion: reduce) { .spinner, .progress-bar.indeterminate { animation: none; opacity: .7; } }
ddiff --git a/apps/yt/public/index.html b/apps/yt/public/index.html
iindex 56755e5..8fa0fa7 100644
---- a/apps/yt/public/index.html
++++ b/apps/yt/public/index.html
@@@ -23,83 +23,9 @@
<!-- Navbar injected by OpenVibeNavbar.init() -->
<div id="app">
-- <!-- Hero -->
-- <section class="hero">
-- <div class="hero-icon"><i class="fa-solid fa-circle-play"></i></div>
-- <h1>YT.OpenVibe</h1>
-- <p class="hero-subtitle">YouTube Downloader</p>
-- </section>
--
-- <!-- URL Input -->
-- <section class="url-section">
-- <div class="url-input-wrap">
-- <i class="fa-solid fa-link url-icon"></i>
-- <input type="url" id="url-input" class="url-input" placeholder="Paste YouTube URL here..." autocomplete="off">
-- <button class="btn btn-primary" id="fetch-btn"><i class="fa-solid fa-magnifying-glass"></i> <span class="btn-label">Fetch</span></button>
-- </div>
-- <p class="url-hint">Supports youtube.com and youtu.be links</p>
-- </section>
--
-- <!-- Loading -->
-- <section class="loading" id="loading" style="display:none">
-- <div class="spinner"></div>
-- <p>Fetching video info...</p>
-- </section>
--
-- <!-- Video Info -->
-- <section class="video-card" id="video-card" style="display:none">
-- <div class="video-thumb" id="video-thumb"></div>
-- <div class="video-info" id="video-info"></div>
-- </section>
--
-- <!-- Format Selection -->
-- <section class="format-section" id="format-section" style="display:none">
-- <h3>Choose Format</h3>
-- <div class="format-groups">
-- <div class="format-group">
-- <h4><i class="fa-solid fa-video"></i> Video</h4>
-- <div class="format-list" id="video-formats"></div>
-- </div>
-- <div class="format-group">
-- <h4><i class="fa-solid fa-music"></i> Audio Only</h4>
-- <div class="format-list" id="audio-formats"></div>
-- </div>
-- </div>
-- <button class="btn btn-primary btn-lg" id="download-btn">
-- <i class="fa-solid fa-download"></i> Download
-- </button>
-- </section>
--
-- <!-- Download Progress -->
-- <section class="progress-section" id="progress-section" style="display:none">
-- <h3 id="progress-title">Downloading...</h3>
-- <div class="progress-bar-wrap">
-- <div class="progress-bar" id="progress-bar"></div>
-- </div>
-- <div class="progress-stats" id="progress-stats"></div>
-- </section>
--
-- <!-- Download Ready -->
-- <section class="done-section" id="done-section" style="display:none">
-- <div class="done-icon"><i class="fa-solid fa-circle-check"></i></div>
-- <h3>Ready!</h3>
-- <p id="done-info"></p>
-- <div class="done-actions">
-- <a class="btn btn-primary btn-lg" id="save-btn" href="#" download>
-- <i class="fa-solid fa-download"></i> Save File
-- </a>
-- <button class="btn btn-secondary" id="new-btn">
-- <i class="fa-solid fa-rotate-left"></i> Download Another
-- </button>
-- </div>
-- </section>
--
-- <!-- Error -->
-- <section class="error-section" id="error-section" style="display:none">
-- <div class="error-icon"><i class="fa-solid fa-circle-exclamation"></i></div>
-- <p id="error-msg"></p>
-- <button class="btn btn-secondary" id="retry-btn"><i class="fa-solid fa-rotate-left"></i> Try Again</button>
-- </section>
++ <!-- The tool frame (the shared one, openvibe-shared >= 4.57.1): the server draws it for this
++ host — the head, the box, the answer slot, the family, the folds and "People also use". -->
++ <!--OVK:FRAME-->
</div>
<!-- Footer -->
@@@ -114,7 +40,11 @@
<script src="/shared/ov-icons.js" defer></script>
<script src="/shared/island.js" defer></script>
<script src="/shared/ui.js" defer></script>
++ <script src="/shared/kit.js" defer></script>
++ <script src="/shared/kit-web.js" defer></script>
++ <script src="/shared/kit-data-web.js" defer></script>
<script src="/js/download-state.js"></script>
++ <script src="/js/tool-frame.js" defer></script>
<script src="/js/app.js"></script>
</body>
</html>
ddiff --git a/apps/yt/public/js/app.js b/apps/yt/public/js/app.js
iindex efb554e..a1ea413 100644
---- a/apps/yt/public/js/app.js
++++ b/apps/yt/public/js/app.js
@@@ -393,12 +393,16 @@
apiBase: 'https://openvibe.network',
history: { type: 'tool', title: 'YT.OpenVibe' },
silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',
++ // The brand is the site ("Tools"), not the subdomain's name: the same brand every
++ // framed tool page carries (owner ask 11).
++ brand: { subLabel: 'Tools', name: 'OpenVibe.Tools' },
});
}
if (typeof OpenVibeFooter !== 'undefined') {
try {
OpenVibeFooter.init({
service: 'yt', variant: 'compact', mount: '#ov-footer', apiBase: 'https://openvibe.network',
++ brandName: 'OpenVibe.Tools',
links: [{ heading: 'YT.OpenVibe', items: [
{ name: 'Video downloader', url: 'https://yt.openvibe.tools' },
{ name: 'Audio converter', url: 'https://audio.openvibe.tools' },
ddiff --git a/apps/yt/server/frame.js b/apps/yt/server/frame.js
nnew file mode 100644
iindex 0000000..370737c
---- /dev/null
++++ b/apps/yt/server/frame.js
@@@ -0,0 +1,170 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// The tool frame for the YouTube downloader (openvibe-shared ≥ 4.57.1, owner ask 11, part 2).
++//
++// The downloader is one tool on two hosts (yt.openvibe.tools and youtube-downloader.openvibe.tools),
++// so its page is a tool page in the shared frame: the head (the crumb, the name, one sentence, the
++// terms), the box (the link field and the app's own steps), the answer (the file it made and Download),
++// the family, the folds and "People also use".
++//
++// The page around the tool changes; the download itself does not. The app's own sections keep their
++// ids, so its script still finds them.
++//
++// The terms come from the app's real configuration: the file's lifetime from the cleanup config
++// (config.js retention), its size and length from the download limits, and the rate from the guard's
++// quota table and the tool's own cost in it.
++// ═══════════════════════════════════════════════════════════════
++
++const kit = require('openvibe-shared/kit');
++const serve = require('openvibe-shared/serve');
++const frame = require('../../_shared/frame')({ kit, serve });
++const config = require('./config');
++const { SPECS } = require('./descriptors');
++
++const esc = kit.esc;
++const SPEC = SPECS.find((s) => s.id === 'yt') || {};
++const SITE = 'https://openvibe.tools';
++
++// The hubs the other tools live in (the gateway's own families).
++const HUBS = [
++ { href: `${SITE}/video-tools`, label: 'Video Tools', short: 'Video' },
++ { href: `${SITE}/audio-tools`, label: 'Audio Tools', short: 'Audio' },
++ { href: `${SITE}/image-tools`, label: 'Image Tools', short: 'Image' },
++ { href: `${SITE}/pdf-tools`, label: 'PDF & Document Tools', short: 'PDF' },
++ { href: `${SITE}/text-tools`, label: 'Text & Logo Tools', short: 'Text' },
++];
++
++/** The length of a video in the words a person reads: "3 hours", "12 minutes". */
++function lengthOf(seconds) {
++ const s = Number(seconds) || 0;
++ if (s >= 3600) return `${Math.round(s / 3600)} hour${Math.round(s / 3600) === 1 ? '' : 's'}`;
++ if (s >= 60) return `${Math.round(s / 60)} minute${Math.round(s / 60) === 1 ? '' : 's'}`;
++ return `${s} second${s === 1 ? '' : 's'}`;
++}
++
++/**
++ * The terms of the downloader, from the app's real configuration and the guard's quota table.
++ * @returns {Array<{text: string, icon: string}>}
++ */
++function termsOf() {
++ const facts = frame.terms({
++ work: 'server',
++ ttlMs: config.retention.fileTTL,
++ maxBytes: config.download.maxFilesize * 1024 * 1024,
++ maxFiles: 1,
++ quotaClass: SPEC.quotaClass || 'tools-download',
++ cost: SPEC.cost || 50,
++ unit: 'downloads',
++ });
++ // The length of the video is the download limit the app enforces.
++ const at = facts.findIndex((f) => f.text.includes('up to'));
++ const limit = { text: `Up to ${lengthOf(config.download.maxDuration)} long`, icon: 'clock' };
++ if (at >= 0) facts.splice(at + 1, 0, limit);
++ else facts.push(limit);
++ return facts;
++}
++
++/** The family: the tool hubs this tool sits next to. */
++function familyItems() {
++ return HUBS.map((h, i) => ({ ...h, current: i === 0 }));
++}
++
++/** "People also use": the hubs, as rows. */
++function relatedItems() {
++ return HUBS.slice(1, 4).map((h) => kit.row({ href: h.href, title: h.label, sub: '' }));
++}
++
++/**
++ * The box: the link field, the one button, and the app's own steps (the video card, the formats, the
++ * progress and the error). The ids are the app's, so its script keeps working.
++ */
++function boxFor() {
++ return `<form class="ovk-tool-form" id="ovk-yt-form" onsubmit="return false">
++ <label class="ovk-tool-label" for="url-input">Video link</label>
++ <div class="ovk-tool-row">
++ <span class="ovk-tool-field"><input id="url-input" name="url" type="url" placeholder="Paste a YouTube link here…" autocomplete="off" spellcheck="false"></span>
++ ${frame.verbButton('Fetch the video', { id: 'fetch-btn', icon: 'search' })}
++ </div>
++ <p class="ovk-try"><span>Try</span><a href="?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3Daqz-KE-bpKQ" data-ovk-try="https://www.youtube.com/watch?v=aqz-KE-bpKQ" data-ovk-name="url" data-ovk-for="#url-input">Big Buck Bunny</a></p>
++ <section class="loading" id="loading" style="display:none"><div class="spinner"></div><p>Fetching video info…</p></section>
++ <section class="video-card" id="video-card" style="display:none">
++ <div class="video-thumb" id="video-thumb"></div>
++ <div class="video-info" id="video-info"></div>
++ </section>
++ <section class="format-section" id="format-section" style="display:none">
++ <h3>Choose a format</h3>
++ <div class="format-groups">
++ <div class="format-group">
++ <h4><i class="fa-solid fa-video"></i> Video</h4>
++ <div class="format-list" id="video-formats"></div>
++ </div>
++ <div class="format-group">
++ <h4><i class="fa-solid fa-music"></i> Audio only</h4>
++ <div class="format-list" id="audio-formats"></div>
++ </div>
++ </div>
++ <div class="ovk-tool-buttons">${frame.verbButton('Start the download', { id: 'download-btn', icon: 'download' })}</div>
++ </section>
++ <section class="progress-section" id="progress-section" style="display:none">
++ <h3 id="progress-title">Downloading…</h3>
++ <div class="progress-bar-wrap"><div class="progress-bar" id="progress-bar"></div></div>
++ <div class="progress-stats" id="progress-stats"></div>
++ </section>
++ <section class="error-section" id="error-section" style="display:none">
++ <div class="error-icon"><i class="fa-solid fa-circle-exclamation"></i></div>
++ <p id="error-msg"></p>
++ <button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" id="retry-btn"><i class="fa-solid fa-arrow-rotate-left"></i> <span>Try again</span></button>
++ </section>
++ </form>`;
++}
++
++/** The answer: the file it made, its size, and Download. */
++function answerFor() {
++ return `<section class="ovk-answer" id="done-section" style="display:none" aria-live="polite">
++ <div class="ovk-answer-head">
++ <h2>Your file is ready</h2>
++ <span class="ovk-answer-acts"><a class="ovk-btn ovk-btn--primary" id="save-btn" href="#" download>${kit.icon('download')}<span>Download</span></a></span>
++ </div>
++ <dl class="ovk-answer-rows"><div><dt>File</dt><dd id="done-info">Your file is ready</dd></div></dl>
++ <div class="ovk-answer-body"><button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" id="new-btn"><i class="fa-solid fa-arrow-rotate-left"></i> <span>Download another</span></button></div>
++ </section>`;
++}
++
++/**
++ * The whole frame for the downloader's page.
++ * @param {string} [host] the host that serves it
++ * @returns {string} HTML
++ */
++function frameFor(host) {
++ const title = 'YouTube Downloader';
++ const task = 'Save a YouTube video as MP4 or WebM, or keep just the audio as MP3.';
++ const faq = [
++ ['Is it free?', 'Yes. A download a minute without an account, and more when you sign in.'],
++ ['How long is my file kept?', 'About an hour, so you can save it. Then it is deleted.'],
++ ['How big can the video be?', `Up to ${frame.size(config.download.maxFilesize * 1024 * 1024)} and ${lengthOf(config.download.maxDuration)} long.`],
++ ['Can I download any video?', 'Your own uploads, public-domain works and videos whose licence allows copies. You are responsible for having the right to save what you download.'],
++ ];
++ return `<div class="ovk-page ovk-page--wide" data-ov-tool-page="yt">${kit.tool({
++ head: frame.head({
++ icon: 'play', hue: 355, family: 'media',
++ crumb: { href: SITE, label: 'Tools' },
++ title, task, facts: termsOf(),
++ }),
++ input: boxFor(),
++ answer: answerFor(),
++ family: kit.family({
++ title: 'More OpenVibe tools', more: { href: SITE, label: 'All tools' },
++ items: familyItems(), label: 'More tools',
++ }),
++ folds: frame.folds({
++ name: title,
++ about: 'The downloader fetches one video with yt-dlp on our server and hands you the file as MP4, WebM, MP3, M4A, Opus or FLAC. The file is kept for about an hour and then deleted.',
++ faq,
++ api: null,
++ }),
++ related: kit.list(relatedItems(), { title: 'People also use' }),
++ })}</div>`;
++}
++
++module.exports = { frameFor, termsOf, boxFor, answerFor, familyItems, relatedItems, HUBS, lengthOf };
ddiff --git a/apps/yt/server/index.js b/apps/yt/server/index.js
iindex 1eb117f..95dd363 100644
---- a/apps/yt/server/index.js
++++ b/apps/yt/server/index.js
@@@ -310,6 +310,12 @@ app.get(['/', '/index.html'], (req, res) => {
if (req.path !== '/') return res.redirect(301, '/');
return seo.sendIndex(req, res);
});
++
++// The tool frame's browser glue (apps/_shared/public/tool-frame.js).
++app.get('/js/tool-frame.js', (_req, res) => {
++ res.set('Cache-Control', 'no-cache');
++ res.sendFile(path.join(__dirname, '..', '..', '_shared', 'public', 'tool-frame.js'));
++});
app.use((req, res) => {
if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });
res.status(404).set('Cache-Control', 'no-cache').type('html')
ddiff --git a/apps/yt/server/seo.js b/apps/yt/server/seo.js
iindex ab2eae3..4633aa0 100644
---- a/apps/yt/server/seo.js
++++ b/apps/yt/server/seo.js
@@@ -13,6 +13,8 @@
const fs = require('fs');
const path = require('path');
const { canonicalHostFor, requestHost, isLocalHost } = require('../../_shared/host-role');
++const serve = require('openvibe-shared/serve');
++const { frameFor } = require('./frame');
const PUBLIC = path.join(__dirname, '..', 'public');
@@@ -107,6 +109,9 @@ function headTags(canonicalHost) {
`<meta name="twitter:title" content="${esc(TITLE)}">`,
`<meta name="twitter:description" content="${esc(DESCRIPTION)}">`,
`<meta name="twitter:image" content="${image}">`,
++ // The shared tool frame's look (openvibe-shared >= 4.57.1), from this app's own pin.
++ `<link rel="stylesheet" href="${serve.url('kit.css')}">`,
++ `<link rel="stylesheet" href="${serve.url('kit-data.css')}">`,
...ld.map(ldScript),
].join('\n ');
}
@@@ -129,7 +134,11 @@ function baseHtml() {
function renderIndex(canonicalHost) {
if (process.env.NODE_ENV !== 'development' && _cache.has(canonicalHost)) return _cache.get(canonicalHost);
-- const out = baseHtml().replace('<!--OV:HEAD-->', headTags(canonicalHost)).replace('<!--OV:CONTENT-->', contentBlock());
++ const out = baseHtml()
++ .replace('<!--OV:HEAD-->', headTags(canonicalHost))
++ .replace('<!--OV:CONTENT-->', contentBlock())
++ // The frame itself: the head, the box, the answer slot, the family, the folds and "People also use".
++ .replace('<!--OVK:FRAME-->', frameFor(canonicalHost));
if (_cache.size > 50) _cache.clear();
_cache.set(canonicalHost, out);
return out;
ddiff --git a/apps/yt/test/frame.test.js b/apps/yt/test/frame.test.js
nnew file mode 100644
iindex 0000000..2facd4a
---- /dev/null
++++ b/apps/yt/test/frame.test.js
@@@ -0,0 +1,116 @@
++'use strict';
++// The tool frame on the YouTube downloader's page (openvibe-shared ≥ 4.57.1, owner ask 11, part 2):
++// • the page is a tool page in the same frame: the head, the box, the answer slot, the family, the
++// folds and "People also use", in that order;
++// • the terms come from the app's real configuration — the file's lifetime from the cleanup config,
++// its size and the video's length from the download limits, the rate from the guard's quota table —
++// and they follow it when it changes;
++// • the one button says the verb, never "Run".
++const assert = require('assert');
++const fs = require('fs');
++const http = require('http');
++const os = require('os');
++const path = require('path');
++const { frameFor, termsOf } = require('../server/frame');
++const config = require('../server/config');
++const { startApp } = require('../../_shared/test/spawn');
++
++/** The frame's order: each marker appears once, and in this order. */
++function inOrder(html, parts) {
++ let at = -1;
++ for (const p of parts) {
++ const i = html.indexOf(p, at + 1);
++ assert.ok(i > at, `${p} after ${parts[parts.indexOf(p) - 1] || 'the top'} (${i} > ${at})`);
++ at = i;
++ }
++}
++
++/** The head's terms, as text. */
++function factsOf(html) {
++ const m = /<ul class="ovk-tool-facts">([\s\S]*?)<\/ul>/.exec(html);
++ if (!m) return [];
++ return m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<\/li>\s*<li>/g, '\n').replace(/<[^>]+>/g, '').split('\n').map((s) => s.trim()).filter(Boolean);
++}
++
++// ── The page is a framed tool page, in the same order ──
++{
++ const html = frameFor('yt.openvibe.tools');
++ inOrder(html, [
++ '<header class="ovk-tool-head', '<h1 class="ovk-tool-title">', 'class="ovk-tool-card"', 'class="ovk-tool-out"',
++ 'class="ovk-family"', 'class="ovk-tool-folds"', 'People also use',
++ ]);
++ assert.ok(html.includes('href="https://openvibe.tools">Tools'), 'the crumb is the tools home');
++ assert.ok(html.includes('id="url-input"'), 'the box has the link field');
++ assert.ok(html.includes('aria-current="page"'), 'the family marks this tool');
++ assert.ok(html.includes('id="save-btn"') && html.includes('<span>Download</span>'), 'the answer downloads the file');
++ assert.strictEqual((html.match(/<h1/g) || []).length, 1, 'one h1');
++}
++
++// ── The terms come from the app's own configuration ──
++{
++ const facts = factsOf(frameFor('yt.openvibe.tools'));
++ assert.deepStrictEqual(facts.slice(0, 2), ['Free', 'Runs on our server; your file is deleted after 60 minutes'], 'free, on our server, and the file\'s lifetime');
++ assert.ok(facts.includes('One file up to 2 GB'), 'the size from the download limits');
++ assert.ok(facts.includes('Up to 3 hours long'), 'the length from the download limits');
++ assert.ok(facts.includes('1 download a minute without an account'), 'the rate from the guard\'s table and the tool\'s own cost');
++
++ const savedTtl = config.retention.fileTTL;
++ const savedSize = config.download.maxFilesize;
++ const savedDuration = config.download.maxDuration;
++ try {
++ config.retention.fileTTL = 30 * 60 * 1000;
++ assert.ok(factsOf(frameFor('yt.openvibe.tools')).includes('Runs on our server; your file is deleted after 30 minutes'), 'the lifetime follows the config');
++ config.download.maxFilesize = 512;
++ assert.ok(factsOf(frameFor('yt.openvibe.tools')).includes('One file up to 512 MB'), 'the size follows the config');
++ config.download.maxDuration = 600;
++ assert.ok(factsOf(frameFor('yt.openvibe.tools')).includes('Up to 10 minutes long'), 'the length follows the config');
++ } finally {
++ config.retention.fileTTL = savedTtl;
++ config.download.maxFilesize = savedSize;
++ config.download.maxDuration = savedDuration;
++ }
++}
++
++// ── The one button says the verb ──
++{
++ const html = frameFor('yt.openvibe.tools');
++ const buttons = [...html.matchAll(/<button[^>]*>([\s\S]*?)<\/button>/g)]
++ .map((m) => m[1].replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<[^>]+>/g, '').trim())
++ .filter(Boolean);
++ assert.ok(buttons.includes('Fetch the video'), 'the box button says the verb');
++ assert.ok(buttons.includes('Start the download'), 'the download button says the verb');
++ assert.ok(!buttons.includes('Run'), 'never Run');
++ assert.ok(/<span>Download<\/span>/.test(html), 'the answer downloads the file');
++}
++
++// ── The page the server sends: the frame, the title, the canonical and the structured data ──
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-yt-frame-'));
++ const app = await startApp('yt', { DATA_DIR: tmp });
++ const get = (host) => new Promise((resolve) => {
++ const req = http.get({ host: '127.0.0.1', port: app.port, path: '/', headers: { Host: host, Connection: 'close' }, timeout: 15000 }, (res) => {
++ let body = '';
++ res.setEncoding('utf8');
++ res.on('data', (d) => { body += d; });
++ res.on('end', () => resolve({ status: res.statusCode, body }));
++ });
++ req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: '' }); });
++ req.on('error', () => resolve({ status: 0, body: '' }));
++ });
++ try {
++ for (const host of ['yt.openvibe.tools', 'youtube-downloader.openvibe.tools']) {
++ const r = await get(host);
++ assert.strictEqual(r.status, 200, `${host}: answers 200`);
++ assert.ok(r.body.includes('<h1 class="ovk-tool-title">'), `${host}: the frame's title`);
++ assert.ok(r.body.includes('<link rel="canonical"'), `${host}: a canonical`);
++ assert.ok(r.body.includes('"@type":"BreadcrumbList"'), `${host}: the structured data`);
++ assert.ok(r.body.includes('/shared/kit.css?'), `${host}: the kit's look`);
++ assert.ok(r.body.includes('/js/tool-frame.js'), `${host}: the frame's behaviour`);
++ assert.strictEqual((r.body.match(/<h1/g) || []).length, 1, `${host}: one h1`);
++ }
++ } finally {
++ await app.kill();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++ console.log('tool frame (yt): the downloader page framed, the terms from the config, the buttons say the verb: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });