openvibers/openvibe.space Commits
openvibe.website gets its own front door: five real website templates, the room's motif, no cookies (#149)
Changes
@@@ -52,3 +52,42 @@ server {proxy_read_timeout 60s;}}++++# openvibe.website itself (and www): OpenVibe.Website's front door, answered by Space's server/website.js (it left++# OpenVibe.Network's parked-domains.conf on 2026-10-31). The same rule as a tenant host, both ways: no cookie in, no++# cookie out, GET and HEAD only. Tenants live under this domain and, until its Public Suffix List entry, a tenant's++# script could set a cookie for the whole of it.++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.website www.openvibe.website;++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.website$request_uri; }++}++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.website www.openvibe.website;++ include snippets/security-txt.conf;++ ssl_certificate /etc/letsencrypt/live/openvibe.website/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.website/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++ client_max_body_size 1k;++ add_header Strict-Transport-Security "max-age=31536000" always;++ location / {++ limit_except GET HEAD { deny all; }++ limit_req zone=ovspace_sites burst=200 nodelay;++ proxy_pass http://127.0.0.1:4940;++ proxy_http_version 1.1;++ proxy_set_header Connection "";++ proxy_set_header Host $host;++ proxy_set_header Cookie "";++ proxy_set_header Authorization "";++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++ proxy_hide_header Set-Cookie;++ proxy_read_timeout 60s;++ }++}@@@ -54,6 +54,14 @@.sp-template p { margin: 0 0 8px; color: var(--text-secondary, #9fb0c8); font-size: 0.9rem; flex: 1; }.sp-template form { margin: 0; }.sp-template-icon { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; font-size: 18px; color: #fff; background: var(--tpl); }++/* The template picked on openvibe.website, leading /spaces/new: one press from a new space. */++.sp-pick { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 16px; align-items: center; margin: 0 0 22px; padding: 18px; border-radius: 18px;++ border: 1px solid color-mix(in srgb, var(--tpl) 55%, var(--border, #2c3d5c)); background: linear-gradient(120deg, color-mix(in srgb, var(--tpl) 18%, var(--bg-secondary, #101828)), var(--bg-secondary, #101828) 70%); }++.sp-pick h2 { margin: 2px 0 4px; font-size: 1.25rem; }++.sp-pick p { margin: 0; }++.sp-pick-text > p:last-child { color: var(--text-secondary, #9fb0c8); }++.sp-pick form { margin: 0; }++@media (max-width: 560px) { .sp-pick { grid-template-columns: auto minmax(0, 1fr); } .sp-pick form { grid-column: 1 / -1; } }.sp-templates.is-compact { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }.sp-how-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin-top: 12px; }nnew file mode 100644@@@ -0,0 +1,107 @@++/* OpenVibe.Website's front door (server/render/website.js): the room's motif in the hero, the template gallery with++ * live previews, and one "be the first" row. Colours come from the room (rooms.js 'website': periwinkle) and the kit's++ * tokens, so every theme dresses it; motion uses the network's tokens, so Calm stills it and reduced motion shows the++ * finished frame. */++++.wb { --wb-type: calc(var(--ov-dur-4, .52s) * 4.6); --wb-step: var(--ov-dur-3, .32s); }++++/* The hero wears the room's colour like the rest of the page: its button and the accent words. */++.wb .sc-btn.sc-primary { background: var(--room-fill); color: var(--room-on); }++.wb .sc-accent { background-image: linear-gradient(120deg, var(--room-accent), color-mix(in srgb, var(--room-accent) 50%, var(--ovk-text))); }++++/* ── The motif: the prompt types, then the page assembles ── */++.wb-stage { position: relative; display: grid; gap: 14px; width: min(520px, 100%); margin-inline: auto; padding-bottom: 8px; }++.wb-prompt { justify-self: start; display: flex; align-items: center; gap: 9px; max-width: 100%; padding: 10px 14px; border-radius: 14px;++ background: var(--ovk-panel); border: 1px solid color-mix(in srgb, var(--room-accent) 40%, var(--ovk-line));++ box-shadow: 0 18px 40px -28px rgba(0, 0, 0, .6); font: 13px/1.3 ui-monospace, "SF Mono", Menlo, Consolas, monospace; color: var(--ovk-text); }++.wb-spark { color: var(--room-accent); }++.wb-typed { display: inline-block; overflow: hidden; white-space: nowrap; width: 32ch;++ animation: wbType var(--wb-type) steps(32, end) both; }++.wb-caret { flex: none; width: 2px; height: 1.15em; background: var(--room-accent); animation: wbBlink calc(var(--wb-step) * 3) steps(1) 6 both; }++@keyframes wbType { from { width: 0; } }++@keyframes wbBlink { 50% { opacity: 0; } }++++.wb-browser { border-radius: 16px; overflow: hidden; border: 1px solid var(--ovk-line); background: var(--ovk-panel);++ box-shadow: 0 40px 80px -40px rgba(0, 0, 0, .65), 0 0 0 1px color-mix(in srgb, var(--room-accent) 12%, transparent); }++.wb-bar, .wb-win-bar { display: flex; align-items: center; gap: 6px; padding: 9px 12px; background: var(--ovk-panel-2); border-bottom: 1px solid var(--ovk-line); }++.wb-bar > i, .wb-win-bar > i { width: 9px; height: 9px; border-radius: 50%; background: var(--ovk-line); flex: none; }++.wb-url { flex: 1; min-width: 0; margin-left: 8px; padding: 4px 10px; border-radius: 8px; background: var(--ovk-input); color: var(--ovk-muted);++ font: 11.5px/1.4 ui-monospace, "SF Mono", Menlo, Consolas, monospace; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }++++/* The miniature is the bakery template's own page, so it keeps that page's warm light colours in every theme. */++.wb-page { display: grid; gap: 12px; padding: 16px; min-height: 300px; background: #fbf7f1; }++.wb-b { animation: wbIn var(--ov-dur-4, .52s) var(--ov-ease-spring, cubic-bezier(.34, 1.56, .64, 1)) both;++ animation-delay: calc(var(--wb-type) + var(--i) * var(--wb-step)); }++@keyframes wbIn { from { opacity: 0; transform: translateY(14px) scale(.98); } }++.wb-nav { display: flex; align-items: center; gap: 8px; }++.wb-nav b { width: 84px; height: 10px; border-radius: 5px; background: #2b1d14; margin-right: auto; }++.wb-nav span { width: 34px; height: 7px; border-radius: 4px; background: #d8c8b8; }++.wb-hero { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, .75fr); gap: 14px; align-items: center; padding: 6px 0 4px; }++.wb-copy { display: grid; gap: 8px; }++.wb-copy b { height: 16px; border-radius: 6px; background: #2b1d14; }++.wb-copy span { height: 7px; border-radius: 4px; background: #c9b6a4; }++.wb-copy .wb-short { width: 64%; }++.wb-copy em { width: 92px; height: 22px; margin-top: 4px; border-radius: 999px; background: #b4532a; }++.wb-loaf { aspect-ratio: 1; border-radius: 32% 68% 54% 46% / 42% 38% 62% 58%;++ background: radial-gradient(circle at 36% 30%, #e9b980 0 16%, transparent 17%), linear-gradient(140deg, #f2c48d, #c27a3f 70%, #8f4f25);++ box-shadow: inset 0 -10px 20px rgba(90, 45, 15, .35); }++.wb-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; padding: 10px; border-radius: 10px; background: #f4e8da; }++.wb-cards span { display: grid; gap: 6px; padding: 9px; border-radius: 8px; background: #fff; border: 1px solid #eadfd2; }++.wb-cards b { height: 7px; border-radius: 4px; background: #2b1d14; }++.wb-cards i { width: 40%; height: 6px; border-radius: 3px; background: #b4532a; }++.wb-foot span { display: block; height: 6px; width: 40%; border-radius: 3px; background: #d8c8b8; }++.wb-ready { position: absolute; right: 14px; bottom: -6px; padding: 6px 12px; border-radius: 999px; font-size: 12.5px; font-weight: 700;++ color: var(--room-on); background: var(--room-fill); box-shadow: 0 10px 24px -12px rgba(0, 0, 0, .6);++ animation: wbIn var(--ov-dur-4, .52s) var(--ov-ease-spring, cubic-bezier(.34, 1.56, .64, 1)) both; animation-delay: calc(var(--wb-type) + 5 * var(--wb-step)); }++++/* ── The templates: each a small window onto the real page ── */++.wb-head { display: grid; gap: 6px; margin-bottom: 20px; }++.wb-head h2 { margin: 0; }++.wb-head p { margin: 0; color: var(--ovk-muted); max-width: 62ch; }++.wb-tpls { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 18px; }++.wb-tpl { display: grid; grid-template-rows: auto 1fr auto; gap: 12px; padding: 12px; border-radius: 18px; border: 1px solid var(--ovk-line);++ background: linear-gradient(165deg, color-mix(in srgb, var(--tpl) 10%, var(--ovk-panel)), var(--ovk-panel) 45%); }++.wb-win { display: block; border-radius: 12px; overflow: hidden; border: 1px solid var(--ovk-line); text-decoration: none;++ transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease-out), box-shadow var(--ov-dur-2, .2s) var(--ov-ease-out, ease-out); }++.wb-win:hover { transform: translateY(-2px); box-shadow: 0 18px 36px -24px rgba(0, 0, 0, .7); }++.wb-win:focus-visible { outline: 3px solid var(--room-accent); outline-offset: 2px; }++.wb-win-bar { padding: 7px 10px; }++.wb-win-bar > i { width: 7px; height: 7px; }++.wb-win-bar > span { min-width: 0; margin-left: 6px; color: var(--ovk-muted); font: 11px/1.3 ui-monospace, "SF Mono", Menlo, Consolas, monospace;++ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }++/* The page lays out at four times the card's width (a desktop), then shrinks to fit: a true thumbnail, not a phone view. */++.wb-shot { position: relative; display: block; aspect-ratio: 4 / 3; overflow: hidden; background: #fff; }++.wb-shot iframe { position: absolute; inset: 0 auto auto 0; width: 400%; height: 400%; border: 0; transform: scale(.25); transform-origin: 0 0; pointer-events: none; }++.wb-tpl-text h3 { margin: 2px 4px 4px; font-size: 1.08rem; }++.wb-tpl-text p { margin: 0 4px; color: var(--ovk-muted); font-size: .93rem; line-height: 1.5; }++.wb-tpl-go { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; padding: 2px 4px 4px; }++.wb-btn { display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 16px; border-radius: 999px;++ background: var(--room-fill); color: var(--room-on); font-weight: 700; text-decoration: none;++ transition: transform var(--ov-dur-1, .12s) var(--ov-ease-out, ease-out), filter var(--ov-dur-1, .12s) var(--ov-ease-out, ease-out); }++.wb-btn:hover { filter: brightness(1.08); }++.wb-btn:active { transform: scale(.97); }++.wb-btn:focus-visible { outline: 3px solid color-mix(in srgb, var(--room-accent) 55%, transparent); outline-offset: 2px; }++.wb-link { color: var(--room-strong); font-weight: 600; text-decoration: none; }++.wb-link:hover { text-decoration: underline; }++++/* ── Sites made here: one honest row while there are none ── */++.wb-first { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 18px 20px; border-radius: 18px;++ border: 1px dashed color-mix(in srgb, var(--room-accent) 45%, var(--ovk-line)); }++.wb-first h2 { margin: 0 0 4px; font-size: 1.15rem; }++.wb-first p { margin: 0; color: var(--ovk-muted); max-width: 60ch; }++++/* On a phone the templates are a sideways rail (the owner's short-pages rule): every one stays in the page, the next++ one peeks in from the edge, and the page itself never scrolls sideways. */++@media (max-width: 640px) {++ .wb-tpls { grid-auto-flow: column; grid-template-columns: none; grid-auto-columns: 84%; overflow-x: auto; overscroll-behavior-x: contain;++ scroll-snap-type: x mandatory; padding-bottom: 8px; scrollbar-width: thin; }++ .wb-tpl { scroll-snap-align: start; }++}++@media (max-width: 560px) {++ .wb-page { min-height: 0; padding: 12px; }++ .wb-ready { right: 8px; }++}++@media (prefers-reduced-motion: reduce) {++ .wb-typed, .wb-caret, .wb-b, .wb-ready { animation: none; }++ .wb-win, .wb-btn { transition: none; }++}@@@ -130,6 +130,9 @@ async function createApp(opts = {}) {crossOriginResourcePolicy: { policy: 'cross-origin' },referrerPolicy: { policy: 'strict-origin-when-cross-origin' },}));++ // openvibe.website's front door (server/website.js): after the platform's headers, before cookies, sign-in and the API,++ // none of which it ever reaches.++ app.use(require('./website').createWebsite({ sitesDomain: hosting.config.sitesDomain, publicDir: PUBLIC_DIR }));app.use(cookieParser());hosting.mountRoutes(app);@@@ -113,7 +113,7 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, ftry {const target = await tenant.resolve(req.headers.host);req.hostTarget = target.kind;-- if (target.kind === 'dashboard') return next();++ if (target.kind === 'dashboard' || target.kind === 'website') return next();if (target.kind === 'site') return await tenant.handle(req, res, target.site);return tenant.unknownHost(req, res);} catch (err) { return next(err); }@@@ -182,12 +182,14 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfigconst PREVIEW_CSP = previewCsp(previewOrigin);/**-- * -> { kind: 'dashboard' } | { kind: 'site', site } | { kind: 'unknown', host }++ * -> { kind: 'dashboard' } | { kind: 'website', host } | { kind: 'site', site } | { kind: 'unknown', host }*/async function resolve(rawHost) {const host = normaliseHost(rawHost);if (!host) return { kind: 'unknown', host: null };if (host === config.dashboardHost || LOOPBACK_HOSTS.has(host)) return { kind: 'dashboard' };++ // The sites domain itself (and www) is OpenVibe.Website's front door (server/website.js), never a tenant.++ if (host === config.sitesDomain || host === `www.${config.sitesDomain}`) return { kind: 'website', host };if (host.endsWith(suffix)) {const label = host.slice(0, -suffix.length);if (!label || label.includes('.')) return { kind: 'unknown', host };nnew file mode 100644@@@ -0,0 +1,132 @@++'use strict';++/**++ * OpenVibe.Website's pages (server/website.js serves them at the sites domain itself): the home and its 404.++ *++ * The home shows rather than tells (the owner, 10-31: rooms that open on promises look unfinished). Its hero is the++ * room's motif from openvibe-shared/rooms: a browser frame that assembles a real site, block by block, while the prompt++ * types. Then the five website templates, each a live preview of the real page (an iframe of /templates/<id> with no++ * script), one press from a new space on openvibe.space. It claims nothing Space does not do: publishing at a person's++ * own address waits for public hosting, and the page says so.++ */++const shell = require('openvibe-shared/shell');++const sc = require('openvibe-shared/showcase');++const rooms = require('openvibe-shared/rooms');++const ovServe = require('openvibe-shared/serve');++const appIcon = require('openvibe-shared/app-icon');++const templates = require('../spaces/templates');++const { asset, escapeHtml: esc } = require('./layout');++++const SPACE = 'https://openvibe.space';++const NAME = 'OpenVibe.Website';++const DESCRIPTION = 'Websites that look finished from the first preview: start from a real shop, portfolio, event, link page or launch page, or describe yours, and change anything with the preview beside it.';++const startHref = (id) => `${SPACE}/auth/login?next=${encodeURIComponent(`/spaces/new${id ? `?template=${id}` : ''}`)}`;++++const FAQ = [++ ['Do I need to know how to code?', 'No. Pick a template and change the words, colours and pictures, or describe the site and OpenVibe AI writes a first version. The code is all there when you want it.'],++ ['When can I put it on my own address?', 'Publishing at yourname.openvibe.website or on your own domain opens with public hosting, once the automatic abuse checks, a designated DMCA agent and the domain\'s Public Suffix List entry are in place. Until then you build, edit and preview, and OpenVibe staff publish.'],++ ['Why do I sign in on openvibe.space?', 'Your sites are kept with your OpenVibe account on OpenVibe.Space, the engine that builds and hosts them. openvibe.website itself keeps no cookies, so no site here can ever touch your sign-in.'],++ ['Can I take my site somewhere else?', 'Yes. A site is plain HTML, CSS and JavaScript; copy its files out of the editor whenever you like.'],++];++++/** The room's motif: the prompt types, then the bakery template's page assembles in its browser, block by block. */++function stage() {++ const block = (cls, i, inner) => `<div class="wb-b ${cls}" style="--i:${i}">${inner}</div>`;++ return `<div class="wb-stage" aria-hidden="true">++<div class="wb-prompt"><span class="wb-spark">✦</span><span class="wb-typed">A bakery site with today's hours</span><span class="wb-caret"></span></div>++<div class="wb-browser">++<div class="wb-bar"><i></i><i></i><i></i><span class="wb-url">rise-and-crumb.openvibe.website</span></div>++<div class="wb-page">++${block('wb-nav', 0, '<b></b><span></span><span></span><span></span>')}++${block('wb-hero', 1, '<div class="wb-copy"><b></b><b class="wb-short"></b><span></span><span class="wb-short"></span><em></em></div><i class="wb-loaf"></i>')}++${block('wb-cards', 2, '<span><b></b><i></i></span><span><b></b><i></i></span><span><b></b><i></i></span>')}++${block('wb-foot', 3, '<span></span>')}++</div>++</div>++<span class="wb-ready">Preview ready</span>++</div>`;++}++++/** A template as visitors see it: a small browser window with the real page inside, and the two ways in. */++function templateCard(t, base) {++ return `<article class="wb-tpl" style="--tpl:${esc(t.accent)}">++<a class="wb-win" href="/templates/${esc(t.id)}" aria-label="${esc(t.name)} at full size">++<span class="wb-win-bar"><i></i><i></i><i></i><span>${esc(base.replace(/^https:\/\//, ''))}/templates/${esc(t.id)}</span></span>++<span class="wb-shot"><iframe src="/templates/${esc(t.id)}" title="${esc(t.name)}" loading="lazy" sandbox="" tabindex="-1" aria-hidden="true"></iframe></span>++</a>++<div class="wb-tpl-text"><h3>${esc(t.name)}</h3><p>${esc(t.description)}</p></div>++<div class="wb-tpl-go"><a class="wb-btn" href="${esc(startHref(t.id))}">Use this template</a><a class="wb-link" href="/templates/${esc(t.id)}">Full size</a></div>++</article>`;++}++++function home({ base }) {++ const sites = templates.sites();++ const body = `<div class="ov-room wb" style="${rooms.style('website')}">++${sc.hero({++ eyebrow: NAME,++ title: 'Websites that look finished.',++ accent: 'From the first preview.',++ lede: 'Start from a real site (a shop, a portfolio, an event, a link page, a product launch) or describe yours to OpenVibe AI. Change anything in the editor with the preview beside it.',++ actions: [++ { label: 'Pick a template', href: '#templates', primary: true },++ { label: 'Describe your site', href: startHref('') },++ ],++ note: 'Building and previewing are open to everyone with an OpenVibe account. Publishing at your own address opens with public hosting.',++ aside: { html: stage() },++ })}++<section class="sc-sec" id="templates" aria-labelledby="wb-tpl-h">++<div class="wb-head"><h2 id="wb-tpl-h">Start from a real site</h2><p>${sites.length} whole sites, each one page you can change line by line. Open one full size to see it the way your visitors will.</p></div>++<div class="wb-tpls">${sites.map((t) => templateCard(t, base)).join('')}</div>++</section>++${sc.steps({ id: 'how', title: 'From idea to address', items: [++ { title: 'Pick a site, or describe yours', text: 'A template is a working site from the first second; OpenVibe AI writes a first version from a sentence.' },++ { title: 'Change anything', text: 'Words, colours, sections and code, with the preview beside the editor so every change shows at once.' },++ { title: 'Sign-in and lists built in', text: 'RSVPs, guestbooks and polls keep their lists on OpenVibe, and visitors sign in with their OpenVibe account.' },++ { title: 'Your own address', text: 'yourname.openvibe.website, or your own domain proved by a TXT record. Every publish is kept, so going back is one step.' },++ ] })}++<section class="sc-sec" aria-labelledby="wb-first-h"><div class="wb-first">++<div><h2 id="wb-first-h">Sites made here</h2><p>None public yet: publishing opens with public hosting. Build yours now and it is ready on the first day.</p></div>++<a class="wb-btn" href="#templates">Pick a template</a>++</div></section>++${sc.faq({ id: 'questions', items: FAQ })}++</div>`;++ return page({ base, title: `${NAME} · Websites that look finished`, canonicalPath: '/', body, jsonLd: [++ { '@context': 'https://schema.org', '@type': 'WebSite', name: NAME, url: `${base}/`, description: DESCRIPTION },++ { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: FAQ.map(([q, a]) => ({ '@type': 'Question', name: q, acceptedAnswer: { '@type': 'Answer', text: a } })) },++ ] });++}++++function notFound({ base }) {++ return page({ base, title: `Not found · ${NAME}`, canonicalPath: '/', robots: 'noindex', body: `<div class="ov-room wb" style="${rooms.style('website')}">${sc.hero({++ eyebrow: NAME, title: 'Nothing lives at this address.',++ lede: 'Sites people make live at their own name, like rise-and-crumb.openvibe.website. This is OpenVibe.Website\'s front door.',++ actions: [{ label: 'Go to the front door', href: '/', primary: true }],++ })}</div>` });++}++++function page({ base, title, canonicalPath, body, jsonLd, robots }) {++ const links = [++ { label: 'Home', href: '/', active: true },++ { label: 'Templates', href: '/#templates' },++ { label: 'Your sites', href: `${SPACE}/spaces/mine` },++ ];++ return shell.page({++ name: NAME, service: 'space', lang: 'en', title, siteName: NAME, description: DESCRIPTION,++ canonical: `${base}${canonicalPath}`, robots: robots || 'index,follow', type: 'website', summary: DESCRIPTION, url: `${base}${canonicalPath}`,++ jsonLd,++ // Nobody is ever signed in on this host (server/website.js): the navbar's sign-in goes to openvibe.space.++ navbar: { service: 'space', apiBase: 'https://openvibe.network', links, sessionUrl: '/auth/me', loginUrl: `${SPACE}/auth/login?next=/spaces/mine` },++ home: '/', navLinks: links.map(({ label, href }) => ({ label, href })),++ footer: { service: 'space', variant: 'auto', path: canonicalPath },++ head: [++ appIcon.headTags({ site: 'space', iconBase: '/assets' }),++ `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">`,++ `<link rel="stylesheet" href="${ovServe.url('room.css')}">`,++ `<link rel="stylesheet" href="${asset('css/space.css')}">`,++ `<link rel="stylesheet" href="${asset('css/website.css')}">`,++ ].join('\n'),++ bodyAttributes: { 'data-page': 'website' },++ body: `<div id="navbar-mount"></div>\n<main id="main" class="page">\n${body}\n</main>`,++ });++}++++module.exports = { home, notFound, DESCRIPTION };@@@ -111,7 +111,9 @@ function createSpacesPages(ctx) {router.get('/spaces/new', view(async (req, res) => {if (!signedIn(req)) throw new ApiError(401, 'auth.required', 'sign in first');-- html(res, 200, render.newPage({ viewer: req.viewer, templates: templates.list(), csrf: csrfOf(req), aiOn: ai.enabled(), buildsLeft: await buildsLeft(req) }));++ // ?template=<id>: someone picked a template on openvibe.website; it leads the page, one press from a new space.++ const pick = req.query.template ? templates.get(String(req.query.template)) : null;++ html(res, 200, render.newPage({ viewer: req.viewer, templates: templates.list(), csrf: csrfOf(req), aiOn: ai.enabled(), buildsLeft: await buildsLeft(req), pick }));}));router.post('/spaces/new', ...action(async (req, res) => {const body = req.body || {};@@@ -93,11 +93,17 @@ function galleryPage({ viewer, spaces, templates, q, sort, csrf }) {return page({ title: 'Spaces', description: 'Make a space: a small app on your OpenVibe account, built by OpenVibe AI or from a template, with sign-in, shared lists, private storage and AI.', path: '/spaces', showcase: true, body });}--function newPage({ viewer, templates, csrf, error, prompt, aiOn, buildsLeft }) {++function newPage({ viewer, templates, csrf, error, prompt, aiOn, buildsLeft, pick = 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>++ <div class="sp-pick-text"><p class="eyebrow">The template you picked</p><h2 id="sp-pick-h">${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>` : '';const body = `<div class="sp-wrap"><p class="eyebrow">New space</p>-- <h1 class="sp-title">What do you want to make?</h1>++ <h1 class="sp-title">${pick ? 'Make it yours' : 'What do you want to make?'}</h1>${error ? `<p class="sp-flash is-bad" role="alert">${esc(error)}</p>` : ''}++ ${picked}<form class="sp-ai-form" method="post" action="/spaces/new"><input type="hidden" name="csrf" value="${esc(csrf)}"><label for="sp-prompt">Describe it and OpenVibe AI builds it</label>@@@ -17,11 +17,20 @@ const TEMPLATES = [{ 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'] },++ // 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'] },];const cache = new Map();function list() { return TEMPLATES; }++/** The website templates (openvibe.website's gallery). */++function sites() { return TEMPLATES.filter((t) => t.kind === 'site'); }function get(id) { return TEMPLATES.find((t) => t.id === id) || null; }/** The template's files ([{ path, content }]), or null for an unknown id. */function files(id) {@@@ -37,4 +46,4 @@ function files(id) {return cache.get(id).map((f) => ({ ...f }));}--module.exports = { list, get, files };++module.exports = { list, sites, get, files };nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++/**++ * OpenVibe.Website's front door at the sites domain itself (plan 5.9; the owner, 2026-10-31: "a bunch of them just++ * redirect and that's it"). http/tenant.js resolves openvibe.website and www.openvibe.website to { kind: 'website' }++ * and this router answers every request to them: the home, the website templates at full size, robots, the sitemap,++ * llms.txt and this site's static files. Anything else is a 404 here; the API, sign-in and the dashboard stay on++ * openvibe.space.++ *++ * No cookie, ever: tenant sites live at <name>.openvibe.website and, until the domain is on the Public Suffix List,++ * a tenant's script could set a cookie for the whole domain. So this host reads none and sets none (nginx strips++ * Cookie and Set-Cookie here as on every tenant host); sign-in and building happen on openvibe.space.++ */++const express = require('express');++const seo = require('openvibe-shared/seo');++const cache = require('openvibe-shared/cache-policy');++const templates = require('./spaces/templates');++++const PAGE_CACHE = cache.htmlHeaders({ maxAge: 300 });++// A template at full size is our own page with no script: scripts, frames, forms and plugins are refused, and it is++// never indexed (the home is the page to find).++const TEMPLATE_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data: https:; frame-ancestors 'self'; base-uri 'none'; form-action 'none'";++++function createWebsite({ sitesDomain, publicDir }) {++ const base = `https://${sitesDomain}`;++ const pages = require('./render/website');++ const router = express.Router();++++ // www is the same door: one address for search engines and links.++ router.use((req, res, next) => {++ if (String(req.headers.host || '').toLowerCase().startsWith('www.')) return res.redirect(301, `${base}${req.originalUrl}`);++ if (req.method !== 'GET' && req.method !== 'HEAD') return res.status(405).set('Allow', 'GET, HEAD').type('text/plain').send('Method not allowed');++ return next();++ });++++ router.get('/', (_req, res) => res.type('html').set('Cache-Control', PAGE_CACHE).send(pages.home({ base })));++++ router.get('/templates/:id', (req, res, next) => {++ const t = templates.get(req.params.id);++ if (!t || t.kind !== 'site') return next();++ res.set({ 'Content-Security-Policy': TEMPLATE_CSP, 'X-Robots-Tag': 'noindex', 'Cache-Control': PAGE_CACHE });++ // The page as it reads with no script: the scripts are left out, not blocked, so nothing complains in a console.++ return res.type('html').send(templates.files(t.id)[0].content.replace(/<script\b[^>]*>[\s\S]*?<\/script>\s*/gi, ''));++ });++++ // The shared navbar's who-is-signed-in read: nobody, always, on this host.++ router.get('/auth/me', (_req, res) => res.set('Cache-Control', 'no-store').json({ user: null }));++++ router.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', PAGE_CACHE)++ .send(seo.robotsTxt({ sitemaps: [`${base}/sitemap.xml`], disallow: ['/templates/'] })));++ router.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', PAGE_CACHE)++ .send(seo.sitemapXml([{ loc: `${base}/`, changefreq: 'weekly', priority: 1 }])));++ router.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', PAGE_CACHE).send(seo.llmsTxt({++ name: 'OpenVibe.Website',++ summary: pages.DESCRIPTION,++ sections: [++ { title: 'Start', links: [{ title: 'Website templates', url: `${base}/#templates`, html: true }, { title: 'Build on OpenVibe.Space', url: 'https://openvibe.space/spaces/new', html: true }] },++ { title: 'Templates', links: templates.sites().map((t) => ({ title: t.name, url: `${base}/templates/${t.id}`, html: true, note: t.description })) },++ ],++ })));++++ router.use('/shared', require('openvibe-shared/serve').handler());++ router.use(express.static(publicDir, { index: false, redirect: false, etag: true }));++ router.use((_req, res) => res.status(404).type('html').set('Cache-Control', PAGE_CACHE).send(pages.notFound({ base })));++++ return (req, res, next) => (req.hostTarget === 'website' ? router(req, res, next) : next());++}++++module.exports = { createWebsite, TEMPLATE_CSP };nnew file mode 100644@@@ -0,0 +1,118 @@++<!doctype html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<title>Rise & Crumb · Neighbourhood bakery</title>++<meta name="description" content="Sourdough, morning buns and celebration cakes, baked every morning on Alder Street.">++<style>++ /* Change the colours here: --accent is the button and link colour, --crust the warm band behind the menu. */++ :root { --bg: #fbf7f1; --card: #ffffff; --ink: #2b1d14; --muted: #7a6656; --line: #eadfd2; --accent: #b4532a; --on-accent: #fff; --crust: #f4e8da; }++ @media (prefers-color-scheme: dark) { :root { --bg: #17110d; --card: #211913; --ink: #f3e9df; --muted: #b8a595; --line: #3a2d23; --accent: #f08a5d; --on-accent: #1d120b; --crust: #1f1711; } }++ * { box-sizing: border-box; }++ body { margin: 0; background: var(--bg); color: var(--ink); font: 17px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; }++ h1, h2, h3 { font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif; line-height: 1.15; margin: 0; text-wrap: balance; }++ a { color: var(--accent); }++ .wrap { width: min(1080px, 100% - 32px); margin-inline: auto; }++ header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-block: 18px; }++ .logo { font: 700 1.25rem "Iowan Old Style", Georgia, serif; color: var(--ink); text-decoration: none; }++ nav { display: flex; gap: 18px; flex-wrap: wrap; }++ nav a { color: var(--muted); text-decoration: none; font-weight: 600; font-size: .95rem; }++ nav a:hover { color: var(--ink); }++ .hero { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, .9fr); gap: 40px; align-items: center; padding-block: 48px 64px; }++ .hero h1 { font-size: clamp(2.4rem, 6vw, 4rem); }++ .hero p { color: var(--muted); font-size: 1.15rem; max-width: 34ch; margin: 16px 0 24px; }++ .btns { display: flex; gap: 12px; flex-wrap: wrap; }++ .btn { display: inline-flex; align-items: center; min-height: 46px; padding: 0 20px; border-radius: 999px; background: var(--accent); color: var(--on-accent); font-weight: 700; text-decoration: none; }++ .btn.ghost { background: transparent; color: var(--ink); box-shadow: inset 0 0 0 1.5px var(--line); }++ .open { display: inline-flex; align-items: center; gap: 8px; margin-top: 20px; font-size: .92rem; color: var(--muted); }++ .open i { width: 9px; height: 9px; border-radius: 50%; background: #3f9d5b; }++ .open.closed i { background: var(--muted); }++ .loaf { aspect-ratio: 1; max-width: 100%; border-radius: 32% 68% 54% 46% / 42% 38% 62% 58%; background: radial-gradient(circle at 34% 30%, #e9b980 0 18%, transparent 19%), radial-gradient(circle at 62% 58%, #d99a5b 0 22%, transparent 23%), linear-gradient(140deg, #f2c48d, #c27a3f 70%, #8f4f25); box-shadow: 0 40px 80px -40px rgba(80, 40, 10, .55), inset 0 -20px 40px rgba(90, 45, 15, .35); position: relative; }++ .loaf::after { content: ""; position: absolute; inset: 22% 18%; border-radius: 50%; background: repeating-linear-gradient(115deg, transparent 0 22px, rgba(255, 240, 220, .55) 22px 26px); mask: radial-gradient(closest-side, #000 70%, transparent); }++ section { padding-block: 56px; }++ .band { background: var(--crust); }++ .head { display: flex; justify-content: space-between; align-items: end; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }++ .head h2 { font-size: clamp(1.8rem, 4vw, 2.4rem); }++ .head p { margin: 0; color: var(--muted); }++ .menu { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 14px; }++ .item { background: var(--card); border: 1px solid var(--line); border-radius: 18px; padding: 18px; display: grid; gap: 6px; }++ .item h3 { font-size: 1.15rem; display: flex; justify-content: space-between; gap: 10px; }++ .item h3 span { font: 700 1rem system-ui, sans-serif; color: var(--accent); font-variant-numeric: tabular-nums; }++ .item p { margin: 0; color: var(--muted); font-size: .95rem; }++ .tag { justify-self: start; font-size: .75rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--accent); }++ .visit { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 28px; }++ table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }++ td { padding: 9px 0; border-bottom: 1px solid var(--line); }++ td:last-child { text-align: right; color: var(--muted); }++ tr.today td { font-weight: 700; color: var(--ink); }++ address { font-style: normal; line-height: 1.8; }++ footer { padding-block: 32px; color: var(--muted); font-size: .9rem; border-top: 1px solid var(--line); }++ @media (max-width: 760px) { .hero { grid-template-columns: 1fr; padding-top: 24px; } .loaf { width: 60%; justify-self: center; order: -1; } }++</style>++</head>++<body>++<div class="wrap">++ <header>++ <a class="logo" href="#top">Rise & Crumb</a>++ <nav aria-label="Sections"><a href="#menu">Menu</a><a href="#visit">Hours & visit</a><a href="#order">Order</a></nav>++ </header>++ <main id="top">++ <div class="hero">++ <div>++ <h1>Bread worth getting up for.</h1>++ <p>Naturally leavened sourdough, morning buns and celebration cakes, baked every morning on Alder Street since 2014.</p>++ <div class="btns"><a class="btn" id="order" href="tel:+15550142233">Call to order for pickup</a><a class="btn ghost" href="#menu">See today's menu</a></div>++ <p class="open" id="open"><i></i><span>Open Tuesday to Sunday, 7 am to 3 pm</span></p>++ </div>++ <div class="loaf" role="img" aria-label="A round sourdough loaf"></div>++ </div>++ </main>++</div>++<section class="band" id="menu">++ <div class="wrap">++ <div class="head"><h2>This morning's bake</h2><p>Sold until it's gone. Whole cakes need two days' notice.</p></div>++ <div class="menu">++ <article class="item"><span class="tag">Every day</span><h3>Country sourdough <span>$9</span></h3><p>Three-day ferment, dark crust, open crumb.</p></article>++ <article class="item"><span class="tag">Every day</span><h3>Morning bun <span>$4.50</span></h3><p>Laminated dough, orange zest, cinnamon sugar.</p></article>++ <article class="item"><span class="tag">Weekends</span><h3>Seeded rye <span>$10</span></h3><p>Sunflower, flax and caraway, sliced on request.</p></article>++ <article class="item"><span class="tag">Every day</span><h3>Olive oil focaccia <span>$7</span></h3><p>Rosemary, flaky salt and a lot of good oil.</p></article>++ <article class="item"><span class="tag">Order ahead</span><h3>Celebration cake <span>from $38</span></h3><p>Vanilla, chocolate or lemon; serves eight to twelve.</p></article>++ <article class="item"><span class="tag">Fridays</span><h3>Cardamom knot <span>$4.75</span></h3><p>Swedish-style, pearl sugar on top.</p></article>++ </div>++ </div>++</section>++<section id="visit">++ <div class="wrap visit">++ <div>++ <h2>Hours</h2>++ <table id="hours">++ <tr data-day="1"><td>Monday</td><td>Closed</td></tr>++ <tr data-day="2"><td>Tuesday</td><td>7 am – 3 pm</td></tr>++ <tr data-day="3"><td>Wednesday</td><td>7 am – 3 pm</td></tr>++ <tr data-day="4"><td>Thursday</td><td>7 am – 3 pm</td></tr>++ <tr data-day="5"><td>Friday</td><td>7 am – 3 pm</td></tr>++ <tr data-day="6"><td>Saturday</td><td>7 am – 3 pm</td></tr>++ <tr data-day="0"><td>Sunday</td><td>8 am – 2 pm</td></tr>++ </table>++ </div>++ <div>++ <h2>Visit</h2>++ <address>412 Alder Street<br>Portland, Oregon<br><a href="tel:+15550142233">(555) 014-2233</a><br><a href="https://www.openstreetmap.org/search?query=412%20Alder%20Street%20Portland">Open the map</a></address>++ </div>++ </div>++</section>++<footer><div class="wrap">© Rise & Crumb. Made with OpenVibe.Website.</div></footer>++<script>++ // Change the hours here as [open hour, close hour] for each day (0 is Sunday); null means closed.++ const HOURS = { 0: [8, 14], 1: null, 2: [7, 15], 3: [7, 15], 4: [7, 15], 5: [7, 15], 6: [7, 15] };++ const now = new Date(), today = HOURS[now.getDay()], h = now.getHours() + now.getMinutes() / 60;++ const row = document.querySelector('#hours tr[data-day="' + now.getDay() + '"]');++ if (row) row.className = 'today';++ const open = document.getElementById('open');++ const fmt = (x) => (x % 12 || 12) + (x < 12 ? ' am' : ' pm');++ if (today && h >= today[0] && h < today[1]) open.lastElementChild.textContent = 'Open now, until ' + fmt(today[1]);++ else { open.classList.add('closed'); open.lastElementChild.textContent = today && h < today[0] ? 'Opens today at ' + fmt(today[0]) : 'Closed now'; }++</script>++</body>++</html>nnew file mode 100644@@@ -0,0 +1,117 @@++<!doctype html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<title>Night Market on Fifth · Saturday 14 November</title>++<meta name="description" content="Forty stalls, live music and food from the neighbourhood, on Fifth Street from 5 to 11 pm.">++<style>++ /* Change the colours here: --accent is the date and the buttons, --glow the light behind the title. */++ :root { --bg: #fff8ef; --card: #ffffff; --ink: #1e1530; --muted: #6e6280; --line: #ecdfd0; --accent: #d9468a; --on-accent: #fff; --glow: #ffd27a; }++ @media (prefers-color-scheme: dark) { :root { --bg: #120d1d; --card: #1b1429; --ink: #f6eefe; --muted: #b0a2c4; --line: #2e2440; --accent: #ff7ab6; --on-accent: #1a0f20; --glow: #6b3fa0; } }++ * { box-sizing: border-box; }++ body { margin: 0; background: var(--bg); color: var(--ink); font: 17px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; }++ h1, h2 { line-height: 1.08; margin: 0; text-wrap: balance; letter-spacing: -.02em; }++ .wrap { width: min(980px, 100% - 32px); margin-inline: auto; }++ .hero { position: relative; overflow: hidden; padding-block: 72px 56px; text-align: center; }++ .hero::before { content: ""; position: absolute; inset: -40% 10% auto; height: 120%; background: radial-gradient(closest-side, var(--glow), transparent); opacity: .7; z-index: -1; }++ .lights { display: flex; justify-content: center; gap: 18px; margin-bottom: 22px; }++ .lights i { width: 12px; height: 12px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 14px var(--accent); }++ .lights i:nth-child(2n) { background: #ffb547; box-shadow: 0 0 14px #ffb547; }++ .date { font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--accent); }++ .hero h1 { font-size: clamp(2.6rem, 8vw, 5.2rem); margin: 10px 0 14px; }++ .hero p { color: var(--muted); font-size: 1.15rem; margin: 0 auto 26px; max-width: 46ch; }++ .facts { display: flex; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 26px; }++ .facts span { padding: 8px 14px; border-radius: 999px; background: var(--card); border: 1px solid var(--line); font-weight: 600; font-size: .95rem; }++ .btn { display: inline-flex; align-items: center; justify-content: center; min-height: 48px; padding: 0 22px; border: 0; border-radius: 999px; background: var(--accent); color: var(--on-accent); font: 700 1rem system-ui, sans-serif; text-decoration: none; cursor: pointer; }++ .btn.ghost { background: transparent; color: var(--ink); box-shadow: inset 0 0 0 1.5px var(--line); }++ section { padding-block: 48px; }++ h2 { font-size: clamp(1.6rem, 4vw, 2.2rem); margin-bottom: 20px; }++ .timeline { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }++ .timeline li { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 16px; padding: 14px 18px; background: var(--card); border: 1px solid var(--line); border-radius: 16px; }++ .timeline time { font-weight: 800; color: var(--accent); font-variant-numeric: tabular-nums; }++ .timeline b { display: block; }++ .timeline span { color: var(--muted); font-size: .95rem; }++ .rsvp { background: var(--card); border: 1px solid var(--line); border-radius: 22px; padding: 24px; display: grid; gap: 16px; }++ .rsvp-top { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }++ .count { font-size: 1.1rem; font-weight: 700; }++ .faces { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; margin: 0; padding: 0; }++ .faces li { display: inline-flex; align-items: center; gap: 8px; padding: 4px 12px 4px 4px; border-radius: 999px; border: 1px solid var(--line); font-size: .92rem; }++ .faces img { width: 28px; height: 28px; border-radius: 50%; background: var(--line); }++ .muted { color: var(--muted); margin: 0; }++ details { border-bottom: 1px solid var(--line); padding: 14px 0; }++ summary { font-weight: 700; cursor: pointer; }++ details p { margin: 8px 0 0; color: var(--muted); }++ footer { padding-block: 32px; color: var(--muted); font-size: .9rem; text-align: center; }++ @media (max-width: 560px) { .timeline li { grid-template-columns: 1fr; gap: 2px; } }++</style>++</head>++<body>++<main>++ <div class="hero">++ <div class="wrap">++ <div class="lights" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></div>++ <span class="date">Saturday 14 November</span>++ <h1>Night Market on Fifth</h1>++ <p>Forty stalls, live music and food from the neighbourhood. Free to get in, all ages, rain or shine under the awnings.</p>++ <div class="facts"><span>5 pm – 11 pm</span><span>Fifth Street, between Oak and Pine</span><span>Free</span></div>++ <a class="btn" href="#rsvp">I'm coming</a>++ </div>++ </div>++ <div class="wrap">++ <section id="schedule">++ <h2>The evening</h2>++ <ol class="timeline">++ <li><time>5:00</time><div><b>Stalls open</b><span>Makers, vintage, plants and the book swap.</span></div></li>++ <li><time>6:30</time><div><b>The Larkspur Trio</b><span>Folk and old swing on the corner stage.</span></div></li>++ <li><time>8:00</time><div><b>Lantern walk</b><span>Kids lead the parade down the street; lanterns at the info tent.</span></div></li>++ <li><time>9:00</time><div><b>DJ Halvard</b><span>Records until close.</span></div></li>++ </ol>++ </section>++ <section id="rsvp">++ <h2>Who's coming</h2>++ <div class="rsvp">++ <div class="rsvp-top"><span class="count" id="count">Say you're coming so we know how many to expect.</span><span id="act"><a class="btn" href="#rsvp">Sign in to say you're coming</a></span></div>++ <ul class="faces" id="faces"></ul>++ </div>++ </section>++ <section id="questions">++ <h2>Questions</h2>++ <details><summary>Can I bring my dog?</summary><p>Yes, on a lead. There's water at the info tent.</p></details>++ <details><summary>Is there parking?</summary><p>The Oak Street garage is free after 6 pm. Bike racks are at both ends of the street.</p></details>++ <details><summary>Can I have a stall?</summary><p>Stalls for the spring market open in February. Sign up for news at the info tent.</p></details>++ </section>++ </div>++</main>++<footer>Night Market on Fifth · Made with OpenVibe.Website</footer>++<script src="/_openvibe/sdk.js"></script>++<script>++ // Everyone who says they're coming is kept in the shared list "rsvps": their OpenVibe name and picture, nothing else.++ const $ = (s) => document.querySelector(s);++ function el(tag, props, ...kids) { const n = Object.assign(document.createElement(tag), props || {}); n.append(...kids); return n; }++ let going = [];++ function render() {++ const mine = OV.user && going.some((r) => r.author.username === OV.user.username);++ $('#count').textContent = going.length ? going.length + (going.length === 1 ? ' person is' : ' people are') + ' coming' : 'Be the first to say you\'re coming.';++ const act = $('#act'); act.replaceChildren();++ if (!OV.user) { const b = el('button', { className: 'btn', type: 'button', textContent: 'Sign in to say you\'re coming' }); b.onclick = () => OV.signIn(); act.append(b); }++ else if (mine) act.append(el('span', { className: 'muted', textContent: 'You\'re on the list. See you there!' }));++ else { const b = el('button', { className: 'btn', type: 'button', textContent: 'I\'m coming' }); b.onclick = async () => { b.disabled = true; try { await OV.items.add('rsvps', { at: Date.now() }); await load(); } catch (e) { b.disabled = false; $('#count').textContent = e.message; } }; act.append(b); }++ $('#faces').replaceChildren(...going.slice(0, 60).map((r) => {++ const img = el('img', { alt: '', loading: 'lazy', src: 'https://openvibe.network/avatar/' + encodeURIComponent(r.author.username) });++ img.onerror = () => img.removeAttribute('src');++ return el('li', {}, img, r.author.display_name);++ }));++ }++ async function load() {++ try {++ const seen = new Set();++ going = (await OV.items.list('rsvps', { limit: 200 })).filter((r) => !seen.has(r.author.username) && seen.add(r.author.username));++ } catch (e) { going = []; }++ render();++ }++ OV.on('user', render);++ OV.ready.then(load);++</script>++</body>++</html>nnew file mode 100644@@@ -0,0 +1,110 @@++<!doctype html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<title>Tally · Shared expenses without the spreadsheet</title>++<meta name="description" content="Tally splits rent, trips and groceries fairly, settles up in one tap and never shows anyone an ad.">++<style>++ /* Change the colours here: --accent is the buttons and highlights. */++ :root { --bg: #ffffff; --soft: #f4f7f5; --card: #ffffff; --ink: #0d1f17; --muted: #5b6e65; --line: #e1e8e4; --accent: #0f8a5f; --on-accent: #fff; }++ @media (prefers-color-scheme: dark) { :root { --bg: #08110d; --soft: #0d1914; --card: #10201a; --ink: #e6f3ec; --muted: #94ab9f; --line: #1d3128; --accent: #3ddc97; --on-accent: #062014; } }++ * { box-sizing: border-box; }++ body { margin: 0; background: var(--bg); color: var(--ink); font: 17px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; }++ h1, h2, h3 { line-height: 1.1; margin: 0; letter-spacing: -.02em; text-wrap: balance; }++ .wrap { width: min(1080px, 100% - 32px); margin-inline: auto; }++ header { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding-block: 18px; }++ .logo { font-weight: 800; font-size: 1.2rem; display: inline-flex; align-items: center; gap: 8px; }++ .logo i { width: 22px; height: 22px; border-radius: 7px; background: var(--accent); }++ .btn { display: inline-flex; align-items: center; justify-content: center; min-height: 46px; padding: 0 20px; border-radius: 12px; background: var(--accent); color: var(--on-accent); font-weight: 700; text-decoration: none; }++ .btn.ghost { background: transparent; color: var(--ink); box-shadow: inset 0 0 0 1.5px var(--line); }++ .hero { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; align-items: center; padding-block: 56px 72px; }++ .hero h1 { font-size: clamp(2.4rem, 5.6vw, 3.8rem); }++ .hero p { color: var(--muted); font-size: 1.15rem; margin: 18px 0 26px; max-width: 40ch; }++ .btns { display: flex; gap: 12px; flex-wrap: wrap; }++ .small { color: var(--muted); font-size: .9rem; margin-top: 14px; }++ .phone { justify-self: center; width: min(300px, 100%); border-radius: 36px; padding: 14px; background: var(--ink); box-shadow: 0 40px 80px -40px rgba(0, 60, 40, .5); }++ .screen { border-radius: 26px; background: var(--soft); padding: 18px; display: grid; gap: 10px; }++ .screen b { font-size: .8rem; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }++ .row { display: flex; justify-content: space-between; align-items: center; padding: 12px 14px; border-radius: 14px; background: var(--card); border: 1px solid var(--line); font-size: .95rem; }++ .row span:last-child { font-weight: 700; font-variant-numeric: tabular-nums; }++ .row.owe span:last-child { color: var(--accent); }++ .settle { text-align: center; padding: 12px; border-radius: 14px; background: var(--accent); color: var(--on-accent); font-weight: 700; }++ section { padding-block: 64px; }++ .soft { background: var(--soft); }++ .center { text-align: center; max-width: 640px; margin: 0 auto 36px; }++ .center h2 { font-size: clamp(1.8rem, 4vw, 2.6rem); }++ .center p { color: var(--muted); margin: 12px 0 0; }++ .grid3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }++ .feat { background: var(--card); border: 1px solid var(--line); border-radius: 18px; padding: 22px; }++ .feat h3 { font-size: 1.15rem; margin: 12px 0 6px; }++ .feat p { margin: 0; color: var(--muted); }++ .ico { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); font-weight: 800; }++ .plans { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; max-width: 760px; margin: 0 auto; }++ .plan { background: var(--card); border: 1px solid var(--line); border-radius: 20px; padding: 26px; display: grid; gap: 12px; align-content: start; }++ .plan.pick { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }++ .price { font-size: 2.4rem; font-weight: 800; letter-spacing: -.02em; }++ .price small { font-size: 1rem; color: var(--muted); font-weight: 500; }++ .plan ul { margin: 0; padding-left: 18px; color: var(--muted); }++ details { border-bottom: 1px solid var(--line); padding: 16px 0; max-width: 720px; margin: 0 auto; }++ summary { font-weight: 700; cursor: pointer; }++ details p { margin: 8px 0 0; color: var(--muted); }++ .final { text-align: center; }++ .final h2 { font-size: clamp(1.8rem, 4vw, 2.6rem); margin-bottom: 20px; }++ footer { padding-block: 28px; border-top: 1px solid var(--line); color: var(--muted); font-size: .9rem; }++ @media (max-width: 780px) { .hero { grid-template-columns: 1fr; } }++</style>++</head>++<body>++<div class="wrap"><header><span class="logo"><i aria-hidden="true"></i>Tally</span><a class="btn ghost" href="#pricing">Pricing</a></header></div>++<main>++ <div class="wrap hero">++ <div>++ <h1>Shared expenses, without the spreadsheet.</h1>++ <p>Tally splits rent, trips and groceries fairly, keeps a running total for everyone, and settles up in one tap.</p>++ <div class="btns"><a class="btn" href="#pricing">Start free</a><a class="btn ghost" href="#how">How it works</a></div>++ <p class="small">Free for up to three groups. No ads, ever.</p>++ </div>++ <div class="phone" aria-label="The Tally app showing a weekend trip" role="img">++ <div class="screen">++ <b>Weekend in Porto</b>++ <div class="row"><span>Apartment</span><span>€420.00</span></div>++ <div class="row"><span>Dinner at Taberna</span><span>€96.50</span></div>++ <div class="row"><span>Train tickets</span><span>€64.00</span></div>++ <div class="row owe"><span>Sam owes you</span><span>€58.40</span></div>++ <div class="settle">Settle up</div>++ </div>++ </div>++ </div>++ <section class="soft" id="how">++ <div class="wrap">++ <div class="center"><h2>Fair, without the awkward conversation</h2><p>Everything a shared flat or a group trip needs, and nothing it doesn't.</p></div>++ <div class="grid3">++ <div class="feat"><span class="ico">÷</span><h3>Split any way</h3><p>Evenly, by share, by percentage, or "I'll get this one".</p></div>++ <div class="feat"><span class="ico">↻</span><h3>Repeats itself</h3><p>Rent and bills come back every month without anyone typing them again.</p></div>++ <div class="feat"><span class="ico">✓</span><h3>Settle in one tap</h3><p>Tally works out the fewest payments so everyone ends up even.</p></div>++ </div>++ </div>++ </section>++ <section id="pricing">++ <div class="wrap">++ <div class="center"><h2>Simple pricing</h2><p>Start free. Upgrade when the group chat gets bigger.</p></div>++ <div class="plans">++ <div class="plan"><h3>Free</h3><div class="price">€0</div><ul><li>Up to three groups</li><li>Unlimited expenses</li><li>Settle up</li></ul><a class="btn ghost" href="#">Start free</a></div>++ <div class="plan pick"><h3>Plus</h3><div class="price">€3 <small>a month</small></div><ul><li>Unlimited groups</li><li>Receipts and categories</li><li>Export to a spreadsheet</li></ul><a class="btn" href="#">Try Plus for a month</a></div>++ </div>++ </div>++ </section>++ <section class="soft" id="questions">++ <div class="wrap">++ <div class="center"><h2>Questions</h2></div>++ <details><summary>Does everyone need the app?</summary><p>No. Friends can see their balance from a link, and join when they like.</p></details>++ <details><summary>Does Tally move money?</summary><p>No. It tells you who pays whom; you pay however you already do.</p></details>++ <details><summary>Can I leave?</summary><p>Any time, with every expense exported first.</p></details>++ </div>++ </section>++ <section class="final"><div class="wrap"><h2>Even, every time.</h2><a class="btn" href="#pricing">Start free</a></div></section>++</main>++<footer><div class="wrap">© Tally · Made with OpenVibe.Website</div></footer>++</body>++</html>nnew file mode 100644@@@ -0,0 +1,53 @@++<!doctype html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<title>Juno Park · links</title>++<meta name="description" content="Juno Park: streams, the podcast, the newsletter and the shop, all in one place.">++<style>++ /* Change the colours here: the page's gradient runs from --from to --to. */++ :root { --from: #ffe1ec; --to: #e4e8ff; --card: rgba(255, 255, 255, .72); --ink: #1d1a2b; --muted: #635d78; --line: rgba(29, 26, 43, .1); --accent: #6d4aff; }++ @media (prefers-color-scheme: dark) { :root { --from: #2a1230; --to: #10183a; --card: rgba(255, 255, 255, .07); --ink: #f3f0ff; --muted: #b3acd0; --line: rgba(255, 255, 255, .12); --accent: #a996ff; } }++ * { box-sizing: border-box; }++ body { margin: 0; min-height: 100vh; background: linear-gradient(160deg, var(--from), var(--to)) fixed; color: var(--ink); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; display: grid; justify-items: center; padding: 48px 16px 32px; }++ main { width: min(480px, 100%); display: grid; gap: 22px; text-align: center; }++ .face { width: 104px; height: 104px; margin: 0 auto; border-radius: 50%; display: grid; place-items: center; font: 800 2.2rem system-ui, sans-serif; color: #fff; background: conic-gradient(from 200deg, #ff7aa8, #8b6cff, #4cc9ff, #ff7aa8); box-shadow: 0 0 0 5px var(--card), 0 18px 40px -18px rgba(60, 30, 120, .6); }++ h1 { margin: 0; font-size: 1.6rem; letter-spacing: -.01em; }++ .bio { margin: 6px 0 0; color: var(--muted); }++ .links { display: grid; gap: 12px; }++ .links a { display: flex; align-items: center; gap: 14px; min-height: 60px; padding: 10px 18px; border-radius: 18px; background: var(--card); border: 1px solid var(--line); color: inherit; text-decoration: none; font-weight: 650; backdrop-filter: blur(10px); text-align: left; transition: transform .15s ease; }++ .links a:hover { transform: translateY(-2px); }++ .links a:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }++ .links svg { width: 22px; height: 22px; flex: none; color: var(--accent); }++ .links small { display: block; font-weight: 500; color: var(--muted); }++ .links .new { margin-left: auto; font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--accent); }++ .social { display: flex; justify-content: center; gap: 10px; }++ .social a { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 50%; background: var(--card); border: 1px solid var(--line); color: var(--ink); }++ .social svg { width: 20px; height: 20px; }++ footer { color: var(--muted); font-size: .82rem; }++ @media (prefers-reduced-motion: reduce) { .links a { transition: none; } }++</style>++</head>++<body>++<main>++ <div>++ <div class="face" aria-hidden="true">JP</div>++ <h1>Juno Park</h1>++ <p class="bio">Cozy games, bad puns and a weekly podcast about tiny worlds.</p>++ </div>++ <nav class="links" aria-label="Links">++ <a href="https://openvibe.live/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="3"/><path d="m10 9 5 3-5 3z" fill="currentColor"/></svg><span>Watch me live<small>Tuesdays and Fridays at 8 pm</small></span><span class="new">Live</span></a>++ <a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v4"/></svg><span>Tiny Worlds podcast<small>Episode 48: islands you can walk around in a minute</small></span></a>++ <a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v16H4z"/><path d="m4 7 8 6 8-6"/></svg><span>The newsletter<small>One cozy game a week, no spam</small></span></a>++ <a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 7h12l-1 13H7z"/><path d="M9 7a3 3 0 0 1 6 0"/></svg><span>Stickers and prints<small>Ships worldwide</small></span></a>++ </nav>++ <div class="social" aria-label="Elsewhere">++ <a href="#" aria-label="Video channel"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="4"/><path d="m10 9 5 3-5 3z" fill="currentColor"/></svg></a>++ <a href="#" aria-label="Photos"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/></svg></a>++ <a href="#" aria-label="Email"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16v12H4z"/><path d="m4 7 8 6 8-6"/></svg></a>++ </div>++ <footer>Made with OpenVibe.Website</footer>++</main>++</body>++</html>nnew file mode 100644@@@ -0,0 +1,75 @@++<!doctype html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<title>Mara Ellison · Designer</title>++<meta name="description" content="Mara Ellison designs identities, books and interfaces for small studios and cultural institutions.">++<style>++ /* Change the colours here; each project tile takes its two colours from its own style="--a:…;--b:…". */++ :root { --bg: #f5f5f2; --ink: #121212; --muted: #6a6a64; --line: #deded8; --accent: #2f54eb; }++ @media (prefers-color-scheme: dark) { :root { --bg: #0e0e0f; --ink: #eeeeea; --muted: #9a9a93; --line: #2a2a2c; --accent: #8ea6ff; } }++ * { box-sizing: border-box; }++ body { margin: 0; background: var(--bg); color: var(--ink); font: 17px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; }++ .mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: .8rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }++ a { color: inherit; }++ .wrap { width: min(1160px, 100% - 32px); margin-inline: auto; }++ header { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding-block: 22px; border-bottom: 1px solid var(--line); }++ header b { font-weight: 700; }++ header nav { display: flex; gap: 20px; }++ header nav a { text-decoration: none; }++ .intro { padding-block: 72px 56px; }++ .intro h1 { font-size: clamp(2.2rem, 6.4vw, 5rem); line-height: 1.02; letter-spacing: -.03em; margin: 12px 0 0; max-width: 16ch; text-wrap: balance; font-weight: 650; }++ .intro h1 em { font-style: normal; color: var(--accent); }++ .work { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 18px; }++ .p { grid-column: span 6; display: grid; gap: 10px; text-decoration: none; }++ .p:nth-child(3n) { grid-column: span 12; }++ .tile { aspect-ratio: 4 / 3; border-radius: 6px; background: linear-gradient(135deg, var(--a), var(--b)); position: relative; overflow: hidden; }++ .p:nth-child(3n) .tile { aspect-ratio: 21 / 9; }++ .tile::before { content: ""; position: absolute; width: 46%; aspect-ratio: 1; right: 8%; bottom: -12%; border-radius: 50%; background: rgba(255, 255, 255, .22); }++ .tile::after { content: ""; position: absolute; left: 7%; top: 12%; width: 28%; height: 5px; background: rgba(255, 255, 255, .75); box-shadow: 0 14px 0 rgba(255, 255, 255, .5), 0 28px 0 rgba(255, 255, 255, .3); }++ .p:hover .tile { filter: saturate(1.15); }++ .meta { display: flex; justify-content: space-between; gap: 12px; }++ .meta h2 { font-size: 1.05rem; margin: 0; font-weight: 600; }++ section { padding-block: 72px; }++ .about { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 32px; border-top: 1px solid var(--line); }++ .about p { margin: 0 0 14px; font-size: 1.2rem; line-height: 1.55; max-width: 60ch; }++ .clients { display: flex; flex-wrap: wrap; gap: 8px 22px; margin-top: 20px; color: var(--muted); }++ .contact { border-top: 1px solid var(--line); }++ .contact a.big { display: inline-block; margin-top: 10px; font-size: clamp(1.6rem, 5vw, 3.2rem); font-weight: 650; letter-spacing: -.02em; text-decoration-thickness: 2px; text-underline-offset: 6px; color: var(--accent); word-break: break-word; }++ footer { padding-block: 28px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }++ @media (max-width: 720px) { .p, .p:nth-child(3n) { grid-column: span 12; } .p:nth-child(3n) .tile { aspect-ratio: 4 / 3; } .about { grid-template-columns: 1fr; } header nav { gap: 14px; } }++</style>++</head>++<body>++<div class="wrap">++ <header><b>Mara Ellison</b><nav aria-label="Sections"><a href="#work">Work</a><a href="#about">About</a><a href="#contact">Contact</a></nav></header>++ <main>++ <div class="intro">++ <span class="mono">Designer · Lisbon and remote</span>++ <h1>Identities, books and interfaces for people who <em>make things</em>.</h1>++ </div>++ <div class="work" id="work">++ <a class="p" href="#contact"><div class="tile" style="--a:#ff6a3d;--b:#ffb44a"></div><div class="meta"><h2>Tidewater Ceramics</h2><span class="mono">Identity · 2026</span></div></a>++ <a class="p" href="#contact"><div class="tile" style="--a:#2f54eb;--b:#7c9bff"></div><div class="meta"><h2>Field Notes on Light</h2><span class="mono">Book · 2025</span></div></a>++ <a class="p" href="#contact"><div class="tile" style="--a:#0f766e;--b:#5eead4"></div><div class="meta"><h2>Harbour Museum wayfinding and website</h2><span class="mono">Signage · Web · 2025</span></div></a>++ <a class="p" href="#contact"><div class="tile" style="--a:#7c3aed;--b:#e879f9"></div><div class="meta"><h2>Nightjar Records</h2><span class="mono">Packaging · 2024</span></div></a>++ <a class="p" href="#contact"><div class="tile" style="--a:#1f2937;--b:#6b7280"></div><div class="meta"><h2>Quiet Hours app</h2><span class="mono">Interface · 2024</span></div></a>++ </div>++ <section class="about" id="about">++ <span class="mono">About</span>++ <div>++ <p>I've spent twelve years helping studios, publishers and museums look like themselves. I work closely with a few clients at a time, from the first sketch to the last file at the printer.</p>++ <p>Before going independent I was a senior designer at Atelier Norte. I teach typography one evening a week.</p>++ <div class="clients mono"><span>Harbour Museum</span><span>Nightjar Records</span><span>Casa Verde Press</span><span>Tidewater</span><span>Open Cities Festival</span></div>++ </div>++ </section>++ <section class="contact" id="contact">++ <span class="mono">Taking projects from March</span><br>++ <a class="big" href="mailto:[email protected]">[email protected]</a>++ </section>++ </main>++ <footer><span class="mono">© Mara Ellison</span><span class="mono">Made with OpenVibe.Website</span></footer>++</div>++</body>++</html>@@@ -316,7 +316,9 @@ function fakeAi() {const templates = require('../server/spaces/templates');for (const tpl of templates.list()) {const html = templates.files(tpl.id)[0].content;-- assert.match(html, /<script src="\/_openvibe\/sdk\.js"><\/script>/, tpl.id);++ // Every app loads the SDK; a website template loads it only when it signs people in or keeps a list.++ if (tpl.kind !== 'site' || tpl.capabilities.length) assert.match(html, /<script src="\/_openvibe\/sdk\.js"><\/script>/, tpl.id);++ else assert.ok(!html.includes('/_openvibe/sdk.js'), `${tpl.id} loads no SDK it does not use`);for (const m of html.matchAll(/<script>([\s\S]*?)<\/script>/g)) new Function(m[1]);}});nnew file mode 100644@@@ -0,0 +1,92 @@++'use strict';++/**++ * OpenVibe.Website's front door (server/website.js; the owner, 2026-10-31: the domain only redirected). The sites++ * domain itself answers with its own home, never a tenant and never the dashboard: the motif, the website templates++ * with live previews of the real pages, one press from a new space on openvibe.space. The host reads and sets no++ * cookie, serves nothing of the API or sign-in, and a template at full size runs no script.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./stageb/boot');++const templates = require('../server/spaces/templates');++++const src = fs.readFileSync(path.join(__dirname, '..', 'server/website.js'), 'utf8');++// The host dispatch: the sites domain and www are the front door, before any tenant lookup, and pass on to the app.++const tenantSrc = fs.readFileSync(path.join(__dirname, '..', 'server/http/tenant.js'), 'utf8');++const hostingSrc = fs.readFileSync(path.join(__dirname, '..', 'server/hosting.js'), 'utf8');++assert.ok(tenantSrc.indexOf("return { kind: 'website', host }") > 0 && tenantSrc.indexOf("return { kind: 'website', host }") < tenantSrc.indexOf('if (host.endsWith(suffix))'), 'the apex is resolved before any tenant');++assert.match(hostingSrc, /target\.kind === 'dashboard' \|\| target\.kind === 'website'\) return next\(\)/, 'the front door goes on to the app, not to a tenant');++++(async () => {++ const t = await boot();++ const alice = t.user('alice');++ try {++ await check('the sites domain is the front door: the motif, every website template, the honest status', async () => {++ const r = await t.get('openvibe.website', '/');++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.match(r.headers['content-type'], /text\/html/);++ assert.ok(r.text.includes('class="wb-stage"') && r.text.includes('class="wb-typed"'), 'the prompt types and the page assembles');++ const sites = templates.sites();++ assert.strictEqual(sites.length, 5, 'five website templates');++ for (const s of sites) {++ assert.ok(r.text.includes(`<iframe src="/templates/${s.id}"`) && r.text.includes('sandbox=""'), `${s.id} previews with no script`);++ assert.ok(r.text.includes(`https://openvibe.space/auth/login?next=${encodeURIComponent(`/spaces/new?template=${s.id}`)}`), `${s.id} is one press from a new space`);++ }++ assert.match(r.text, /Publishing at your own address opens with public hosting/, 'it claims no publishing it does not have');++ assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.website\/">/);++ assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');++ assert.ok(r.text.includes('"@type":"FAQPage"'), 'its questions are structured data');++ });++++ await check('no cookie, ever: none set, none read, no sign-in or API on this host', async () => {++ const home = await t.get('openvibe.website', '/', { headers: { cookie: 'ov_token=x; sp_sess=y' } });++ assert.strictEqual(home.headers['set-cookie'], undefined, 'the home sets no cookie');++ for (const p of ['/auth/login', '/api/v1/projects', '/dashboard', '/spaces/new', '/updates']) {++ const r = await t.get('openvibe.website', p);++ assert.strictEqual(r.status, 404, `${p} is not served here`);++ assert.strictEqual(r.headers['set-cookie'], undefined, `${p} sets no cookie`);++ }++ const me = await t.get('openvibe.website', '/auth/me', { session: alice });++ assert.deepStrictEqual(me.json(), { user: null }, 'nobody is signed in here, even with a session cookie');++ const post = await t.request({ method: 'POST', host: 'openvibe.website', path: '/', body: 'x=1' });++ assert.strictEqual(post.status, 405);++ assert.ok(!/cookieParser|createAuthRoutes|req\.cookies/.test(src), 'the router never touches cookies');++ });++++ await check('a template at full size is the real page with no script, never indexed', async () => {++ const r = await t.get('openvibe.website', '/templates/site-bakery');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Rise & Crumb/);++ assert.match(r.headers['content-security-policy'], /default-src 'none'/);++ assert.ok(!/script-src/.test(r.headers['content-security-policy']), 'no script source at all');++ assert.strictEqual(r.headers['x-robots-tag'], 'noindex');++ assert.ok(!/<script/i.test(r.text), 'its scripts are left out, not blocked');++ assert.strictEqual((await t.get('openvibe.website', '/templates/poll')).status, 404, 'an app template is not a website');++ assert.strictEqual((await t.get('openvibe.website', '/templates/..%2fpoll')).status, 404);++ });++++ await check('www is the same door (and no one can name a site www)', async () => {++ const r = await t.get('www.openvibe.website', '/templates/site-event?x=1');++ assert.strictEqual(r.status, 301);++ assert.strictEqual(r.headers.location, 'https://openvibe.website/templates/site-event?x=1');++ });++++ await check('robots, the sitemap and llms.txt speak for this host', async () => {++ assert.match((await t.get('openvibe.website', '/robots.txt')).text, /Sitemap: https:\/\/openvibe\.website\/sitemap\.xml/);++ assert.match((await t.get('openvibe.website', '/sitemap.xml')).text, /<loc>https:\/\/openvibe\.website\/<\/loc>/);++ const llms = (await t.get('openvibe.website', '/llms.txt')).text;++ assert.match(llms, /^# OpenVibe\.Website/);++ for (const s of templates.sites()) assert.ok(llms.includes(`https://openvibe.website/templates/${s.id}`), s.id);++ });++++ await check('openvibe.space leads with a template picked on the front door', async () => {++ const r = await t.request({ method: 'GET', host: 'openvibe.space', path: '/spaces/new?template=site-portfolio', session: alice });++ assert.strictEqual(r.status, 200, r.text.slice(0, 200));++ assert.match(r.text, /<section class="sp-pick"[^>]*>[\s\S]*?<h2 id="sp-pick-h">Portfolio<\/h2>[\s\S]*?name="template" value="site-portfolio"/);++ const plain = await t.request({ method: 'GET', host: 'openvibe.space', path: '/spaces/new?template=nope', session: alice });++ assert.ok(!plain.text.includes('class="sp-pick"'), 'an unknown template picks nothing');++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -52,3 +52,42 @@ server {
@@ -52,3 +52,42 @@ server {
proxy_read_timeout 60s;
proxy_read_timeout 60s;
}
}
}
}
+
+# openvibe.website itself (and www): OpenVibe.Website's front door, answered by Space's server/website.js (it left
+# OpenVibe.Network's parked-domains.conf on 2026-10-31). The same rule as a tenant host, both ways: no cookie in, no
+# cookie out, GET and HEAD only. Tenants live under this domain and, until its Public Suffix List entry, a tenant's
+# script could set a cookie for the whole of it.
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.website www.openvibe.website;
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.website$request_uri; }
+}
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.website www.openvibe.website;
+ include snippets/security-txt.conf;
+ ssl_certificate /etc/letsencrypt/live/openvibe.website/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.website/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ client_max_body_size 1k;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ location / {
+ limit_except GET HEAD { deny all; }
+ limit_req zone=ovspace_sites burst=200 nodelay;
+ proxy_pass http://127.0.0.1:4940;
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ proxy_set_header Host $host;
+ proxy_set_header Cookie "";
+ proxy_set_header Authorization "";
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_hide_header Set-Cookie;
+ proxy_read_timeout 60s;
+ }
+}
@@ -54,6 +54,14 @@
@@ -54,6 +54,14 @@
.sp-template p { margin: 0 0 8px; color: var(--text-secondary, #9fb0c8); font-size: 0.9rem; flex: 1; }
.sp-template p { margin: 0 0 8px; color: var(--text-secondary, #9fb0c8); font-size: 0.9rem; flex: 1; }
.sp-template form { margin: 0; }
.sp-template form { margin: 0; }
.sp-template-icon { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; font-size: 18px; color: #fff; background: var(--tpl); }
.sp-template-icon { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; font-size: 18px; color: #fff; background: var(--tpl); }
+/* The template picked on openvibe.website, leading /spaces/new: one press from a new space. */
+.sp-pick { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 16px; align-items: center; margin: 0 0 22px; padding: 18px; border-radius: 18px;
+ border: 1px solid color-mix(in srgb, var(--tpl) 55%, var(--border, #2c3d5c)); background: linear-gradient(120deg, color-mix(in srgb, var(--tpl) 18%, var(--bg-secondary, #101828)), var(--bg-secondary, #101828) 70%); }
+.sp-pick h2 { margin: 2px 0 4px; font-size: 1.25rem; }
+.sp-pick p { margin: 0; }
+.sp-pick-text > p:last-child { color: var(--text-secondary, #9fb0c8); }
+.sp-pick form { margin: 0; }
+@media (max-width: 560px) { .sp-pick { grid-template-columns: auto minmax(0, 1fr); } .sp-pick form { grid-column: 1 / -1; } }
.sp-templates.is-compact { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
.sp-templates.is-compact { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
.sp-how-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin-top: 12px; }
.sp-how-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; margin-top: 12px; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
+/* OpenVibe.Website's front door (server/render/website.js): the room's motif in the hero, the template gallery with
+ * live previews, and one "be the first" row. Colours come from the room (rooms.js 'website': periwinkle) and the kit's
+ * tokens, so every theme dresses it; motion uses the network's tokens, so Calm stills it and reduced motion shows the
+ * finished frame. */
+
+.wb { --wb-type: calc(var(--ov-dur-4, .52s) * 4.6); --wb-step: var(--ov-dur-3, .32s); }
+
+/* The hero wears the room's colour like the rest of the page: its button and the accent words. */
+.wb .sc-btn.sc-primary { background: var(--room-fill); color: var(--room-on); }
+.wb .sc-accent { background-image: linear-gradient(120deg, var(--room-accent), color-mix(in srgb, var(--room-accent) 50%, var(--ovk-text))); }
+
+/* ── The motif: the prompt types, then the page assembles ── */
+.wb-stage { position: relative; display: grid; gap: 14px; width: min(520px, 100%); margin-inline: auto; padding-bottom: 8px; }
+.wb-prompt { justify-self: start; display: flex; align-items: center; gap: 9px; max-width: 100%; padding: 10px 14px; border-radius: 14px;
+ background: var(--ovk-panel); border: 1px solid color-mix(in srgb, var(--room-accent) 40%, var(--ovk-line));
+ box-shadow: 0 18px 40px -28px rgba(0, 0, 0, .6); font: 13px/1.3 ui-monospace, "SF Mono", Menlo, Consolas, monospace; color: var(--ovk-text); }
+.wb-spark { color: var(--room-accent); }
+.wb-typed { display: inline-block; overflow: hidden; white-space: nowrap; width: 32ch;
+ animation: wbType var(--wb-type) steps(32, end) both; }
+.wb-caret { flex: none; width: 2px; height: 1.15em; background: var(--room-accent); animation: wbBlink calc(var(--wb-step) * 3) steps(1) 6 both; }
+@keyframes wbType { from { width: 0; } }
+@keyframes wbBlink { 50% { opacity: 0; } }
+
+.wb-browser { border-radius: 16px; overflow: hidden; border: 1px solid var(--ovk-line); background: var(--ovk-panel);
+ box-shadow: 0 40px 80px -40px rgba(0, 0, 0, .65), 0 0 0 1px color-mix(in srgb, var(--room-accent) 12%, transparent); }
+.wb-bar, .wb-win-bar { display: flex; align-items: center; gap: 6px; padding: 9px 12px; background: var(--ovk-panel-2); border-bottom: 1px solid var(--ovk-line); }
+.wb-bar > i, .wb-win-bar > i { width: 9px; height: 9px; border-radius: 50%; background: var(--ovk-line); flex: none; }
+.wb-url { flex: 1; min-width: 0; margin-left: 8px; padding: 4px 10px; border-radius: 8px; background: var(--ovk-input); color: var(--ovk-muted);
+ font: 11.5px/1.4 ui-monospace, "SF Mono", Menlo, Consolas, monospace; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
+
+/* The miniature is the bakery template's own page, so it keeps that page's warm light colours in every theme. */
+.wb-page { display: grid; gap: 12px; padding: 16px; min-height: 300px; background: #fbf7f1; }
+.wb-b { animation: wbIn var(--ov-dur-4, .52s) var(--ov-ease-spring, cubic-bezier(.34, 1.56, .64, 1)) both;
+ animation-delay: calc(var(--wb-type) + var(--i) * var(--wb-step)); }
+@keyframes wbIn { from { opacity: 0; transform: translateY(14px) scale(.98); } }
+.wb-nav { display: flex; align-items: center; gap: 8px; }
+.wb-nav b { width: 84px; height: 10px; border-radius: 5px; background: #2b1d14; margin-right: auto; }
+.wb-nav span { width: 34px; height: 7px; border-radius: 4px; background: #d8c8b8; }
+.wb-hero { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, .75fr); gap: 14px; align-items: center; padding: 6px 0 4px; }
+.wb-copy { display: grid; gap: 8px; }
+.wb-copy b { height: 16px; border-radius: 6px; background: #2b1d14; }
+.wb-copy span { height: 7px; border-radius: 4px; background: #c9b6a4; }
+.wb-copy .wb-short { width: 64%; }
+.wb-copy em { width: 92px; height: 22px; margin-top: 4px; border-radius: 999px; background: #b4532a; }
+.wb-loaf { aspect-ratio: 1; border-radius: 32% 68% 54% 46% / 42% 38% 62% 58%;
+ background: radial-gradient(circle at 36% 30%, #e9b980 0 16%, transparent 17%), linear-gradient(140deg, #f2c48d, #c27a3f 70%, #8f4f25);
+ box-shadow: inset 0 -10px 20px rgba(90, 45, 15, .35); }
+.wb-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; padding: 10px; border-radius: 10px; background: #f4e8da; }
+.wb-cards span { display: grid; gap: 6px; padding: 9px; border-radius: 8px; background: #fff; border: 1px solid #eadfd2; }
+.wb-cards b { height: 7px; border-radius: 4px; background: #2b1d14; }
+.wb-cards i { width: 40%; height: 6px; border-radius: 3px; background: #b4532a; }
+.wb-foot span { display: block; height: 6px; width: 40%; border-radius: 3px; background: #d8c8b8; }
+.wb-ready { position: absolute; right: 14px; bottom: -6px; padding: 6px 12px; border-radius: 999px; font-size: 12.5px; font-weight: 700;
+ color: var(--room-on); background: var(--room-fill); box-shadow: 0 10px 24px -12px rgba(0, 0, 0, .6);
+ animation: wbIn var(--ov-dur-4, .52s) var(--ov-ease-spring, cubic-bezier(.34, 1.56, .64, 1)) both; animation-delay: calc(var(--wb-type) + 5 * var(--wb-step)); }
+
+/* ── The templates: each a small window onto the real page ── */
+.wb-head { display: grid; gap: 6px; margin-bottom: 20px; }
+.wb-head h2 { margin: 0; }
+.wb-head p { margin: 0; color: var(--ovk-muted); max-width: 62ch; }
+.wb-tpls { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); gap: 18px; }
+.wb-tpl { display: grid; grid-template-rows: auto 1fr auto; gap: 12px; padding: 12px; border-radius: 18px; border: 1px solid var(--ovk-line);
+ background: linear-gradient(165deg, color-mix(in srgb, var(--tpl) 10%, var(--ovk-panel)), var(--ovk-panel) 45%); }
+.wb-win { display: block; border-radius: 12px; overflow: hidden; border: 1px solid var(--ovk-line); text-decoration: none;
+ transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease-out), box-shadow var(--ov-dur-2, .2s) var(--ov-ease-out, ease-out); }
+.wb-win:hover { transform: translateY(-2px); box-shadow: 0 18px 36px -24px rgba(0, 0, 0, .7); }
+.wb-win:focus-visible { outline: 3px solid var(--room-accent); outline-offset: 2px; }
+.wb-win-bar { padding: 7px 10px; }
+.wb-win-bar > i { width: 7px; height: 7px; }
+.wb-win-bar > span { min-width: 0; margin-left: 6px; color: var(--ovk-muted); font: 11px/1.3 ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
+/* The page lays out at four times the card's width (a desktop), then shrinks to fit: a true thumbnail, not a phone view. */
+.wb-shot { position: relative; display: block; aspect-ratio: 4 / 3; overflow: hidden; background: #fff; }
+.wb-shot iframe { position: absolute; inset: 0 auto auto 0; width: 400%; height: 400%; border: 0; transform: scale(.25); transform-origin: 0 0; pointer-events: none; }
+.wb-tpl-text h3 { margin: 2px 4px 4px; font-size: 1.08rem; }
+.wb-tpl-text p { margin: 0 4px; color: var(--ovk-muted); font-size: .93rem; line-height: 1.5; }
+.wb-tpl-go { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; padding: 2px 4px 4px; }
+.wb-btn { display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 0 16px; border-radius: 999px;
+ background: var(--room-fill); color: var(--room-on); font-weight: 700; text-decoration: none;
+ transition: transform var(--ov-dur-1, .12s) var(--ov-ease-out, ease-out), filter var(--ov-dur-1, .12s) var(--ov-ease-out, ease-out); }
+.wb-btn:hover { filter: brightness(1.08); }
+.wb-btn:active { transform: scale(.97); }
+.wb-btn:focus-visible { outline: 3px solid color-mix(in srgb, var(--room-accent) 55%, transparent); outline-offset: 2px; }
+.wb-link { color: var(--room-strong); font-weight: 600; text-decoration: none; }
+.wb-link:hover { text-decoration: underline; }
+
+/* ── Sites made here: one honest row while there are none ── */
+.wb-first { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 18px 20px; border-radius: 18px;
+ border: 1px dashed color-mix(in srgb, var(--room-accent) 45%, var(--ovk-line)); }
+.wb-first h2 { margin: 0 0 4px; font-size: 1.15rem; }
+.wb-first p { margin: 0; color: var(--ovk-muted); max-width: 60ch; }
+
+/* On a phone the templates are a sideways rail (the owner's short-pages rule): every one stays in the page, the next
+ one peeks in from the edge, and the page itself never scrolls sideways. */
+@media (max-width: 640px) {
+ .wb-tpls { grid-auto-flow: column; grid-template-columns: none; grid-auto-columns: 84%; overflow-x: auto; overscroll-behavior-x: contain;
+ scroll-snap-type: x mandatory; padding-bottom: 8px; scrollbar-width: thin; }
+ .wb-tpl { scroll-snap-align: start; }
+}
+@media (max-width: 560px) {
+ .wb-page { min-height: 0; padding: 12px; }
+ .wb-ready { right: 8px; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .wb-typed, .wb-caret, .wb-b, .wb-ready { animation: none; }
+ .wb-win, .wb-btn { transition: none; }
+}
@@ -130,6 +130,9 @@ async function createApp(opts = {}) {
@@ -130,6 +130,9 @@ async function createApp(opts = {}) {
crossOriginResourcePolicy: { policy: 'cross-origin' },
crossOriginResourcePolicy: { policy: 'cross-origin' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));
}));
+ // openvibe.website's front door (server/website.js): after the platform's headers, before cookies, sign-in and the API,
+ // none of which it ever reaches.
+ app.use(require('./website').createWebsite({ sitesDomain: hosting.config.sitesDomain, publicDir: PUBLIC_DIR }));
app.use(cookieParser());
app.use(cookieParser());
hosting.mountRoutes(app);
hosting.mountRoutes(app);
@@ -113,7 +113,7 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
@@ -113,7 +113,7 @@ function createHosting(app, { db, auth, config: suppliedConfig, now, resolver, f
try {
try {
const target = await tenant.resolve(req.headers.host);
const target = await tenant.resolve(req.headers.host);
req.hostTarget = target.kind;
req.hostTarget = target.kind;
- if (target.kind === 'dashboard') return next();
+ if (target.kind === 'dashboard' || target.kind === 'website') return next();
if (target.kind === 'site') return await tenant.handle(req, res, target.site);
if (target.kind === 'site') return await tenant.handle(req, res, target.site);
return tenant.unknownHost(req, res);
return tenant.unknownHost(req, res);
} catch (err) { return next(err); }
} catch (err) { return next(err); }
@@ -182,12 +182,14 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
@@ -182,12 +182,14 @@ function createTenantServer({ store, config, blobs, takedowns = null, siteConfig
const PREVIEW_CSP = previewCsp(previewOrigin);
const PREVIEW_CSP = previewCsp(previewOrigin);
/**
/**
- * -> { kind: 'dashboard' } | { kind: 'site', site } | { kind: 'unknown', host }
+ * -> { kind: 'dashboard' } | { kind: 'website', host } | { kind: 'site', site } | { kind: 'unknown', host }
*/
*/
async function resolve(rawHost) {
async function resolve(rawHost) {
const host = normaliseHost(rawHost);
const host = normaliseHost(rawHost);
if (!host) return { kind: 'unknown', host: null };
if (!host) return { kind: 'unknown', host: null };
if (host === config.dashboardHost || LOOPBACK_HOSTS.has(host)) return { kind: 'dashboard' };
if (host === config.dashboardHost || LOOPBACK_HOSTS.has(host)) return { kind: 'dashboard' };
+ // The sites domain itself (and www) is OpenVibe.Website's front door (server/website.js), never a tenant.
+ if (host === config.sitesDomain || host === `www.${config.sitesDomain}`) return { kind: 'website', host };
if (host.endsWith(suffix)) {
if (host.endsWith(suffix)) {
const label = host.slice(0, -suffix.length);
const label = host.slice(0, -suffix.length);
if (!label || label.includes('.')) return { kind: 'unknown', host };
if (!label || label.includes('.')) return { kind: 'unknown', host };
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+'use strict';
+/**
+ * OpenVibe.Website's pages (server/website.js serves them at the sites domain itself): the home and its 404.
+ *
+ * The home shows rather than tells (the owner, 10-31: rooms that open on promises look unfinished). Its hero is the
+ * room's motif from openvibe-shared/rooms: a browser frame that assembles a real site, block by block, while the prompt
+ * types. Then the five website templates, each a live preview of the real page (an iframe of /templates/<id> with no
+ * script), one press from a new space on openvibe.space. It claims nothing Space does not do: publishing at a person's
+ * own address waits for public hosting, and the page says so.
+ */
+const shell = require('openvibe-shared/shell');
+const sc = require('openvibe-shared/showcase');
+const rooms = require('openvibe-shared/rooms');
+const ovServe = require('openvibe-shared/serve');
+const appIcon = require('openvibe-shared/app-icon');
+const templates = require('../spaces/templates');
+const { asset, escapeHtml: esc } = require('./layout');
+
+const SPACE = 'https://openvibe.space';
+const NAME = 'OpenVibe.Website';
+const DESCRIPTION = 'Websites that look finished from the first preview: start from a real shop, portfolio, event, link page or launch page, or describe yours, and change anything with the preview beside it.';
+const startHref = (id) => `${SPACE}/auth/login?next=${encodeURIComponent(`/spaces/new${id ? `?template=${id}` : ''}`)}`;
+
+const FAQ = [
+ ['Do I need to know how to code?', 'No. Pick a template and change the words, colours and pictures, or describe the site and OpenVibe AI writes a first version. The code is all there when you want it.'],
+ ['When can I put it on my own address?', 'Publishing at yourname.openvibe.website or on your own domain opens with public hosting, once the automatic abuse checks, a designated DMCA agent and the domain\'s Public Suffix List entry are in place. Until then you build, edit and preview, and OpenVibe staff publish.'],
+ ['Why do I sign in on openvibe.space?', 'Your sites are kept with your OpenVibe account on OpenVibe.Space, the engine that builds and hosts them. openvibe.website itself keeps no cookies, so no site here can ever touch your sign-in.'],
+ ['Can I take my site somewhere else?', 'Yes. A site is plain HTML, CSS and JavaScript; copy its files out of the editor whenever you like.'],
+];
+
+/** The room's motif: the prompt types, then the bakery template's page assembles in its browser, block by block. */
+function stage() {
+ const block = (cls, i, inner) => `<div class="wb-b ${cls}" style="--i:${i}">${inner}</div>`;
+ return `<div class="wb-stage" aria-hidden="true">
+<div class="wb-prompt"><span class="wb-spark">✦</span><span class="wb-typed">A bakery site with today's hours</span><span class="wb-caret"></span></div>
+<div class="wb-browser">
+<div class="wb-bar"><i></i><i></i><i></i><span class="wb-url">rise-and-crumb.openvibe.website</span></div>
+<div class="wb-page">
+${block('wb-nav', 0, '<b></b><span></span><span></span><span></span>')}
+${block('wb-hero', 1, '<div class="wb-copy"><b></b><b class="wb-short"></b><span></span><span class="wb-short"></span><em></em></div><i class="wb-loaf"></i>')}
+${block('wb-cards', 2, '<span><b></b><i></i></span><span><b></b><i></i></span><span><b></b><i></i></span>')}
+${block('wb-foot', 3, '<span></span>')}
+</div>
+</div>
+<span class="wb-ready">Preview ready</span>
+</div>`;
+}
+
+/** A template as visitors see it: a small browser window with the real page inside, and the two ways in. */
+function templateCard(t, base) {
+ return `<article class="wb-tpl" style="--tpl:${esc(t.accent)}">
+<a class="wb-win" href="/templates/${esc(t.id)}" aria-label="${esc(t.name)} at full size">
+<span class="wb-win-bar"><i></i><i></i><i></i><span>${esc(base.replace(/^https:\/\//, ''))}/templates/${esc(t.id)}</span></span>
+<span class="wb-shot"><iframe src="/templates/${esc(t.id)}" title="${esc(t.name)}" loading="lazy" sandbox="" tabindex="-1" aria-hidden="true"></iframe></span>
+</a>
+<div class="wb-tpl-text"><h3>${esc(t.name)}</h3><p>${esc(t.description)}</p></div>
+<div class="wb-tpl-go"><a class="wb-btn" href="${esc(startHref(t.id))}">Use this template</a><a class="wb-link" href="/templates/${esc(t.id)}">Full size</a></div>
+</article>`;
+}
+
+function home({ base }) {
+ const sites = templates.sites();
+ const body = `<div class="ov-room wb" style="${rooms.style('website')}">
+${sc.hero({
+ eyebrow: NAME,
+ title: 'Websites that look finished.',
+ accent: 'From the first preview.',
+ lede: 'Start from a real site (a shop, a portfolio, an event, a link page, a product launch) or describe yours to OpenVibe AI. Change anything in the editor with the preview beside it.',
+ actions: [
+ { label: 'Pick a template', href: '#templates', primary: true },
+ { label: 'Describe your site', href: startHref('') },
+ ],
+ note: 'Building and previewing are open to everyone with an OpenVibe account. Publishing at your own address opens with public hosting.',
+ aside: { html: stage() },
+ })}
+<section class="sc-sec" id="templates" aria-labelledby="wb-tpl-h">
+<div class="wb-head"><h2 id="wb-tpl-h">Start from a real site</h2><p>${sites.length} whole sites, each one page you can change line by line. Open one full size to see it the way your visitors will.</p></div>
+<div class="wb-tpls">${sites.map((t) => templateCard(t, base)).join('')}</div>
+</section>
+${sc.steps({ id: 'how', title: 'From idea to address', items: [
+ { title: 'Pick a site, or describe yours', text: 'A template is a working site from the first second; OpenVibe AI writes a first version from a sentence.' },
+ { title: 'Change anything', text: 'Words, colours, sections and code, with the preview beside the editor so every change shows at once.' },
+ { title: 'Sign-in and lists built in', text: 'RSVPs, guestbooks and polls keep their lists on OpenVibe, and visitors sign in with their OpenVibe account.' },
+ { title: 'Your own address', text: 'yourname.openvibe.website, or your own domain proved by a TXT record. Every publish is kept, so going back is one step.' },
+ ] })}
+<section class="sc-sec" aria-labelledby="wb-first-h"><div class="wb-first">
+<div><h2 id="wb-first-h">Sites made here</h2><p>None public yet: publishing opens with public hosting. Build yours now and it is ready on the first day.</p></div>
+<a class="wb-btn" href="#templates">Pick a template</a>
+</div></section>
+${sc.faq({ id: 'questions', items: FAQ })}
+</div>`;
+ return page({ base, title: `${NAME} · Websites that look finished`, canonicalPath: '/', body, jsonLd: [
+ { '@context': 'https://schema.org', '@type': 'WebSite', name: NAME, url: `${base}/`, description: DESCRIPTION },
+ { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: FAQ.map(([q, a]) => ({ '@type': 'Question', name: q, acceptedAnswer: { '@type': 'Answer', text: a } })) },
+ ] });
+}
+
+function notFound({ base }) {
+ return page({ base, title: `Not found · ${NAME}`, canonicalPath: '/', robots: 'noindex', body: `<div class="ov-room wb" style="${rooms.style('website')}">${sc.hero({
+ eyebrow: NAME, title: 'Nothing lives at this address.',
+ lede: 'Sites people make live at their own name, like rise-and-crumb.openvibe.website. This is OpenVibe.Website\'s front door.',
+ actions: [{ label: 'Go to the front door', href: '/', primary: true }],
+ })}</div>` });
+}
+
+function page({ base, title, canonicalPath, body, jsonLd, robots }) {
+ const links = [
+ { label: 'Home', href: '/', active: true },
+ { label: 'Templates', href: '/#templates' },
+ { label: 'Your sites', href: `${SPACE}/spaces/mine` },
+ ];
+ return shell.page({
+ name: NAME, service: 'space', lang: 'en', title, siteName: NAME, description: DESCRIPTION,
+ canonical: `${base}${canonicalPath}`, robots: robots || 'index,follow', type: 'website', summary: DESCRIPTION, url: `${base}${canonicalPath}`,
+ jsonLd,
+ // Nobody is ever signed in on this host (server/website.js): the navbar's sign-in goes to openvibe.space.
+ navbar: { service: 'space', apiBase: 'https://openvibe.network', links, sessionUrl: '/auth/me', loginUrl: `${SPACE}/auth/login?next=/spaces/mine` },
+ home: '/', navLinks: links.map(({ label, href }) => ({ label, href })),
+ footer: { service: 'space', variant: 'auto', path: canonicalPath },
+ head: [
+ appIcon.headTags({ site: 'space', iconBase: '/assets' }),
+ `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">`,
+ `<link rel="stylesheet" href="${ovServe.url('room.css')}">`,
+ `<link rel="stylesheet" href="${asset('css/space.css')}">`,
+ `<link rel="stylesheet" href="${asset('css/website.css')}">`,
+ ].join('\n'),
+ bodyAttributes: { 'data-page': 'website' },
+ body: `<div id="navbar-mount"></div>\n<main id="main" class="page">\n${body}\n</main>`,
+ });
+}
+
+module.exports = { home, notFound, DESCRIPTION };
@@ -111,7 +111,9 @@ function createSpacesPages(ctx) {
@@ -111,7 +111,9 @@ function createSpacesPages(ctx) {
router.get('/spaces/new', view(async (req, res) => {
router.get('/spaces/new', view(async (req, res) => {
if (!signedIn(req)) throw new ApiError(401, 'auth.required', 'sign in first');
if (!signedIn(req)) throw new ApiError(401, 'auth.required', 'sign in first');
- html(res, 200, render.newPage({ viewer: req.viewer, templates: templates.list(), csrf: csrfOf(req), aiOn: ai.enabled(), buildsLeft: await buildsLeft(req) }));
+ // ?template=<id>: someone picked a template on openvibe.website; it leads the page, one press from a new space.
+ const pick = req.query.template ? templates.get(String(req.query.template)) : null;
+ html(res, 200, render.newPage({ viewer: req.viewer, templates: templates.list(), csrf: csrfOf(req), aiOn: ai.enabled(), buildsLeft: await buildsLeft(req), pick }));
}));
}));
router.post('/spaces/new', ...action(async (req, res) => {
router.post('/spaces/new', ...action(async (req, res) => {
const body = req.body || {};
const body = req.body || {};
@@ -93,11 +93,17 @@ function galleryPage({ viewer, spaces, templates, q, sort, csrf }) {
@@ -93,11 +93,17 @@ function galleryPage({ viewer, spaces, templates, q, sort, csrf }) {
return page({ title: 'Spaces', description: 'Make a space: a small app on your OpenVibe account, built by OpenVibe AI or from a template, with sign-in, shared lists, private storage and AI.', path: '/spaces', showcase: true, body });
return page({ title: 'Spaces', description: 'Make a space: a small app on your OpenVibe account, built by OpenVibe AI or from a template, with sign-in, shared lists, private storage and AI.', path: '/spaces', showcase: true, body });
}
}
-function newPage({ viewer, templates, csrf, error, prompt, aiOn, buildsLeft }) {
+function newPage({ viewer, templates, csrf, error, prompt, aiOn, buildsLeft, pick = 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>
+ <div class="sp-pick-text"><p class="eyebrow">The template you picked</p><h2 id="sp-pick-h">${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>` : '';
const body = `<div class="sp-wrap">
const body = `<div class="sp-wrap">
<p class="eyebrow">New space</p>
<p class="eyebrow">New space</p>
- <h1 class="sp-title">What do you want to make?</h1>
+ <h1 class="sp-title">${pick ? 'Make it yours' : 'What do you want to make?'}</h1>
${error ? `<p class="sp-flash is-bad" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="sp-flash is-bad" role="alert">${esc(error)}</p>` : ''}
+ ${picked}
<form class="sp-ai-form" method="post" action="/spaces/new">
<form class="sp-ai-form" method="post" action="/spaces/new">
<input type="hidden" name="csrf" value="${esc(csrf)}">
<input type="hidden" name="csrf" value="${esc(csrf)}">
<label for="sp-prompt">Describe it and OpenVibe AI builds it</label>
<label for="sp-prompt">Describe it and OpenVibe AI builds it</label>
@@ -17,11 +17,20 @@ const TEMPLATES = [
@@ -17,11 +17,20 @@ const TEMPLATES = [
{ 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: '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: '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: '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'] },
+ // 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: 'blank', name: 'Blank', icon: 'fa-file', accent: '#7c3aed', description: 'An empty page with sign-in, to build from scratch.', capabilities: ['profile'] },
];
];
const cache = new Map();
const cache = new Map();
function list() { return TEMPLATES; }
function list() { return TEMPLATES; }
+/** The website templates (openvibe.website's gallery). */
+function sites() { return TEMPLATES.filter((t) => t.kind === 'site'); }
function get(id) { return TEMPLATES.find((t) => t.id === id) || null; }
function get(id) { return TEMPLATES.find((t) => t.id === id) || null; }
/** The template's files ([{ path, content }]), or null for an unknown id. */
/** The template's files ([{ path, content }]), or null for an unknown id. */
function files(id) {
function files(id) {
@@ -37,4 +46,4 @@ function files(id) {
@@ -37,4 +46,4 @@ function files(id) {
return cache.get(id).map((f) => ({ ...f }));
return cache.get(id).map((f) => ({ ...f }));
}
}
-module.exports = { list, get, files };
+module.exports = { list, sites, get, files };
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+/**
+ * OpenVibe.Website's front door at the sites domain itself (plan 5.9; the owner, 2026-10-31: "a bunch of them just
+ * redirect and that's it"). http/tenant.js resolves openvibe.website and www.openvibe.website to { kind: 'website' }
+ * and this router answers every request to them: the home, the website templates at full size, robots, the sitemap,
+ * llms.txt and this site's static files. Anything else is a 404 here; the API, sign-in and the dashboard stay on
+ * openvibe.space.
+ *
+ * No cookie, ever: tenant sites live at <name>.openvibe.website and, until the domain is on the Public Suffix List,
+ * a tenant's script could set a cookie for the whole domain. So this host reads none and sets none (nginx strips
+ * Cookie and Set-Cookie here as on every tenant host); sign-in and building happen on openvibe.space.
+ */
+const express = require('express');
+const seo = require('openvibe-shared/seo');
+const cache = require('openvibe-shared/cache-policy');
+const templates = require('./spaces/templates');
+
+const PAGE_CACHE = cache.htmlHeaders({ maxAge: 300 });
+// A template at full size is our own page with no script: scripts, frames, forms and plugins are refused, and it is
+// never indexed (the home is the page to find).
+const TEMPLATE_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data: https:; frame-ancestors 'self'; base-uri 'none'; form-action 'none'";
+
+function createWebsite({ sitesDomain, publicDir }) {
+ const base = `https://${sitesDomain}`;
+ const pages = require('./render/website');
+ const router = express.Router();
+
+ // www is the same door: one address for search engines and links.
+ router.use((req, res, next) => {
+ if (String(req.headers.host || '').toLowerCase().startsWith('www.')) return res.redirect(301, `${base}${req.originalUrl}`);
+ if (req.method !== 'GET' && req.method !== 'HEAD') return res.status(405).set('Allow', 'GET, HEAD').type('text/plain').send('Method not allowed');
+ return next();
+ });
+
+ router.get('/', (_req, res) => res.type('html').set('Cache-Control', PAGE_CACHE).send(pages.home({ base })));
+
+ router.get('/templates/:id', (req, res, next) => {
+ const t = templates.get(req.params.id);
+ if (!t || t.kind !== 'site') return next();
+ res.set({ 'Content-Security-Policy': TEMPLATE_CSP, 'X-Robots-Tag': 'noindex', 'Cache-Control': PAGE_CACHE });
+ // The page as it reads with no script: the scripts are left out, not blocked, so nothing complains in a console.
+ return res.type('html').send(templates.files(t.id)[0].content.replace(/<script\b[^>]*>[\s\S]*?<\/script>\s*/gi, ''));
+ });
+
+ // The shared navbar's who-is-signed-in read: nobody, always, on this host.
+ router.get('/auth/me', (_req, res) => res.set('Cache-Control', 'no-store').json({ user: null }));
+
+ router.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', PAGE_CACHE)
+ .send(seo.robotsTxt({ sitemaps: [`${base}/sitemap.xml`], disallow: ['/templates/'] })));
+ router.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', PAGE_CACHE)
+ .send(seo.sitemapXml([{ loc: `${base}/`, changefreq: 'weekly', priority: 1 }])));
+ router.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', PAGE_CACHE).send(seo.llmsTxt({
+ name: 'OpenVibe.Website',
+ summary: pages.DESCRIPTION,
+ sections: [
+ { title: 'Start', links: [{ title: 'Website templates', url: `${base}/#templates`, html: true }, { title: 'Build on OpenVibe.Space', url: 'https://openvibe.space/spaces/new', html: true }] },
+ { title: 'Templates', links: templates.sites().map((t) => ({ title: t.name, url: `${base}/templates/${t.id}`, html: true, note: t.description })) },
+ ],
+ })));
+
+ router.use('/shared', require('openvibe-shared/serve').handler());
+ router.use(express.static(publicDir, { index: false, redirect: false, etag: true }));
+ router.use((_req, res) => res.status(404).type('html').set('Cache-Control', PAGE_CACHE).send(pages.notFound({ base })));
+
+ return (req, res, next) => (req.hostTarget === 'website' ? router(req, res, next) : next());
+}
+
+module.exports = { createWebsite, TEMPLATE_CSP };
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Rise & Crumb · Neighbourhood bakery</title>
+<meta name="description" content="Sourdough, morning buns and celebration cakes, baked every morning on Alder Street.">
+<style>
+ /* Change the colours here: --accent is the button and link colour, --crust the warm band behind the menu. */
+ :root { --bg: #fbf7f1; --card: #ffffff; --ink: #2b1d14; --muted: #7a6656; --line: #eadfd2; --accent: #b4532a; --on-accent: #fff; --crust: #f4e8da; }
+ @media (prefers-color-scheme: dark) { :root { --bg: #17110d; --card: #211913; --ink: #f3e9df; --muted: #b8a595; --line: #3a2d23; --accent: #f08a5d; --on-accent: #1d120b; --crust: #1f1711; } }
+ * { box-sizing: border-box; }
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 17px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; }
+ h1, h2, h3 { font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif; line-height: 1.15; margin: 0; text-wrap: balance; }
+ a { color: var(--accent); }
+ .wrap { width: min(1080px, 100% - 32px); margin-inline: auto; }
+ header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-block: 18px; }
+ .logo { font: 700 1.25rem "Iowan Old Style", Georgia, serif; color: var(--ink); text-decoration: none; }
+ nav { display: flex; gap: 18px; flex-wrap: wrap; }
+ nav a { color: var(--muted); text-decoration: none; font-weight: 600; font-size: .95rem; }
+ nav a:hover { color: var(--ink); }
+ .hero { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, .9fr); gap: 40px; align-items: center; padding-block: 48px 64px; }
+ .hero h1 { font-size: clamp(2.4rem, 6vw, 4rem); }
+ .hero p { color: var(--muted); font-size: 1.15rem; max-width: 34ch; margin: 16px 0 24px; }
+ .btns { display: flex; gap: 12px; flex-wrap: wrap; }
+ .btn { display: inline-flex; align-items: center; min-height: 46px; padding: 0 20px; border-radius: 999px; background: var(--accent); color: var(--on-accent); font-weight: 700; text-decoration: none; }
+ .btn.ghost { background: transparent; color: var(--ink); box-shadow: inset 0 0 0 1.5px var(--line); }
+ .open { display: inline-flex; align-items: center; gap: 8px; margin-top: 20px; font-size: .92rem; color: var(--muted); }
+ .open i { width: 9px; height: 9px; border-radius: 50%; background: #3f9d5b; }
+ .open.closed i { background: var(--muted); }
+ .loaf { aspect-ratio: 1; max-width: 100%; border-radius: 32% 68% 54% 46% / 42% 38% 62% 58%; background: radial-gradient(circle at 34% 30%, #e9b980 0 18%, transparent 19%), radial-gradient(circle at 62% 58%, #d99a5b 0 22%, transparent 23%), linear-gradient(140deg, #f2c48d, #c27a3f 70%, #8f4f25); box-shadow: 0 40px 80px -40px rgba(80, 40, 10, .55), inset 0 -20px 40px rgba(90, 45, 15, .35); position: relative; }
+ .loaf::after { content: ""; position: absolute; inset: 22% 18%; border-radius: 50%; background: repeating-linear-gradient(115deg, transparent 0 22px, rgba(255, 240, 220, .55) 22px 26px); mask: radial-gradient(closest-side, #000 70%, transparent); }
+ section { padding-block: 56px; }
+ .band { background: var(--crust); }
+ .head { display: flex; justify-content: space-between; align-items: end; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
+ .head h2 { font-size: clamp(1.8rem, 4vw, 2.4rem); }
+ .head p { margin: 0; color: var(--muted); }
+ .menu { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 14px; }
+ .item { background: var(--card); border: 1px solid var(--line); border-radius: 18px; padding: 18px; display: grid; gap: 6px; }
+ .item h3 { font-size: 1.15rem; display: flex; justify-content: space-between; gap: 10px; }
+ .item h3 span { font: 700 1rem system-ui, sans-serif; color: var(--accent); font-variant-numeric: tabular-nums; }
+ .item p { margin: 0; color: var(--muted); font-size: .95rem; }
+ .tag { justify-self: start; font-size: .75rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--accent); }
+ .visit { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 28px; }
+ table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
+ td { padding: 9px 0; border-bottom: 1px solid var(--line); }
+ td:last-child { text-align: right; color: var(--muted); }
+ tr.today td { font-weight: 700; color: var(--ink); }
+ address { font-style: normal; line-height: 1.8; }
+ footer { padding-block: 32px; color: var(--muted); font-size: .9rem; border-top: 1px solid var(--line); }
+ @media (max-width: 760px) { .hero { grid-template-columns: 1fr; padding-top: 24px; } .loaf { width: 60%; justify-self: center; order: -1; } }
+</style>
+</head>
+<body>
+<div class="wrap">
+ <header>
+ <a class="logo" href="#top">Rise & Crumb</a>
+ <nav aria-label="Sections"><a href="#menu">Menu</a><a href="#visit">Hours & visit</a><a href="#order">Order</a></nav>
+ </header>
+ <main id="top">
+ <div class="hero">
+ <div>
+ <h1>Bread worth getting up for.</h1>
+ <p>Naturally leavened sourdough, morning buns and celebration cakes, baked every morning on Alder Street since 2014.</p>
+ <div class="btns"><a class="btn" id="order" href="tel:+15550142233">Call to order for pickup</a><a class="btn ghost" href="#menu">See today's menu</a></div>
+ <p class="open" id="open"><i></i><span>Open Tuesday to Sunday, 7 am to 3 pm</span></p>
+ </div>
+ <div class="loaf" role="img" aria-label="A round sourdough loaf"></div>
+ </div>
+ </main>
+</div>
+<section class="band" id="menu">
+ <div class="wrap">
+ <div class="head"><h2>This morning's bake</h2><p>Sold until it's gone. Whole cakes need two days' notice.</p></div>
+ <div class="menu">
+ <article class="item"><span class="tag">Every day</span><h3>Country sourdough <span>$9</span></h3><p>Three-day ferment, dark crust, open crumb.</p></article>
+ <article class="item"><span class="tag">Every day</span><h3>Morning bun <span>$4.50</span></h3><p>Laminated dough, orange zest, cinnamon sugar.</p></article>
+ <article class="item"><span class="tag">Weekends</span><h3>Seeded rye <span>$10</span></h3><p>Sunflower, flax and caraway, sliced on request.</p></article>
+ <article class="item"><span class="tag">Every day</span><h3>Olive oil focaccia <span>$7</span></h3><p>Rosemary, flaky salt and a lot of good oil.</p></article>
+ <article class="item"><span class="tag">Order ahead</span><h3>Celebration cake <span>from $38</span></h3><p>Vanilla, chocolate or lemon; serves eight to twelve.</p></article>
+ <article class="item"><span class="tag">Fridays</span><h3>Cardamom knot <span>$4.75</span></h3><p>Swedish-style, pearl sugar on top.</p></article>
+ </div>
+ </div>
+</section>
+<section id="visit">
+ <div class="wrap visit">
+ <div>
+ <h2>Hours</h2>
+ <table id="hours">
+ <tr data-day="1"><td>Monday</td><td>Closed</td></tr>
+ <tr data-day="2"><td>Tuesday</td><td>7 am – 3 pm</td></tr>
+ <tr data-day="3"><td>Wednesday</td><td>7 am – 3 pm</td></tr>
+ <tr data-day="4"><td>Thursday</td><td>7 am – 3 pm</td></tr>
+ <tr data-day="5"><td>Friday</td><td>7 am – 3 pm</td></tr>
+ <tr data-day="6"><td>Saturday</td><td>7 am – 3 pm</td></tr>
+ <tr data-day="0"><td>Sunday</td><td>8 am – 2 pm</td></tr>
+ </table>
+ </div>
+ <div>
+ <h2>Visit</h2>
+ <address>412 Alder Street<br>Portland, Oregon<br><a href="tel:+15550142233">(555) 014-2233</a><br><a href="https://www.openstreetmap.org/search?query=412%20Alder%20Street%20Portland">Open the map</a></address>
+ </div>
+ </div>
+</section>
+<footer><div class="wrap">© Rise & Crumb. Made with OpenVibe.Website.</div></footer>
+<script>
+ // Change the hours here as [open hour, close hour] for each day (0 is Sunday); null means closed.
+ const HOURS = { 0: [8, 14], 1: null, 2: [7, 15], 3: [7, 15], 4: [7, 15], 5: [7, 15], 6: [7, 15] };
+ const now = new Date(), today = HOURS[now.getDay()], h = now.getHours() + now.getMinutes() / 60;
+ const row = document.querySelector('#hours tr[data-day="' + now.getDay() + '"]');
+ if (row) row.className = 'today';
+ const open = document.getElementById('open');
+ const fmt = (x) => (x % 12 || 12) + (x < 12 ? ' am' : ' pm');
+ if (today && h >= today[0] && h < today[1]) open.lastElementChild.textContent = 'Open now, until ' + fmt(today[1]);
+ else { open.classList.add('closed'); open.lastElementChild.textContent = today && h < today[0] ? 'Opens today at ' + fmt(today[0]) : 'Closed now'; }
+</script>
+</body>
+</html>
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Night Market on Fifth · Saturday 14 November</title>
+<meta name="description" content="Forty stalls, live music and food from the neighbourhood, on Fifth Street from 5 to 11 pm.">
+<style>
+ /* Change the colours here: --accent is the date and the buttons, --glow the light behind the title. */
+ :root { --bg: #fff8ef; --card: #ffffff; --ink: #1e1530; --muted: #6e6280; --line: #ecdfd0; --accent: #d9468a; --on-accent: #fff; --glow: #ffd27a; }
+ @media (prefers-color-scheme: dark) { :root { --bg: #120d1d; --card: #1b1429; --ink: #f6eefe; --muted: #b0a2c4; --line: #2e2440; --accent: #ff7ab6; --on-accent: #1a0f20; --glow: #6b3fa0; } }
+ * { box-sizing: border-box; }
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 17px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; }
+ h1, h2 { line-height: 1.08; margin: 0; text-wrap: balance; letter-spacing: -.02em; }
+ .wrap { width: min(980px, 100% - 32px); margin-inline: auto; }
+ .hero { position: relative; overflow: hidden; padding-block: 72px 56px; text-align: center; }
+ .hero::before { content: ""; position: absolute; inset: -40% 10% auto; height: 120%; background: radial-gradient(closest-side, var(--glow), transparent); opacity: .7; z-index: -1; }
+ .lights { display: flex; justify-content: center; gap: 18px; margin-bottom: 22px; }
+ .lights i { width: 12px; height: 12px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 14px var(--accent); }
+ .lights i:nth-child(2n) { background: #ffb547; box-shadow: 0 0 14px #ffb547; }
+ .date { font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--accent); }
+ .hero h1 { font-size: clamp(2.6rem, 8vw, 5.2rem); margin: 10px 0 14px; }
+ .hero p { color: var(--muted); font-size: 1.15rem; margin: 0 auto 26px; max-width: 46ch; }
+ .facts { display: flex; justify-content: center; flex-wrap: wrap; gap: 10px; margin-bottom: 26px; }
+ .facts span { padding: 8px 14px; border-radius: 999px; background: var(--card); border: 1px solid var(--line); font-weight: 600; font-size: .95rem; }
+ .btn { display: inline-flex; align-items: center; justify-content: center; min-height: 48px; padding: 0 22px; border: 0; border-radius: 999px; background: var(--accent); color: var(--on-accent); font: 700 1rem system-ui, sans-serif; text-decoration: none; cursor: pointer; }
+ .btn.ghost { background: transparent; color: var(--ink); box-shadow: inset 0 0 0 1.5px var(--line); }
+ section { padding-block: 48px; }
+ h2 { font-size: clamp(1.6rem, 4vw, 2.2rem); margin-bottom: 20px; }
+ .timeline { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
+ .timeline li { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 16px; padding: 14px 18px; background: var(--card); border: 1px solid var(--line); border-radius: 16px; }
+ .timeline time { font-weight: 800; color: var(--accent); font-variant-numeric: tabular-nums; }
+ .timeline b { display: block; }
+ .timeline span { color: var(--muted); font-size: .95rem; }
+ .rsvp { background: var(--card); border: 1px solid var(--line); border-radius: 22px; padding: 24px; display: grid; gap: 16px; }
+ .rsvp-top { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
+ .count { font-size: 1.1rem; font-weight: 700; }
+ .faces { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; margin: 0; padding: 0; }
+ .faces li { display: inline-flex; align-items: center; gap: 8px; padding: 4px 12px 4px 4px; border-radius: 999px; border: 1px solid var(--line); font-size: .92rem; }
+ .faces img { width: 28px; height: 28px; border-radius: 50%; background: var(--line); }
+ .muted { color: var(--muted); margin: 0; }
+ details { border-bottom: 1px solid var(--line); padding: 14px 0; }
+ summary { font-weight: 700; cursor: pointer; }
+ details p { margin: 8px 0 0; color: var(--muted); }
+ footer { padding-block: 32px; color: var(--muted); font-size: .9rem; text-align: center; }
+ @media (max-width: 560px) { .timeline li { grid-template-columns: 1fr; gap: 2px; } }
+</style>
+</head>
+<body>
+<main>
+ <div class="hero">
+ <div class="wrap">
+ <div class="lights" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></div>
+ <span class="date">Saturday 14 November</span>
+ <h1>Night Market on Fifth</h1>
+ <p>Forty stalls, live music and food from the neighbourhood. Free to get in, all ages, rain or shine under the awnings.</p>
+ <div class="facts"><span>5 pm – 11 pm</span><span>Fifth Street, between Oak and Pine</span><span>Free</span></div>
+ <a class="btn" href="#rsvp">I'm coming</a>
+ </div>
+ </div>
+ <div class="wrap">
+ <section id="schedule">
+ <h2>The evening</h2>
+ <ol class="timeline">
+ <li><time>5:00</time><div><b>Stalls open</b><span>Makers, vintage, plants and the book swap.</span></div></li>
+ <li><time>6:30</time><div><b>The Larkspur Trio</b><span>Folk and old swing on the corner stage.</span></div></li>
+ <li><time>8:00</time><div><b>Lantern walk</b><span>Kids lead the parade down the street; lanterns at the info tent.</span></div></li>
+ <li><time>9:00</time><div><b>DJ Halvard</b><span>Records until close.</span></div></li>
+ </ol>
+ </section>
+ <section id="rsvp">
+ <h2>Who's coming</h2>
+ <div class="rsvp">
+ <div class="rsvp-top"><span class="count" id="count">Say you're coming so we know how many to expect.</span><span id="act"><a class="btn" href="#rsvp">Sign in to say you're coming</a></span></div>
+ <ul class="faces" id="faces"></ul>
+ </div>
+ </section>
+ <section id="questions">
+ <h2>Questions</h2>
+ <details><summary>Can I bring my dog?</summary><p>Yes, on a lead. There's water at the info tent.</p></details>
+ <details><summary>Is there parking?</summary><p>The Oak Street garage is free after 6 pm. Bike racks are at both ends of the street.</p></details>
+ <details><summary>Can I have a stall?</summary><p>Stalls for the spring market open in February. Sign up for news at the info tent.</p></details>
+ </section>
+ </div>
+</main>
+<footer>Night Market on Fifth · Made with OpenVibe.Website</footer>
+<script src="/_openvibe/sdk.js"></script>
+<script>
+ // Everyone who says they're coming is kept in the shared list "rsvps": their OpenVibe name and picture, nothing else.
+ const $ = (s) => document.querySelector(s);
+ function el(tag, props, ...kids) { const n = Object.assign(document.createElement(tag), props || {}); n.append(...kids); return n; }
+ let going = [];
+ function render() {
+ const mine = OV.user && going.some((r) => r.author.username === OV.user.username);
+ $('#count').textContent = going.length ? going.length + (going.length === 1 ? ' person is' : ' people are') + ' coming' : 'Be the first to say you\'re coming.';
+ const act = $('#act'); act.replaceChildren();
+ if (!OV.user) { const b = el('button', { className: 'btn', type: 'button', textContent: 'Sign in to say you\'re coming' }); b.onclick = () => OV.signIn(); act.append(b); }
+ else if (mine) act.append(el('span', { className: 'muted', textContent: 'You\'re on the list. See you there!' }));
+ else { const b = el('button', { className: 'btn', type: 'button', textContent: 'I\'m coming' }); b.onclick = async () => { b.disabled = true; try { await OV.items.add('rsvps', { at: Date.now() }); await load(); } catch (e) { b.disabled = false; $('#count').textContent = e.message; } }; act.append(b); }
+ $('#faces').replaceChildren(...going.slice(0, 60).map((r) => {
+ const img = el('img', { alt: '', loading: 'lazy', src: 'https://openvibe.network/avatar/' + encodeURIComponent(r.author.username) });
+ img.onerror = () => img.removeAttribute('src');
+ return el('li', {}, img, r.author.display_name);
+ }));
+ }
+ async function load() {
+ try {
+ const seen = new Set();
+ going = (await OV.items.list('rsvps', { limit: 200 })).filter((r) => !seen.has(r.author.username) && seen.add(r.author.username));
+ } catch (e) { going = []; }
+ render();
+ }
+ OV.on('user', render);
+ OV.ready.then(load);
+</script>
+</body>
+</html>
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Tally · Shared expenses without the spreadsheet</title>
+<meta name="description" content="Tally splits rent, trips and groceries fairly, settles up in one tap and never shows anyone an ad.">
+<style>
+ /* Change the colours here: --accent is the buttons and highlights. */
+ :root { --bg: #ffffff; --soft: #f4f7f5; --card: #ffffff; --ink: #0d1f17; --muted: #5b6e65; --line: #e1e8e4; --accent: #0f8a5f; --on-accent: #fff; }
+ @media (prefers-color-scheme: dark) { :root { --bg: #08110d; --soft: #0d1914; --card: #10201a; --ink: #e6f3ec; --muted: #94ab9f; --line: #1d3128; --accent: #3ddc97; --on-accent: #062014; } }
+ * { box-sizing: border-box; }
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 17px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; }
+ h1, h2, h3 { line-height: 1.1; margin: 0; letter-spacing: -.02em; text-wrap: balance; }
+ .wrap { width: min(1080px, 100% - 32px); margin-inline: auto; }
+ header { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding-block: 18px; }
+ .logo { font-weight: 800; font-size: 1.2rem; display: inline-flex; align-items: center; gap: 8px; }
+ .logo i { width: 22px; height: 22px; border-radius: 7px; background: var(--accent); }
+ .btn { display: inline-flex; align-items: center; justify-content: center; min-height: 46px; padding: 0 20px; border-radius: 12px; background: var(--accent); color: var(--on-accent); font-weight: 700; text-decoration: none; }
+ .btn.ghost { background: transparent; color: var(--ink); box-shadow: inset 0 0 0 1.5px var(--line); }
+ .hero { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; align-items: center; padding-block: 56px 72px; }
+ .hero h1 { font-size: clamp(2.4rem, 5.6vw, 3.8rem); }
+ .hero p { color: var(--muted); font-size: 1.15rem; margin: 18px 0 26px; max-width: 40ch; }
+ .btns { display: flex; gap: 12px; flex-wrap: wrap; }
+ .small { color: var(--muted); font-size: .9rem; margin-top: 14px; }
+ .phone { justify-self: center; width: min(300px, 100%); border-radius: 36px; padding: 14px; background: var(--ink); box-shadow: 0 40px 80px -40px rgba(0, 60, 40, .5); }
+ .screen { border-radius: 26px; background: var(--soft); padding: 18px; display: grid; gap: 10px; }
+ .screen b { font-size: .8rem; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
+ .row { display: flex; justify-content: space-between; align-items: center; padding: 12px 14px; border-radius: 14px; background: var(--card); border: 1px solid var(--line); font-size: .95rem; }
+ .row span:last-child { font-weight: 700; font-variant-numeric: tabular-nums; }
+ .row.owe span:last-child { color: var(--accent); }
+ .settle { text-align: center; padding: 12px; border-radius: 14px; background: var(--accent); color: var(--on-accent); font-weight: 700; }
+ section { padding-block: 64px; }
+ .soft { background: var(--soft); }
+ .center { text-align: center; max-width: 640px; margin: 0 auto 36px; }
+ .center h2 { font-size: clamp(1.8rem, 4vw, 2.6rem); }
+ .center p { color: var(--muted); margin: 12px 0 0; }
+ .grid3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
+ .feat { background: var(--card); border: 1px solid var(--line); border-radius: 18px; padding: 22px; }
+ .feat h3 { font-size: 1.15rem; margin: 12px 0 6px; }
+ .feat p { margin: 0; color: var(--muted); }
+ .ico { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); font-weight: 800; }
+ .plans { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; max-width: 760px; margin: 0 auto; }
+ .plan { background: var(--card); border: 1px solid var(--line); border-radius: 20px; padding: 26px; display: grid; gap: 12px; align-content: start; }
+ .plan.pick { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
+ .price { font-size: 2.4rem; font-weight: 800; letter-spacing: -.02em; }
+ .price small { font-size: 1rem; color: var(--muted); font-weight: 500; }
+ .plan ul { margin: 0; padding-left: 18px; color: var(--muted); }
+ details { border-bottom: 1px solid var(--line); padding: 16px 0; max-width: 720px; margin: 0 auto; }
+ summary { font-weight: 700; cursor: pointer; }
+ details p { margin: 8px 0 0; color: var(--muted); }
+ .final { text-align: center; }
+ .final h2 { font-size: clamp(1.8rem, 4vw, 2.6rem); margin-bottom: 20px; }
+ footer { padding-block: 28px; border-top: 1px solid var(--line); color: var(--muted); font-size: .9rem; }
+ @media (max-width: 780px) { .hero { grid-template-columns: 1fr; } }
+</style>
+</head>
+<body>
+<div class="wrap"><header><span class="logo"><i aria-hidden="true"></i>Tally</span><a class="btn ghost" href="#pricing">Pricing</a></header></div>
+<main>
+ <div class="wrap hero">
+ <div>
+ <h1>Shared expenses, without the spreadsheet.</h1>
+ <p>Tally splits rent, trips and groceries fairly, keeps a running total for everyone, and settles up in one tap.</p>
+ <div class="btns"><a class="btn" href="#pricing">Start free</a><a class="btn ghost" href="#how">How it works</a></div>
+ <p class="small">Free for up to three groups. No ads, ever.</p>
+ </div>
+ <div class="phone" aria-label="The Tally app showing a weekend trip" role="img">
+ <div class="screen">
+ <b>Weekend in Porto</b>
+ <div class="row"><span>Apartment</span><span>€420.00</span></div>
+ <div class="row"><span>Dinner at Taberna</span><span>€96.50</span></div>
+ <div class="row"><span>Train tickets</span><span>€64.00</span></div>
+ <div class="row owe"><span>Sam owes you</span><span>€58.40</span></div>
+ <div class="settle">Settle up</div>
+ </div>
+ </div>
+ </div>
+ <section class="soft" id="how">
+ <div class="wrap">
+ <div class="center"><h2>Fair, without the awkward conversation</h2><p>Everything a shared flat or a group trip needs, and nothing it doesn't.</p></div>
+ <div class="grid3">
+ <div class="feat"><span class="ico">÷</span><h3>Split any way</h3><p>Evenly, by share, by percentage, or "I'll get this one".</p></div>
+ <div class="feat"><span class="ico">↻</span><h3>Repeats itself</h3><p>Rent and bills come back every month without anyone typing them again.</p></div>
+ <div class="feat"><span class="ico">✓</span><h3>Settle in one tap</h3><p>Tally works out the fewest payments so everyone ends up even.</p></div>
+ </div>
+ </div>
+ </section>
+ <section id="pricing">
+ <div class="wrap">
+ <div class="center"><h2>Simple pricing</h2><p>Start free. Upgrade when the group chat gets bigger.</p></div>
+ <div class="plans">
+ <div class="plan"><h3>Free</h3><div class="price">€0</div><ul><li>Up to three groups</li><li>Unlimited expenses</li><li>Settle up</li></ul><a class="btn ghost" href="#">Start free</a></div>
+ <div class="plan pick"><h3>Plus</h3><div class="price">€3 <small>a month</small></div><ul><li>Unlimited groups</li><li>Receipts and categories</li><li>Export to a spreadsheet</li></ul><a class="btn" href="#">Try Plus for a month</a></div>
+ </div>
+ </div>
+ </section>
+ <section class="soft" id="questions">
+ <div class="wrap">
+ <div class="center"><h2>Questions</h2></div>
+ <details><summary>Does everyone need the app?</summary><p>No. Friends can see their balance from a link, and join when they like.</p></details>
+ <details><summary>Does Tally move money?</summary><p>No. It tells you who pays whom; you pay however you already do.</p></details>
+ <details><summary>Can I leave?</summary><p>Any time, with every expense exported first.</p></details>
+ </div>
+ </section>
+ <section class="final"><div class="wrap"><h2>Even, every time.</h2><a class="btn" href="#pricing">Start free</a></div></section>
+</main>
+<footer><div class="wrap">© Tally · Made with OpenVibe.Website</div></footer>
+</body>
+</html>
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Juno Park · links</title>
+<meta name="description" content="Juno Park: streams, the podcast, the newsletter and the shop, all in one place.">
+<style>
+ /* Change the colours here: the page's gradient runs from --from to --to. */
+ :root { --from: #ffe1ec; --to: #e4e8ff; --card: rgba(255, 255, 255, .72); --ink: #1d1a2b; --muted: #635d78; --line: rgba(29, 26, 43, .1); --accent: #6d4aff; }
+ @media (prefers-color-scheme: dark) { :root { --from: #2a1230; --to: #10183a; --card: rgba(255, 255, 255, .07); --ink: #f3f0ff; --muted: #b3acd0; --line: rgba(255, 255, 255, .12); --accent: #a996ff; } }
+ * { box-sizing: border-box; }
+ body { margin: 0; min-height: 100vh; background: linear-gradient(160deg, var(--from), var(--to)) fixed; color: var(--ink); font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; display: grid; justify-items: center; padding: 48px 16px 32px; }
+ main { width: min(480px, 100%); display: grid; gap: 22px; text-align: center; }
+ .face { width: 104px; height: 104px; margin: 0 auto; border-radius: 50%; display: grid; place-items: center; font: 800 2.2rem system-ui, sans-serif; color: #fff; background: conic-gradient(from 200deg, #ff7aa8, #8b6cff, #4cc9ff, #ff7aa8); box-shadow: 0 0 0 5px var(--card), 0 18px 40px -18px rgba(60, 30, 120, .6); }
+ h1 { margin: 0; font-size: 1.6rem; letter-spacing: -.01em; }
+ .bio { margin: 6px 0 0; color: var(--muted); }
+ .links { display: grid; gap: 12px; }
+ .links a { display: flex; align-items: center; gap: 14px; min-height: 60px; padding: 10px 18px; border-radius: 18px; background: var(--card); border: 1px solid var(--line); color: inherit; text-decoration: none; font-weight: 650; backdrop-filter: blur(10px); text-align: left; transition: transform .15s ease; }
+ .links a:hover { transform: translateY(-2px); }
+ .links a:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
+ .links svg { width: 22px; height: 22px; flex: none; color: var(--accent); }
+ .links small { display: block; font-weight: 500; color: var(--muted); }
+ .links .new { margin-left: auto; font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--accent); }
+ .social { display: flex; justify-content: center; gap: 10px; }
+ .social a { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 50%; background: var(--card); border: 1px solid var(--line); color: var(--ink); }
+ .social svg { width: 20px; height: 20px; }
+ footer { color: var(--muted); font-size: .82rem; }
+ @media (prefers-reduced-motion: reduce) { .links a { transition: none; } }
+</style>
+</head>
+<body>
+<main>
+ <div>
+ <div class="face" aria-hidden="true">JP</div>
+ <h1>Juno Park</h1>
+ <p class="bio">Cozy games, bad puns and a weekly podcast about tiny worlds.</p>
+ </div>
+ <nav class="links" aria-label="Links">
+ <a href="https://openvibe.live/"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="3"/><path d="m10 9 5 3-5 3z" fill="currentColor"/></svg><span>Watch me live<small>Tuesdays and Fridays at 8 pm</small></span><span class="new">Live</span></a>
+ <a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v4"/></svg><span>Tiny Worlds podcast<small>Episode 48: islands you can walk around in a minute</small></span></a>
+ <a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v16H4z"/><path d="m4 7 8 6 8-6"/></svg><span>The newsletter<small>One cozy game a week, no spam</small></span></a>
+ <a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 7h12l-1 13H7z"/><path d="M9 7a3 3 0 0 1 6 0"/></svg><span>Stickers and prints<small>Ships worldwide</small></span></a>
+ </nav>
+ <div class="social" aria-label="Elsewhere">
+ <a href="#" aria-label="Video channel"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="4"/><path d="m10 9 5 3-5 3z" fill="currentColor"/></svg></a>
+ <a href="#" aria-label="Photos"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4"/></svg></a>
+ <a href="#" aria-label="Email"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16v12H4z"/><path d="m4 7 8 6 8-6"/></svg></a>
+ </div>
+ <footer>Made with OpenVibe.Website</footer>
+</main>
+</body>
+</html>
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Mara Ellison · Designer</title>
+<meta name="description" content="Mara Ellison designs identities, books and interfaces for small studios and cultural institutions.">
+<style>
+ /* Change the colours here; each project tile takes its two colours from its own style="--a:…;--b:…". */
+ :root { --bg: #f5f5f2; --ink: #121212; --muted: #6a6a64; --line: #deded8; --accent: #2f54eb; }
+ @media (prefers-color-scheme: dark) { :root { --bg: #0e0e0f; --ink: #eeeeea; --muted: #9a9a93; --line: #2a2a2c; --accent: #8ea6ff; } }
+ * { box-sizing: border-box; }
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 17px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; }
+ .mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: .8rem; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
+ a { color: inherit; }
+ .wrap { width: min(1160px, 100% - 32px); margin-inline: auto; }
+ header { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding-block: 22px; border-bottom: 1px solid var(--line); }
+ header b { font-weight: 700; }
+ header nav { display: flex; gap: 20px; }
+ header nav a { text-decoration: none; }
+ .intro { padding-block: 72px 56px; }
+ .intro h1 { font-size: clamp(2.2rem, 6.4vw, 5rem); line-height: 1.02; letter-spacing: -.03em; margin: 12px 0 0; max-width: 16ch; text-wrap: balance; font-weight: 650; }
+ .intro h1 em { font-style: normal; color: var(--accent); }
+ .work { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 18px; }
+ .p { grid-column: span 6; display: grid; gap: 10px; text-decoration: none; }
+ .p:nth-child(3n) { grid-column: span 12; }
+ .tile { aspect-ratio: 4 / 3; border-radius: 6px; background: linear-gradient(135deg, var(--a), var(--b)); position: relative; overflow: hidden; }
+ .p:nth-child(3n) .tile { aspect-ratio: 21 / 9; }
+ .tile::before { content: ""; position: absolute; width: 46%; aspect-ratio: 1; right: 8%; bottom: -12%; border-radius: 50%; background: rgba(255, 255, 255, .22); }
+ .tile::after { content: ""; position: absolute; left: 7%; top: 12%; width: 28%; height: 5px; background: rgba(255, 255, 255, .75); box-shadow: 0 14px 0 rgba(255, 255, 255, .5), 0 28px 0 rgba(255, 255, 255, .3); }
+ .p:hover .tile { filter: saturate(1.15); }
+ .meta { display: flex; justify-content: space-between; gap: 12px; }
+ .meta h2 { font-size: 1.05rem; margin: 0; font-weight: 600; }
+ section { padding-block: 72px; }
+ .about { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 32px; border-top: 1px solid var(--line); }
+ .about p { margin: 0 0 14px; font-size: 1.2rem; line-height: 1.55; max-width: 60ch; }
+ .clients { display: flex; flex-wrap: wrap; gap: 8px 22px; margin-top: 20px; color: var(--muted); }
+ .contact { border-top: 1px solid var(--line); }
+ .contact a.big { display: inline-block; margin-top: 10px; font-size: clamp(1.6rem, 5vw, 3.2rem); font-weight: 650; letter-spacing: -.02em; text-decoration-thickness: 2px; text-underline-offset: 6px; color: var(--accent); word-break: break-word; }
+ footer { padding-block: 28px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
+ @media (max-width: 720px) { .p, .p:nth-child(3n) { grid-column: span 12; } .p:nth-child(3n) .tile { aspect-ratio: 4 / 3; } .about { grid-template-columns: 1fr; } header nav { gap: 14px; } }
+</style>
+</head>
+<body>
+<div class="wrap">
+ <header><b>Mara Ellison</b><nav aria-label="Sections"><a href="#work">Work</a><a href="#about">About</a><a href="#contact">Contact</a></nav></header>
+ <main>
+ <div class="intro">
+ <span class="mono">Designer · Lisbon and remote</span>
+ <h1>Identities, books and interfaces for people who <em>make things</em>.</h1>
+ </div>
+ <div class="work" id="work">
+ <a class="p" href="#contact"><div class="tile" style="--a:#ff6a3d;--b:#ffb44a"></div><div class="meta"><h2>Tidewater Ceramics</h2><span class="mono">Identity · 2026</span></div></a>
+ <a class="p" href="#contact"><div class="tile" style="--a:#2f54eb;--b:#7c9bff"></div><div class="meta"><h2>Field Notes on Light</h2><span class="mono">Book · 2025</span></div></a>
+ <a class="p" href="#contact"><div class="tile" style="--a:#0f766e;--b:#5eead4"></div><div class="meta"><h2>Harbour Museum wayfinding and website</h2><span class="mono">Signage · Web · 2025</span></div></a>
+ <a class="p" href="#contact"><div class="tile" style="--a:#7c3aed;--b:#e879f9"></div><div class="meta"><h2>Nightjar Records</h2><span class="mono">Packaging · 2024</span></div></a>
+ <a class="p" href="#contact"><div class="tile" style="--a:#1f2937;--b:#6b7280"></div><div class="meta"><h2>Quiet Hours app</h2><span class="mono">Interface · 2024</span></div></a>
+ </div>
+ <section class="about" id="about">
+ <span class="mono">About</span>
+ <div>
+ <p>I've spent twelve years helping studios, publishers and museums look like themselves. I work closely with a few clients at a time, from the first sketch to the last file at the printer.</p>
+ <p>Before going independent I was a senior designer at Atelier Norte. I teach typography one evening a week.</p>
+ <div class="clients mono"><span>Harbour Museum</span><span>Nightjar Records</span><span>Casa Verde Press</span><span>Tidewater</span><span>Open Cities Festival</span></div>
+ </div>
+ </section>
+ <section class="contact" id="contact">
+ <span class="mono">Taking projects from March</span><br>
+ <a class="big" href="mailto:[email protected]">[email protected]</a>
+ </section>
+ </main>
+ <footer><span class="mono">© Mara Ellison</span><span class="mono">Made with OpenVibe.Website</span></footer>
+</div>
+</body>
+</html>
@@ -316,7 +316,9 @@ function fakeAi() {
@@ -316,7 +316,9 @@ function fakeAi() {
const templates = require('../server/spaces/templates');
const templates = require('../server/spaces/templates');
for (const tpl of templates.list()) {
for (const tpl of templates.list()) {
const html = templates.files(tpl.id)[0].content;
const html = templates.files(tpl.id)[0].content;
- assert.match(html, /<script src="\/_openvibe\/sdk\.js"><\/script>/, tpl.id);
+ // Every app loads the SDK; a website template loads it only when it signs people in or keeps a list.
+ if (tpl.kind !== 'site' || tpl.capabilities.length) assert.match(html, /<script src="\/_openvibe\/sdk\.js"><\/script>/, tpl.id);
+ else assert.ok(!html.includes('/_openvibe/sdk.js'), `${tpl.id} loads no SDK it does not use`);
for (const m of html.matchAll(/<script>([\s\S]*?)<\/script>/g)) new Function(m[1]);
for (const m of html.matchAll(/<script>([\s\S]*?)<\/script>/g)) new Function(m[1]);
}
}
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+'use strict';
+/**
+ * OpenVibe.Website's front door (server/website.js; the owner, 2026-10-31: the domain only redirected). The sites
+ * domain itself answers with its own home, never a tenant and never the dashboard: the motif, the website templates
+ * with live previews of the real pages, one press from a new space on openvibe.space. The host reads and sets no
+ * cookie, serves nothing of the API or sign-in, and a template at full size runs no script.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./stageb/boot');
+const templates = require('../server/spaces/templates');
+
+const src = fs.readFileSync(path.join(__dirname, '..', 'server/website.js'), 'utf8');
+// The host dispatch: the sites domain and www are the front door, before any tenant lookup, and pass on to the app.
+const tenantSrc = fs.readFileSync(path.join(__dirname, '..', 'server/http/tenant.js'), 'utf8');
+const hostingSrc = fs.readFileSync(path.join(__dirname, '..', 'server/hosting.js'), 'utf8');
+assert.ok(tenantSrc.indexOf("return { kind: 'website', host }") > 0 && tenantSrc.indexOf("return { kind: 'website', host }") < tenantSrc.indexOf('if (host.endsWith(suffix))'), 'the apex is resolved before any tenant');
+assert.match(hostingSrc, /target\.kind === 'dashboard' \|\| target\.kind === 'website'\) return next\(\)/, 'the front door goes on to the app, not to a tenant');
+
+(async () => {
+ const t = await boot();
+ const alice = t.user('alice');
+ try {
+ await check('the sites domain is the front door: the motif, every website template, the honest status', async () => {
+ const r = await t.get('openvibe.website', '/');
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.match(r.headers['content-type'], /text\/html/);
+ assert.ok(r.text.includes('class="wb-stage"') && r.text.includes('class="wb-typed"'), 'the prompt types and the page assembles');
+ const sites = templates.sites();
+ assert.strictEqual(sites.length, 5, 'five website templates');
+ for (const s of sites) {
+ assert.ok(r.text.includes(`<iframe src="/templates/${s.id}"`) && r.text.includes('sandbox=""'), `${s.id} previews with no script`);
+ assert.ok(r.text.includes(`https://openvibe.space/auth/login?next=${encodeURIComponent(`/spaces/new?template=${s.id}`)}`), `${s.id} is one press from a new space`);
+ }
+ assert.match(r.text, /Publishing at your own address opens with public hosting/, 'it claims no publishing it does not have');
+ assert.match(r.text, /<link rel="canonical" href="https:\/\/openvibe\.website\/">/);
+ assert.strictEqual((r.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1');
+ assert.ok(r.text.includes('"@type":"FAQPage"'), 'its questions are structured data');
+ });
+
+ await check('no cookie, ever: none set, none read, no sign-in or API on this host', async () => {
+ const home = await t.get('openvibe.website', '/', { headers: { cookie: 'ov_token=x; sp_sess=y' } });
+ assert.strictEqual(home.headers['set-cookie'], undefined, 'the home sets no cookie');
+ for (const p of ['/auth/login', '/api/v1/projects', '/dashboard', '/spaces/new', '/updates']) {
+ const r = await t.get('openvibe.website', p);
+ assert.strictEqual(r.status, 404, `${p} is not served here`);
+ assert.strictEqual(r.headers['set-cookie'], undefined, `${p} sets no cookie`);
+ }
+ const me = await t.get('openvibe.website', '/auth/me', { session: alice });
+ assert.deepStrictEqual(me.json(), { user: null }, 'nobody is signed in here, even with a session cookie');
+ const post = await t.request({ method: 'POST', host: 'openvibe.website', path: '/', body: 'x=1' });
+ assert.strictEqual(post.status, 405);
+ assert.ok(!/cookieParser|createAuthRoutes|req\.cookies/.test(src), 'the router never touches cookies');
+ });
+
+ await check('a template at full size is the real page with no script, never indexed', async () => {
+ const r = await t.get('openvibe.website', '/templates/site-bakery');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Rise & Crumb/);
+ assert.match(r.headers['content-security-policy'], /default-src 'none'/);
+ assert.ok(!/script-src/.test(r.headers['content-security-policy']), 'no script source at all');
+ assert.strictEqual(r.headers['x-robots-tag'], 'noindex');
+ assert.ok(!/<script/i.test(r.text), 'its scripts are left out, not blocked');
+ assert.strictEqual((await t.get('openvibe.website', '/templates/poll')).status, 404, 'an app template is not a website');
+ assert.strictEqual((await t.get('openvibe.website', '/templates/..%2fpoll')).status, 404);
+ });
+
+ await check('www is the same door (and no one can name a site www)', async () => {
+ const r = await t.get('www.openvibe.website', '/templates/site-event?x=1');
+ assert.strictEqual(r.status, 301);
+ assert.strictEqual(r.headers.location, 'https://openvibe.website/templates/site-event?x=1');
+ });
+
+ await check('robots, the sitemap and llms.txt speak for this host', async () => {
+ assert.match((await t.get('openvibe.website', '/robots.txt')).text, /Sitemap: https:\/\/openvibe\.website\/sitemap\.xml/);
+ assert.match((await t.get('openvibe.website', '/sitemap.xml')).text, /<loc>https:\/\/openvibe\.website\/<\/loc>/);
+ const llms = (await t.get('openvibe.website', '/llms.txt')).text;
+ assert.match(llms, /^# OpenVibe\.Website/);
+ for (const s of templates.sites()) assert.ok(llms.includes(`https://openvibe.website/templates/${s.id}`), s.id);
+ });
+
+ await check('openvibe.space leads with a template picked on the front door', async () => {
+ const r = await t.request({ method: 'GET', host: 'openvibe.space', path: '/spaces/new?template=site-portfolio', session: alice });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 200));
+ assert.match(r.text, /<section class="sp-pick"[^>]*>[\s\S]*?<h2 id="sp-pick-h">Portfolio<\/h2>[\s\S]*?name="template" value="site-portfolio"/);
+ const plain = await t.request({ method: 'GET', host: 'openvibe.space', path: '/spaces/new?template=nope', session: alice });
+ assert.ok(!plain.text.includes('class="sp-pick"'), 'an unknown template picks nothing');
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });