openvibers/openvibe.cam Commits

Cam: the camera app feels like a phone camera (#87)

OP OpenVibers committed 6 days ago · 4ee89fec9e · 3 files, +206 −49

The owner (11-07): the camera app should feel like an app, especially on a phone, with a bigger interface and
animations. A phone gets the whole screen under the bar: the viewfinder with grid and timer on top, a filter strip
that previews live on the camera, the gallery thumb, Photo/Burst over a round shutter, and flip. The shutter turns the
camera on; a capture flashes, the countdown shows a big number, the thumb pops with the new photo. A tablet or desktop
gets a large framed viewfinder with the same controls. Every data-cm hook is kept. The home's hero copy is plain:
'A camera that is yours'.

Changes

ddiff --git a/public/css/cam.css b/public/css/cam.css
iindex 0b79cd6..9105c07 100644
---- a/public/css/cam.css
++++ b/public/css/cam.css
@@@ -59,26 +59,6 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
/* The motif: an iris opening onto a contact sheet */
--/* The camera: a dark viewfinder with thirds, the controls beside it on a wide screen */
--.cm-camera { display: grid; gap: 14px; margin-top: 18px; }
--@media (min-width: 960px) { .cm-camera { grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr); align-items: start; } .cm-camera > .cm-status, .cm-camera > .cm-field, .cm-camera > noscript { grid-column: 2; } }
--.cm-stage { position: relative; overflow: hidden; aspect-ratio: 4 / 3; border-radius: 22px; background: #05070c; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .06), 0 22px 50px -30px rgba(0, 0, 0, .8); }
--.cm-stage video, .cm-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
--.cm-stage::after { content: ''; position: absolute; inset: 14px; border-radius: 12px; pointer-events: none;
-- background: linear-gradient(currentColor, currentColor) top left / 22px 2px no-repeat, linear-gradient(currentColor, currentColor) top left / 2px 22px no-repeat,
-- linear-gradient(currentColor, currentColor) top right / 22px 2px no-repeat, linear-gradient(currentColor, currentColor) top right / 2px 22px no-repeat,
-- linear-gradient(currentColor, currentColor) bottom left / 22px 2px no-repeat, linear-gradient(currentColor, currentColor) bottom left / 2px 22px no-repeat,
-- linear-gradient(currentColor, currentColor) bottom right / 22px 2px no-repeat, linear-gradient(currentColor, currentColor) bottom right / 2px 22px no-repeat;
-- color: rgba(255, 255, 255, .55); }
--.cm-grid-overlay { position: absolute; inset: 0; pointer-events: none;
-- background: linear-gradient(90deg, transparent calc(33.333% - .5px), rgba(255, 255, 255, .35) calc(33.333% - .5px) calc(33.333% + .5px), transparent calc(33.333% + .5px) calc(66.666% - .5px), rgba(255, 255, 255, .35) calc(66.666% - .5px) calc(66.666% + .5px), transparent calc(66.666% + .5px)),
-- linear-gradient(transparent calc(33.333% - .5px), rgba(255, 255, 255, .35) calc(33.333% - .5px) calc(33.333% + .5px), transparent calc(33.333% + .5px) calc(66.666% - .5px), rgba(255, 255, 255, .35) calc(66.666% - .5px) calc(66.666% + .5px), transparent calc(66.666% + .5px)); }
--.cm-grid-overlay[hidden] { display: none; }
--.cm-controls { display: grid; gap: 10px; grid-template-columns: 1fr 1fr; align-content: start; }
--.cm-controls .cm-field { grid-column: 1 / -1; }
--.cm-controls [data-cm-start], .cm-controls [data-cm-shoot] { grid-column: 1 / -1; min-height: 48px; justify-content: center; }
--.cm-controls [data-cm-shoot]:not([disabled]) { background: var(--accent-strong, var(--room-accent)); color: var(--on-accent-strong, #fff); border-color: transparent; }
--.cm-status { margin: 0; font-variant-numeric: tabular-nums; color: var(--text-secondary, #96a7c2); }
/* Photos, stills and cameras */
.cm-photos, .cm-stills, .cm-cameras { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr)); }
@@@ -110,10 +90,6 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.cm-grants th { font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
/* Phones: the motif is a faint corner mark behind the hero text, so it drops the contact sheet and fades the iris */
--/* 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). */
@@@ -222,3 +198,90 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
/* Inline code in prose breaks anywhere: a full URL in a step ran 61 px past a 320 px screen (2026-11-01). */
.cm-steps code, .cm-fine code, main li code, main p code { overflow-wrap: anywhere; }
++
++
++/* ── The camera app (11-07: "feel more like an app, especially on phone … a bigger interface … animations") ──────────
++ A phone gets the whole screen under the bar: the viewfinder, its controls on it like a phone camera (grid and timer on
++ top, the filter strip, the gallery thumb, the shutter and the flip at the bottom). A tablet or a desktop gets a large
++ framed viewfinder with the same controls. Motion only where something happens: the shutter press, the flash, the
++ countdown, the filter strip's snap. */
++.cm-cam-app { display: grid; gap: 22px; }
++.cm-camera { position: relative; display: grid; gap: 12px; margin: 0; }
++.cm-stage { position: relative; overflow: hidden; border-radius: 26px; background: #04060b; aspect-ratio: 4 / 3; max-height: 72vh; box-shadow: inset 0 0 0 1px rgba(255,255,255,.06), 0 30px 70px -40px rgba(0,0,0,.9); isolation: isolate; }
++.cm-stage video, .cm-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; transition: filter .25s ease; }
++.cm-camera[data-facing="user"] .cm-stage video { transform: scaleX(-1); }
++.cm-grid-overlay { position: absolute; inset: 0; pointer-events: none; z-index: 1;
++ background: linear-gradient(90deg, transparent calc(33.333% - .5px), rgba(255,255,255,.35) calc(33.333% - .5px) calc(33.333% + .5px), transparent calc(33.333% + .5px) calc(66.666% - .5px), rgba(255,255,255,.35) calc(66.666% - .5px) calc(66.666% + .5px), transparent calc(66.666% + .5px)),
++ linear-gradient(transparent calc(33.333% - .5px), rgba(255,255,255,.35) calc(33.333% - .5px) calc(33.333% + .5px), transparent calc(33.333% + .5px) calc(66.666% - .5px), rgba(255,255,255,.35) calc(66.666% - .5px) calc(66.666% + .5px), transparent calc(66.666% + .5px)); }
++.cm-grid-overlay[hidden] { display: none; }
++/* The flash on capture, and the countdown's big number. */
++.cm-flash { position: absolute; inset: 0; z-index: 4; background: #fff; opacity: 0; pointer-events: none; }
++.cm-flash.is-on { animation: cm-flash .42s ease-out; }
++@keyframes cm-flash { 0% { opacity: .85; } 100% { opacity: 0; } }
++.cm-count { position: absolute; inset: 0; z-index: 3; display: grid; place-items: center; font-size: clamp(72px, 22vw, 160px); font-weight: 900; color: #fff; text-shadow: 0 6px 30px rgba(0,0,0,.5); pointer-events: none; }
++.cm-count:empty { display: none; }
++.cm-count.is-tick { animation: cm-tick .9s cubic-bezier(.2,1.3,.4,1); }
++@keyframes cm-tick { 0% { transform: scale(1.6); opacity: 0; } 30% { transform: scale(1); opacity: 1; } 100% { opacity: .2; } }
++/* Off: a calm card on the dark stage, one big button. */
++.cm-off { position: absolute; inset: 0; z-index: 5; display: grid; place-content: center; justify-items: center; gap: 12px; padding: 24px; text-align: center; color: #e8ecf3;
++ background: radial-gradient(120% 90% at 50% 20%, color-mix(in srgb, var(--room-accent) 22%, #04060b) 0%, #04060b 70%); }
++.cm-camera:not([data-state="off"]) .cm-off { display: none; }
++.cm-off-title { margin: 6px 0 0; font-size: clamp(26px, 4vw, 34px); letter-spacing: -.03em; }
++.cm-off-text { margin: 0; max-width: 34ch; color: rgba(232,236,243,.75); line-height: 1.5; }
++.cm-off-note { margin: 0; font-size: 14px; color: rgba(232,236,243,.75); }
++.cm-off-note a { color: #fff; font-weight: 700; }
++.cm-btn-big { min-height: 52px; padding: 0 26px; border-radius: 999px; font-size: 16px; font-weight: 800; background: var(--room-accent); color: var(--room-on, #fff); border-color: transparent; box-shadow: 0 10px 30px -12px var(--room-accent); }
++.cm-pick { position: relative; display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 999px; color: rgba(232,236,243,.85); font-weight: 650; cursor: pointer; }
++.cm-pick svg { width: 18px; height: 18px; }
++.cm-pick input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
++.cm-pick:hover { background: rgba(255,255,255,.08); }
++/* On the stage: the top bar and the filter strip. */
++.cm-top { position: absolute; z-index: 2; top: 12px; left: 12px; right: 12px; display: flex; justify-content: flex-end; gap: 8px; }
++.cm-round, .cm-pill { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 44px; min-width: 44px; padding: 0 12px; border: 0; border-radius: 999px; color: #fff; background: rgba(4,6,11,.45); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); font: inherit; font-size: 13px; font-weight: 750; cursor: pointer; transition: background-color .15s, transform .15s; }
++.cm-round { padding: 0; width: 44px; }
++.cm-round svg, .cm-pill svg { width: 20px; height: 20px; }
++.cm-round[aria-pressed="true"], .cm-pill.is-set { background: color-mix(in srgb, var(--room-accent) 75%, rgba(4,6,11,.4)); }
++.cm-round:active, .cm-pill:active { transform: scale(.94); }
++.cm-filters { position: absolute; z-index: 2; left: 0; right: 0; bottom: 12px; display: flex; gap: 8px; padding: 4px 14px; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; }
++.cm-filters::-webkit-scrollbar { display: none; }
++.cm-swatch { flex: none; scroll-snap-align: center; display: grid; justify-items: center; gap: 4px; padding: 4px; border: 0; background: none; color: #fff; font: inherit; font-size: 11.5px; font-weight: 700; cursor: pointer; text-shadow: 0 1px 6px rgba(0,0,0,.6); }
++.cm-swatch-chip { width: 46px; height: 46px; border-radius: 14px; box-shadow: 0 0 0 2px rgba(255,255,255,.35); transition: box-shadow .15s, transform .15s;
++ background: radial-gradient(circle at 30% 30%, #ffd27a 0 18%, transparent 19%), linear-gradient(160deg, #6fb6ff, #8f6bff 45%, #ff7aa8 75%, #ffcf6b); }
++.cm-swatch[aria-checked="true"] .cm-swatch-chip { box-shadow: 0 0 0 3px #fff; transform: scale(1.06); }
++.cm-camera[data-state="off"] .cm-top, .cm-camera[data-state="off"] .cm-filters { display: none; }
++/* Under the stage: the gallery thumb, the mode switch over the shutter, the flip. */
++.cm-bar { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; padding: 0 6px; }
++.cm-thumb { justify-self: start; display: grid; place-items: center; width: 52px; height: 52px; border-radius: 14px; overflow: hidden; color: var(--text-secondary, #96a7c2); background: var(--bg-card, #0f1729); box-shadow: inset 0 0 0 1px var(--border, #1f2d47); }
++.cm-thumb img { width: 100%; height: 100%; object-fit: cover; }
++.cm-thumb svg { width: 24px; height: 24px; }
++.cm-thumb.is-new { animation: cm-pop .5s cubic-bezier(.2,1.5,.35,1); }
++@keyframes cm-pop { 0% { transform: scale(.6); } 100% { transform: none; } }
++.cm-flip { justify-self: end; color: var(--text-primary, #e6edf7); background: var(--bg-card, #0f1729); box-shadow: inset 0 0 0 1px var(--border, #1f2d47); -webkit-backdrop-filter: none; backdrop-filter: none; }
++.cm-shoot-wrap { display: grid; justify-items: center; gap: 8px; }
++.cm-modes { display: inline-flex; gap: 2px; padding: 3px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
++.cm-mode { height: 30px; padding: 0 12px; border: 0; border-radius: 999px; background: none; color: var(--text-secondary, #96a7c2); font: inherit; font-size: 12.5px; font-weight: 750; letter-spacing: .02em; text-transform: uppercase; cursor: pointer; }
++.cm-mode[aria-checked="true"] { color: var(--room-strong, var(--room-accent)); background: color-mix(in srgb, var(--room-accent) 16%, transparent); }
++.cm-shutter { position: relative; display: grid; place-items: center; width: 76px; height: 76px; padding: 0; border: 0; border-radius: 50%; background: none; cursor: pointer; }
++.cm-shutter::before { content: ""; position: absolute; inset: 0; border-radius: 50%; box-shadow: inset 0 0 0 4px var(--text-primary, #e6edf7); }
++.cm-shutter span { width: 60px; height: 60px; border-radius: 50%; background: var(--text-primary, #e6edf7); transition: transform .12s ease, background-color .15s; }
++.cm-shutter:active span, .cm-shutter.is-firing span { transform: scale(.86); }
++.cm-camera[data-mode="burst"] .cm-shutter span { background: var(--room-accent); }
++.cm-shutter:focus-visible { outline: 3px solid var(--room-accent); outline-offset: 4px; }
++.cm-under { display: grid; gap: 8px; justify-items: center; }
++.cm-caption { width: min(100%, 520px); }
++.cm-caption input { width: 100%; min-height: 44px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); color: inherit; font: inherit; text-align: center; box-sizing: border-box; }
++.cm-caption input:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; }
++.cm-status { margin: 0; font-size: 13px; font-variant-numeric: tabular-nums; color: var(--text-secondary, #96a7c2); }
++.cm-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
++.cm-count-label { font-size: 13px; color: var(--text-muted, #7386a3); }
++/* A phone: the whole screen under the bar, the controls in the thumb's reach. */
++@media (max-width: 700px) {
++ .cm-cam-app { gap: 18px; }
++ .cm-wrap.cm-cam-app, .cm-cam-app { padding-top: 0; }
++ .cm-camera { margin: 0 calc(-1 * var(--cm-gutter, 16px)); min-height: calc(100dvh - var(--ovnav-h, 60px)); grid-template-rows: 1fr auto auto; gap: 10px; padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)); }
++ .cm-stage { aspect-ratio: auto; max-height: none; min-height: 0; border-radius: 0 0 26px 26px; }
++ .cm-bar { padding: 0 16px; }
++ .cm-under { padding: 0 16px; }
++}
++@media (min-width: 701px) { .cm-cam-app { padding-top: 18px; } .cm-stage { width: 100%; } .cm-bar { width: min(100%, 560px); margin: 0 auto; } .cm-filters { justify-content: safe center; } }
++@media (prefers-reduced-motion: reduce) { .cm-flash.is-on, .cm-count.is-tick, .cm-thumb.is-new { animation: none; } .cm-stage video { transition: none; } }
ddiff --git a/public/js/cam.js b/public/js/cam.js
iindex 8c6403d..8f93407 100644
---- a/public/js/cam.js
++++ b/public/js/cam.js
@@@ -39,6 +39,17 @@
var gridEl = root.querySelector('[data-cm-grid]');
var fileInput = root.querySelector('[data-cm-file]');
var gallery = document.getElementById('cm-gallery');
++ // The app's own controls (11-07): flip, the timer pill, the filter strip, the modes, the flash and the thumb.
++ var flipBtn = root.querySelector('[data-cm-flip]');
++ var timerBtn = root.querySelector('[data-cm-timer-btn]');
++ var timerLabel = root.querySelector('[data-cm-timer-label]');
++ var flashEl = root.querySelector('[data-cm-flash]');
++ var countEl = root.querySelector('[data-cm-count]');
++ var thumb = root.querySelector('[data-cm-thumb]');
++ var picks = Array.prototype.slice.call(root.querySelectorAll('[data-cm-filter-pick]'));
++ var modeBtns = Array.prototype.slice.call(root.querySelectorAll('[data-cm-mode]'));
++ var facing = 'environment';
++ var mode = 'photo';
var stream = null;
var busy = false;
@@@ -47,21 +58,42 @@
function start() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
-- say('This browser has no camera input. Pick a picture below instead.');
-- return;
++ say('This browser has no camera input. Pick a picture instead.');
++ return Promise.resolve(false);
}
-- navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' }, audio: false }).then(function (s) {
++ if (stream) stream.getTracks().forEach(function (t) { t.stop(); });
++ // A phone opens on its back camera; a computer on whatever it has. Flip swaps them.
++ return navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: facing }, width: { ideal: 1920 }, height: { ideal: 1440 } }, audio: false }).then(function (s) {
stream = s;
video.srcObject = s;
++ var track = s.getVideoTracks()[0];
++ var settings = track && track.getSettings ? track.getSettings() : {};
++ root.dataset.facing = settings.facingMode || (facing === 'user' ? 'user' : 'environment');
return video.play();
}).then(function () {
shootBtn.disabled = false;
-- startBtn.textContent = 'Camera on';
++ root.dataset.state = 'on';
say('The camera is on. Nothing is recorded.');
++ return true;
}).catch(function () {
-- say('The camera was not allowed. Allow it in the browser, or pick a picture below.');
++ root.dataset.state = 'off';
++ say('The camera was not allowed. Allow it in the browser, or pick a picture.');
++ return false;
});
}
++ // The filter shows live on the preview, the way a phone camera does.
++ function setFilter(name) {
++ filterSel.value = name;
++ video.style.filter = FILTERS[name] || 'none';
++ picks.forEach(function (b) { b.setAttribute('aria-checked', b.dataset.cmFilterPick === name ? 'true' : 'false'); });
++ }
++ function flash() {
++ if (!flashEl) return;
++ flashEl.classList.remove('is-on'); void flashEl.offsetWidth; flashEl.classList.add('is-on');
++ shootBtn.classList.add('is-firing');
++ setTimeout(function () { shootBtn.classList.remove('is-firing'); }, 160);
++ if (navigator.vibrate) { try { navigator.vibrate(12); } catch (e) { /* not every browser */ } }
++ }
function drawFiltered() {
var w = video.videoWidth || 1280;
@@@ -94,6 +126,12 @@
a.appendChild(img);
li.appendChild(a);
list.insertBefore(li, list.firstChild);
++ // The thumb shows the newest photo, with a small pop.
++ if (thumb) {
++ thumb.textContent = '';
++ var t = document.createElement('img'); t.src = p.pics_direct; t.alt = ''; thumb.appendChild(t);
++ thumb.classList.remove('is-new'); void thumb.offsetWidth; thumb.classList.add('is-new');
++ }
}
function upload(blob, type, filterName) {
@@@ -118,19 +156,23 @@
function snap() {
if (!stream) { say('Turn the camera on first.'); return; }
++ flash();
drawFiltered();
canvas.toBlob(function (blob) { if (blob) upload(blob, 'image/jpeg', filterSel.value); }, 'image/jpeg', 0.92);
}
function countdown(n) {
-- if (n <= 0) { snap(); return; }
++ if (n <= 0) { if (countEl) countEl.textContent = ''; if (mode === 'burst') burst(); else snap(); return; }
say(n + '…');
++ if (countEl) { countEl.textContent = String(n); countEl.classList.remove('is-tick'); void countEl.offsetWidth; countEl.classList.add('is-tick'); }
setTimeout(function () { countdown(n - 1); }, 1000);
}
function shoot() {
++ // The shutter turns the camera on when it is off, like opening a phone camera.
++ if (!stream) { start(); return; }
var n = parseInt(timerSel.value, 10) || 0;
-- if (n > 0) countdown(n); else snap();
++ if (n > 0) countdown(n); else if (mode === 'burst') burst(); else snap();
}
function burst() {
@@@ -140,7 +182,24 @@
}
startBtn.addEventListener('click', start);
++ shootBtn.disabled = false;
shootBtn.addEventListener('click', shoot);
++ if (flipBtn) flipBtn.addEventListener('click', function () { facing = facing === 'user' ? 'environment' : 'user'; if (stream) start(); });
++ if (timerBtn) timerBtn.addEventListener('click', function () {
++ var steps = ['0', '3', '5', '10'];
++ var next = steps[(steps.indexOf(timerSel.value) + 1) % steps.length];
++ timerSel.value = next;
++ if (timerLabel) timerLabel.textContent = next === '0' ? 'Off' : next + 's';
++ timerBtn.classList.toggle('is-set', next !== '0');
++ timerBtn.setAttribute('aria-label', next === '0' ? 'Timer: off' : 'Timer: ' + next + ' seconds');
++ });
++ picks.forEach(function (b) { b.addEventListener('click', function () { setFilter(b.dataset.cmFilterPick); b.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' }); }); });
++ modeBtns.forEach(function (b) { b.addEventListener('click', function () {
++ mode = b.dataset.cmMode;
++ root.dataset.mode = mode;
++ modeBtns.forEach(function (x) { x.setAttribute('aria-checked', x === b ? 'true' : 'false'); });
++ shootBtn.setAttribute('aria-label', mode === 'burst' ? 'Take three photos' : 'Take the photo');
++ }); });
burstBtn.addEventListener('click', burst);
if (gridBtn) gridBtn.addEventListener('click', function () {
if (!gridEl) return;
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 9cd4af0..bbc6eb4 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -106,8 +106,8 @@ function home({ signedIn = false } = {}) {
const room = sc.room({
room: 'cam',
hero: {
-- title: 'Every camera has a moment',
-- 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.',
++ title: 'A camera that is yours',
++ lede: 'Live filters that run on your phone, photos saved to your OpenVibe.Pics, private cameras you share by the week, and a one-tap still from any stream.',
start: { href: '/app', label: 'Open the camera' },
chips: [{ label: 'My cameras', href: '/my' }, { label: 'The API', href: '/developers' }],
motif: shutterMotif(),
@@@ -153,33 +153,68 @@ function home({ signedIn = false } = {}) {
return `<div class="cm-wrap cm-home" id="cm-home">${room}${live}</div>`;
}
++// The camera app (the owner, 11-07: "the camera app should feel more like an app, especially on phone … a bigger
++// interface … animations"): a viewfinder that fills a phone, the controls on it like a phone camera (grid and timer on
++// top, a filter strip that previews live, the gallery thumb, the shutter and the flip at the bottom), and a large
++// framed viewfinder on a tablet or a desktop. Every control keeps its data-cm hook, so the page still works the same
++// way, and without JavaScript the picker and the API remain.
++const ICONS = {
++ grid: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M9 3.5v17M15 3.5v17M3.5 9h17M3.5 15h17"/></svg>',
++ timer: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="13.5" r="7"/><path d="M12 13.5V10M9.5 3.5h5"/></svg>',
++ flip: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9a8 8 0 0 1 14.3-3.3L20 7.5M20 3.5v4h-4M20 15a8 8 0 0 1-14.3 3.3L4 16.5M4 20.5v-4h4"/></svg>',
++ photos: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="5" width="17" height="14" rx="3"/><path d="m3.5 16 5-5 4 4 2.5-2.5 5.5 5.5"/></svg>',
++ upload: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15.5V4M7.5 8.5 12 4l4.5 4.5M5 15.5V19a1.5 1.5 0 0 0 1.5 1.5h11A1.5 1.5 0 0 0 19 19v-3.5"/></svg>',
++};
++const FILTER_CSS = { none: 'none', mono: 'grayscale(1) contrast(1.08)', warm: 'sepia(.35) saturate(1.15)', cool: 'hue-rotate(-10deg) saturate(1.1)', fade: 'saturate(.8) contrast(.92) brightness(1.06)', vivid: 'saturate(1.45) contrast(1.06)', blur: 'blur(1.5px)' };
++
function app({ signedIn = false, photos = [] } = {}) {
const options = Object.entries(FILTER_LABEL).map(([v, label]) => `<option value="${esc(v)}">${esc(label)}</option>`).join('');
-- return `<div class="cm-wrap" id="cm-app">
--<header class="cm-page-h"><p class="cm-kicker">The camera app</p><h1>Take a photo</h1>
--<p class="cm-lede">The preview, the timer, the burst and every filter run in your browser. The picture you keep goes to OpenVibe.Pics; Cam stores the link, the filter and your caption.</p></header>
--${signedIn ? '' : `<p class="cm-note">You can try the camera now. <a href="/auth/login?next=%2Fapp">Sign in</a> to save a photo to your Pics.</p>`}
--<section class="cm-camera" id="cm-camera" data-cm-camera>
++ const swatches = Object.entries(FILTER_LABEL).map(([v, label], i) => `<button class="cm-swatch" type="button" role="radio" aria-checked="${i === 0 ? 'true' : 'false'}" data-cm-filter-pick="${esc(v)}"><span class="cm-swatch-chip" style="filter:${esc(FILTER_CSS[v] || 'none')}" aria-hidden="true"></span><span>${esc(label)}</span></button>`).join('');
++ const last = photos[0];
++ const thumb = last && last.pics_direct ? `<img src="${esc(last.pics_direct)}" alt="" loading="lazy">` : ICONS.photos;
++ return `<div class="cm-wrap cm-cam-app" id="cm-app">
++<section class="cm-camera" id="cm-camera" data-cm-camera data-state="off" aria-label="Camera">
<div class="cm-stage">
<video data-cm-preview playsinline muted aria-label="Camera preview"></video>
<canvas data-cm-canvas hidden></canvas>
<div class="cm-grid-overlay" data-cm-grid hidden aria-hidden="true"></div>
++<div class="cm-flash" data-cm-flash aria-hidden="true"></div>
++<div class="cm-count" data-cm-count aria-hidden="true"></div>
++<div class="cm-off" data-cm-off>
++<span class="ov-icon" data-icon="cam" data-size="64" aria-hidden="true"></span>
++<h1 class="cm-off-title">Camera</h1>
++<p class="cm-off-text">Live filters on your device. Photos you keep go to your OpenVibe.Pics; nothing is recorded.</p>
++<button class="cm-btn cm-btn-big" type="button" data-cm-start>Turn on the camera</button>
++<label class="cm-pick">${ICONS.upload}<span>Or pick a picture</span><input data-cm-file type="file" accept="image/jpeg,image/png,image/webp"></label>
++${signedIn ? '' : '<p class="cm-off-note"><a href="/auth/login?next=%2Fapp">Sign in</a> to save photos to your Pics.</p>'}
++</div>
++<div class="cm-top">
++<button class="cm-round" type="button" data-cm-grid-toggle aria-pressed="false" aria-label="Grid">${ICONS.grid}</button>
++<button class="cm-pill" type="button" data-cm-timer-btn aria-label="Timer: off">${ICONS.timer}<b data-cm-timer-label>Off</b></button>
++</div>
++<div class="cm-filters" role="radiogroup" aria-label="Filter" data-cm-filters>${swatches}</div>
</div>
--<div class="cm-controls">
--<button class="cm-btn" type="button" data-cm-start>Turn the camera on</button>
--<button class="cm-btn cm-btn-quiet" type="button" data-cm-shoot disabled>Take the photo</button>
--<label class="cm-field"><span>Timer</span><select data-cm-timer><option value="0">Off</option><option value="3">3 seconds</option><option value="5">5 seconds</option><option value="10">10 seconds</option></select></label>
--<button class="cm-btn cm-btn-quiet" type="button" data-cm-burst>Burst of 3</button>
--<label class="cm-field"><span>Filter</span><select data-cm-filter>${options}</select></label>
--<label class="cm-field"><span>Caption</span><input data-cm-caption type="text" maxlength="280" placeholder="A line for the photo"></label>
--<button class="cm-btn cm-btn-quiet" type="button" data-cm-grid-toggle aria-pressed="false">Grid</button>
++<div class="cm-bar">
++<a class="cm-thumb" data-cm-thumb href="${signedIn ? '#cm-gallery' : '/auth/login?next=%2Fapp'}" aria-label="Your photos">${thumb}</a>
++<div class="cm-shoot-wrap">
++<div class="cm-modes" role="radiogroup" aria-label="Mode"><button class="cm-mode" type="button" role="radio" aria-checked="true" data-cm-mode="photo">Photo</button><button class="cm-mode" type="button" role="radio" aria-checked="false" data-cm-mode="burst">Burst</button></div>
++<button class="cm-shutter" type="button" data-cm-shoot aria-label="Take the photo"><span></span></button>
</div>
++<button class="cm-round cm-flip" type="button" data-cm-flip aria-label="Switch camera">${ICONS.flip}</button>
++</div>
++<div class="cm-under">
++<label class="cm-caption"><span class="cm-sr">Caption</span><input data-cm-caption type="text" maxlength="280" placeholder="Add a caption (optional)"></label>
<p class="cm-status" data-cm-status role="status">The camera is off.</p>
--<label class="cm-field"><span>Or pick a picture</span><input data-cm-file type="file" accept="image/jpeg,image/png,image/webp"></label>
--<noscript><p class="cm-fine">The live preview needs JavaScript. You can still use the API at <code>POST /api/v1/photos</code>.</p></noscript>
++</div>
++<div class="cm-legacy" hidden>
++<select data-cm-timer aria-hidden="true" tabindex="-1"><option value="0">Off</option><option value="3">3 seconds</option><option value="5">5 seconds</option><option value="10">10 seconds</option></select>
++<select data-cm-filter aria-hidden="true" tabindex="-1">${options}</select>
++<button type="button" data-cm-burst tabindex="-1">Burst of 3</button>
++</div>
++<noscript><p class="cm-fine">The live camera needs JavaScript. You can still pick a picture, or use the API at <code>POST /api/v1/photos</code>.</p></noscript>
</section>
<section class="cm-section" id="cm-gallery" aria-labelledby="cm-gallery-h">
--<div class="cm-head-row"><h2 id="cm-gallery-h">Your photos</h2>${signedIn ? `<span class="cm-count">${photos.length} recent</span>` : ''}</div>
++<div class="cm-head-row"><h2 id="cm-gallery-h">Your photos</h2>${signedIn ? `<span class="cm-count-label">${photos.length} recent</span>` : ''}</div>
${signedIn ? (photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner: true })).join('')}</ul>` : empty('No photo yet. Take one above; it will appear here once Pics has it.', '/app', 'Take a photo')) : empty('Sign in to see the photos you saved to Pics.', '/auth/login?next=%2Fapp', 'Sign in with OpenVibe')}
</section>
</div>`;