diff --git a/public/app.js b/public/app.js index 0f8b06a..3694573 100644 --- a/public/app.js +++ b/public/app.js @@ -92,7 +92,7 @@ function mountCameraHandle(enabled){ function cancelCapture(){clearInterval(countdownTimer);capture?.cancel();capture=null;recording=false;busy=false;home()} function beginRecording(){if(busy||!capture)return;busy=true;let n=3;const button=$('[data-action=begin-recording]');button.disabled=true;$('#capture-countdown').textContent=`Starting in ${n}…`;countdownTimer=setInterval(()=>{n--;if(n){$('#capture-countdown').textContent=`Starting in ${n}…`;return}clearInterval(countdownTimer);run(async()=>{try{capture.begin();recording=true;busy=false;paused=false;$('#capture-countdown').textContent='';$('.recording')?.classList.add('is-live');$('#capture-heading').textContent='Show what you mean.';$('#capture-status').textContent='Your video stays local. Stop to review and publish.';$('#setup-actions').hidden=true;$('#live-actions').hidden=false;$('#recording-status').textContent='RECORDING IN PROGRESS'}catch(e){cancelCapture();throw e}})},1000)} async function startRecording(){if(recording||busy)return;if(emergencyBlob)throw Error('Download the unsaved recording and choose I saved my copy before recording again.');busy=true;const options={mic:$('#mic').checked,camera:$('#camera').checked,system:$('#system').checked,micId:$('#mic-source')?.value||'',defer:true,quality:$('#record-quality')?.value||'balanced',bubble:cameraBubble={x:.76,y:.72,size:.28},markup:recordMarkup=[]};draft={id:randomId(),chunks:[],createdAt:Date.now(),seconds:0};const writer=createDraftWriter(d=>db.saveDraft(d),()=>notify('Recovery storage is unavailable. Keep this tab open and download your video after stopping.'));const session=capture=new Capture({setupEnded:()=>{cancelCapture();notify('Screen sharing ended before recording. Choose a source again.')},chunk:(blob,seconds)=>{draft.chunks.push(blob);draft.seconds=seconds;if(seconds-(draft.savedSeconds||0)>=5){draft.savedSeconds=seconds;writer.save(structuredClone(draft))}},status:s=>{if($('#record-time'))$('#record-time').textContent=formatTime(s)},metrics:updateCaptureMetrics,finished:async(blob,duration,e,diagnostics)=>{busy=true;recording=false;try{writer.save(structuredClone(draft));await writer.flush();if(!blob.size)throw Error('No video was captured. Try recording again.');blob=await fixed(blob,duration);const info=await metadata(blob,duration),c={id:randomId(),owner:secret(),title:'A quick walkthrough',blob,type:blob.type.split(';')[0],createdAt:Date.now(),diagnostics,...info};await db.finish(c,draft.id);clips=await db.all();busy=false;editor(c);notify(stopMessage(e?'error':diagnostics?.reason))}catch(err){busy=false;if(!blob.size){home();error(err);return}resetURL();emergencyBlob=blob;emergencyURL=URL.createObjectURL(blob);shell(`

Download your recording now.

The captured video is still in this tab, but could not be saved to the local library. Download it before leaving or closing this tab.

`,'Recording');}}});try{const prepared=session.start(options);shell(`
BROWSER PERMISSION

Choose what to share.

Waiting for your browser’s screen picker or device permission. Nothing is recording yet.

Select a screen, window or tab in the browser prompt. Requests time out after one minute. If the prompt blocks this page, dismiss it in your browser. You can then retry or import a device recording.

`,'Recording permissions');const stream=await prepared;if(capture!==session)return;busy=false;recording=true;paused=false;shell(`
CHOOSE WHAT TO SHOW

Prepare your recording.

Nothing is recording yet. Add masks and marks on the preview, then begin.

${options.camera?'
Drag the camera handle to move it.
':''}${markupTools()}

Drag on the preview to add a mark (up to 40). Opaque masks cover pixels; soft blur is a visual effect and may leave details readable. Masks stay at fixed frame positions, so keep sensitive content inside them. Only frames captured after a change include that change. For full-screen walkthroughs, return here to change marks, or mask the saved video before publishing.

0 MBRemaining time is estimated while recording

Keep this tab open. Local recording stops at 30 minutes or 250 MB. Shared clips are limited to 10 minutes / 50 MB. Shared audio depends on your browser and source. Shortcuts: Alt+Shift+P pauses/resumes; Alt+Shift+S stops.
`,'Recording');$('#live-preview').srcObject=stream;fitCaptureFrame($('#live-preview'));$('#live-preview').play().catch(()=>{});mountTools(recordMarkup);mountCameraHandle(options.camera);updateCaptureMetrics({bytes:0,remaining:LOCAL_MAX_SECONDS,shareRemaining:MAX_SECONDS,level:0,shareable:true})}catch(e){session.cancel();if(capture!==session)return;capture=null;busy=false;recording=false;home();throw Error(e.name==='NotAllowedError'?'Recording permission was declined. Nothing was uploaded. Try again or import a video.':e.message)}} -async function publish(){let c=selected;if(c)c.title=validTitle($('#recording-title-input')?$('#recording-title-input').value:c.title);if(!health||!c?.blob)throw Error('Sharing is not available yet.');const problem=sharingProblem(c);if(problem)throw Error(problem);const {linkDays,password,newIdentity}=shareOptions(c,Number($('#link-days')?.value||c.linkDays||7),$('#share-password')?.value||'');if(newIdentity){c={...c,id:randomId(),owner:secret(),revoked:false,shared:false,cloudDeleted:false,cloudMissing:false,uploadAttempted:false,passwordProtected:!!password};await db.put(c);selected=c;clips=await db.all()}c.linkDays=linkDays;c.passwordProtected=!!password||!!c.passwordProtected;busy=true;uploadController=new AbortController();const button=$('[data-action=publish]');button.disabled=true;try{const input={id:c.id,owner:c.owner,title:c.title,type:c.type,duration:c.duration,size:c.blob.size,start:0,end:c.duration,linkDays,password,captions:c.captions||[],chapters:c.chapters||[],report:c.report||null,reviewStatus:c.reviewStatus||'needs-feedback'};$('#cancel-upload').hidden=false;$('#upload-meter').hidden=false;$('#upload-meter').value=0;$('#upload-progress').textContent='Preparing a private upload…';c.uploadAttempted=true;await db.put(c);let reservation;try{reservation=await retryTransfer(()=>api('reserve',input),attempt=>$('#upload-progress').textContent=`Sharing service interrupted. Retrying upload setup (${attempt}/3)…`)}catch(e){if(e.status!==409)throw e;const state=await api('manage',{id:c.id,owner:c.owner});if(!['expired','deleted','revoked'].includes(state.status))throw e;c.revoked=true;await db.put(c);busy=false;uploadController=null;return await publish()}const uploaded=new Set(reservation.status==='published'?[]:(await retryTransfer(()=>api('upload-state',{id:c.id,owner:c.owner}))).parts||[]);$('#upload-progress').textContent=uploaded.size?'Resuming saved upload… Your local copy is safe.':'Uploading video… Your local copy is safe.';for(let part=0,offset=0;reservation.status!=='published'&&offset{const reader=new FileReader();reader.onload=()=>resolve(reader.result.split(',')[1]);reader.onerror=()=>reject(Error('Could not read the local video.'));reader.readAsDataURL(chunk)});await retryTransfer(()=>api('upload-chunk',{id:c.id,owner:c.owner,part,data}),attempt=>$('#upload-progress').textContent=`Connection interrupted. Retrying part ${part+1} (${attempt}/3)…`);$('#upload-meter').value=Math.round(Math.min(c.blob.size,offset+CHUNK_BYTES)/c.blob.size*100);$('#upload-progress').textContent=`Uploading ${Math.round(Math.min(c.blob.size,offset+CHUNK_BYTES)/c.blob.size*100)}% · your local copy is safe.`}$('#upload-progress').textContent='Finalizing your video…';const shared=await retryTransfer(()=>api('publish',{id:c.id,owner:c.owner}),attempt=>$('#upload-progress').textContent=`Sharing service interrupted. Retrying finalization (${attempt}/3)…`);c.shared=true;c.uploadPaused=false;c.passwordProtected=shared.passwordProtected;c.expiresAt=shared.expiresAt;await db.put(c);clips=await db.all();busy=false;editor(c);notify('Your link is ready. The local copy is still here.')}catch(e){busy=false;if(e.name==='AbortError')c.uploadPaused=true;await db.put(c);editor(c);throw e}finally{uploadController=null;busy=false;button.disabled=false}} +async function publish(){let c=selected?{...selected,title:validTitle($('#recording-title-input')?$('#recording-title-input').value:selected.title)}:null;if(!health||!c?.blob)throw Error('Sharing is not available yet.');const problem=sharingProblem(c);if(problem)throw Error(problem);const {linkDays,password,newIdentity}=shareOptions(c,Number($('#link-days')?.value||c.linkDays||7),$('#share-password')?.value||'');if(newIdentity){c={...c,id:randomId(),owner:secret(),revoked:false,shared:false,cloudDeleted:false,cloudMissing:false,uploadAttempted:false,passwordProtected:!!password};await db.put(c);selected=c;clips=await db.all()}c.linkDays=linkDays;c.passwordProtected=!!password||!!c.passwordProtected;let savedLocally=false;busy=true;uploadController=new AbortController();const button=$('[data-action=publish]');button.disabled=true;try{const input={id:c.id,owner:c.owner,title:c.title,type:c.type,duration:c.duration,size:c.blob.size,start:0,end:c.duration,linkDays,password,captions:c.captions||[],chapters:c.chapters||[],report:c.report||null,reviewStatus:c.reviewStatus||'needs-feedback'};$('#cancel-upload').hidden=false;$('#upload-meter').hidden=false;$('#upload-meter').value=0;$('#upload-progress').textContent='Preparing a private upload…';c.uploadAttempted=true;await db.put(c);selected=c;savedLocally=true;let reservation;try{reservation=await retryTransfer(()=>api('reserve',input),attempt=>$('#upload-progress').textContent=`Sharing service interrupted. Retrying upload setup (${attempt}/3)…`)}catch(e){if(e.status!==409)throw e;const state=await api('manage',{id:c.id,owner:c.owner});if(!['expired','deleted','revoked'].includes(state.status))throw e;c.revoked=true;await db.put(c);busy=false;uploadController=null;return await publish()}const uploaded=new Set(reservation.status==='published'?[]:(await retryTransfer(()=>api('upload-state',{id:c.id,owner:c.owner}))).parts||[]);$('#upload-progress').textContent=uploaded.size?'Resuming saved upload… Your local copy is safe.':'Uploading video… Your local copy is safe.';for(let part=0,offset=0;reservation.status!=='published'&&offset{const reader=new FileReader();reader.onload=()=>resolve(reader.result.split(',')[1]);reader.onerror=()=>reject(Error('Could not read the local video.'));reader.readAsDataURL(chunk)});await retryTransfer(()=>api('upload-chunk',{id:c.id,owner:c.owner,part,data}),attempt=>$('#upload-progress').textContent=`Connection interrupted. Retrying part ${part+1} (${attempt}/3)…`);$('#upload-meter').value=Math.round(Math.min(c.blob.size,offset+CHUNK_BYTES)/c.blob.size*100);$('#upload-progress').textContent=`Uploading ${Math.round(Math.min(c.blob.size,offset+CHUNK_BYTES)/c.blob.size*100)}% · your local copy is safe.`}$('#upload-progress').textContent='Finalizing your video…';const shared=await retryTransfer(()=>api('publish',{id:c.id,owner:c.owner}),attempt=>$('#upload-progress').textContent=`Sharing service interrupted. Retrying finalization (${attempt}/3)…`);c.shared=true;c.uploadPaused=false;c.passwordProtected=shared.passwordProtected;c.expiresAt=shared.expiresAt;await db.put(c);clips=await db.all();busy=false;editor(c);notify('Your link is ready. The local copy is still here.')}catch(e){busy=false;if(savedLocally){if(e.name==='AbortError')c.uploadPaused=true;await db.put(c);editor(c)}throw e}finally{uploadController=null;busy=false;button.disabled=false}} function saveFile(blob,name){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),60000)} function download(){const a=document.createElement('a');a.href=objectURL||URL.createObjectURL(selected.blob);a.download=selected.title.replace(/[^\p{L}\p{N} _-]/gu,'').slice(0,80)+(selected.type==='video/mp4'?'.mp4':'.webm');document.body.append(a);a.click();a.remove()} function privacy(){if(recording||busy)return;shell(`
CLEAR BY DEFAULT

Your screen.
Your call.

ScreenNote only uploads a video when you choose Publish. Recording and trimming happen in your browser.

Before you publish

Recordings and recovery chunks are stored in this browser. Download videos you want to keep. Clearing site data, private browsing or losing this device can remove your local library.

After you publish

Older links from the previous storage service may be unavailable. Their videos have not been copied while that service denies access. If you have the original local video, refresh its shared status and publish a replacement link. Keep your original recovery keys.

The whole reviewed video is uploaded to private Supabase storage. An unlisted link lets anyone who has it watch and reply without a ScreenNote account. Do not include passwords or information you do not want the recipient to see. Unlisted does not mean encrypted end to end.

Links expire after 1, 3 or 7 days, as chosen before publication. Optional sharing passwords are verified on the server; issued playback URLs are bearer links with a five-minute lifetime. Captions, chapters and bug-report text you save are shared with viewers. Stop sharing blocks new viewer requests; already issued playback URLs may work for up to five more minutes. Expired uploads and abandoned reservations become eligible for deletion after a further 24 hours. ScreenNote reclaims them when new uploads reach shared capacity; cleanup is demand-driven, not a timed deletion guarantee.

Small, honest first release

Back up your recovery keys to restore management access after changing browsers or clearing site data. Keep them private: they authorize stopping sharing and deleting uploads. Videos are not included.

Clips are limited to ten minutes and 50 MB. Shared uploads use a 1 GB storage budget. Pending uploads reserve space for both chunks and the assembled video. Deleting an uploaded video frees its slot after cleanup succeeds. Old expired uploads are also reclaimed when capacity is full. Local recording and downloads remain usable when sharing is full. Hosting quotas may pause online sharing; no paid upgrade is enabled by this application.

Screen capture needs a supported desktop browser. On phones or browsers without screen capture, record with your device and import MP4 or WebM. Camera, microphone and shared-screen audio require your browser’s permissions. Online sharing uses Supabase private file storage and a database for recording details and feedback. The app is hosted on Vercel. No AI analysis, advertising trackers or per-viewer charges are built in.

Replies and reactions

Replies are visible to everyone with the link. Names are self-entered, not verified identities. You can reply to any comment or reply. New replies reopen resolved threads. Comment authors can edit or remove their comments from the browser that posted them; the video creator can also remove comments and resolve threads. Clearing site data loses anonymous comment controls. Unsent drafts are kept in this tab’s session storage. Optional bubbles preview comments in the inline player; native fullscreen and picture in picture show the video only. Each clip supports 100 total comments and replies, including removed entries. Reactions use an anonymous browser identifier and are not verified unique-person counts.

`,'Privacy')} @@ -113,7 +113,7 @@ function dialog(title,body,save,label='Save'){ };modal.showModal(); } async function run(fn){try{if($('#error'))$('#error').textContent='';await fn()}catch(e){error(e)}} -const actions={'bug-report':()=>{const panel=$('.extra-panel');panel.open=true;panel.scrollIntoView({block:'nearest',behavior:'smooth'});$('#report-actual').focus()},'cancel-upload':()=>uploadController?.abort(Object.assign(Error('Upload paused. Your local copy is safe. Publish again to resume saved parts.'),{name:'AbortError'})),'new-recording':()=>{const setup=$('#record-setup');setup.open=true;setup.scrollIntoView({block:'nearest',behavior:'smooth'});(setup.querySelector('[data-action=record]:not(:disabled)')||$('#import-video')).focus()},'clear-filters':()=>{libraryFilters={query:'',status:'all',sort:'newest'};home()},help,'check-sharing':checkSharing,'list-mics':listMicrophones,'begin-recording':beginRecording,'cancel-capture':cancelCapture,recovery,'acknowledge-recovery':()=>{emergencyBlob=null;if(emergencyURL)URL.revokeObjectURL(emergencyURL);emergencyURL=null;home()},'refresh-share':async()=>{const c=selected;busy=true;try{const state=await api('manage',{id:c.id,owner:c.owner});Object.assign(c,state,{shared:state.status==='published',revoked:['revoked','expired','deleted'].includes(state.status),cloudDeleted:state.cleanupComplete});await db.put(c);clips=await db.all();busy=false;editor(c);notify('Shared status refreshed.')}catch(e){if(e.status!==404)throw e;c.shared=false;c.revoked=true;c.cloudMissing=true;await db.put(c);clips=await db.all();busy=false;editor(c);notify(c.blob?'The previous upload is unavailable. Your local video is safe; publish a replacement link.':'The previous upload is unavailable. Import the original video to publish a replacement link.')}finally{busy=false}},'backup-keys':()=>{const raw=recoveryDocument(clips,location.origin);if(!JSON.parse(raw).keys.length)throw Error('No uploaded recordings need recovery keys yet.');saveFile(new Blob([raw],{type:'application/json'}),'screennote-recovery.json');notify('Recovery keys downloaded. Keep this file private; download videos separately.')},'restore-keys':restoreKeys,'delete-cloud':()=>dialog('Delete the uploaded video?',`

This permanently deletes the uploaded video and stops its shared link. ${selected.blob?'Your local file remains saved on this device.':'There is no local video file on this device.'}

`,async()=>{await api('delete',{id:selected.id,owner:selected.owner});selected.shared=false;selected.revoked=true;selected.cloudDeleted=true;await db.put(selected);clips=await db.all();editor(selected)},'Delete uploaded video'),home:()=>{if(location.pathname.startsWith('/v/'))location.assign('/');else home()},privacy,record:startRecording,stop:()=>{capture.stop();$('#record-time').textContent='Saving…';$('#pause-button').disabled=true;$('[data-action=stop]').disabled=true;},pause:()=>{paused=!paused;paused?capture.pause():capture.resume();$('#pause-button').innerHTML=icon(paused?'play':'pause')+(paused?'Resume':'Pause');$('#recording-status').textContent=paused?'PAUSED · NOT CAPTURING':'RECORDING IN PROGRESS';$('.recording')?.classList.toggle('is-paused',paused)},theme:()=>{const theme=document.documentElement.dataset.theme==='dark'?'light':'dark';document.documentElement.dataset.theme=theme;try{localStorage.setItem('screennote-theme',theme)}catch{}},compress:async()=>{const c=selected;if(!c?.blob||c.duration>MAX_SECONDS)throw Error('Trim a clip up to 10 minutes first.');busy=true;const b=$('[data-action=compress]');b.disabled=true;try{notify('Making a smaller copy at playback speed. Keep this tab visible.');const blob=await fixed(await trimVideo(c.blob,0,c.duration,p=>$('#upload-progress').textContent=`Making smaller copy · ${Math.round(p*100)}%`,[],'saver'),c.duration),info=await metadata(blob,c.duration);if(blob.size>=c.blob.size)throw Error('This copy would not be smaller. Try trimming a shorter section instead.');const copy={...c,id:randomId(),owner:secret(),title:c.title.slice(0,80)+' · smaller',blob,...info,shared:false,revoked:false,uploadAttempted:false,passwordProtected:false,cloudDeleted:false,cloudMissing:false,createdAt:Date.now()};await db.put(copy);clips=await db.all();busy=false;editor(copy);notify(sharingProblem(copy)||'Smaller copy saved. Your original stays in your library.')}finally{busy=false;b.disabled=false}},sample:async()=>{const r=await fetch('/sample.webm');if(!r.ok)throw Error('Sample clip is unavailable. Import your own video.');await importVideo(new File([await r.blob()],'A little context goes a long way.webm',{type:'video/webm'}),{sample:true,title:'A clearer checkout · sample walkthrough'});Object.assign(selected,{captions:[{start:0,end:4,text:'Show the detail that needs attention.'},{start:4,end:9,text:'Add a timestamp so feedback points to the right moment.'},{start:9,end:15,text:'Reply, resolve and keep the work moving.'}],chapters:[{time:0,title:'Show the detail'},{time:5,title:'Pin the feedback'},{time:10,title:'Resolve the conversation'}]});await db.put(selected);editor(selected)},download,publish,'copy-link':async()=>{const link=location.origin+'/v/'+selected.id;try{await navigator.clipboard.writeText(link);notify('Link copied.')}catch{dialog('Your share link',`
`,async()=>{},'Done')}},'save-title':async()=>{const c=selected,title=validTitle($('#recording-title-input').value);busy=true;try{if((c.shared||c.uploadAttempted)&&!c.cloudDeleted&&!c.cloudMissing){try{await api('title',{id:c.id,owner:c.owner,title})}catch(e){if(e.status!==404||c.shared)throw e}}c.title=title;await db.put(c);clips=await db.all();$('#title-status').textContent='Title saved.'}finally{busy=false}},rename:()=>dialog('Give it a clear title',`
`,async f=>{const title=f.get('title').trim();if(!title)throw Error('Add a title.');selected.title=title;await db.put(selected);clips=await db.all();editor(selected)}),'remove-local':()=>dialog(selected.blob?'Remove this local copy?':'Remove recovery access?',`

${selected.blob?'This removes the video from this browser.':'This removes the saved management keys from this browser.'} ${(selected.shared||selected.revoked||selected.uploadAttempted)&&!selected.cloudDeleted?'This also removes this browser’s ability to manage the uploaded video. Delete the uploaded video first if you want to remove it from the web.':selected.blob?'Download it first if you want to keep it.':'Keep an exported recovery file if you need this access later.'}

`,async()=>{await db.remove(selected.id);clips=await db.all();busy=false;home()},selected.blob?'Remove local copy':'Remove recovery access'),revoke:()=>dialog('Stop sharing this video?',`

New viewers will no longer be able to open it. Playback URLs already issued may work for up to five minutes. ${selected.blob?'Your local video stays here.':'Your recovered management keys stay here.'}

`,async()=>{await api('revoke',{id:selected.id,owner:selected.owner});selected.shared=false;selected.revoked=true;await db.put(selected);clips=await db.all();editor(selected)},'Stop sharing'),trim:async()=>{const c=selected,start=Number($('#trim-start').value),end=Number($('#trim-end').value);if(!Number.isFinite(start)||!Number.isFinite(end)||start<0||end>c.duration+.05||end-start<.25)throw Error('Choose a valid range of at least 0.25 seconds.');busy=true;$('[data-action=trim]').disabled=true;try{let blob=await trimVideo(c.blob,start,end,p=>$('#trim-progress').textContent=`Trimming locally · ${Math.round(p*100)}%`);blob=await fixed(blob,end-start);const info=await metadata(blob,end-start),copy={id:randomId(),owner:secret(),title:c.title.slice(0,85)+' · trimmed',...trimExtras(c,start,end),blob,type:blob.type.split(';')[0],createdAt:Date.now(),...info};await db.put(copy);clips=await db.all();busy=false;editor(copy);notify('Trimmed copy saved. The original is still in your library.')}finally{busy=false;const b=$('[data-action=trim]');if(b)b.disabled=false}}}; +const actions={'bug-report':()=>{const panel=$('.extra-panel');panel.open=true;panel.scrollIntoView({block:'nearest',behavior:'smooth'});$('#report-actual').focus()},'cancel-upload':()=>uploadController?.abort(Object.assign(Error('Upload paused. Your local copy is safe. Publish again to resume saved parts.'),{name:'AbortError'})),'new-recording':()=>{const setup=$('#record-setup');setup.open=true;setup.scrollIntoView({block:'nearest',behavior:'smooth'});(setup.querySelector('[data-action=record]:not(:disabled)')||$('#import-video')).focus()},'clear-filters':()=>{libraryFilters={query:'',status:'all',sort:'newest'};home()},help,'check-sharing':checkSharing,'list-mics':listMicrophones,'begin-recording':beginRecording,'cancel-capture':cancelCapture,recovery,'acknowledge-recovery':()=>{emergencyBlob=null;if(emergencyURL)URL.revokeObjectURL(emergencyURL);emergencyURL=null;home()},'refresh-share':async()=>{const c=selected;busy=true;try{const state=await api('manage',{id:c.id,owner:c.owner});Object.assign(c,state,{shared:state.status==='published',revoked:['revoked','expired','deleted'].includes(state.status),cloudDeleted:state.cleanupComplete});await db.put(c);clips=await db.all();busy=false;editor(c);notify('Shared status refreshed.')}catch(e){if(e.status!==404)throw e;c.shared=false;c.revoked=true;c.cloudMissing=true;await db.put(c);clips=await db.all();busy=false;editor(c);notify(c.blob?'The previous upload is unavailable. Your local video is safe; publish a replacement link.':'The previous upload is unavailable. Import the original video to publish a replacement link.')}finally{busy=false}},'backup-keys':()=>{const raw=recoveryDocument(clips,location.origin);if(!JSON.parse(raw).keys.length)throw Error('No uploaded recordings need recovery keys yet.');saveFile(new Blob([raw],{type:'application/json'}),'screennote-recovery.json');notify('Recovery keys downloaded. Keep this file private; download videos separately.')},'restore-keys':restoreKeys,'delete-cloud':()=>dialog('Delete the uploaded video?',`

This permanently deletes the uploaded video and stops its shared link. ${selected.blob?'Your local file remains saved on this device.':'There is no local video file on this device.'}

`,async()=>{await api('delete',{id:selected.id,owner:selected.owner});selected.shared=false;selected.revoked=true;selected.cloudDeleted=true;await db.put(selected);clips=await db.all();editor(selected)},'Delete uploaded video'),home:()=>{if(location.pathname.startsWith('/v/'))location.assign('/');else home()},privacy,record:startRecording,stop:()=>{capture.stop();$('#record-time').textContent='Saving…';$('#pause-button').disabled=true;$('[data-action=stop]').disabled=true;},pause:()=>{paused=!paused;paused?capture.pause():capture.resume();$('#pause-button').innerHTML=icon(paused?'play':'pause')+(paused?'Resume':'Pause');$('#recording-status').textContent=paused?'PAUSED · NOT CAPTURING':'RECORDING IN PROGRESS';$('.recording')?.classList.toggle('is-paused',paused)},theme:()=>{const theme=document.documentElement.dataset.theme==='dark'?'light':'dark';document.documentElement.dataset.theme=theme;try{localStorage.setItem('screennote-theme',theme)}catch{}},compress:async()=>{const c=selected;if(!c?.blob||c.duration>MAX_SECONDS)throw Error('Trim a clip up to 10 minutes first.');busy=true;const b=$('[data-action=compress]');b.disabled=true;try{notify('Making a smaller copy at playback speed. Keep this tab visible.');const blob=await fixed(await trimVideo(c.blob,0,c.duration,p=>$('#upload-progress').textContent=`Making smaller copy · ${Math.round(p*100)}%`,[],'saver'),c.duration),info=await metadata(blob,c.duration);if(blob.size>=c.blob.size)throw Error('This copy would not be smaller. Try trimming a shorter section instead.');const copy={...c,id:randomId(),owner:secret(),title:c.title.slice(0,80)+' · smaller',blob,...info,shared:false,revoked:false,uploadAttempted:false,passwordProtected:false,cloudDeleted:false,cloudMissing:false,createdAt:Date.now()};await db.put(copy);clips=await db.all();busy=false;editor(copy);notify(sharingProblem(copy)||'Smaller copy saved. Your original stays in your library.')}finally{busy=false;b.disabled=false}},sample:async()=>{const r=await fetch('/sample.webm');if(!r.ok)throw Error('Sample clip is unavailable. Import your own video.');await importVideo(new File([await r.blob()],'A little context goes a long way.webm',{type:'video/webm'}),{sample:true,title:'A clearer checkout · sample walkthrough'});Object.assign(selected,{captions:[{start:0,end:4,text:'Show the detail that needs attention.'},{start:4,end:9,text:'Add a timestamp so feedback points to the right moment.'},{start:9,end:15,text:'Reply, resolve and keep the work moving.'}],chapters:[{time:0,title:'Show the detail'},{time:5,title:'Pin the feedback'},{time:10,title:'Resolve the conversation'}]});await db.put(selected);editor(selected)},download,publish,'copy-link':async()=>{const link=location.origin+'/v/'+selected.id;try{await navigator.clipboard.writeText(link);notify('Link copied.')}catch{dialog('Your share link',`
`,async()=>{},'Done')}},'save-title':async()=>{const c=selected,title=validTitle($('#recording-title-input').value);busy=true;try{if((c.shared||c.uploadAttempted)&&!c.cloudDeleted&&!c.cloudMissing){try{await api('title',{id:c.id,owner:c.owner,title})}catch(e){if(e.status!==404||c.shared)throw e}}const saved={...c,title};await db.put(saved);selected=saved;clips=await db.all();$('#title-status').textContent='Title saved.'}finally{busy=false}},rename:()=>dialog('Give it a clear title',`
`,async f=>{const title=f.get('title').trim();if(!title)throw Error('Add a title.');selected.title=title;await db.put(selected);clips=await db.all();editor(selected)}),'remove-local':()=>dialog(selected.blob?'Remove this local copy?':'Remove recovery access?',`

${selected.blob?'This removes the video from this browser.':'This removes the saved management keys from this browser.'} ${(selected.shared||selected.revoked||selected.uploadAttempted)&&!selected.cloudDeleted?'This also removes this browser’s ability to manage the uploaded video. Delete the uploaded video first if you want to remove it from the web.':selected.blob?'Download it first if you want to keep it.':'Keep an exported recovery file if you need this access later.'}

`,async()=>{await db.remove(selected.id);clips=await db.all();busy=false;home()},selected.blob?'Remove local copy':'Remove recovery access'),revoke:()=>dialog('Stop sharing this video?',`

New viewers will no longer be able to open it. Playback URLs already issued may work for up to five minutes. ${selected.blob?'Your local video stays here.':'Your recovered management keys stay here.'}

`,async()=>{await api('revoke',{id:selected.id,owner:selected.owner});selected.shared=false;selected.revoked=true;await db.put(selected);clips=await db.all();editor(selected)},'Stop sharing'),trim:async()=>{const c=selected,start=Number($('#trim-start').value),end=Number($('#trim-end').value);if(!Number.isFinite(start)||!Number.isFinite(end)||start<0||end>c.duration+.05||end-start<.25)throw Error('Choose a valid range of at least 0.25 seconds.');busy=true;$('[data-action=trim]').disabled=true;try{let blob=await trimVideo(c.blob,start,end,p=>$('#trim-progress').textContent=`Trimming locally · ${Math.round(p*100)}%`);blob=await fixed(blob,end-start);const info=await metadata(blob,end-start),copy={id:randomId(),owner:secret(),title:c.title.slice(0,85)+' · trimmed',...trimExtras(c,start,end),blob,type:blob.type.split(';')[0],createdAt:Date.now(),...info};await db.put(copy);clips=await db.all();busy=false;editor(copy);notify('Trimmed copy saved. The original is still in your library.')}finally{busy=false;const b=$('[data-action=trim]');if(b)b.disabled=false}}}; app.addEventListener('click',e=>{const b=e.target.closest('[data-action],[data-open],[data-rename]');if(!b||b.disabled||busy&&!['cancel-capture','cancel-upload'].includes(b.dataset.action))return;if((b.dataset.open||['home','help','privacy','recovery','refresh-share'].includes(b.dataset.action))&&!confirmEditorLeave())return;if(b.dataset.rename){const c=clips.find(c=>c.id===b.dataset.rename);dialog('Rename recording',`
`,async f=>{const title=validTitle(f.get('title'));if((c.shared||c.uploadAttempted)&&!c.cloudDeleted&&!c.cloudMissing)await api('title',{id:c.id,owner:c.owner,title});c.title=title;await db.put(c);clips=await db.all();renderLibrary()});return}if(b.dataset.open){editor(clips.find(c=>c.id===b.dataset.open));return}run(()=>actions[b.dataset.action]?.())}); window.addEventListener('beforeunload',e=>{if(recording||busy||emergencyBlob||unsavedTitle()){e.preventDefault();e.returnValue=''}}); async function viewerPage(id){try{await db.open();const local=await db.get(id);viewerOwner=local?.owner||''}catch{viewerOwner=''}try{vote=localStorage.getItem('screennote-vote-'+id)||''}catch{}app.innerHTML=`
${brand()}Make your own video ${icon('arrow')}

Opening your message…

`;try{viewData=await api('view',viewerPassword?{id,accessPassword:viewerPassword}:null,id);renderViewer()}catch(e){if(e.status===401){$('#main').innerHTML=`

A password keeps this video private.

${viewerPassword?'Password not accepted. ':''}Ask the sender for the sharing password.

`;$('#unlock-form').onsubmit=e=>{e.preventDefault();viewerPassword=$('#viewer-password').value;run(()=>viewerPage(id))};return}$('#main').innerHTML=`

This message is unavailable.

The link may have expired, stopped sharing, or the hosting service may be unavailable.

Open ScreenNote`}} @@ -153,3 +153,4 @@ if(globalThis.AppHost)window.handleAndroidBack=()=>{if(busy||recording)return tr if(globalThis.AppHost)window.requestAndroidHome=()=>{if(!busy&&!recording&&confirmEditorLeave())home();return true}; if(globalThis.AppHost)window.confirmAndroidRecordingImport=()=>{if(busy||recording||emergencyBlob){notify("Finish the current operation and save any recovered video before importing a recording.");return false}return confirmEditorLeave()}; + diff --git a/public/style.css b/public/style.css index 0be989e..baba897 100644 --- a/public/style.css +++ b/public/style.css @@ -1,4 +1,4 @@ -:root{--bg:#f7f6f2;--panel:#fff;--soft:#eeede7;--text:#292c29;--muted:#6c716a;--line:#dedfd7;--accent:#c44726;--tint:#f7e7df;--dark:#242d28;--light:#f3f4ea;color:var(--text);background:var(--bg);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none;color-scheme:light}*{box-sizing:border-box}:root[data-theme=dark]{--bg:#141b18;--panel:#1b2520;--soft:#25332b;--text:#eef1e9;--muted:#adbaad;--line:#3b4b3e;--accent:#ffb29a;--tint:#3f2d26;--dark:#0c120f;--light:#eef4e9;color-scheme:dark}body{margin:0}button,input,textarea{font:inherit}button,a,input,textarea{touch-action:manipulation}button,a{cursor:pointer}button{color:inherit}button:disabled{opacity:.5;cursor:wait}button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid #dd8155;outline-offset:3px}button{min-height:44px}button svg{width:18px;height:18px}svg{width:21px;height:21px;flex:none}a{color:var(--accent)}h1,h2,h3,p{margin-top:0}h1{font-size:clamp(32px,4vw,50px);font-weight:550;letter-spacing:-1.9px;line-height:1.08;margin-bottom:17px}h2{font-size:21px;letter-spacing:-.6px;font-weight:550}h3{font-size:15px;font-weight:600}p{line-height:1.65}.muted{color:var(--muted)}.small{font-size:12px}.eyebrow{font-size:10px;letter-spacing:1.6px;font-weight:650;text-transform:uppercase;color:var(--accent);margin-bottom:15px}.shell{display:grid;grid-template-columns:220px minmax(0,1fr);min-height:100dvh}.sidebar{width:220px;position:fixed;inset:0 auto 0 0;padding:28px 18px;border-right:1px solid var(--line);background:var(--panel);display:flex;flex-direction:column;overflow-y:auto}.brand{background:none;border:0;display:flex;align-items:center;gap:9px;font-weight:650;font-size:21px;letter-spacing:-.8px;text-align:left;padding:0}.brand img{width:32px;height:32px}.nav-label{margin:50px 12px 12px;font-size:10px;font-weight:650;color:var(--muted);letter-spacing:1.3px}.nav{display:grid;gap:8px}.nav button{border:0;text-align:left;padding:13px 14px;border-radius:8px;background:none;display:flex;gap:10px;align-items:center;font-size:13px}.nav .active{background:var(--tint);color:var(--accent);font-weight:650}.side-note{margin-top:auto;padding:22px 12px 0;font-size:12px;line-height:1.7;color:var(--muted);border-top:1px solid var(--line)}.side-note strong{display:block;color:var(--text);margin-bottom:8px}.workspace{grid-column:2;min-width:0}.topbar{height:78px;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:0 42px;border-bottom:1px solid var(--line)}.topbar .brand{display:none}.tag{font-size:10px;letter-spacing:1px;text-transform:uppercase;color:var(--muted)}.top-actions{display:flex;gap:12px;align-items:center}.icon-button{min-width:44px;min-height:44px;border:1px solid var(--line);border-radius:8px;background:var(--panel);display:inline-grid;place-items:center}.content{max-width:1400px;padding:42px;margin:auto}.heading{display:flex;gap:24px;justify-content:space-between;align-items:center;margin-bottom:27px}.heading p{font-size:14px;color:var(--muted);max-width:480px;margin-bottom:0}.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;border:1px solid var(--line);border-radius:7px;padding:11px 16px;font-size:13px;font-weight:600;color:var(--text);background:var(--panel);text-decoration:none;white-space:nowrap}.btn:hover,.icon-button:hover{filter:brightness(.96)}.btn.primary{background:var(--accent);color:var(--panel);border-color:var(--accent)}:root[data-theme=dark] .btn.primary{color:#2f211b}.btn.dark{background:var(--text);color:var(--bg);border-color:var(--text)}.btn.danger{color:var(--accent)}.actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.record-grid{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(250px,1fr);gap:24px}.stage{border-radius:13px;min-height:340px;background:var(--dark);position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--light)}.stage:before{content:'';position:absolute;inset:0;background-image:radial-gradient(#8b9e8b32 .75px,transparent .75px);background-size:16px 16px;pointer-events:none}.stage-inner{position:relative;text-align:center;padding:32px}.screen-symbol{width:155px;height:102px;border:1px solid #a9b8a26b;border-radius:9px;margin:0 auto 30px;display:grid;place-items:center;position:relative;background:#354038}.screen-symbol:after{content:'';position:absolute;width:52px;height:4px;background:#859580;bottom:-14px;border-radius:2px}.play-disc{width:48px;height:48px;display:grid;place-items:center;background:#f2efdd;border-radius:50%;color:#344236}.stage h2{font-size:24px;letter-spacing:-.7px;margin-bottom:8px}.stage p{font-size:12px;color:#b6c3b1;margin:0}.stage video{width:100%;max-height:440px;display:block;position:relative}.stage-top{position:absolute;top:18px;left:20px;font-size:10px;letter-spacing:1px;border:1px solid #64755e;border-radius:20px;padding:6px 10px;z-index:1}.stage .live-dot{display:inline-block;background:#ef7759;border-radius:50%;width:7px;height:7px;margin-right:6px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:25px;min-width:0}.panel h2{margin-bottom:8px}.panel p{font-size:12px;color:var(--muted)}.option{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:17px 0;border-bottom:1px solid var(--line)}.option>div{display:flex;gap:12px;align-items:center}.option strong{display:block;font-size:13px;font-weight:550}.option small{display:block;color:var(--muted);font-size:11px;margin-top:4px}.option input{width:36px;height:21px;accent-color:var(--accent);flex:none}.record-options .btn.primary{width:100%;margin-top:24px}.record-options .import-label{display:flex;justify-content:center;cursor:pointer;padding:12px 0;font-size:12px;color:var(--muted);font-weight:600}.file-input{width:100%;font-size:12px;margin-top:8px;color:var(--muted)}.meta-strip{display:flex;justify-content:space-between;gap:18px;font-size:11px;color:var(--muted);padding:15px 0 0;line-height:1.6}.section-head{display:flex;justify-content:space-between;align-items:center;gap:15px;margin:42px 0 20px}.section-head h2{margin:0}.clip-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.clip-card{border:1px solid var(--line);background:var(--panel);border-radius:10px;overflow:hidden;min-width:0}.clip-art{aspect-ratio:16/9;background:var(--soft);display:grid;place-items:center;position:relative}.clip-art img{width:100%;height:100%;object-fit:cover}.clip-art .duration{position:absolute;right:12px;bottom:12px;font-size:10px;border-radius:4px;padding:4px 6px;background:#17231edf;color:#fff}.clip-body{padding:17px}.clip-body h3{overflow-wrap:anywhere;margin-bottom:7px}.clip-body .btn{margin-top:14px;width:100%;font-size:12px}.clip-status{font-size:10px;color:var(--muted)}.empty{grid-column:1/-1;text-align:center;padding:37px;border:1px dashed var(--line);border-radius:12px;background:var(--soft)}.empty p{font-size:12px;color:var(--muted)}.empty h3{margin-bottom:7px}.empty .btn{margin-top:5px}.footer{display:flex;justify-content:space-between;gap:16px;font-size:11px;color:var(--muted);margin-top:40px;padding-top:22px;border-top:1px solid var(--line);line-height:1.7}.error-box{border:1px solid #d78c71;background:var(--tint);padding:14px 18px;border-radius:8px;margin-bottom:20px;font-size:13px;line-height:1.6;overflow-wrap:anywhere}.error-box:empty{display:none}.editor-grid{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:24px}.video-panel{background:var(--dark);border-radius:12px;overflow:hidden}.video-panel video{display:block;width:100%;aspect-ratio:16/9;max-height:65vh}.field{display:flex;flex-direction:column;gap:8px;margin-bottom:18px;min-width:0}.field label{font-size:12px;font-weight:600}.field input,.field textarea{width:100%;min-height:45px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--text);padding:11px 12px;font-size:14px}.field textarea{resize:vertical;min-height:90px}.field-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}.small-label{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin:0 0 10px}.editor-actions{padding:18px 0;display:flex;gap:10px;flex-wrap:wrap}.notice{background:var(--soft);border-radius:8px;padding:14px 17px;font-size:12px;line-height:1.7;color:var(--muted);margin:18px 0}.share-link{word-break:break-all;display:block;padding:11px 12px;background:var(--soft);font-size:12px;border:1px solid var(--line);border-radius:6px;margin-bottom:12px;color:var(--accent)}.full{width:100%}.progress{height:5px;background:var(--soft);border-radius:3px;overflow:hidden;margin-top:16px}.progress>div{height:100%;background:var(--accent);transition:width .2s}.live-controls{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:20px}.timer{font-size:30px;font-variant-numeric:tabular-nums;font-weight:500;min-width:100px}.recording .stage{border:2px solid #da6847}.reactions{display:flex;gap:10px;margin:20px 0;flex-wrap:wrap}.reactions button[aria-pressed=true]{border-color:var(--accent);background:var(--tint)}.comment{padding:17px 0;border-bottom:1px solid var(--line);font-size:13px}.comment-head{display:flex;align-items:center;gap:10px;margin-bottom:8px}.comment-head strong{font-size:12px}.timestamp{color:var(--accent);background:var(--tint);border:0;min-height:30px;font-size:11px;padding:4px 8px;border-radius:4px}.comment p{overflow-wrap:anywhere;margin-bottom:0;white-space:pre-wrap}.viewer-top{max-width:1200px;margin:auto;padding:23px 32px;display:flex;justify-content:space-between;gap:20px;border-bottom:1px solid var(--line)}.viewer{max-width:1200px;margin:auto;padding:36px 32px}.viewer h1{font-size:34px;line-height:1.25;overflow-wrap:anywhere}.viewer-grid{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(270px,1fr);gap:26px}.reply-panel{max-height:800px;overflow:auto}.viewer .video-panel video{min-height:220px}.privacy{max-width:760px}.privacy h2{margin-top:30px}#notice{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);max-width:calc(100% - 30px);background:var(--text);color:var(--bg);font-size:13px;line-height:1.6;padding:13px 20px;border-radius:8px;z-index:50;display:none}.skip{position:fixed;left:10px;top:-80px;background:var(--panel);padding:12px;z-index:99}.skip:focus{top:10px}.loading{padding:40px}dialog{max-width:560px;width:calc(100% - 32px);max-height:calc(100dvh - 32px);border:1px solid var(--line);padding:25px;border-radius:12px;background:var(--panel);color:var(--text)}dialog::backdrop{background:#0e1a1377}.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:20px}.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)} +:root{--bg:#f7f6f2;--panel:#fff;--soft:#eeede7;--text:#292c29;--muted:#62685f;--line:#dedfd7;--accent:#b43b1f;--tint:#f7e7df;--dark:#242d28;--light:#f3f4ea;color:var(--text);background:var(--bg);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none;color-scheme:light}*{box-sizing:border-box}:root[data-theme=dark]{--bg:#141b18;--panel:#1b2520;--soft:#25332b;--text:#eef1e9;--muted:#adbaad;--line:#3b4b3e;--accent:#ffb29a;--tint:#3f2d26;--dark:#0c120f;--light:#eef4e9;color-scheme:dark}body{margin:0}button,input,textarea{font:inherit}button,a,input,textarea{touch-action:manipulation}button,a{cursor:pointer}button{color:inherit}button:disabled{opacity:.5;cursor:wait}button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible{outline:3px solid #dd8155;outline-offset:3px}button{min-height:44px}button svg{width:18px;height:18px}svg{width:21px;height:21px;flex:none}a{color:var(--accent)}h1,h2,h3,p{margin-top:0}h1{font-size:clamp(32px,4vw,50px);font-weight:550;letter-spacing:-1.9px;line-height:1.08;margin-bottom:17px}h2{font-size:21px;letter-spacing:-.6px;font-weight:550}h3{font-size:15px;font-weight:600}p{line-height:1.65}.muted{color:var(--muted)}.small{font-size:12px}.eyebrow{font-size:10px;letter-spacing:1.6px;font-weight:650;text-transform:uppercase;color:var(--accent);margin-bottom:15px}.shell{display:grid;grid-template-columns:220px minmax(0,1fr);min-height:100dvh}.sidebar{width:220px;position:fixed;inset:0 auto 0 0;padding:28px 18px;border-right:1px solid var(--line);background:var(--panel);display:flex;flex-direction:column;overflow-y:auto}.brand{background:none;border:0;display:flex;align-items:center;gap:9px;font-weight:650;font-size:21px;letter-spacing:-.8px;text-align:left;padding:0}.brand img{width:32px;height:32px}.nav-label{margin:50px 12px 12px;font-size:10px;font-weight:650;color:var(--muted);letter-spacing:1.3px}.nav{display:grid;gap:8px}.nav button{border:0;text-align:left;padding:13px 14px;border-radius:8px;background:none;display:flex;gap:10px;align-items:center;font-size:13px}.nav .active{background:var(--tint);color:var(--accent);font-weight:650}.side-note{margin-top:auto;padding:22px 12px 0;font-size:12px;line-height:1.7;color:var(--muted);border-top:1px solid var(--line)}.side-note strong{display:block;color:var(--text);margin-bottom:8px}.workspace{grid-column:2;min-width:0}.topbar{height:78px;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:0 42px;border-bottom:1px solid var(--line)}.topbar .brand{display:none}.tag{font-size:10px;letter-spacing:1px;text-transform:uppercase;color:var(--muted)}.top-actions{display:flex;gap:12px;align-items:center}.icon-button{min-width:44px;min-height:44px;border:1px solid var(--line);border-radius:8px;background:var(--panel);display:inline-grid;place-items:center}.content{max-width:1400px;padding:42px;margin:auto}.heading{display:flex;gap:24px;justify-content:space-between;align-items:center;margin-bottom:27px}.heading p{font-size:14px;color:var(--muted);max-width:480px;margin-bottom:0}.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;border:1px solid var(--line);border-radius:7px;padding:11px 16px;font-size:13px;font-weight:600;color:var(--text);background:var(--panel);text-decoration:none;white-space:nowrap}.btn:hover,.icon-button:hover{filter:brightness(.96)}.btn.primary{background:var(--accent);color:var(--panel);border-color:var(--accent)}:root[data-theme=dark] .btn.primary{color:#2f211b}.btn.dark{background:var(--text);color:var(--bg);border-color:var(--text)}.btn.danger{color:var(--accent)}.actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.record-grid{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(250px,1fr);gap:24px}.stage{border-radius:13px;min-height:340px;background:var(--dark);position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;color:var(--light)}.stage:before{content:'';position:absolute;inset:0;background-image:radial-gradient(#8b9e8b32 .75px,transparent .75px);background-size:16px 16px;pointer-events:none}.stage-inner{position:relative;text-align:center;padding:32px}.screen-symbol{width:155px;height:102px;border:1px solid #a9b8a26b;border-radius:9px;margin:0 auto 30px;display:grid;place-items:center;position:relative;background:#354038}.screen-symbol:after{content:'';position:absolute;width:52px;height:4px;background:#859580;bottom:-14px;border-radius:2px}.play-disc{width:48px;height:48px;display:grid;place-items:center;background:#f2efdd;border-radius:50%;color:#344236}.stage h2{font-size:24px;letter-spacing:-.7px;margin-bottom:8px}.stage p{font-size:12px;color:#b6c3b1;margin:0}.stage video{width:100%;max-height:440px;display:block;position:relative}.stage-top{position:absolute;top:18px;left:20px;font-size:10px;letter-spacing:1px;border:1px solid #64755e;border-radius:20px;padding:6px 10px;z-index:1}.stage .live-dot{display:inline-block;background:#ef7759;border-radius:50%;width:7px;height:7px;margin-right:6px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:25px;min-width:0}.panel h2{margin-bottom:8px}.panel p{font-size:12px;color:var(--muted)}.option{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:17px 0;border-bottom:1px solid var(--line)}.option>div{display:flex;gap:12px;align-items:center}.option strong{display:block;font-size:13px;font-weight:550}.option small{display:block;color:var(--muted);font-size:11px;margin-top:4px}.option input{width:36px;height:21px;accent-color:var(--accent);flex:none}.record-options .btn.primary{width:100%;margin-top:24px}.record-options .import-label{display:flex;justify-content:center;cursor:pointer;padding:12px 0;font-size:12px;color:var(--muted);font-weight:600}.file-input{width:100%;font-size:12px;margin-top:8px;color:var(--muted)}.meta-strip{display:flex;justify-content:space-between;gap:18px;font-size:11px;color:var(--muted);padding:15px 0 0;line-height:1.6}.section-head{display:flex;justify-content:space-between;align-items:center;gap:15px;margin:42px 0 20px}.section-head h2{margin:0}.clip-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.clip-card{border:1px solid var(--line);background:var(--panel);border-radius:10px;overflow:hidden;min-width:0}.clip-art{aspect-ratio:16/9;background:var(--soft);display:grid;place-items:center;position:relative}.clip-art img{width:100%;height:100%;object-fit:cover}.clip-art .duration{position:absolute;right:12px;bottom:12px;font-size:10px;border-radius:4px;padding:4px 6px;background:#17231edf;color:#fff}.clip-body{padding:17px}.clip-body h3{overflow-wrap:anywhere;margin-bottom:7px}.clip-body .btn{margin-top:14px;width:100%;font-size:12px}.clip-status{font-size:10px;color:var(--muted)}.empty{grid-column:1/-1;text-align:center;padding:37px;border:1px dashed var(--line);border-radius:12px;background:var(--soft)}.empty p{font-size:12px;color:var(--muted)}.empty h3{margin-bottom:7px}.empty .btn{margin-top:5px}.footer{display:flex;justify-content:space-between;gap:16px;font-size:11px;color:var(--muted);margin-top:40px;padding-top:22px;border-top:1px solid var(--line);line-height:1.7}.error-box{border:1px solid #d78c71;background:var(--tint);padding:14px 18px;border-radius:8px;margin-bottom:20px;font-size:13px;line-height:1.6;overflow-wrap:anywhere}.error-box:empty{display:none}.editor-grid{display:grid;grid-template-columns:minmax(0,1fr) 300px;gap:24px}.video-panel{background:var(--dark);border-radius:12px;overflow:hidden}.video-panel video{display:block;width:100%;aspect-ratio:16/9;max-height:65vh}.field{display:flex;flex-direction:column;gap:8px;margin-bottom:18px;min-width:0}.field label{font-size:12px;font-weight:600}.field input,.field textarea{width:100%;min-height:45px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--text);padding:11px 12px;font-size:14px}.field textarea{resize:vertical;min-height:90px}.field-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}.small-label{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin:0 0 10px}.editor-actions{padding:18px 0;display:flex;gap:10px;flex-wrap:wrap}.notice{background:var(--soft);border-radius:8px;padding:14px 17px;font-size:12px;line-height:1.7;color:var(--muted);margin:18px 0}.share-link{word-break:break-all;display:block;padding:11px 12px;background:var(--soft);font-size:12px;border:1px solid var(--line);border-radius:6px;margin-bottom:12px;color:var(--accent)}.full{width:100%}.progress{height:5px;background:var(--soft);border-radius:3px;overflow:hidden;margin-top:16px}.progress>div{height:100%;background:var(--accent);transition:width .2s}.live-controls{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:20px}.timer{font-size:30px;font-variant-numeric:tabular-nums;font-weight:500;min-width:100px}.recording .stage{border:2px solid #da6847}.reactions{display:flex;gap:10px;margin:20px 0;flex-wrap:wrap}.reactions button[aria-pressed=true]{border-color:var(--accent);background:var(--tint)}.comment{padding:17px 0;border-bottom:1px solid var(--line);font-size:13px}.comment-head{display:flex;align-items:center;gap:10px;margin-bottom:8px}.comment-head strong{font-size:12px}.timestamp{color:var(--accent);background:var(--tint);border:0;min-height:30px;font-size:11px;padding:4px 8px;border-radius:4px}.comment p{overflow-wrap:anywhere;margin-bottom:0;white-space:pre-wrap}.viewer-top{max-width:1200px;margin:auto;padding:23px 32px;display:flex;justify-content:space-between;gap:20px;border-bottom:1px solid var(--line)}.viewer{max-width:1200px;margin:auto;padding:36px 32px}.viewer h1{font-size:34px;line-height:1.25;overflow-wrap:anywhere}.viewer-grid{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(270px,1fr);gap:26px}.reply-panel{max-height:800px;overflow:auto}.viewer .video-panel video{min-height:220px}.privacy{max-width:760px}.privacy h2{margin-top:30px}#notice{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);max-width:calc(100% - 30px);background:var(--text);color:var(--bg);font-size:13px;line-height:1.6;padding:13px 20px;border-radius:8px;z-index:50;display:none}.skip{position:fixed;left:10px;top:-80px;background:var(--panel);padding:12px;z-index:99}.skip:focus{top:10px}.loading{padding:40px}dialog{max-width:560px;width:calc(100% - 32px);max-height:calc(100dvh - 32px);border:1px solid var(--line);padding:25px;border-radius:12px;background:var(--panel);color:var(--text)}dialog::backdrop{background:#0e1a1377}.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:20px}.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)} @media(min-width:1600px){.content{padding-top:58px}}@media(max-width:1150px){.record-grid{grid-template-columns:minmax(0,1.3fr) minmax(240px,1fr)}.content{padding:30px}.topbar{padding:0 30px}.clip-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.editor-grid{grid-template-columns:minmax(0,1fr)}.editor-grid>aside{display:grid;grid-template-columns:1fr 1fr;gap:20px}.viewer-grid{grid-template-columns:minmax(0,1fr)}}@media(max-width:1024px){.shell{grid-template-columns:180px minmax(0,1fr)}.sidebar{width:180px;padding:25px 14px}.sidebar .brand{font-size:18px}.sidebar .brand img{width:28px;height:28px}.content{padding:28px 24px}.topbar{padding:0 24px}.record-grid{grid-template-columns:minmax(0,1fr)}.record-options .option{padding:13px 0}.record-options .btn.primary{margin-top:19px}.stage{min-height:310px}}@media(max-width:767px){.shell{display:block}.sidebar{display:none}.workspace{display:block}.topbar{height:70px;padding:0 20px}.topbar .brand{display:flex;font-size:19px}.breadcrumb{display:none}.tag{font-size:9px}.top-actions{gap:9px}.content{padding:28px 20px}.heading{align-items:start;gap:16px}.heading h1{font-size:36px}.heading p{font-size:13px}.heading .actions .btn{padding:10px 12px}.record-grid{gap:16px}.stage{min-height:280px}.stage h2{font-size:23px}.panel{padding:21px}.meta-strip{flex-wrap:wrap;gap:4px;font-size:11px}.section-head{margin-top:32px}.clip-grid{gap:14px}.clip-body{padding:14px}.clip-body h3{font-size:14px}.empty{padding:28px 20px}.footer{display:block}.footer span{display:block;margin-bottom:7px}.editor-grid>aside{display:block}.editor-grid>aside>.panel{margin-bottom:17px}.viewer-top{padding:18px 20px}.viewer-top .brand{font-size:18px}.viewer{padding:28px 20px}.viewer h1{font-size:27px;letter-spacing:-1px}.field input,.field textarea{font-size:16px}.viewer .video-panel video{min-height:0}.viewer-grid{gap:20px}.reply-panel{max-height:none}.editor-actions{gap:8px}.editor-actions .btn{font-size:12px}.timer{font-size:27px}}@media(max-width:480px){.clip-grid{grid-template-columns:minmax(0,1fr)}.topbar .tag{display:none}.heading{flex-wrap:wrap}.heading h1{font-size:35px}.screen-symbol{width:130px;height:90px}.record-grid .stage{min-height:265px}.content{padding:26px 17px}.field-row{gap:10px}.viewer-top>.btn{font-size:11px;padding:9px 11px}.stage-inner{padding:24px}.live-controls{flex-wrap:wrap}.reactions .btn{font-size:12px;padding:9px 11px}.clip-art{aspect-ratio:2/1}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}} .record-options .btn{white-space:normal}.file-input::file-selector-button{border:1px solid var(--line);border-radius:5px;background:var(--soft);color:var(--text);padding:8px 10px;margin-right:10px;font:inherit;cursor:pointer}button:disabled{cursor:not-allowed} @@ -74,3 +74,4 @@ progress{width:100%;height:12px;margin-top:16px;accent-color:var(--accent)}selec /* The import target stays accessible to its label without widening narrow pages. */ .file-input.visually-hidden{position:fixed;width:1px;height:1px;left:0;top:0;padding:0;margin:0;clip-path:inset(50%);overflow:hidden} .viewer-review{display:inline-block;margin:0 0 20px;font-size:12px} + diff --git a/tests/publishing-title.test.js b/tests/publishing-title.test.js new file mode 100644 index 0000000..d036bde --- /dev/null +++ b/tests/publishing-title.test.js @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import {shareOptions} from '../lib/share-options.js'; +import {sharingProblem} from '../lib/recording-policy.js'; +import {validTitle} from '../lib/titles.js'; +const source=fs.readFileSync(new URL('../public/app.js',import.meta.url),'utf8'); +const publish=source.slice(source.indexOf('async function publish()'),source.indexOf('function saveFile(')); +function context({password='short',diskFailure=false}={}){ + const selected={id:'local-recording',title:'Saved title',blob:{size:1000},type:'video/webm',duration:7}; + const fields={'#recording-title-input':{value:'A new unsaved title'},'#link-days':{value:'7'},'#share-password':{value:password},'[data-action=publish]':{disabled:false},'#cancel-upload':{},'#upload-meter':{},'#upload-progress':{}}; + const ctx=vm.createContext({selected,validTitle,shareOptions,sharingProblem,AbortController,uploadController:null,health:true,busy:false,$:s=>fields[s],db:{put:async()=>{throw Error('Device storage is full')}},editor:()=>{throw Error('Must preserve the editor field on local save failure')}}); + vm.runInContext(publish,ctx);return{ctx,selected,fields}; +} +test('invalid publish settings cannot mark an unsaved title as saved',async()=>{ + const{ctx,selected,fields}=context();await assert.rejects(vm.runInContext('publish()',ctx),/sharing password/); + assert.equal(selected.title,'Saved title');assert.equal(fields['#recording-title-input'].value,'A new unsaved title');assert.equal(ctx.busy,false); +}); +test('local persistence failure preserves the saved identity and edited title',async()=>{ + const{ctx,selected,fields}=context({password:'',diskFailure:true});await assert.rejects(vm.runInContext('publish()',ctx),/Device storage is full/); + assert.equal(ctx.selected,selected);assert.equal(selected.title,'Saved title');assert.equal(selected.uploadAttempted,undefined);assert.equal(fields['#recording-title-input'].value,'A new unsaved title');assert.equal(ctx.busy,false);assert.equal(fields['[data-action=publish]'].disabled,false); +}); diff --git a/verification/title-contrast-release-2026-10-27.md b/verification/title-contrast-release-2026-10-27.md new file mode 100644 index 0000000..114859f --- /dev/null +++ b/verification/title-contrast-release-2026-10-27.md @@ -0,0 +1,11 @@ +# ScreenNote title persistence and contrast release + +Baseline: GitHub main 294b986a054588f682e1446fae100288cc7a1505, the merged recording/workspace PR #3. The separately opened revert PR #4 is unmerged and contains no explanatory human comment; this release does not act on it. + +Scope: preserve current main, including native recording/recovery changes, while fixing local title state around publish validation or IndexedDB failure. Use copied candidate objects and commit selected state only after persistence succeeds. Retain edited fields on initial publish-save failure. Darken sampled muted/accent text to the audited contrast tokens. + +The prior audit's local commits survived in the shared repository object database. These two source changes and publishing-title regressions were reconciled against the actual current main source. Newer restoreKeys recovery handling is explicitly preserved. + +Validation before publication: 111 Node tests pass, no skips; build passes. The focused publishing regression asserts invalid settings and failed local persistence do not overwrite the saved object. Previous audited ten-width browser evidence was recovered; a fresh browser retest and production deployment verification are recorded separately. + +No runtime dependency, migration, storage permission, Android native source, recording limit or customer data change is included. Physical capture quality and Play Store readiness remain outside this scoped release.