openvibers/openvibe.space Commits

Icons: OpenVibe glyphs, no Font Awesome (#314)

OP OpenVibers committed 4 h ago · 4f960fdb19 · 6 files, +93 −88

Changes

ddiff --git a/server/render/hosting-pages.js b/server/render/hosting-pages.js
iindex 0ff651a..f4c4bf0 100644
---- a/server/render/hosting-pages.js
++++ b/server/render/hosting-pages.js
@@@ -10,7 +10,7 @@ const { esc } = require('./hosting-layout');
const bytes = (n) => (n >= 1073741824 ? `${(n / 1073741824).toFixed(2)} GiB` : n >= 1048576 ? `${(n / 1048576).toFixed(1)} MiB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KiB` : `${n} B`);
const when = (ms) => (ms ? `<time datetime="${new Date(ms).toISOString()}">${esc(new Date(ms).toISOString().replace('T', ' ').slice(0, 16))} UTC</time>` : '');
const short = (id) => esc(String(id || '').slice(0, 12));
--const icon = (name) => `<i class="fa-solid ${name}" aria-hidden="true"></i>`;
++const icon = (name) => `<i class="ovg-fill" data-ov-icon="${esc(name)}" aria-hidden="true"></i>`;
const badge = (text, tone = 'neutral') => `<span class="hd-badge hd-badge--${tone}">${esc(text)}</span>`;
const STATE_TONE = { ready: 'ok', failed: 'bad', receiving: 'warn', processing: 'warn' };
@@@ -32,18 +32,18 @@ function card(title, inner, { sub = '', actions = '', cls = '', id = '' } = {})
function home({ projects, csrf, canCreate = true }) {
const cards = projects.map((p) => `<a class="hd-tile" href="/projects/${esc(p.id)}">
--<span class="hd-tile__icon">${icon('fa-folder-open')}</span>
++<span class="hd-tile__icon">${icon('folder')}</span>
<span class="hd-tile__body"><strong>${esc(p.name)}</strong><span class="hd-tile__meta">${badge(p.environment, p.environment === 'sandbox' ? 'warn' : 'accent')}${p.role ? ` ${badge(p.role)}` : ''}</span><span class="hd-muted">Created ${when(p.created_at)}</span></span>
--<span class="hd-tile__go" aria-hidden="true">${icon('fa-arrow-right')}</span></a>`).join('');
++<span class="hd-tile__go" aria-hidden="true">${icon('arrow-right')}</span></a>`).join('');
const create = !canCreate
-- ? `<div class="hd-callout">${icon('fa-lock')}<p>OpenVibe.Space is not open for hosting yet. It opens after a launch review (abuse handling and a designated DMCA agent first); until then only OpenVibe staff can create projects.</p></div>`
++ ? `<div class="hd-callout">${icon('lock')}<p>OpenVibe.Space is not open for hosting yet. It opens after a launch review (abuse handling and a designated DMCA agent first); until then only OpenVibe staff can create projects.</p></div>`
: form('/projects', csrf, `<div class="hd-fields">
<label class="hd-field">Name <input name="name" required maxlength="80" placeholder="My website"></label>
<label class="hd-field">Environment <select name="environment"><option value="production">production</option><option value="sandbox">sandbox (smaller quotas, noindex, no custom domains)</option></select></label>
--</div><button type="submit" class="hd-btn hd-btn--primary">${icon('fa-plus')} Create project</button>`, { cls: 'hd-form' });
-- return `<div class="hd-head"><p class="hd-eyebrow">${icon('fa-globe')} Static hosting</p>${kit.pageHead({ title: `Your projects`, icon: 'server', back: { href: '/', label: 'Home' } })}
++</div><button type="submit" class="hd-btn hd-btn--primary">${icon('plus')} Create project</button>`, { cls: 'hd-form' });
++ return `<div class="hd-head"><p class="hd-eyebrow">${icon('globe')} Static hosting</p>${kit.pageHead({ title: `Your projects`, icon: 'server', back: { href: '/', label: 'Home' } })}
<p class="hd-sub">A project holds sites, their deploys and domains, and the people who may change them.</p></div>
--${projects.length ? `<div class="hd-tiles">${cards}</div>` : `<div class="hd-empty">${icon('fa-folder')}<p>You have no projects yet.</p></div>`}
++${projects.length ? `<div class="hd-tiles">${cards}</div>` : `<div class="hd-empty">${icon('folder')}<p>You have no projects yet.</p></div>`}
${card('New project', create, { sub: canCreate ? 'Production sites can take custom domains; a sandbox is for trying things and is never indexed.' : '' })}`;
}
@@@ -67,7 +67,7 @@ ${meter('Custom domains', usage.customDomains, quota.customDomains)}
function project({ project: p, role, sites, quota, usage, members, csrf, siteUrl }) {
const canMaintain = role === 'owner' || role === 'maintainer' || role === 'staff';
const siteRows = sites.map((s) => `<li class="hd-row">
--<span class="hd-row__main"><a class="hd-row__title" href="/sites/${esc(s.id)}">${esc(s.name)}</a><a class="hd-url" href="${esc(siteUrl(s))}" rel="noopener">${esc(siteUrl(s).replace(/^https?:\/\//, ''))} ${icon('fa-arrow-up-right-from-square')}</a></span>
++<span class="hd-row__main"><a class="hd-row__title" href="/sites/${esc(s.id)}">${esc(s.name)}</a><a class="hd-url" href="${esc(siteUrl(s))}" rel="noopener">${esc(siteUrl(s).replace(/^https?:\/\//, ''))} ${icon('open')}</a></span>
<span class="hd-row__side">${s.active_deploy_id ? `${badge('live', 'ok')} <code>${short(s.active_deploy_id)}</code>` : badge('nothing published')}</span></li>`).join('');
const memberRows = members.map((m) => `<tr><td><code>${esc(m.principal)}</code></td><td>${badge(m.role, m.role === 'owner' ? 'accent' : 'neutral')}</td><td class="hd-actions">${role === 'owner' && m.principal !== p.owner_subject
? form(`/projects/${p.id}/members/remove`, csrf, `<input type="hidden" name="principal" value="${esc(m.principal)}"><button type="submit" class="hd-link hd-link--danger">Remove</button>`, { cls: 'hd-inline' }) : ''}</td></tr>`).join('');
@@@ -76,8 +76,8 @@ ${kit.pageHead({ title: `${p.name}`, icon: 'server', back: { href: '/', label: '
<p class="hd-chips">${badge(p.environment, p.environment === 'sandbox' ? 'warn' : 'accent')} ${badge(`your role: ${role}`)} <code>${esc(p.id)}</code>${p.network_project_id ? ` <span class="hd-muted">Network project <code>${esc(p.network_project_id)}</code></span>` : ''}</p></div>
<div class="hd-split">
<div class="hd-split__main">
--${card('Sites', `${sites.length ? `<ul class="hd-rows">${siteRows}</ul>` : `<div class="hd-empty hd-empty--small">${icon('fa-globe')}<p>No sites yet.</p></div>`}
--${canMaintain ? form(`/projects/${p.id}/sites`, csrf, `<label class="hd-field">New site name <input name="name" required pattern="[a-z0-9-]{3,40}" maxlength="40" placeholder="my-site"></label><button type="submit" class="hd-btn hd-btn--primary">${icon('fa-plus')} Create site</button>`, { cls: 'hd-form hd-form--row' }) : ''}`, { sub: 'Each site is served at its own address under openvibe.website.' })}
++${card('Sites', `${sites.length ? `<ul class="hd-rows">${siteRows}</ul>` : `<div class="hd-empty hd-empty--small">${icon('globe')}<p>No sites yet.</p></div>`}
++${canMaintain ? form(`/projects/${p.id}/sites`, csrf, `<label class="hd-field">New site name <input name="name" required pattern="[a-z0-9-]{3,40}" maxlength="40" placeholder="my-site"></label><button type="submit" class="hd-btn hd-btn--primary">${icon('plus')} Create site</button>`, { cls: 'hd-form hd-form--row' }) : ''}`, { sub: 'Each site is served at its own address under openvibe.website.' })}
${card('Members', `<div class="hd-table-wrap" data-ovk-scroll><table class="hd-table"><thead><tr><th>Principal</th><th>Role</th><th></th></tr></thead><tbody>${memberRows}</tbody></table></div>
${role === 'owner' ? form(`/projects/${p.id}/members`, csrf, `<label class="hd-field">Principal <input name="principal" required placeholder="usr_… or app:app_…"></label>
<label class="hd-field">Role <select name="role"><option value="deployer">deployer</option><option value="maintainer">maintainer</option></select></label><button type="submit" class="hd-btn">Add or change</button>`, { cls: 'hd-form hd-form--row' }) : ''}`, { sub: 'Deployers upload and activate; maintainers also manage sites, domains and configuration.' })}
@@@ -98,7 +98,7 @@ function domainBlock(d, instructions, csrf) {
+ rec(instructions.verification) + instructions.routing.map(rec).join('')
+ `</tbody></table></div><p class="hd-muted">${esc(instructions.tls)}</p></details>`;
body += '<div class="hd-domain__actions">';
-- if (d.status !== 'verified') body += form(`/domains/${d.id}/verify`, csrf, `<button type="submit" class="hd-btn hd-btn--small">${icon('fa-rotate')} Check DNS now</button>`, { cls: 'hd-inline' });
++ if (d.status !== 'verified') body += form(`/domains/${d.id}/verify`, csrf, `<button type="submit" class="hd-btn hd-btn--small">${icon('retry')} Check DNS now</button>`, { cls: 'hd-inline' });
body += form(`/domains/${d.id}/delete`, csrf, '<button type="submit" class="hd-link hd-link--danger">Remove</button>', { cls: 'hd-inline' });
body += '</div>';
}
@@@ -115,7 +115,7 @@ function site({ site: s, project: p, role, deploys, activations, domains, siteCo
const active = d.id === s.active_deploy_id;
const previewing = d.id === previewId;
const actions = [];
-- if (previewing && d.state === 'ready') actions.push(`<a class="hd-btn hd-btn--small" href="${esc(previewHref(d.id))}" target="_blank" rel="noopener">${icon('fa-eye')} Open preview</a>`);
++ if (previewing && d.state === 'ready') actions.push(`<a class="hd-btn hd-btn--small" href="${esc(previewHref(d.id))}" target="_blank" rel="noopener">${icon('eye')} Open preview</a>`);
if (canDeploy && d.state === 'ready' && !active) actions.push(form(`/deploys/${d.id}/activate`, csrf, `<input type="hidden" name="expected_active" value="${esc(s.active_deploy_id || '')}"><button type="submit" class="hd-btn hd-btn--small hd-btn--primary">Activate</button>`, { cls: 'hd-inline' }));
if (canMaintain && !active) actions.push(form(`/deploys/${d.id}/delete`, csrf, '<button type="submit" class="hd-link hd-link--danger">Delete</button>', { cls: 'hd-inline' }));
return `<tr${active ? ' class="hd-active"' : ''}><td><a href="/deploys/${esc(d.id)}"><code>${short(d.id)}</code></a>${active ? ` ${badge('active', 'accent')}` : ''}${previewing ? ` ${badge('preview', 'warn')}` : ''}</td>`
@@@ -123,13 +123,13 @@ function site({ site: s, project: p, role, deploys, activations, domains, siteCo
}).join('');
const history = activations.slice(0, 10).map((a) => `<li>${when(a.created_at)} <span>${esc(a.kind)} → <code>${short(a.deploy_id)}</code>${a.previous_deploy_id ? ` <span class="hd-muted">(was <code>${short(a.previous_deploy_id)}</code>)</span>` : ''} <span class="hd-muted">by <code>${esc(a.actor)}</code></span></span></li>`).join('');
const status = s.active_deploy_id ? `${badge('live', 'ok')} <code>${short(s.active_deploy_id)}</code>` : badge('nothing published');
-- const rollback = canDeploy && s.active_deploy_id ? form(`/sites/${s.id}/rollback`, csrf, `<input type="hidden" name="expected_active" value="${esc(s.active_deploy_id)}"><button type="submit" class="hd-btn hd-btn--small">${icon('fa-clock-rotate-left')} Roll back to the previous deploy</button>`, { cls: 'hd-inline' }) : '';
++ const rollback = canDeploy && s.active_deploy_id ? form(`/sites/${s.id}/rollback`, csrf, `<input type="hidden" name="expected_active" value="${esc(s.active_deploy_id)}"><button type="submit" class="hd-btn hd-btn--small">${icon('history')} Roll back to the previous deploy</button>`, { cls: 'hd-inline' }) : '';
return `<div class="hd-head">${crumbs([['/dashboard', 'Projects'], [`/projects/${p.id}`, p.name]])}
${kit.pageHead({ title: `${s.name}`, icon: 'server', back: { href: '/', label: 'Home' } })}
--<p class="hd-chips"><a class="hd-url hd-url--big" href="${esc(url)}" rel="noopener">${esc(url.replace(/^https?:\/\//, ''))} ${icon('fa-arrow-up-right-from-square')}</a> ${status} <code>${esc(s.id)}</code></p></div>
++<p class="hd-chips"><a class="hd-url hd-url--big" href="${esc(url)}" rel="noopener">${esc(url.replace(/^https?:\/\//, ''))} ${icon('open')}</a> ${status} <code>${esc(s.id)}</code></p></div>
${canDeploy ? card('Upload a deploy', form(`/sites/${s.id}/deploys`, csrf, `<div class="hd-sources" data-max-files="${uploadLimits.maxFiles}" data-max-file-bytes="${uploadLimits.maxFileBytes}" data-max-upload-bytes="${uploadLimits.maxUploadBytes}">
--<fieldset class="hd-source"><legend>${icon('fa-folder-open')} A folder</legend><input type="file" name="files" multiple webkitdirectory aria-label="A folder to upload" data-deploy-folder><div data-folder-picker></div><input type="hidden" name="strip" value="folder"></fieldset>
--<fieldset class="hd-source"><legend>${icon('fa-box-archive')} …or an archive (.tar.gz / .tar)</legend><input type="file" name="archive" accept=".tar,.tgz,.gz,application/gzip,application/x-tar" aria-label="An archive to upload (.tar.gz or .tar)" data-deploy-archive><div data-archive-picker></div></fieldset>
++<fieldset class="hd-source"><legend>${icon('folder')} A folder</legend><input type="file" name="files" multiple webkitdirectory aria-label="A folder to upload" data-deploy-folder><div data-folder-picker></div><input type="hidden" name="strip" value="folder"></fieldset>
++<fieldset class="hd-source"><legend>${icon('archive')} …or an archive (.tar.gz / .tar)</legend><input type="file" name="archive" accept=".tar,.tgz,.gz,application/gzip,application/x-tar" aria-label="An archive to upload (.tar.gz or .tar)" data-deploy-archive><div data-archive-picker></div></fieldset>
</div>
<p class="hd-muted" id="deploy-limit">Up to ${uploadLimits.maxFiles.toLocaleString('en-US')} files, ${bytes(uploadLimits.maxFileBytes)} per file and ${bytes(uploadLimits.maxUploadBytes)} per upload. Archives must be .tar, .tgz or .tar.gz.</p><p role="alert" data-deploy-error hidden></p><div data-deploy-progress></div>
<label class="hd-field hd-field--narrow">Deploy from subfolder (optional) <input name="root" placeholder="dist"></label>
@@@ -138,13 +138,13 @@ ${canDeploy ? card('Upload a deploy', form(`/sites/${s.id}/deploys`, csrf, `<div
<label class="hd-choice"><input type="radio" name="mode" value="ready"><span><strong>Keep it ready</strong><span class="hd-muted">Activate later from the list below.</span></span></label>
<label class="hd-choice"><input type="radio" name="mode" value="preview"><span><strong>Preview only</strong><span class="hd-muted">A private address for members of this project, for one hour.</span></span></label>
</fieldset>
--<button type="submit" class="hd-btn hd-btn--primary">${icon('fa-cloud-arrow-up')} Upload</button>`, { cls: 'hd-form', enctype: 'multipart/form-data' }),
++<button type="submit" class="hd-btn hd-btn--primary">${icon('upload')} Upload</button>`, { cls: 'hd-form', enctype: 'multipart/form-data' }),
{ sub: 'Static files only: HTML, CSS, JavaScript, JSON, images, fonts, audio, video, PDF, WebAssembly. No build runs and nothing is executed. Hidden files (except <code>.well-known/</code>), links and server-side code are refused.' }) : ''}
--${card('Deploys', `${previewId ? `<p class="hd-notice">${icon('fa-eye')} A preview of <code>${short(previewId)}</code> is open to members of this project at <a href="${esc(previewHref(previewId))}" target="_blank" rel="noopener">${esc(previewHref(previewId))}</a> until ${when(Number(s.preview_expires_at))}. It is never indexed, and activating or rolling back ends it.</p>` : ''}
--${deploys.length ? `<div class="hd-table-wrap" data-ovk-scroll><table class="hd-table"><thead><tr><th>Deploy</th><th>State</th><th class="hd-num">Files</th><th class="hd-num">Size</th><th>Uploaded</th><th></th></tr></thead><tbody>${rows}</tbody></table></div>` : `<div class="hd-empty hd-empty--small">${icon('fa-box-open')}<p>No deploys yet.</p></div>`}
++${card('Deploys', `${previewId ? `<p class="hd-notice">${icon('eye')} A preview of <code>${short(previewId)}</code> is open to members of this project at <a href="${esc(previewHref(previewId))}" target="_blank" rel="noopener">${esc(previewHref(previewId))}</a> until ${when(Number(s.preview_expires_at))}. It is never indexed, and activating or rolling back ends it.</p>` : ''}
++${deploys.length ? `<div class="hd-table-wrap" data-ovk-scroll><table class="hd-table"><thead><tr><th>Deploy</th><th>State</th><th class="hd-num">Files</th><th class="hd-num">Size</th><th>Uploaded</th><th></th></tr></thead><tbody>${rows}</tbody></table></div>` : `<div class="hd-empty hd-empty--small">${icon('box')}<p>No deploys yet.</p></div>`}
${history ? `<details class="hd-details"><summary>Activation history</summary><ul class="hd-history">${history}</ul></details>` : ''}`, { actions: rollback, sub: 'Every deploy is kept. Activating one switches the site at once; nothing is rebuilt.' })}
${card('Domains', `<ul class="hd-domains">${domains.map((d) => domainBlock(d.domain, d.instructions, csrf)).join('')}</ul>
--${canMaintain && p.environment !== 'sandbox' ? form(`/sites/${s.id}/domains`, csrf, `<label class="hd-field">Custom domain <input name="hostname" required placeholder="www.example.org"></label><button type="submit" class="hd-btn">${icon('fa-plus')} Add domain</button>`, { cls: 'hd-form hd-form--row' }) : ''}`, { sub: 'A custom domain is served once its TXT record is found.' })}
++${canMaintain && p.environment !== 'sandbox' ? form(`/sites/${s.id}/domains`, csrf, `<label class="hd-field">Custom domain <input name="hostname" required placeholder="www.example.org"></label><button type="submit" class="hd-btn">${icon('plus')} Add domain</button>`, { cls: 'hd-form hd-form--row' }) : ''}`, { sub: 'A custom domain is served once its TXT record is found.' })}
${canMaintain ? card('Per-site configuration', form(`/sites/${s.id}/config`, csrf, `<label class="hd-field">Headers <span class="hd-muted">one per line: <code>Name: value</code></span><textarea name="headers" rows="4" spellcheck="false" placeholder="X-Frame-Options: SAMEORIGIN">${esc(siteConfig.headersText)}</textarea></label>
<label class="hd-field">Redirects <span class="hd-muted">one per line: <code>from -&gt; to [status]</code></span><textarea name="redirects" rows="4" spellcheck="false" placeholder="/old -&gt; /new 301">${esc(siteConfig.redirectsText)}</textarea></label>
<label class="hd-confirm"><input type="checkbox" name="spa" value="1"${siteConfig.spa ? ' checked' : ''}> Serve index.html for extensionless paths (single-page app)</label>
@@@ -166,7 +166,7 @@ ${files.length ? card('Files', `<div class="hd-table-wrap" data-ovk-scroll><tabl
}
function errorPage({ status, message }) {
-- return `<div class="hd-error">${icon(status === 404 ? 'fa-circle-question' : status === 403 ? 'fa-lock' : status === 401 ? 'fa-right-to-bracket' : 'fa-triangle-exclamation')}
++ return `<div class="hd-error">${icon(status === 404 ? 'help' : status === 403 ? 'lock' : status === 401 ? 'enter' : 'alert')}
${kit.pageHead({ title: `${status === 404 ? 'Not found' : status === 403 ? 'Not allowed' : status === 401 ? 'Sign in first' : 'Something went wrong'}`, icon: 'server', back: { href: '/', label: 'Home' } })}<p>${esc(message)}</p><p><a class="hd-btn" href="/dashboard">Back to your projects</a></p></div>`;
}
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex cd047e4..327b627 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -72,7 +72,7 @@ function navbarInit(opts) {
{ label: 'Hosting', href: '/dashboard' },
{ label: 'Community', href: `${config.communityUrl}/forum` },
],
-- menu: { after: [{ label: 'OpenVibe.Network', href: NETWORK_URL, icon: 'fa-globe' }] },
++ menu: { after: [{ label: 'OpenVibe.Network', href: NETWORK_URL, icon: 'globe' }] },
history: { type: opts.historyType || 'page', title: opts.historyTitle || opts.title },
silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 1df92d6..3399043 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -1,6 +1,8 @@
'use strict';
const kit = require('openvibe-shared/kit');
const sectionIcon = (name) => kit.icon(name, { cls: 'ovk-heading-icon' });
++// A template's glyph, in the ring icon the shared showcase draws (it only knows Font Awesome names otherwise).
++const RING = { 'book-open': 'docs', robot: 'bot', chart: 'report', zap: 'live', help: 'help', steps: 'quest', store: 'trade', pencil: 'design', calendar: 'calendar', link: 'link', rocket: 'space', file: 'docs' };
/**
* Page templates. Plain template strings, every value escaped, no framework. The markup is the whole
@@@ -67,7 +69,7 @@ function homePage() {
{ id: 'templates', label: 'Templates', html: sc.features({
title: 'Start from a template',
lede: 'Each one is a working space you can open in the editor and change: sign-in, data and AI already wired.',
-- items: TEMPLATES.filter((t) => t.id !== 'blank').map((t) => ({ icon: t.icon, title: t.name, text: t.description, href: '/spaces/new' })),
++ items: TEMPLATES.filter((t) => t.id !== 'blank').map((t) => ({ icon: `ov:${RING[t.icon] || 'space'}`, title: t.name, text: t.description, href: '/spaces/new' })),
}) },
{ id: 'make', label: 'Making one', html: sc.steps({
title: 'How a space comes together',
@@@ -89,14 +91,14 @@ function homePage() {
{ id: 'now', label: 'What works', html: sc.features({
title: 'What works now',
items: [
-- { icon: 'fa-cubes', title: 'Immutable deploys', text: 'Every deploy is content-addressed and kept: files are stored once by their hash, and a deploy never changes after upload.' },
-- { icon: 'fa-clock-rotate-left', title: 'Previews and rollback', text: 'A private preview for the project\'s members, one-step activation, and a rollback to the deploy that was live before.' },
-- { icon: 'fa-code-branch', title: 'Git deploys', text: 'Point a site at a public repository and branch; each ingest lands as a preview for someone to approve.' },
-- { icon: 'fa-globe', title: 'Your own address', text: 'Sites serve under openvibe.website. A custom domain is served once its TXT record proves it is yours.' },
-- { icon: 'fa-sliders', title: 'Headers and redirects', text: 'Per-site response headers, redirects and a single-page-app fallback. Platform security headers always win.' },
-- { icon: 'fa-users', title: 'Members and roles', text: 'Owners, maintainers and deployers per project; an app can deploy too, with a grant and a role, never as the owner.' },
-- { icon: 'fa-shield-halved', title: 'Takedowns kept for review', text: 'OpenVibe staff can stop serving a site or a whole project while its content is kept for review, and lift it again.' },
-- { icon: 'fa-terminal', title: 'An API for all of it', text: 'Projects, sites, deploys, domains and configuration over /api/v1, with OpenVibe.Network tokens and space.* capabilities.' },
++ { icon: 'ov:space', title: 'Immutable deploys', text: 'Every deploy is content-addressed and kept: files are stored once by their hash, and a deploy never changes after upload.' },
++ { icon: 'ov:history', title: 'Previews and rollback', text: 'A private preview for the project\'s members, one-step activation, and a rollback to the deploy that was live before.' },
++ { icon: 'ov:git', title: 'Git deploys', text: 'Point a site at a public repository and branch; each ingest lands as a preview for someone to approve.' },
++ { icon: 'ov:website', title: 'Your own address', text: 'Sites serve under openvibe.website. A custom domain is served once its TXT record proves it is yours.' },
++ { icon: 'ov:theme', title: 'Headers and redirects', text: 'Per-site response headers, redirects and a single-page-app fallback. Platform security headers always win.' },
++ { icon: 'ov:community', title: 'Members and roles', text: 'Owners, maintainers and deployers per project; an app can deploy too, with a grant and a role, never as the owner.' },
++ { icon: 'ov:shield', title: 'Takedowns kept for review', text: 'OpenVibe staff can stop serving a site or a whole project while its content is kept for review, and lift it again.' },
++ { icon: 'ov:code', title: 'An API for all of it', text: 'Projects, sites, deploys, domains and configuration over /api/v1, with OpenVibe.Network tokens and space.* capabilities.' },
],
}) },
{ id: 'api', label: 'The API', html: sc.code({
@@@ -131,8 +133,8 @@ curl -X POST "https://openvibe.space/api/v1/sites/$SITE_ID/deploys?activate=1" \
accent: 'An app on your OpenVibe account.',
lede: 'Describe what you want and OpenVibe AI builds it, or start from a template and change anything in the editor. A space signs visitors in with OpenVibe, keeps shared lists and private data, and talks to OpenVibe AI.',
actions: [
-- { label: 'Make a space', href: '/spaces/new', primary: true, icon: 'fa-wand-magic-sparkles' },
-- { label: 'Browse spaces', href: '/spaces', icon: 'fa-shapes' },
++ { label: 'Make a space', href: '/spaces/new', primary: true, icon: 'ov:ai' },
++ { label: 'Browse spaces', href: '/spaces', icon: 'ov:space' },
],
note: 'The builder, the editor and the preview work for everyone signed in. Publishing a space at its own address opens with public hosting.',
}) + pickBlock({ key: 'parts', label: 'What Space does', head: 'What Space does', panes: parts })
ddiff --git a/server/spaces/render.js b/server/spaces/render.js
iindex 704a2d6..8c7006c 100644
---- a/server/spaces/render.js
++++ b/server/spaces/render.js
@@@ -10,10 +10,10 @@ const sectionIcon = (name) => kit.icon(name, { cls: 'ovk-heading-icon' });
const { renderPage, escapeHtml: esc, asset } = require('../render/layout');
const CAP_TEXT = {
-- profile: { icon: 'fa-user', text: 'Know who you are: your username and display name' },
-- data: { icon: 'fa-list', text: 'Add things you write to its shared lists, under your name' },
-- kv: { icon: 'fa-box-archive', text: 'Keep your own private data in this space' },
-- ai: { icon: 'fa-wand-magic-sparkles', text: 'Ask OpenVibe AI for you (it counts against your daily AI allowance)' },
++ profile: { icon: 'user', text: 'Know who you are: your username and display name' },
++ data: { icon: 'list', text: 'Add things you write to its shared lists, under your name' },
++ kv: { icon: 'archive', text: 'Keep your own private data in this space' },
++ ai: { icon: 'wand', text: 'Ask OpenVibe AI for you (it counts against your daily AI allowance)' },
};
const caps = (s) => { try { return JSON.parse(s.capabilities || '[]'); } catch { return []; } };
const ago = (ms) => {
@@@ -30,7 +30,7 @@ function page(o) {
}
const signInHref = (next) => `/auth/login?next=${encodeURIComponent(next)}`;
--const capList = (list) => `<ul class="sp-caps">${list.filter((c) => CAP_TEXT[c]).map((c) => `<li><i class="fa-solid ${CAP_TEXT[c].icon}" aria-hidden="true"></i> ${esc(CAP_TEXT[c].text)}</li>`).join('')}</ul>`;
++const capList = (list) => `<ul class="sp-caps">${list.filter((c) => CAP_TEXT[c]).map((c) => `<li><i class="ovg-fill" data-ov-icon="${CAP_TEXT[c].icon}" aria-hidden="true"></i> ${esc(CAP_TEXT[c].text)}</li>`).join('')}</ul>`;
function card(s, { mine = false } = {}) {
const live = Boolean(s.site_id);
@@@ -40,13 +40,13 @@ function card(s, { mine = false } = {}) {
<h3>${esc(s.name)}</h3>
<p>${esc(s.description || 'No description yet.')}</p>
</a>
-- <footer><span>${mine ? `edited ${esc(ago(s.updated_at))}` : `by @${esc(s.owner_username || 'someone')}`}</span>${s.forks ? `<span><i class="fa-solid fa-code-fork" aria-hidden="true"></i> ${Number(s.forks)}</span>` : ''}</footer>
++ <footer><span>${mine ? `edited ${esc(ago(s.updated_at))}` : `by @${esc(s.owner_username || 'someone')}`}</span>${s.forks ? `<span><i class="ovg-fill" data-ov-icon="branch" aria-hidden="true"></i> ${Number(s.forks)}</span>` : ''}</footer>
</article>`;
}
function templateCards(templates, { viewer, csrf, compact = false }) {
return `<div class="sp-templates ${compact ? 'is-compact' : ''}">${templates.map((t) => `<div class="sp-template" style="--tpl:${esc(t.accent)}">
-- <span class="sp-template-icon"><i class="fa-solid ${esc(t.icon)}" aria-hidden="true"></i></span>
++ <span class="sp-template-icon"><i class="ovg-fill" data-ov-icon="${esc(t.icon)}" aria-hidden="true"></i></span>
<h3>${esc(t.name)}</h3><p>${esc(t.description)}</p>
${viewer ? `<form method="post" action="/spaces/new"><input type="hidden" name="csrf" value="${esc(csrf)}"><input type="hidden" name="template" value="${esc(t.id)}"><button class="sp-btn">Start from this</button></form>`
: `<a class="sp-btn" href="${signInHref('/spaces/new')}">Sign in to start</a>`}
@@@ -59,8 +59,8 @@ function galleryPage({ viewer, spaces, templates, q, sort, csrf }) {
accent: 'An app on your OpenVibe account.',
lede: 'Describe it and OpenVibe AI builds it, or start from a template and change anything in the editor. Spaces sign visitors in with OpenVibe, keep shared lists and private data, talk to OpenVibe AI and run their own server code.',
actions: [
-- { label: 'Make a space', href: viewer ? '/spaces/new' : signInHref('/spaces/new'), primary: true, icon: 'fa-wand-magic-sparkles' },
-- ...(viewer ? [{ label: 'Your spaces', href: '/spaces/mine', icon: 'fa-shapes' }] : []),
++ { label: 'Make a space', href: viewer ? '/spaces/new' : signInHref('/spaces/new'), primary: true, icon: 'ov:ai' },
++ ...(viewer ? [{ label: 'Your spaces', href: '/spaces/mine', icon: 'ov:space' }] : []),
],
});
const list = spaces.length ? `<div class="sp-grid">${spaces.map((s) => card(s)).join('')}</div>`
@@@ -84,10 +84,10 @@ function galleryPage({ viewer, spaces, templates, q, sort, csrf }) {
<section class="sp-section sp-how" aria-labelledby="sp-how">
<h2 id="sp-how" class="ovk-titled">${sectionIcon('members')}What a space can do</h2>
<div class="sp-how-grid">
-- <div><i class="fa-solid fa-right-to-bracket" aria-hidden="true"></i><h3>Sign in with OpenVibe</h3><p>Visitors sign in with their OpenVibe account and approve what the space may do, once.</p></div>
-- <div><i class="fa-solid fa-list" aria-hidden="true"></i><h3>Shared lists</h3><p>Guestbooks, polls, leaderboards: things visitors add, under their name, that everyone can read.</p></div>
-- <div><i class="fa-solid fa-box-archive" aria-hidden="true"></i><h3>Private storage</h3><p>Each visitor's own data in your space: to-dos, saves, settings.</p></div>
-- <div><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i><h3>OpenVibe AI</h3><p>Give your space an AI with your own instructions; it answers your visitors.</p></div>
++ <div><i class="ovg-fill" data-ov-icon="enter" aria-hidden="true"></i><h3>Sign in with OpenVibe</h3><p>Visitors sign in with their OpenVibe account and approve what the space may do, once.</p></div>
++ <div><i class="ovg-fill" data-ov-icon="list" aria-hidden="true"></i><h3>Shared lists</h3><p>Guestbooks, polls, leaderboards: things visitors add, under their name, that everyone can read.</p></div>
++ <div><i class="ovg-fill" data-ov-icon="archive" aria-hidden="true"></i><h3>Private storage</h3><p>Each visitor's own data in your space: to-dos, saves, settings.</p></div>
++ <div><i class="ovg-fill" data-ov-icon="wand" aria-hidden="true"></i><h3>OpenVibe AI</h3><p>Give your space an AI with your own instructions; it answers your visitors.</p></div>
</div>
<p class="sp-note">Spaces are plain HTML, CSS and JavaScript with the space SDK (<code>/_openvibe/sdk.js</code>). The AI builder, the editor and the preview work for everyone signed in. Publishing a space at its own address opens together with OpenVibe.Space's public hosting.</p>
</section>`;
@@@ -96,12 +96,12 @@ function galleryPage({ viewer, spaces, templates, q, sort, csrf }) {
// A description handed over from another OpenVibe site: say so, and put the Build button one press away.
const handedOver = (from, prompt) => (prompt && from === 'codes'
-- ? '<p class="sp-handover" role="status"><i class="fa-solid fa-code" aria-hidden="true"></i> From OpenVibe Codes: check what you wrote, then press Build it.</p>'
++ ? '<p class="sp-handover" role="status"><i class="ovg-fill" data-ov-icon="code" aria-hidden="true"></i> From OpenVibe Codes: check what you wrote, then press Build it.</p>'
: '');
function newPage({ viewer, templates, csrf, error, prompt, aiOn, buildsLeft, pick = null, from = null }) {
const picked = pick ? `<section class="sp-pick" style="--tpl:${esc(pick.accent)}" aria-labelledby="sp-pick-h">
-- <span class="sp-template-icon"><i class="fa-solid ${esc(pick.icon)}" aria-hidden="true"></i></span>
++ <span class="sp-template-icon"><i class="ovg-fill" data-ov-icon="${esc(pick.icon)}" aria-hidden="true"></i></span>
<div class="sp-pick-text"><p class="eyebrow">The template you picked</p><h2 id="sp-pick-h" class="ovk-titled">${sectionIcon('grid')}${esc(pick.name)}</h2><p>${esc(pick.description)}</p></div>
<form method="post" action="/spaces/new"><input type="hidden" name="csrf" value="${esc(csrf)}"><input type="hidden" name="template" value="${esc(pick.id)}"><button class="sp-btn">Start from this</button></form>
</section>` : '';
@@@ -115,7 +115,7 @@ function newPage({ viewer, templates, csrf, error, prompt, aiOn, buildsLeft, pic
<input type="hidden" name="csrf" value="${esc(csrf)}">
<label for="sp-prompt">Describe it and OpenVibe AI builds it</label>
<textarea id="sp-prompt" name="prompt" rows="4" maxlength="4000" placeholder="A trivia quiz about 90s cartoons with a leaderboard…" ${aiOn ? '' : 'disabled'}>${esc(prompt || '')}</textarea>
-- <div class="sp-row"><button class="sp-btn" ${aiOn ? '' : 'disabled'}${prompt && aiOn ? ' autofocus' : ''}><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Build it</button>
++ <div class="sp-row"><button class="sp-btn" ${aiOn ? '' : 'disabled'}${prompt && aiOn ? ' autofocus' : ''}><i class="ovg-fill" data-ov-icon="wand" aria-hidden="true"></i> Build it</button>
<span class="sp-muted">${aiOn ? `${Number(buildsLeft)} AI builds left today. It takes about a minute; you can change everything after.` : 'OpenVibe AI is not available right now; start from a template instead.'}</span></div>
<div class="sp-ideas" aria-label="Ideas">${['A daily mood check-in with a shared chart', 'A pixel art board everyone can paint on', 'A recipe idea generator that asks what is in my fridge', 'A study flashcard deck I can quiz myself with'].map((i) => `<button type="button" class="sp-chip" data-idea="${esc(i)}">${esc(i)}</button>`).join('')}</div>
</form>
@@@ -128,7 +128,7 @@ function newPage({ viewer, templates, csrf, error, prompt, aiOn, buildsLeft, pic
function minePage({ spaces, shareDefault = 'public', csrf = '' }) {
const body = `<div class="sp-wrap">
-- <div class="sp-head">${kit.pageHead({ title: `Your spaces`, icon: 'home', back: { href: '/', label: 'Home' } })}<a class="sp-btn" href="/spaces/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> New space</a></div>
++ <div class="sp-head">${kit.pageHead({ title: `Your spaces`, icon: 'home', back: { href: '/', label: 'Home' } })}<a class="sp-btn" href="/spaces/new"><i class="ovg-fill" data-ov-icon="plus" aria-hidden="true"></i> New space</a></div>
<form method="post" action="/spaces/mine/share-default"><input type="hidden" name="csrf" value="${esc(csrf)}">
<label>New published spaces <select name="audience"><option value="public"${shareDefault === 'public' ? ' selected' : ''}>Everyone</option><option value="link"${shareDefault === 'link' ? ' selected' : ''}>Anyone with the link</option></select></label>
<label><input type="checkbox" name="apply" value="1"> Apply to all my published spaces</label>
@@@ -161,10 +161,10 @@ function spacePage({ viewer, space, owner, url, csrf, forkedFrom }) {
const live = Boolean(space.site_id);
const share = kit.share({ title: space.name, url: live && url ? url : null, text: space.description || '', kind: 'space', id: space.id });
const actions = [
-- live ? `<a class="sp-btn" href="${esc(url)}" rel="noopener"><i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> Open</a>` : '', share,
-- owner ? `<a class="sp-btn ${live ? 'sp-btn-quiet' : ''}" href="/spaces/${esc(space.slug)}/edit"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit</a>` : '',
-- viewer ? `<form method="post" action="/spaces/${esc(space.slug)}/fork"><input type="hidden" name="csrf" value="${esc(csrf)}"><button class="sp-btn sp-btn-quiet"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</button></form>`
-- : `<a class="sp-btn sp-btn-quiet" href="${signInHref(`/spaces/${space.slug}`)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Sign in to fork</a>`,
++ live ? `<a class="sp-btn" href="${esc(url)}" rel="noopener"><i class="ovg-fill" data-ov-icon="open" aria-hidden="true"></i> Open</a>` : '', share,
++ owner ? `<a class="sp-btn ${live ? 'sp-btn-quiet' : ''}" href="/spaces/${esc(space.slug)}/edit"><i class="ovg-fill" data-ov-icon="pencil" aria-hidden="true"></i> Edit</a>` : '',
++ viewer ? `<form method="post" action="/spaces/${esc(space.slug)}/fork"><input type="hidden" name="csrf" value="${esc(csrf)}"><button class="sp-btn sp-btn-quiet"><i class="ovg-fill" data-ov-icon="branch" aria-hidden="true"></i> Fork</button></form>`
++ : `<a class="sp-btn sp-btn-quiet" href="${signInHref(`/spaces/${space.slug}`)}"><i class="ovg-fill" data-ov-icon="branch" aria-hidden="true"></i> Sign in to fork</a>`,
].join('');
const body = `<div class="sp-wrap">
<p class="eyebrow">${live ? 'Space' : 'Draft space'}</p>
@@@ -197,45 +197,45 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
};
// The claim (openvibe.website, plan 5.9): a signed-in person who holds the secret keeps the draft.
const claim = guest ? (guest.signedIn
-- ? `<form method="post" action="/spaces/${esc(guest.id)}/claim"><input type="hidden" name="key" value="${esc(guest.key)}"><input type="hidden" name="csrf" value="${esc(guest.claimCsrf || '')}"><button class="sp-btn" type="submit"><i class="fa-solid fa-book" aria-hidden="true"></i> Keep this site</button></form>`
++ ? `<form method="post" action="/spaces/${esc(guest.id)}/claim"><input type="hidden" name="key" value="${esc(guest.key)}"><input type="hidden" name="csrf" value="${esc(guest.claimCsrf || '')}"><button class="sp-btn" type="submit"><i class="ovg-fill" data-ov-icon="book" aria-hidden="true"></i> Keep this site</button></form>`
: `<p class="sp-muted">This draft is yours until you sign in. <a class="sp-btn sp-btn-quiet" href="${esc(`/auth/login?next=${encodeURIComponent(`/spaces/${guest.id}/edit?key=${guest.key}`)}`)}">Sign in to keep it</a></p>`) : '';
const body = `<div class="sp-editor" id="sp-editor" data-view="preview">
<header class="sp-ed-bar">
-- <a class="sp-ed-back" href="${guest ? '/spaces/guest' : `/spaces/${esc(space.slug)}`}" aria-label="Back"><i class="fa-solid fa-arrow-left" aria-hidden="true"></i></a>
++ <a class="sp-ed-back" href="${guest ? '/spaces/guest' : `/spaces/${esc(space.slug)}`}" aria-label="Back"><i class="ovg-fill" data-ov-icon="arrow-left" aria-hidden="true"></i></a>
<h1 class="sp-ed-name" id="ed-name">${esc(space.name)}</h1>
<span class="sp-ed-status" id="ed-status" role="status"></span>
<div class="sp-ed-actions">
-- <button class="sp-btn sp-btn-quiet" id="ed-settings" type="button"><i class="fa-solid fa-sliders" aria-hidden="true"></i> Settings</button>
-- <button class="sp-btn sp-btn-quiet" id="ed-save" type="button"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> Save</button>
-- ${guest ? '' : `<button class="sp-btn" id="ed-publish" type="button"><i class="fa-solid fa-globe" aria-hidden="true"></i> ${space.site_id ? 'Update' : 'Publish'}</button>`}
++ <button class="sp-btn sp-btn-quiet" id="ed-settings" type="button"><i class="ovg-fill" data-ov-icon="sliders" aria-hidden="true"></i> Settings</button>
++ <button class="sp-btn sp-btn-quiet" id="ed-save" type="button"><i class="ovg-fill" data-ov-icon="save" aria-hidden="true"></i> Save</button>
++ ${guest ? '' : `<button class="sp-btn" id="ed-publish" type="button"><i class="ovg-fill" data-ov-icon="globe" aria-hidden="true"></i> ${space.site_id ? 'Update' : 'Publish'}</button>`}
</div>
</header>
${claim ? `<div class="sp-ed-claim"><p><strong>Keep this site</strong> — sign in and it becomes yours, with an address of its own.</p>${claim}</div>` : ''}
<div class="sp-ed-ai">
<form id="ed-ai-form"><label class="sr-only" for="ed-ai">Ask OpenVibe AI to change this space</label>
-- <i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i>
++ <i class="ovg-fill" data-ov-icon="wand" aria-hidden="true"></i>
<input id="ed-ai" maxlength="4000" autocomplete="off" placeholder="${aiOn ? 'Ask AI to change it: “make the buttons bigger and add a dark mode toggle”' : 'OpenVibe AI is not available right now'}" ${aiOn ? '' : 'disabled'}>
<button class="sp-btn" ${aiOn ? '' : 'disabled'}>Change</button></form>
<div class="sp-ed-agent" id="ed-agent" role="status" aria-live="polite" data-state="running" hidden>
<span class="sp-ed-agent-dot" aria-hidden="true"></span>
<span class="sp-ed-agent-words"><b id="ed-agent-title">Your agent is building this</b> <span class="sp-muted" id="ed-agent-text"></span></span>
-- <button class="sp-btn sp-btn-quiet sp-btn-sm" id="ed-agent-open" type="button"><i class="fa-solid fa-list-check" aria-hidden="true"></i> <span id="ed-agent-open-label">Watch in your Dock</span></button>
++ <button class="sp-btn sp-btn-quiet sp-btn-sm" id="ed-agent-open" type="button"><i class="ovg-fill" data-ov-icon="steps" aria-hidden="true"></i> <span id="ed-agent-open-label">Watch in your Dock</span></button>
<button class="sp-ed-agent-x" id="ed-agent-x" type="button" aria-label="Hide" hidden>×</button>
</div>
</div>
-- <div class="sp-ed-switch" role="tablist" aria-label="Show"><button type="button" role="tab" data-view="preview" aria-selected="true"><i class="fa-solid fa-eye" aria-hidden="true"></i> Preview</button><button type="button" role="tab" data-view="code" aria-selected="false"><i class="fa-solid fa-code" aria-hidden="true"></i> Code</button></div>
++ <div class="sp-ed-switch" role="tablist" aria-label="Show"><button type="button" role="tab" data-view="preview" aria-selected="true"><i class="ovg-fill" data-ov-icon="eye" aria-hidden="true"></i> Preview</button><button type="button" role="tab" data-view="code" aria-selected="false"><i class="ovg-fill" data-ov-icon="code" aria-hidden="true"></i> Code</button></div>
<div class="sp-ed-main">
<section class="sp-ed-code" aria-label="Files">
<div class="sp-ed-tabs" data-ovk-scroll id="ed-tabs" role="tablist"></div>
<textarea id="ed-code" spellcheck="false" aria-label="File contents"></textarea>
</section>
<section class="sp-ed-preview" aria-label="Preview">
-- <div class="sp-ed-preview-bar"><span><i class="fa-solid fa-eye" aria-hidden="true"></i> Preview <span class="sp-muted">(runs as you)</span></span><button class="sp-btn sp-btn-quiet sp-btn-sm" id="ed-run" type="button"><i class="fa-solid fa-rotate-right" aria-hidden="true"></i> Run</button></div>
++ <div class="sp-ed-preview-bar"><span><i class="ovg-fill" data-ov-icon="eye" aria-hidden="true"></i> Preview <span class="sp-muted">(runs as you)</span></span><button class="sp-btn sp-btn-quiet sp-btn-sm" id="ed-run" type="button"><i class="ovg-fill" data-ov-icon="retry" aria-hidden="true"></i> Run</button></div>
<iframe id="ed-frame" title="Preview of the space" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock"></iframe>
-- <details class="sp-ed-fnlog" id="ed-fnlog" hidden><summary><i class="fa-solid fa-terminal" aria-hidden="true"></i> Function log <span class="sp-muted" id="ed-fnlog-last"></span></summary><ol id="ed-fnlog-list"></ol></details>
++ <details class="sp-ed-fnlog" id="ed-fnlog" hidden><summary><i class="ovg-fill" data-ov-icon="terminal" aria-hidden="true"></i> Function log <span class="sp-muted" id="ed-fnlog-last"></span></summary><ol id="ed-fnlog-list"></ol></details>
</section>
</div>
-- <div class="sp-ed-building" id="ed-building" hidden><div><i class="fa-solid fa-wand-magic-sparkles fa-bounce" aria-hidden="true"></i><h2 id="ed-building-title" class="ovk-titled">${sectionIcon('grid')}OpenVibe AI is building…</h2><p id="ed-building-text"></p><p class="sp-muted">This usually takes under a minute or two. You can leave and come back.</p></div></div>
++ <div class="sp-ed-building" id="ed-building" hidden><div><i class="ovg-fill" data-ov-icon="busy" data-ovg="busy" aria-hidden="true"></i><h2 id="ed-building-title" class="ovk-titled">${sectionIcon('grid')}OpenVibe AI is building…</h2><p id="ed-building-text"></p><p class="sp-muted">This usually takes under a minute or two. You can leave and come back.</p></div></div>
<dialog class="sp-ed-dialog" id="ed-dialog">
<form method="dialog" id="ed-settings-form">
<h2 class="ovk-titled">${sectionIcon('grid')}Settings</h2>
@@@ -275,12 +275,12 @@ function editorPage({ space, files, csrf, canPublish, url, aiOn, buildsLeft, tem
*/
function guestPage({ templates, error, prompt, aiOn, pick = null, from = null }) {
const picked = pick ? `<section class="sp-pick" style="--tpl:${esc(pick.accent)}" aria-labelledby="sp-pick-h">
-- <span class="sp-template-icon"><i class="fa-solid ${esc(pick.icon)}" aria-hidden="true"></i></span>
++ <span class="sp-template-icon"><i class="ovg-fill" data-ov-icon="${esc(pick.icon)}" aria-hidden="true"></i></span>
<div class="sp-pick-text"><p class="eyebrow">The template you picked</p><h2 id="sp-pick-h" class="ovk-titled">${sectionIcon('grid')}${esc(pick.name)}</h2><p>${esc(pick.description)}</p></div>
<form method="post" action="/spaces/guest"><input type="hidden" name="template" value="${esc(pick.id)}"><button class="sp-btn">Start from this</button></form>
</section>` : '';
const cards = templates.map((t) => `<div class="sp-template" style="--tpl:${esc(t.accent)}">
-- <span class="sp-template-icon"><i class="fa-solid ${esc(t.icon)}" aria-hidden="true"></i></span>
++ <span class="sp-template-icon"><i class="ovg-fill" data-ov-icon="${esc(t.icon)}" aria-hidden="true"></i></span>
<h3>${esc(t.name)}</h3><p>${esc(t.description)}</p>
<form method="post" action="/spaces/guest"><input type="hidden" name="template" value="${esc(t.id)}"><button class="sp-btn">Start from this</button></form>
</div>`).join('');
@@@ -292,7 +292,7 @@ function guestPage({ templates, error, prompt, aiOn, pick = null, from = null })
<form class="sp-ai-form" method="post" action="/spaces/guest">
<label for="sp-prompt">Describe it and OpenVibe AI builds it</label>
<textarea id="sp-prompt" name="prompt" rows="4" maxlength="4000" placeholder="A bakery site with today&rsquo;s hours, where to find us and how to order…" ${aiOn ? '' : 'disabled'}>${esc(prompt || '')}</textarea>
-- <div class="sp-row"><button class="sp-btn" ${aiOn ? '' : 'disabled'}${prompt && aiOn ? ' autofocus' : ''}><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> Build it</button>
++ <div class="sp-row"><button class="sp-btn" ${aiOn ? '' : 'disabled'}${prompt && aiOn ? ' autofocus' : ''}><i class="ovg-fill" data-ov-icon="wand" aria-hidden="true"></i> Build it</button>
<span class="sp-muted">${aiOn ? 'It takes about a minute; you can change everything after.' : 'OpenVibe AI is not available right now; start from a template instead.'}</span></div>
</form>
<h2 class="ovk-titled sp-subtitle">${sectionIcon('grid')}Or start from a template</h2>
ddiff --git a/server/spaces/templates.js b/server/spaces/templates.js
iindex 03b5b04..b871c3b 100644
---- a/server/spaces/templates.js
++++ b/server/spaces/templates.js
@@@ -10,21 +10,21 @@ const path = require('path');
const DIR = path.join(__dirname, '..', '..', 'templates', 'spaces');
const TEMPLATES = [
-- { id: 'guestbook', name: 'Guestbook', icon: 'fa-book-open', accent: '#c2410c', description: 'Visitors sign in and leave a note for everyone to read.', capabilities: ['profile', 'data'] },
-- { id: 'ai-buddy', name: 'AI buddy', icon: 'fa-robot', accent: '#4f46e5', description: 'A chat with an AI character you describe; each visitor\'s conversation is kept for them.', capabilities: ['profile', 'kv', 'ai'],
++ { id: 'guestbook', name: 'Guestbook', icon: 'book-open', accent: '#c2410c', description: 'Visitors sign in and leave a note for everyone to read.', capabilities: ['profile', 'data'] },
++ { id: 'ai-buddy', name: 'AI buddy', icon: 'robot', accent: '#4f46e5', description: 'A chat with an AI character you describe; each visitor\'s conversation is kept for them.', capabilities: ['profile', 'kv', 'ai'],
instructions: 'You are a friendly, curious and slightly funny companion. Keep answers short and warm, ask a question back now and then, and never pretend to be a person.' },
-- { id: 'poll', name: 'Quick poll', icon: 'fa-chart-column', accent: '#059669', description: 'One question, a few answers, one vote each, live results.', capabilities: ['profile', 'data'] },
-- { id: 'reaction', name: 'Reaction game', icon: 'fa-bolt', accent: '#16a34a', description: 'Click when it turns green; best times go on a leaderboard.', capabilities: ['profile', 'data'] },
-- { id: 'trivia', name: 'Trivia', icon: 'fa-circle-question', accent: '#d97706', description: 'Questions whose answers stay on the server (a space function), with how many got each right.', capabilities: [], functions: ['check'] },
-- { id: 'todo', name: 'Private to-dos', icon: 'fa-list-check', accent: '#ca8a04', description: 'A to-do list saved to each visitor\'s own account.', capabilities: ['profile', 'kv'] },
++ { id: 'poll', name: 'Quick poll', icon: 'chart', accent: '#059669', description: 'One question, a few answers, one vote each, live results.', capabilities: ['profile', 'data'] },
++ { id: 'reaction', name: 'Reaction game', icon: 'zap', accent: '#16a34a', description: 'Click when it turns green; best times go on a leaderboard.', capabilities: ['profile', 'data'] },
++ { id: 'trivia', name: 'Trivia', icon: 'help', accent: '#d97706', description: 'Questions whose answers stay on the server (a space function), with how many got each right.', capabilities: [], functions: ['check'] },
++ { id: 'todo', name: 'Private to-dos', icon: 'steps', accent: '#ca8a04', description: 'A to-do list saved to each visitor\'s own account.', capabilities: ['profile', 'kv'] },
// Websites (openvibe.website, plan 5.9): whole sites to change, not apps. Each is one page that reads well with no
// script at all (the home's previews run none); the event page's "who's coming" is a shared list.
-- { id: 'site-bakery', kind: 'site', name: 'Business site', icon: 'fa-store', accent: '#b4532a', description: 'A local shop: what you sell, today\'s hours with open or closed, where to find you and how to order.', capabilities: [] },
-- { id: 'site-portfolio', kind: 'site', name: 'Portfolio', icon: 'fa-pen-nib', accent: '#2f54eb', description: 'Your work in a grid, a short about and one big way to reach you.', capabilities: [] },
-- { id: 'site-event', kind: 'site', name: 'Event page', icon: 'fa-calendar-days', accent: '#d9468a', description: 'The date, the place, the evening hour by hour, and who\'s coming (sign in to say you are).', capabilities: ['profile', 'data'] },
-- { id: 'site-links', kind: 'site', name: 'Link in bio', icon: 'fa-link', accent: '#6d4aff', description: 'One page for everything you do: your streams, your shop, your newsletter.', capabilities: [] },
-- { id: 'site-landing', kind: 'site', name: 'Product landing page', icon: 'fa-rocket', accent: '#0f8a5f', description: 'What it does, how it works, pricing and questions, with one clear button.', capabilities: [] },
-- { id: 'blank', name: 'Blank', icon: 'fa-file', accent: '#7c3aed', description: 'An empty page with sign-in, to build from scratch.', capabilities: ['profile'] },
++ { id: 'site-bakery', kind: 'site', name: 'Business site', icon: 'store', accent: '#b4532a', description: 'A local shop: what you sell, today\'s hours with open or closed, where to find you and how to order.', capabilities: [] },
++ { id: 'site-portfolio', kind: 'site', name: 'Portfolio', icon: 'pencil', accent: '#2f54eb', description: 'Your work in a grid, a short about and one big way to reach you.', capabilities: [] },
++ { id: 'site-event', kind: 'site', name: 'Event page', icon: 'calendar', accent: '#d9468a', description: 'The date, the place, the evening hour by hour, and who\'s coming (sign in to say you are).', capabilities: ['profile', 'data'] },
++ { id: 'site-links', kind: 'site', name: 'Link in bio', icon: 'link', accent: '#6d4aff', description: 'One page for everything you do: your streams, your shop, your newsletter.', capabilities: [] },
++ { id: 'site-landing', kind: 'site', name: 'Product landing page', icon: 'rocket', accent: '#0f8a5f', description: 'What it does, how it works, pricing and questions, with one clear button.', capabilities: [] },
++ { id: 'blank', name: 'Blank', icon: 'file', accent: '#7c3aed', description: 'An empty page with sign-in, to build from scratch.', capabilities: ['profile'] },
];
const cache = new Map();
ddiff --git a/test/no-font-awesome.test.js b/test/no-font-awesome.test.js
iindex 94afe0f..b55bf81 100644
---- a/test/no-font-awesome.test.js
++++ b/test/no-font-awesome.test.js
@@@ -62,18 +62,21 @@ for (const f of LAYOUTS) {
assert.ok(/shell\.page|ov-glyphs\.js|\/shared\/navbar/.test(t), `${f} renders pages without the navbar or /shared/ov-glyphs.js`);
}
--// Every fa- name in the pages, the scripts and the server views is drawn by a glyph or a brand.
--const MOD = /^(solid|regular|light|thin|duotone|brands|sharp|classic|fw|lg|xl|xs|sm|2xs|2xl|\d+x|li|ul|border|inverse|pull-left|pull-right|rotate-\d+|rotate-by|flip-horizontal|flip-vertical|flip-both|width-auto|spin|spin-pulse|spin-reverse|pulse|beat|beat-fade|bounce|fade|flip|shake|stack|stack-1x|stack-2x|layers|layers-text|layers-counter|swap-opacity|stack-text)$/;
++// No Font Awesome markup is left: every icon names an OpenVibe glyph or brand, and the name is one that is drawn.
const files = [];
for (const r of CODE_ROOTS) files.push(...walk(path.join(ROOT, r), /\.(html|js)$/));
const missing = new Map();
for (const f of files) {
-- for (const m of fs.readFileSync(f, 'utf8').matchAll(/\bfa-([a-z0-9]+(?:-[a-z0-9]+)*)\b/g)) {
-- const n = m[1];
-- if (MOD.test(n) || glyphs.fromFa(n) || brands.fromFa(n)) continue;
-- if (!missing.has(n)) missing.set(n, rel(f));
++ const t = fs.readFileSync(f, 'utf8');
++ const fa = t.match(/class=["'][^"']*\bfa-[a-z][^"']*["']/);
++ assert.ok(!fa, `${rel(f)} still uses Font Awesome markup: ${fa ? fa[0] : ''}`);
++ assert.ok(!/\bfa-(solid|regular|brands|light)\b/.test(t), `${rel(f)} still names a Font Awesome style class`);
++ for (const m of t.matchAll(/data-ov-(icon|brand)="([a-z0-9-]+)"/g)) {
++ const [kind, name] = [m[1], m[2]];
++ const drawn = kind === 'icon' ? glyphs.NAMES.includes(name) : Boolean(brands.BRANDS[name]);
++ if (!drawn && !missing.has(`${kind}:${name}`)) missing.set(`${kind}:${name}`, rel(f));
}
}
--assert.deepStrictEqual([...missing].map(([n, f]) => `fa-${n} (${f})`), [], 'every icon this site names is drawn by the OpenVibe glyphs or brands');
++assert.deepStrictEqual([...missing].map(([n, f]) => `${n} (${f})`), [], 'every icon this site names is drawn by the OpenVibe glyphs or brands');
--console.log(`no font awesome: ${pages.length} pages, ${files.length} files, every fa- name drawn`);
++console.log(`no font awesome: ${pages.length} pages, ${files.length} files, every icon named is drawn`);