openvibers/openvibe.cam Commits
Cam's home shows the real thing: a viewfinder, a live still, a grant, every filter (#28)
Changes
@@@ -58,18 +58,6 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex.cm-count { min-width: 28px; padding: 2px 9px; border-radius: 999px; background: color-mix(in srgb, var(--room-accent) 16%, transparent); color: var(--room-strong); font-weight: 800; font-size: .8rem; text-align: center; font-variant-numeric: tabular-nums; }/* The motif: an iris opening onto a contact sheet */--.cm-shutter { position: relative; width: min(100%, 420px); aspect-ratio: 1; margin: 0 auto; color: var(--room-accent); }--.cm-iris { position: absolute; inset: 8% 30% 30% 8%; width: 62%; height: 62%; }--html.ov-motion .cm-iris path { transform-box: view-box; animation: cm-iris 6s cubic-bezier(.4,0,.2,1) infinite; }--@keyframes cm-iris { 0%, 100% { opacity: .75; } 45%, 55% { opacity: .2; } }--.cm-frame-grid { position: absolute; right: 4%; bottom: 6%; width: 52%; display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }--.cm-frame { aspect-ratio: 4 / 3; border-radius: 6px; border: 1px solid color-mix(in srgb, var(--room-accent) 45%, transparent); background: color-mix(in srgb, var(--room-accent) 10%, var(--bg-card, #0f1729)); }--.cm-frame-1, .cm-frame-5 { background: color-mix(in srgb, var(--room-accent) 32%, var(--bg-card, #0f1729)); }--html.ov-motion .cm-frame { animation: cm-pop .5s cubic-bezier(.2,.7,.2,1) both; }--html.ov-motion .cm-frame-2 { animation-delay: .08s; } html.ov-motion .cm-frame-3 { animation-delay: .16s; } html.ov-motion .cm-frame-4 { animation-delay: .24s; }--html.ov-motion .cm-frame-5 { animation-delay: .32s; } html.ov-motion .cm-frame-6 { animation-delay: .4s; }--@keyframes cm-pop { from { opacity: 0; transform: translateY(6px) scale(.96); } }--@media (max-width: 759px) { .cm-shutter { width: 220px; } }/* The camera: a dark viewfinder with thirds, the controls beside it on a wide screen */.cm-camera { display: grid; gap: 14px; margin-top: 18px; }@@@ -120,11 +108,96 @@ html.ov-motion .cm-frame-5 { animation-delay: .32s; } html.ov-motion .cm-frame-6.cm-grants th, .cm-grants td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border, #1f2d47); white-space: nowrap; }.cm-grants tr:last-child td { border-bottom: 0; }.cm-grants th { font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }--@media (prefers-reduced-motion: reduce) { .cm-iris path, .cm-frame { animation: none !important; } }/* Phones: the motif is a faint corner mark behind the hero text, so it drops the contact sheet and fades the iris */--@media (max-width: 759px) { .cm-frame-grid { display: none; } .cm-iris { opacity: .3; } }/* The controls read top to bottom: power and shutter, the settings, then the two toggles side by side */.cm-controls [data-cm-start], .cm-controls [data-cm-shoot] { order: 1; }.cm-controls .cm-field { order: 2; }.cm-controls [data-cm-burst], .cm-controls [data-cm-grid-toggle] { order: 3; justify-content: center; }++++/* ── The home's scenes, shown for real (10-31): a live frame and its still, a camera's grant, the filters ── */++/* The filters, the same CSS as public/js/cam.js's FILTERS (test/home-scenes.test.js keeps them equal). */++.cm-f-none { filter: none; }++.cm-f-mono { filter: grayscale(1) contrast(1.08); }++.cm-f-warm { filter: sepia(.35) saturate(1.15); }++.cm-f-cool { filter: hue-rotate(-10deg) saturate(1.1); }++.cm-f-fade { filter: saturate(.8) contrast(.92) brightness(1.06); }++.cm-f-vivid { filter: saturate(1.45) contrast(1.06); }++.cm-f-blur { filter: blur(1.5px); }++++.cm-demo-live, .cm-demo-filters { margin: 0; display: grid; gap: 10px; width: min(380px, 100%); justify-self: center; }++.cm-live-frame { position: relative; overflow: hidden; border-radius: 14px; aspect-ratio: 4 / 3; box-shadow: 0 24px 50px -30px rgba(0, 0, 0, .7); }++.cm-live-frame img, .cm-f-main img { display: block; width: 100%; height: 100%; object-fit: cover; }++.cm-live-badge { position: absolute; top: 10px; left: 10px; padding: 3px 8px; border-radius: 6px; background: var(--ovk-live, #ef4444); color: #fff;++ font-size: 11px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }++.cm-live-btn { position: absolute; right: 10px; bottom: 10px; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%;++ background: rgba(10, 12, 18, .66); color: #fff; box-shadow: 0 0 0 2px rgba(255, 255, 255, .7);++ animation: cmPress var(--ov-dur-2, .2s) var(--ov-ease-out, ease-out) both; animation-delay: var(--ov-dur-4, .52s); }++.cm-live-btn .ovk-i { width: 20px; height: 20px; }++.cm-live-flash { position: absolute; inset: 0; background: #fff; opacity: 0;++ animation: cmFlash var(--ov-dur-4, .52s) var(--ov-ease-out, ease-out) both; animation-delay: calc(var(--ov-dur-4, .52s) + var(--ov-dur-1, .12s)); }++.cm-live-chat { margin: 0; padding: 9px 12px; border-radius: 12px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); font-size: 14px;++ animation: cmRise var(--ov-dur-3, .32s) var(--ov-ease-out, ease-out) both; animation-delay: calc(var(--ov-dur-4, .52s) * 2); }++.cm-live-who { font-weight: 700; color: var(--room-strong, var(--ovk-accent)); }++@keyframes cmPress { 50% { transform: scale(.88); } }++@keyframes cmFlash { 0% { opacity: 0; } 15% { opacity: .85; } 100% { opacity: 0; } }++@keyframes cmRise { from { opacity: 0; transform: translateY(8px); } }++++.cm-demo-grant { display: grid; gap: 10px; width: min(380px, 100%); justify-self: center; padding: 12px; border-radius: 16px;++ background: var(--ovk-panel); border: 1px solid var(--ovk-line); box-shadow: 0 24px 50px -30px rgba(0, 0, 0, .6); }++.cm-g-cam { display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; gap: 12px; align-items: center; }++.cm-g-cam img { width: 64px; height: 48px; border-radius: 8px; object-fit: cover; }++.cm-g-name { display: grid; min-width: 0; }++.cm-g-name span { color: var(--ovk-muted); font-size: 13px; }++.cm-g-lock { color: var(--room-accent, var(--ovk-accent)); }++.cm-g-lock svg { width: 20px; height: 20px; display: block; }++.cm-g-grant { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 12px; align-items: center; padding: 10px 12px; border-radius: 12px;++ background: var(--ovk-panel-2); }++.cm-g-who { font-weight: 700; }++.cm-g-left { color: var(--ovk-muted); font-size: 13px; text-align: right; }++.cm-g-bar { grid-column: 1 / -1; height: 6px; border-radius: 3px; background: var(--ovk-line); overflow: hidden; }++.cm-g-bar i { display: block; width: 71%; height: 100%; border-radius: inherit; background: var(--room-accent, var(--ovk-accent));++ transform-origin: 0 50%; animation: cmGrow var(--ov-dur-4, .52s) var(--ov-ease-out, ease-out) both; }++.cm-g-revoke { grid-column: 1 / -1; justify-self: start; font-size: 13px; font-weight: 700; color: var(--ovk-bad, #ef4444); }++@keyframes cmGrow { from { transform: scaleX(0); } }++++.cm-f-main { position: relative; overflow: hidden; border-radius: 14px; aspect-ratio: 4 / 3; box-shadow: 0 24px 50px -30px rgba(0, 0, 0, .7); }++.cm-f-saved { position: absolute; left: 10px; bottom: 10px; padding: 5px 10px; border-radius: 999px; background: rgba(10, 12, 18, .7); color: #fff;++ font-size: 12.5px; font-weight: 700; animation: cmRise var(--ov-dur-3, .32s) var(--ov-ease-out, ease-out) both; animation-delay: var(--ov-dur-4, .52s); }++.cm-f-strip { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; }++.cm-f-strip li { display: grid; gap: 4px; justify-items: center; font-size: 11px; color: var(--ovk-muted); }++.cm-f-strip img { width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 6px; outline: 2px solid transparent; outline-offset: 1px; }++.cm-f-strip li.is-on { color: var(--ovk-text); font-weight: 700; }++.cm-f-strip li.is-on img { outline-color: var(--room-accent, var(--ovk-accent)); }++++@media (prefers-reduced-motion: reduce) {++ .cm-live-btn, .cm-live-flash, .cm-live-chat, .cm-g-bar i, .cm-f-saved { animation: none; }++}++++/* The room's motif: the camera's viewfinder on the sample picture. The focus square locks once and the shutter waits;++ nothing loops. */++.cm-vf { position: relative; width: min(100%, 420px); aspect-ratio: 4 / 3; margin: 0 auto; border-radius: 22px; overflow: hidden;++ box-shadow: 0 40px 80px -40px rgba(0, 0, 0, .75), 0 0 0 1px color-mix(in srgb, var(--room-accent) 30%, transparent); }++.cm-vf img { display: block; width: 100%; height: 100%; object-fit: cover; }++.cm-vf-c { position: absolute; width: 28px; height: 28px; border: 0 solid rgba(255, 255, 255, .9); }++.cm-vf-c.tl { top: 14px; left: 14px; border-top-width: 3px; border-left-width: 3px; border-top-left-radius: 8px; }++.cm-vf-c.tr { top: 14px; right: 14px; border-top-width: 3px; border-right-width: 3px; border-top-right-radius: 8px; }++.cm-vf-c.bl { bottom: 14px; left: 14px; border-bottom-width: 3px; border-left-width: 3px; border-bottom-left-radius: 8px; }++.cm-vf-c.br { bottom: 14px; right: 14px; border-bottom-width: 3px; border-right-width: 3px; border-bottom-right-radius: 8px; }++.cm-vf-focus { position: absolute; left: 60%; top: 42%; width: 64px; height: 64px; margin: -32px 0 0 -32px; border: 2px solid #ffd27a; border-radius: 10px;++ animation: cmFocus var(--ov-dur-4, .52s) var(--ov-ease-spring, cubic-bezier(.34, 1.56, .64, 1)) both; animation-delay: var(--ov-dur-3, .32s); }++@keyframes cmFocus { from { transform: scale(1.5); opacity: 0; } }++.cm-vf-top { position: absolute; top: 18px; left: 50%; transform: translateX(-50%); display: flex; gap: 8px; padding: 4px 10px; border-radius: 999px;++ background: rgba(10, 12, 18, .6); color: #fff; font-size: 12px; }++.cm-vf-top b { color: #ffd27a; }++.cm-vf-shutter { position: absolute; left: 50%; bottom: 18px; width: 54px; height: 54px; margin-left: -27px; border-radius: 50%; background: #fff;++ box-shadow: 0 0 0 4px rgba(10, 12, 18, .45), 0 0 0 7px rgba(255, 255, 255, .85); }++.cm-live-btn svg { width: 20px; height: 20px; display: block; }++/* On a phone the room kit turns its picture into a faint corner decoration; Cam's picture is the product, so it++ stays in the page, under the buttons, at full strength. */++@media (max-width: 759px) {++ .cm-home .sc-room-pic { position: static; z-index: auto; width: auto; opacity: 1; margin-top: 18px; pointer-events: auto; }++ .cm-vf { width: min(100%, 280px); border-radius: 18px; }++ .cm-vf-shutter { width: 44px; height: 44px; margin-left: -22px; bottom: 14px; }++}++@media (prefers-reduced-motion: reduce) { .cm-vf-focus { animation: none; } }nnew file mode 100644@@@ -0,0 +1,45 @@++<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 240" width="320" height="240">++ <!-- Cam's sample picture: a ridge at golden hour, drawn rather than photographed so it shows no one. Its warm sky,++ blue water and green trees make every filter on the home page visibly different. -->++ <defs>++ <linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">++ <stop offset="0" stop-color="#3b5bb8"/>++ <stop offset=".45" stop-color="#e98a6b"/>++ <stop offset=".72" stop-color="#ffc56b"/>++ </linearGradient>++ <linearGradient id="lake" x1="0" y1="0" x2="0" y2="1">++ <stop offset="0" stop-color="#f2a26e"/>++ <stop offset=".35" stop-color="#5f79b8"/>++ <stop offset="1" stop-color="#22345f"/>++ </linearGradient>++ <radialGradient id="sun" cx=".5" cy=".5" r=".5">++ <stop offset="0" stop-color="#fff4cf"/>++ <stop offset=".55" stop-color="#ffd27a"/>++ <stop offset="1" stop-color="#ffd27a" stop-opacity="0"/>++ </radialGradient>++ </defs>++ <rect width="320" height="240" fill="url(#sky)"/>++ <circle cx="214" cy="118" r="46" fill="url(#sun)"/>++ <circle cx="214" cy="118" r="17" fill="#fff6d8"/>++ <path d="M0 132 L38 104 L70 120 L112 82 L150 112 L182 96 L230 128 L268 100 L320 124 V160 H0 Z" fill="#7d5a8c" opacity=".85"/>++ <path d="M0 146 L44 122 L88 138 L132 112 L176 136 L214 122 L262 140 L320 128 V160 H0 Z" fill="#4c3f6b"/>++ <rect y="158" width="320" height="82" fill="url(#lake)"/>++ <ellipse cx="214" cy="170" rx="30" ry="3" fill="#fff1c8" opacity=".85"/>++ <ellipse cx="214" cy="180" rx="20" ry="2" fill="#fff1c8" opacity=".6"/>++ <ellipse cx="214" cy="189" rx="12" ry="1.6" fill="#fff1c8" opacity=".4"/>++ <path d="M0 240 V196 Q40 186 84 198 T170 204 L170 240 Z" fill="#1f3a2c"/>++ <g fill="#2f6b45">++ <path d="M30 202 L42 160 L54 202 Z"/>++ <path d="M52 206 L66 150 L80 206 Z"/>++ <path d="M78 204 L88 172 L98 204 Z"/>++ <path d="M110 208 L122 176 L134 208 Z"/>++ </g>++ <g fill="#3f8a59" opacity=".9">++ <path d="M58 182 L66 158 L74 182 Z"/>++ <path d="M36 188 L42 168 L48 188 Z"/>++ </g>++ <g stroke="#2a1f3d" stroke-width="1.6" fill="none" stroke-linecap="round" opacity=".7">++ <path d="M150 70 q4 -4 8 0 q4 -4 8 0"/>++ <path d="M176 58 q3 -3 6 0 q3 -3 6 0"/>++ </g>++</svg>@@@ -33,14 +33,45 @@ function frameUrl(config, camera) {return `${String(config.upstreams.media).replace(/\/+$/, '')}/live/${encodeURIComponent(camera.slot)}/frame.jpg?w=640`;}--/** A shutter iris opening onto a grid of frames, and a photo flying into a gallery. */++// The home's three scenes show the real thing (the owner, 10-31: placeholder pictures made rooms look unfinished). They++// share one picture, public/img/cam-sample.svg, drawn so it shows no one.++const SAMPLE = '/img/cam-sample.svg';++const CAMERA = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 8h3l2-3h6l2 3h3v11H4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="2"/></svg>';++const LOCK = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="5" y="11" width="14" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3" fill="none" stroke="currentColor" stroke-width="2"/></svg>';++++/** The cam button on a live stream: the frame, the shutter's flash, and the line it posts in the chat. */++function liveStillDemo() {++ return `<figure class="cm-demo-live" role="img" aria-label="A live stream, the cam button's flash, and the chat line it posts">++<div class="cm-live-frame"><img src="${SAMPLE}" alt="" width="320" height="240" loading="lazy"><span class="cm-live-badge">Live</span><span class="cm-live-flash"></span><span class="cm-live-btn">${CAMERA}</span></div>++<p class="cm-live-chat"><span class="cm-live-who">ada</span> caught a still: <q>the sun just touched the ridge</q></p>++</figure>`;++}++++/** A paired camera and the one grant on it: who, for how long, and the way to take it back. */++function grantDemo() {++ return `<div class="cm-demo-grant" role="img" aria-label="A private camera shared with one person for a week">++<div class="cm-g-cam"><img src="${SAMPLE}" alt="" width="64" height="48" loading="lazy"><span class="cm-g-name"><b>Back garden</b><span>Raspberry Pi · private</span></span><span class="cm-g-lock">${LOCK}</span></div>++<div class="cm-g-grant"><span class="cm-g-who">@ada can watch</span><span class="cm-g-left">5 of 7 days left</span><span class="cm-g-bar"><i></i></span><span class="cm-g-revoke">Revoke</span></div>++</div>`;++}++++/** One picture through every filter the camera app has (the same CSS as public/js/cam.js), Warm picked. */++function filtersDemo() {++ const thumbs = Object.entries(FILTER_LABEL).map(([id, label]) => `<li${id === 'warm' ? ' class="is-on"' : ''}><img class="cm-f-${id}" src="${SAMPLE}" alt="" width="64" height="48" loading="lazy"><span>${esc(label)}</span></li>`).join('');++ return `<figure class="cm-demo-filters" role="img" aria-label="One picture in each of the camera's filters, with Warm picked and saved to Pics">++<div class="cm-f-main"><img class="cm-f-warm" src="${SAMPLE}" alt="" width="320" height="240" loading="lazy"><span class="cm-f-saved">Warm · saved to Pics</span></div>++<ul class="cm-f-strip">${thumbs}</ul>++</figure>`;++}++++/** The room's motif: the camera's own viewfinder on the sample picture, the focus locking, the shutter. */function shutterMotif() {-- const frames = [1, 2, 3, 4, 5, 6].map((n) => `<span class="cm-frame cm-frame-${n}"></span>`).join('');-- return `<div class="cm-shutter" aria-hidden="true">--<svg class="cm-iris" viewBox="0 0 200 200"><circle cx="100" cy="100" r="82" fill="none" stroke="currentColor" stroke-width="2" opacity=".45"/>--${[0, 45, 90, 135].map((a) => `<path d="M100 30 A70 70 0 0 1 170 100" transform="rotate(${a} 100 100)" fill="none" stroke="currentColor" stroke-width="9" stroke-linecap="round" opacity=".75"/>`).join('')}--<circle cx="100" cy="100" r="26" fill="currentColor" opacity=".9"/></svg>--<div class="cm-frame-grid">${frames}</div>++ return `<div class="cm-vf" aria-hidden="true">++<img class="cm-f-warm" src="${SAMPLE}" alt="" width="320" height="240">++<span class="cm-vf-c tl"></span><span class="cm-vf-c tr"></span><span class="cm-vf-c bl"></span><span class="cm-vf-c br"></span>++<span class="cm-vf-focus"></span>++<span class="cm-vf-top"><b>Warm</b><span>4:3</span></span>++<span class="cm-vf-shutter"></span></div>`;}@@@ -79,26 +110,25 @@ function home({ signedIn = false } = {}) {lede: 'Take a photo with the filters you choose, keep a camera of your own behind a private wall, and share it with one person for a week — or snap a still from a Live stream and send it to the chat.',start: { href: '/app', label: 'Open the camera' },chips: [{ label: 'My cameras', href: '/my' }, { label: 'The API', href: '/developers' }],-- proof: [{ value: '0', label: 'public cameras' }],motif: shutterMotif(),},scenes: [{title: 'A still from a stream',text: 'The cam button takes the frame OpenVibe.Media already serves, saves it to your OpenVibe.Pics as a still with your caption, and hands the chat line back to Live. Nothing is recorded and nothing is analysed.',-- html: `<figure class="cm-demo-still"><img src="/img/cam-shutter.svg" alt="" width="320" height="180"><figcaption>“the exact second the robot fell over”</figcaption></figure>`,++ html: liveStillDemo(),action: { href: '/developers', label: 'How the cam button calls Cam' },},{title: 'A camera behind a grant',text: 'Pair a phone, a Pi or an IP camera you own. Its wall is private; you name one viewer, choose a week, and revoke it at any time. The next request is refused — there is no cached copy of a grant.',-- html: `<div class="cm-demo-grant"><span class="cm-demo-cam">Front door</span><span class="cm-demo-arrow">→</span><span class="cm-demo-viewer">@ada</span><span class="cm-demo-days">7 days</span></div>`,++ html: grantDemo(),action: { href: '/my', label: 'Pair a camera' },},{title: 'A photo to Pics',text: 'The camera app runs the colour, light and blur on your device. The picture goes to OpenVibe.Pics; Cam keeps the link and the filter name, never the bytes.',-- html: `<figure class="cm-demo-photo"><img src="/img/cam-shutter.svg" alt="" width="320" height="240"><figcaption>Warm · saved to Pics</figcaption></figure>`,++ html: filtersDemo(),action: { href: '/app', label: 'Take a photo' },},],nnew file mode 100644@@@ -0,0 +1,30 @@++'use strict';++// Cam's home shows the real thing (the owner, 10-31: placeholder pictures made the rooms look unfinished): a live frame++// with the cam button's flash and the chat line it posts, a camera's one grant, and one picture through every filter the++// camera app has, with the same CSS as the app, so the home can never show a filter the camera does not apply.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const views = require('../server/views');++++const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/cam.css'), 'utf8');++const js = fs.readFileSync(path.join(__dirname, '..', 'public/js/cam.js'), 'utf8');++const sample = fs.readFileSync(path.join(__dirname, '..', 'public/img/cam-sample.svg'), 'utf8');++++const block = js.slice(js.indexOf('var FILTERS = {'), js.indexOf('};', js.indexOf('var FILTERS = {')));++const app = Object.fromEntries([...block.matchAll(/(\w+):\s*'([^']*)'/g)].map((m) => [m[1], m[2]]));++assert.ok(Object.keys(app).length >= 7, 'the camera app\'s filters were read');++for (const [id, filter] of Object.entries(app)) {++ const m = css.match(new RegExp(`\\.cm-f-${id} \\{ filter: ([^;]+); \\}`));++ assert.ok(m, `cam.css draws the ${id} filter`);++ assert.strictEqual(m[1], filter, `the home's ${id} is the camera's ${id}`);++}++++const home = views.home({ signedIn: false });++assert.ok(home.includes('class="cm-demo-live"') && home.includes('class="cm-live-flash"') && home.includes('caught a still'), 'a live still, shown');++assert.ok(home.includes('class="cm-demo-grant"') && home.includes('can watch') && home.includes('Revoke'), 'a grant, shown');++assert.ok(home.includes('class="cm-demo-filters"'), 'the filters, shown');++for (const id of Object.keys(app)) assert.ok(home.includes(`class="cm-f-${id}"`), `the ${id} filter is in the strip`);++assert.ok(!/<img src="\/img\/cam-shutter\.svg"/.test(home), 'no placeholder picture in a scene');++assert.ok(!/<script/i.test(sample) && !/href=|xlink:/i.test(sample), 'the sample picture is a plain drawing');++console.log(`cam home scenes: ${Object.keys(app).length} filters equal to the camera's, three real scenes`);@@@ -26,7 +26,7 @@ function jsonLd(html) {assert.strictEqual(r.status, 200);assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');assert.strictEqual(r.headers.get('vary'), 'Cookie');-- for (const marker of ['id="cm-home"', 'class="sc-room', 'id="cm-rules"', 'Nothing public without its owner', 'No face recognition', '/img/cam-shutter.svg', 'href="/app"']) assert.ok(r.text.includes(marker), marker);++ for (const marker of ['id="cm-home"', 'class="sc-room', 'id="cm-rules"', 'Nothing public without its owner', 'No face recognition', '/img/cam-sample.svg', 'href="/app"']) assert.ok(r.text.includes(marker), marker);assert.match(r.text, /<main id="main"/);assert.match(r.text, /<footer id="ov-footer"/);const types = jsonLd(r.text).map((x) => x['@type']);
@@ -58,18 +58,6 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -58,18 +58,6 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.cm-count { min-width: 28px; padding: 2px 9px; border-radius: 999px; background: color-mix(in srgb, var(--room-accent) 16%, transparent); color: var(--room-strong); font-weight: 800; font-size: .8rem; text-align: center; font-variant-numeric: tabular-nums; }
.cm-count { min-width: 28px; padding: 2px 9px; border-radius: 999px; background: color-mix(in srgb, var(--room-accent) 16%, transparent); color: var(--room-strong); font-weight: 800; font-size: .8rem; text-align: center; font-variant-numeric: tabular-nums; }
/* The motif: an iris opening onto a contact sheet */
/* The motif: an iris opening onto a contact sheet */
-.cm-shutter { position: relative; width: min(100%, 420px); aspect-ratio: 1; margin: 0 auto; color: var(--room-accent); }
-.cm-iris { position: absolute; inset: 8% 30% 30% 8%; width: 62%; height: 62%; }
-html.ov-motion .cm-iris path { transform-box: view-box; animation: cm-iris 6s cubic-bezier(.4,0,.2,1) infinite; }
-@keyframes cm-iris { 0%, 100% { opacity: .75; } 45%, 55% { opacity: .2; } }
-.cm-frame-grid { position: absolute; right: 4%; bottom: 6%; width: 52%; display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
-.cm-frame { aspect-ratio: 4 / 3; border-radius: 6px; border: 1px solid color-mix(in srgb, var(--room-accent) 45%, transparent); background: color-mix(in srgb, var(--room-accent) 10%, var(--bg-card, #0f1729)); }
-.cm-frame-1, .cm-frame-5 { background: color-mix(in srgb, var(--room-accent) 32%, var(--bg-card, #0f1729)); }
-html.ov-motion .cm-frame { animation: cm-pop .5s cubic-bezier(.2,.7,.2,1) both; }
-html.ov-motion .cm-frame-2 { animation-delay: .08s; } html.ov-motion .cm-frame-3 { animation-delay: .16s; } html.ov-motion .cm-frame-4 { animation-delay: .24s; }
-html.ov-motion .cm-frame-5 { animation-delay: .32s; } html.ov-motion .cm-frame-6 { animation-delay: .4s; }
-@keyframes cm-pop { from { opacity: 0; transform: translateY(6px) scale(.96); } }
-@media (max-width: 759px) { .cm-shutter { width: 220px; } }
/* The camera: a dark viewfinder with thirds, the controls beside it on a wide screen */
/* The camera: a dark viewfinder with thirds, the controls beside it on a wide screen */
.cm-camera { display: grid; gap: 14px; margin-top: 18px; }
.cm-camera { display: grid; gap: 14px; margin-top: 18px; }
@@ -120,11 +108,96 @@ html.ov-motion .cm-frame-5 { animation-delay: .32s; } html.ov-motion .cm-frame-6
@@ -120,11 +108,96 @@ html.ov-motion .cm-frame-5 { animation-delay: .32s; } html.ov-motion .cm-frame-6
.cm-grants th, .cm-grants td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border, #1f2d47); white-space: nowrap; }
.cm-grants th, .cm-grants td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border, #1f2d47); white-space: nowrap; }
.cm-grants tr:last-child td { border-bottom: 0; }
.cm-grants tr:last-child td { border-bottom: 0; }
.cm-grants th { font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
.cm-grants th { font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
-@media (prefers-reduced-motion: reduce) { .cm-iris path, .cm-frame { animation: none !important; } }
/* Phones: the motif is a faint corner mark behind the hero text, so it drops the contact sheet and fades the iris */
/* Phones: the motif is a faint corner mark behind the hero text, so it drops the contact sheet and fades the iris */
-@media (max-width: 759px) { .cm-frame-grid { display: none; } .cm-iris { opacity: .3; } }
/* The controls read top to bottom: power and shutter, the settings, then the two toggles side by side */
/* The controls read top to bottom: power and shutter, the settings, then the two toggles side by side */
.cm-controls [data-cm-start], .cm-controls [data-cm-shoot] { order: 1; }
.cm-controls [data-cm-start], .cm-controls [data-cm-shoot] { order: 1; }
.cm-controls .cm-field { order: 2; }
.cm-controls .cm-field { order: 2; }
.cm-controls [data-cm-burst], .cm-controls [data-cm-grid-toggle] { order: 3; justify-content: center; }
.cm-controls [data-cm-burst], .cm-controls [data-cm-grid-toggle] { order: 3; justify-content: center; }
+
+/* ── The home's scenes, shown for real (10-31): a live frame and its still, a camera's grant, the filters ── */
+/* The filters, the same CSS as public/js/cam.js's FILTERS (test/home-scenes.test.js keeps them equal). */
+.cm-f-none { filter: none; }
+.cm-f-mono { filter: grayscale(1) contrast(1.08); }
+.cm-f-warm { filter: sepia(.35) saturate(1.15); }
+.cm-f-cool { filter: hue-rotate(-10deg) saturate(1.1); }
+.cm-f-fade { filter: saturate(.8) contrast(.92) brightness(1.06); }
+.cm-f-vivid { filter: saturate(1.45) contrast(1.06); }
+.cm-f-blur { filter: blur(1.5px); }
+
+.cm-demo-live, .cm-demo-filters { margin: 0; display: grid; gap: 10px; width: min(380px, 100%); justify-self: center; }
+.cm-live-frame { position: relative; overflow: hidden; border-radius: 14px; aspect-ratio: 4 / 3; box-shadow: 0 24px 50px -30px rgba(0, 0, 0, .7); }
+.cm-live-frame img, .cm-f-main img { display: block; width: 100%; height: 100%; object-fit: cover; }
+.cm-live-badge { position: absolute; top: 10px; left: 10px; padding: 3px 8px; border-radius: 6px; background: var(--ovk-live, #ef4444); color: #fff;
+ font-size: 11px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
+.cm-live-btn { position: absolute; right: 10px; bottom: 10px; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%;
+ background: rgba(10, 12, 18, .66); color: #fff; box-shadow: 0 0 0 2px rgba(255, 255, 255, .7);
+ animation: cmPress var(--ov-dur-2, .2s) var(--ov-ease-out, ease-out) both; animation-delay: var(--ov-dur-4, .52s); }
+.cm-live-btn .ovk-i { width: 20px; height: 20px; }
+.cm-live-flash { position: absolute; inset: 0; background: #fff; opacity: 0;
+ animation: cmFlash var(--ov-dur-4, .52s) var(--ov-ease-out, ease-out) both; animation-delay: calc(var(--ov-dur-4, .52s) + var(--ov-dur-1, .12s)); }
+.cm-live-chat { margin: 0; padding: 9px 12px; border-radius: 12px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); font-size: 14px;
+ animation: cmRise var(--ov-dur-3, .32s) var(--ov-ease-out, ease-out) both; animation-delay: calc(var(--ov-dur-4, .52s) * 2); }
+.cm-live-who { font-weight: 700; color: var(--room-strong, var(--ovk-accent)); }
+@keyframes cmPress { 50% { transform: scale(.88); } }
+@keyframes cmFlash { 0% { opacity: 0; } 15% { opacity: .85; } 100% { opacity: 0; } }
+@keyframes cmRise { from { opacity: 0; transform: translateY(8px); } }
+
+.cm-demo-grant { display: grid; gap: 10px; width: min(380px, 100%); justify-self: center; padding: 12px; border-radius: 16px;
+ background: var(--ovk-panel); border: 1px solid var(--ovk-line); box-shadow: 0 24px 50px -30px rgba(0, 0, 0, .6); }
+.cm-g-cam { display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; gap: 12px; align-items: center; }
+.cm-g-cam img { width: 64px; height: 48px; border-radius: 8px; object-fit: cover; }
+.cm-g-name { display: grid; min-width: 0; }
+.cm-g-name span { color: var(--ovk-muted); font-size: 13px; }
+.cm-g-lock { color: var(--room-accent, var(--ovk-accent)); }
+.cm-g-lock svg { width: 20px; height: 20px; display: block; }
+.cm-g-grant { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 12px; align-items: center; padding: 10px 12px; border-radius: 12px;
+ background: var(--ovk-panel-2); }
+.cm-g-who { font-weight: 700; }
+.cm-g-left { color: var(--ovk-muted); font-size: 13px; text-align: right; }
+.cm-g-bar { grid-column: 1 / -1; height: 6px; border-radius: 3px; background: var(--ovk-line); overflow: hidden; }
+.cm-g-bar i { display: block; width: 71%; height: 100%; border-radius: inherit; background: var(--room-accent, var(--ovk-accent));
+ transform-origin: 0 50%; animation: cmGrow var(--ov-dur-4, .52s) var(--ov-ease-out, ease-out) both; }
+.cm-g-revoke { grid-column: 1 / -1; justify-self: start; font-size: 13px; font-weight: 700; color: var(--ovk-bad, #ef4444); }
+@keyframes cmGrow { from { transform: scaleX(0); } }
+
+.cm-f-main { position: relative; overflow: hidden; border-radius: 14px; aspect-ratio: 4 / 3; box-shadow: 0 24px 50px -30px rgba(0, 0, 0, .7); }
+.cm-f-saved { position: absolute; left: 10px; bottom: 10px; padding: 5px 10px; border-radius: 999px; background: rgba(10, 12, 18, .7); color: #fff;
+ font-size: 12.5px; font-weight: 700; animation: cmRise var(--ov-dur-3, .32s) var(--ov-ease-out, ease-out) both; animation-delay: var(--ov-dur-4, .52s); }
+.cm-f-strip { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; }
+.cm-f-strip li { display: grid; gap: 4px; justify-items: center; font-size: 11px; color: var(--ovk-muted); }
+.cm-f-strip img { width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 6px; outline: 2px solid transparent; outline-offset: 1px; }
+.cm-f-strip li.is-on { color: var(--ovk-text); font-weight: 700; }
+.cm-f-strip li.is-on img { outline-color: var(--room-accent, var(--ovk-accent)); }
+
+@media (prefers-reduced-motion: reduce) {
+ .cm-live-btn, .cm-live-flash, .cm-live-chat, .cm-g-bar i, .cm-f-saved { animation: none; }
+}
+
+/* The room's motif: the camera's viewfinder on the sample picture. The focus square locks once and the shutter waits;
+ nothing loops. */
+.cm-vf { position: relative; width: min(100%, 420px); aspect-ratio: 4 / 3; margin: 0 auto; border-radius: 22px; overflow: hidden;
+ box-shadow: 0 40px 80px -40px rgba(0, 0, 0, .75), 0 0 0 1px color-mix(in srgb, var(--room-accent) 30%, transparent); }
+.cm-vf img { display: block; width: 100%; height: 100%; object-fit: cover; }
+.cm-vf-c { position: absolute; width: 28px; height: 28px; border: 0 solid rgba(255, 255, 255, .9); }
+.cm-vf-c.tl { top: 14px; left: 14px; border-top-width: 3px; border-left-width: 3px; border-top-left-radius: 8px; }
+.cm-vf-c.tr { top: 14px; right: 14px; border-top-width: 3px; border-right-width: 3px; border-top-right-radius: 8px; }
+.cm-vf-c.bl { bottom: 14px; left: 14px; border-bottom-width: 3px; border-left-width: 3px; border-bottom-left-radius: 8px; }
+.cm-vf-c.br { bottom: 14px; right: 14px; border-bottom-width: 3px; border-right-width: 3px; border-bottom-right-radius: 8px; }
+.cm-vf-focus { position: absolute; left: 60%; top: 42%; width: 64px; height: 64px; margin: -32px 0 0 -32px; border: 2px solid #ffd27a; border-radius: 10px;
+ animation: cmFocus var(--ov-dur-4, .52s) var(--ov-ease-spring, cubic-bezier(.34, 1.56, .64, 1)) both; animation-delay: var(--ov-dur-3, .32s); }
+@keyframes cmFocus { from { transform: scale(1.5); opacity: 0; } }
+.cm-vf-top { position: absolute; top: 18px; left: 50%; transform: translateX(-50%); display: flex; gap: 8px; padding: 4px 10px; border-radius: 999px;
+ background: rgba(10, 12, 18, .6); color: #fff; font-size: 12px; }
+.cm-vf-top b { color: #ffd27a; }
+.cm-vf-shutter { position: absolute; left: 50%; bottom: 18px; width: 54px; height: 54px; margin-left: -27px; border-radius: 50%; background: #fff;
+ box-shadow: 0 0 0 4px rgba(10, 12, 18, .45), 0 0 0 7px rgba(255, 255, 255, .85); }
+.cm-live-btn svg { width: 20px; height: 20px; display: block; }
+/* On a phone the room kit turns its picture into a faint corner decoration; Cam's picture is the product, so it
+ stays in the page, under the buttons, at full strength. */
+@media (max-width: 759px) {
+ .cm-home .sc-room-pic { position: static; z-index: auto; width: auto; opacity: 1; margin-top: 18px; pointer-events: auto; }
+ .cm-vf { width: min(100%, 280px); border-radius: 18px; }
+ .cm-vf-shutter { width: 44px; height: 44px; margin-left: -22px; bottom: 14px; }
+}
+@media (prefers-reduced-motion: reduce) { .cm-vf-focus { animation: none; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 240" width="320" height="240">
+ <!-- Cam's sample picture: a ridge at golden hour, drawn rather than photographed so it shows no one. Its warm sky,
+ blue water and green trees make every filter on the home page visibly different. -->
+ <defs>
+ <linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0" stop-color="#3b5bb8"/>
+ <stop offset=".45" stop-color="#e98a6b"/>
+ <stop offset=".72" stop-color="#ffc56b"/>
+ </linearGradient>
+ <linearGradient id="lake" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0" stop-color="#f2a26e"/>
+ <stop offset=".35" stop-color="#5f79b8"/>
+ <stop offset="1" stop-color="#22345f"/>
+ </linearGradient>
+ <radialGradient id="sun" cx=".5" cy=".5" r=".5">
+ <stop offset="0" stop-color="#fff4cf"/>
+ <stop offset=".55" stop-color="#ffd27a"/>
+ <stop offset="1" stop-color="#ffd27a" stop-opacity="0"/>
+ </radialGradient>
+ </defs>
+ <rect width="320" height="240" fill="url(#sky)"/>
+ <circle cx="214" cy="118" r="46" fill="url(#sun)"/>
+ <circle cx="214" cy="118" r="17" fill="#fff6d8"/>
+ <path d="M0 132 L38 104 L70 120 L112 82 L150 112 L182 96 L230 128 L268 100 L320 124 V160 H0 Z" fill="#7d5a8c" opacity=".85"/>
+ <path d="M0 146 L44 122 L88 138 L132 112 L176 136 L214 122 L262 140 L320 128 V160 H0 Z" fill="#4c3f6b"/>
+ <rect y="158" width="320" height="82" fill="url(#lake)"/>
+ <ellipse cx="214" cy="170" rx="30" ry="3" fill="#fff1c8" opacity=".85"/>
+ <ellipse cx="214" cy="180" rx="20" ry="2" fill="#fff1c8" opacity=".6"/>
+ <ellipse cx="214" cy="189" rx="12" ry="1.6" fill="#fff1c8" opacity=".4"/>
+ <path d="M0 240 V196 Q40 186 84 198 T170 204 L170 240 Z" fill="#1f3a2c"/>
+ <g fill="#2f6b45">
+ <path d="M30 202 L42 160 L54 202 Z"/>
+ <path d="M52 206 L66 150 L80 206 Z"/>
+ <path d="M78 204 L88 172 L98 204 Z"/>
+ <path d="M110 208 L122 176 L134 208 Z"/>
+ </g>
+ <g fill="#3f8a59" opacity=".9">
+ <path d="M58 182 L66 158 L74 182 Z"/>
+ <path d="M36 188 L42 168 L48 188 Z"/>
+ </g>
+ <g stroke="#2a1f3d" stroke-width="1.6" fill="none" stroke-linecap="round" opacity=".7">
+ <path d="M150 70 q4 -4 8 0 q4 -4 8 0"/>
+ <path d="M176 58 q3 -3 6 0 q3 -3 6 0"/>
+ </g>
+</svg>
@@ -33,14 +33,45 @@ function frameUrl(config, camera) {
@@ -33,14 +33,45 @@ function frameUrl(config, camera) {
return `${String(config.upstreams.media).replace(/\/+$/, '')}/live/${encodeURIComponent(camera.slot)}/frame.jpg?w=640`;
return `${String(config.upstreams.media).replace(/\/+$/, '')}/live/${encodeURIComponent(camera.slot)}/frame.jpg?w=640`;
}
}
-/** A shutter iris opening onto a grid of frames, and a photo flying into a gallery. */
+// The home's three scenes show the real thing (the owner, 10-31: placeholder pictures made rooms look unfinished). They
+// share one picture, public/img/cam-sample.svg, drawn so it shows no one.
+const SAMPLE = '/img/cam-sample.svg';
+const CAMERA = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 8h3l2-3h6l2 3h3v11H4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><circle cx="12" cy="13" r="3.5" fill="none" stroke="currentColor" stroke-width="2"/></svg>';
+const LOCK = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="5" y="11" width="14" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3" fill="none" stroke="currentColor" stroke-width="2"/></svg>';
+
+/** The cam button on a live stream: the frame, the shutter's flash, and the line it posts in the chat. */
+function liveStillDemo() {
+ return `<figure class="cm-demo-live" role="img" aria-label="A live stream, the cam button's flash, and the chat line it posts">
+<div class="cm-live-frame"><img src="${SAMPLE}" alt="" width="320" height="240" loading="lazy"><span class="cm-live-badge">Live</span><span class="cm-live-flash"></span><span class="cm-live-btn">${CAMERA}</span></div>
+<p class="cm-live-chat"><span class="cm-live-who">ada</span> caught a still: <q>the sun just touched the ridge</q></p>
+</figure>`;
+}
+
+/** A paired camera and the one grant on it: who, for how long, and the way to take it back. */
+function grantDemo() {
+ return `<div class="cm-demo-grant" role="img" aria-label="A private camera shared with one person for a week">
+<div class="cm-g-cam"><img src="${SAMPLE}" alt="" width="64" height="48" loading="lazy"><span class="cm-g-name"><b>Back garden</b><span>Raspberry Pi · private</span></span><span class="cm-g-lock">${LOCK}</span></div>
+<div class="cm-g-grant"><span class="cm-g-who">@ada can watch</span><span class="cm-g-left">5 of 7 days left</span><span class="cm-g-bar"><i></i></span><span class="cm-g-revoke">Revoke</span></div>
+</div>`;
+}
+
+/** One picture through every filter the camera app has (the same CSS as public/js/cam.js), Warm picked. */
+function filtersDemo() {
+ const thumbs = Object.entries(FILTER_LABEL).map(([id, label]) => `<li${id === 'warm' ? ' class="is-on"' : ''}><img class="cm-f-${id}" src="${SAMPLE}" alt="" width="64" height="48" loading="lazy"><span>${esc(label)}</span></li>`).join('');
+ return `<figure class="cm-demo-filters" role="img" aria-label="One picture in each of the camera's filters, with Warm picked and saved to Pics">
+<div class="cm-f-main"><img class="cm-f-warm" src="${SAMPLE}" alt="" width="320" height="240" loading="lazy"><span class="cm-f-saved">Warm · saved to Pics</span></div>
+<ul class="cm-f-strip">${thumbs}</ul>
+</figure>`;
+}
+
+/** The room's motif: the camera's own viewfinder on the sample picture, the focus locking, the shutter. */
function shutterMotif() {
function shutterMotif() {
- const frames = [1, 2, 3, 4, 5, 6].map((n) => `<span class="cm-frame cm-frame-${n}"></span>`).join('');
- return `<div class="cm-shutter" aria-hidden="true">
-<svg class="cm-iris" viewBox="0 0 200 200"><circle cx="100" cy="100" r="82" fill="none" stroke="currentColor" stroke-width="2" opacity=".45"/>
-${[0, 45, 90, 135].map((a) => `<path d="M100 30 A70 70 0 0 1 170 100" transform="rotate(${a} 100 100)" fill="none" stroke="currentColor" stroke-width="9" stroke-linecap="round" opacity=".75"/>`).join('')}
-<circle cx="100" cy="100" r="26" fill="currentColor" opacity=".9"/></svg>
-<div class="cm-frame-grid">${frames}</div>
+ return `<div class="cm-vf" aria-hidden="true">
+<img class="cm-f-warm" src="${SAMPLE}" alt="" width="320" height="240">
+<span class="cm-vf-c tl"></span><span class="cm-vf-c tr"></span><span class="cm-vf-c bl"></span><span class="cm-vf-c br"></span>
+<span class="cm-vf-focus"></span>
+<span class="cm-vf-top"><b>Warm</b><span>4:3</span></span>
+<span class="cm-vf-shutter"></span>
</div>`;
</div>`;
}
}
@@ -79,26 +110,25 @@ function home({ signedIn = false } = {}) {
@@ -79,26 +110,25 @@ function home({ signedIn = false } = {}) {
lede: 'Take a photo with the filters you choose, keep a camera of your own behind a private wall, and share it with one person for a week — or snap a still from a Live stream and send it to the chat.',
lede: 'Take a photo with the filters you choose, keep a camera of your own behind a private wall, and share it with one person for a week — or snap a still from a Live stream and send it to the chat.',
start: { href: '/app', label: 'Open the camera' },
start: { href: '/app', label: 'Open the camera' },
chips: [{ label: 'My cameras', href: '/my' }, { label: 'The API', href: '/developers' }],
chips: [{ label: 'My cameras', href: '/my' }, { label: 'The API', href: '/developers' }],
- proof: [{ value: '0', label: 'public cameras' }],
motif: shutterMotif(),
motif: shutterMotif(),
},
},
scenes: [
scenes: [
{
{
title: 'A still from a stream',
title: 'A still from a stream',
text: 'The cam button takes the frame OpenVibe.Media already serves, saves it to your OpenVibe.Pics as a still with your caption, and hands the chat line back to Live. Nothing is recorded and nothing is analysed.',
text: 'The cam button takes the frame OpenVibe.Media already serves, saves it to your OpenVibe.Pics as a still with your caption, and hands the chat line back to Live. Nothing is recorded and nothing is analysed.',
- html: `<figure class="cm-demo-still"><img src="/img/cam-shutter.svg" alt="" width="320" height="180"><figcaption>“the exact second the robot fell over”</figcaption></figure>`,
+ html: liveStillDemo(),
action: { href: '/developers', label: 'How the cam button calls Cam' },
action: { href: '/developers', label: 'How the cam button calls Cam' },
},
},
{
{
title: 'A camera behind a grant',
title: 'A camera behind a grant',
text: 'Pair a phone, a Pi or an IP camera you own. Its wall is private; you name one viewer, choose a week, and revoke it at any time. The next request is refused — there is no cached copy of a grant.',
text: 'Pair a phone, a Pi or an IP camera you own. Its wall is private; you name one viewer, choose a week, and revoke it at any time. The next request is refused — there is no cached copy of a grant.',
- html: `<div class="cm-demo-grant"><span class="cm-demo-cam">Front door</span><span class="cm-demo-arrow">→</span><span class="cm-demo-viewer">@ada</span><span class="cm-demo-days">7 days</span></div>`,
+ html: grantDemo(),
action: { href: '/my', label: 'Pair a camera' },
action: { href: '/my', label: 'Pair a camera' },
},
},
{
{
title: 'A photo to Pics',
title: 'A photo to Pics',
text: 'The camera app runs the colour, light and blur on your device. The picture goes to OpenVibe.Pics; Cam keeps the link and the filter name, never the bytes.',
text: 'The camera app runs the colour, light and blur on your device. The picture goes to OpenVibe.Pics; Cam keeps the link and the filter name, never the bytes.',
- html: `<figure class="cm-demo-photo"><img src="/img/cam-shutter.svg" alt="" width="320" height="240"><figcaption>Warm · saved to Pics</figcaption></figure>`,
+ html: filtersDemo(),
action: { href: '/app', label: 'Take a photo' },
action: { href: '/app', label: 'Take a photo' },
},
},
],
],
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+'use strict';
+// Cam's home shows the real thing (the owner, 10-31: placeholder pictures made the rooms look unfinished): a live frame
+// with the cam button's flash and the chat line it posts, a camera's one grant, and one picture through every filter the
+// camera app has, with the same CSS as the app, so the home can never show a filter the camera does not apply.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const views = require('../server/views');
+
+const css = fs.readFileSync(path.join(__dirname, '..', 'public/css/cam.css'), 'utf8');
+const js = fs.readFileSync(path.join(__dirname, '..', 'public/js/cam.js'), 'utf8');
+const sample = fs.readFileSync(path.join(__dirname, '..', 'public/img/cam-sample.svg'), 'utf8');
+
+const block = js.slice(js.indexOf('var FILTERS = {'), js.indexOf('};', js.indexOf('var FILTERS = {')));
+const app = Object.fromEntries([...block.matchAll(/(\w+):\s*'([^']*)'/g)].map((m) => [m[1], m[2]]));
+assert.ok(Object.keys(app).length >= 7, 'the camera app\'s filters were read');
+for (const [id, filter] of Object.entries(app)) {
+ const m = css.match(new RegExp(`\\.cm-f-${id} \\{ filter: ([^;]+); \\}`));
+ assert.ok(m, `cam.css draws the ${id} filter`);
+ assert.strictEqual(m[1], filter, `the home's ${id} is the camera's ${id}`);
+}
+
+const home = views.home({ signedIn: false });
+assert.ok(home.includes('class="cm-demo-live"') && home.includes('class="cm-live-flash"') && home.includes('caught a still'), 'a live still, shown');
+assert.ok(home.includes('class="cm-demo-grant"') && home.includes('can watch') && home.includes('Revoke'), 'a grant, shown');
+assert.ok(home.includes('class="cm-demo-filters"'), 'the filters, shown');
+for (const id of Object.keys(app)) assert.ok(home.includes(`class="cm-f-${id}"`), `the ${id} filter is in the strip`);
+assert.ok(!/<img src="\/img\/cam-shutter\.svg"/.test(home), 'no placeholder picture in a scene');
+assert.ok(!/<script/i.test(sample) && !/href=|xlink:/i.test(sample), 'the sample picture is a plain drawing');
+console.log(`cam home scenes: ${Object.keys(app).length} filters equal to the camera's, three real scenes`);
@@ -26,7 +26,7 @@ function jsonLd(html) {
@@ -26,7 +26,7 @@ function jsonLd(html) {
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
assert.strictEqual(r.headers.get('vary'), 'Cookie');
assert.strictEqual(r.headers.get('vary'), 'Cookie');
- for (const marker of ['id="cm-home"', 'class="sc-room', 'id="cm-rules"', 'Nothing public without its owner', 'No face recognition', '/img/cam-shutter.svg', 'href="/app"']) assert.ok(r.text.includes(marker), marker);
+ for (const marker of ['id="cm-home"', 'class="sc-room', 'id="cm-rules"', 'Nothing public without its owner', 'No face recognition', '/img/cam-sample.svg', 'href="/app"']) assert.ok(r.text.includes(marker), marker);
assert.match(r.text, /<main id="main"/);
assert.match(r.text, /<main id="main"/);
assert.match(r.text, /<footer id="ov-footer"/);
assert.match(r.text, /<footer id="ov-footer"/);
const types = jsonLd(r.text).map((x) => x['@type']);
const types = jsonLd(r.text).map((x) => x['@type']);