openvibers/openvibe.cam Commits
Cam: the camera app feels like a phone camera (#87)
Changes
@@@ -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; } }@@@ -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;@@@ -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>`;
@@ -59,26 +59,6 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -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 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 */
/* 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)); }
.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
@@ -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); }
.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 */
/* 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 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). */
/* 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
@@ -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). */
/* 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; }
.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; } }
@@ -39,6 +39,17 @@
@@ -39,6 +39,17 @@
var gridEl = root.querySelector('[data-cm-grid]');
var gridEl = root.querySelector('[data-cm-grid]');
var fileInput = root.querySelector('[data-cm-file]');
var fileInput = root.querySelector('[data-cm-file]');
var gallery = document.getElementById('cm-gallery');
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 stream = null;
var busy = false;
var busy = false;
@@ -47,21 +58,42 @@
@@ -47,21 +58,42 @@
function start() {
function start() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
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;
stream = s;
video.srcObject = 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();
return video.play();
}).then(function () {
}).then(function () {
shootBtn.disabled = false;
shootBtn.disabled = false;
- startBtn.textContent = 'Camera on';
+ root.dataset.state = 'on';
say('The camera is on. Nothing is recorded.');
say('The camera is on. Nothing is recorded.');
+ return true;
}).catch(function () {
}).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() {
function drawFiltered() {
var w = video.videoWidth || 1280;
var w = video.videoWidth || 1280;
@@ -94,6 +126,12 @@
@@ -94,6 +126,12 @@
a.appendChild(img);
a.appendChild(img);
li.appendChild(a);
li.appendChild(a);
list.insertBefore(li, list.firstChild);
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) {
function upload(blob, type, filterName) {
@@ -118,19 +156,23 @@
@@ -118,19 +156,23 @@
function snap() {
function snap() {
if (!stream) { say('Turn the camera on first.'); return; }
if (!stream) { say('Turn the camera on first.'); return; }
+ flash();
drawFiltered();
drawFiltered();
canvas.toBlob(function (blob) { if (blob) upload(blob, 'image/jpeg', filterSel.value); }, 'image/jpeg', 0.92);
canvas.toBlob(function (blob) { if (blob) upload(blob, 'image/jpeg', filterSel.value); }, 'image/jpeg', 0.92);
}
}
function countdown(n) {
function countdown(n) {
- if (n <= 0) { snap(); return; }
+ if (n <= 0) { if (countEl) countEl.textContent = ''; if (mode === 'burst') burst(); else snap(); return; }
say(n + '…');
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);
setTimeout(function () { countdown(n - 1); }, 1000);
}
}
function shoot() {
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;
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() {
function burst() {
@@ -140,7 +182,24 @@
@@ -140,7 +182,24 @@
}
}
startBtn.addEventListener('click', start);
startBtn.addEventListener('click', start);
+ shootBtn.disabled = false;
shootBtn.addEventListener('click', shoot);
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);
burstBtn.addEventListener('click', burst);
if (gridBtn) gridBtn.addEventListener('click', function () {
if (gridBtn) gridBtn.addEventListener('click', function () {
if (!gridEl) return;
if (!gridEl) return;
@@ -106,8 +106,8 @@ function home({ signedIn = false } = {}) {
@@ -106,8 +106,8 @@ function home({ signedIn = false } = {}) {
const room = sc.room({
const room = sc.room({
room: 'cam',
room: 'cam',
hero: {
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' },
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' }],
motif: shutterMotif(),
motif: shutterMotif(),
@@ -153,33 +153,68 @@ function home({ signedIn = false } = {}) {
@@ -153,33 +153,68 @@ function home({ signedIn = false } = {}) {
return `<div class="cm-wrap cm-home" id="cm-home">${room}${live}</div>`;
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 = [] } = {}) {
function app({ signedIn = false, photos = [] } = {}) {
const options = Object.entries(FILTER_LABEL).map(([v, label]) => `<option value="${esc(v)}">${esc(label)}</option>`).join('');
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">
<div class="cm-stage">
<video data-cm-preview playsinline muted aria-label="Camera preview"></video>
<video data-cm-preview playsinline muted aria-label="Camera preview"></video>
<canvas data-cm-canvas hidden></canvas>
<canvas data-cm-canvas hidden></canvas>
<div class="cm-grid-overlay" data-cm-grid hidden aria-hidden="true"></div>
<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>
-<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>
</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>
<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>
<section class="cm-section" id="cm-gallery" aria-labelledby="cm-gallery-h">
<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')}
${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>
</section>
</div>`;
</div>`;