openvibers/openvibe.cam Commits

Cam's home shows the real thing: a viewfinder, a live still, a grant, every filter (#28)

OP OpenVibers committed 13 days ago · 274c2ab2c0 · 5 files, +204 −26

Changes

ddiff --git a/public/css/cam.css b/public/css/cam.css
iindex ed02504..5081aba 100644
---- a/public/css/cam.css
++++ b/public/css/cam.css
@@@ -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; } }
ddiff --git a/public/img/cam-sample.svg b/public/img/cam-sample.svg
nnew file mode 100644
iindex 0000000..901bb52
---- /dev/null
++++ b/public/img/cam-sample.svg
@@@ -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>
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 739513a..9cd4af0 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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' },
},
],
ddiff --git a/test/home-scenes.test.js b/test/home-scenes.test.js
nnew file mode 100644
iindex 0000000..8e5bed7
---- /dev/null
++++ b/test/home-scenes.test.js
@@@ -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`);
ddiff --git a/test/pages.test.js b/test/pages.test.js
iindex c742ba5..fb6f65b 100644
---- a/test/pages.test.js
++++ b/test/pages.test.js
@@@ -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']);