Practice ⌄
ROUND 01 / 050 / 500
ROUND 01 / 050 / 500
A daily perception game
Practice ⌄
ROUND 01 / 050 / 500
ROUND 01 / 050 / 500
A daily perception game
Practice ⌄
ROUND 01 / 050 / 500
ROUND 01 / 050 / 500
A daily perception game
Practice ⌄
ROUND 01 / 050 / 500
ROUND 01 / 050 / 500
','
')
+html=html.replace('
','
')
common_css=mobile_play+Path('src/design-options/trivia.css').read_text()+Path('src/design-options/theme-switcher.css').read_text()+Path('src/design-options/summary-comparisons.css').read_text()+Path('src/design-options/share-results.css').read_text()+Path('src/design-options/player-storage.css').read_text()+Path('src/design-options/play-navigation.css').read_text()
theme_assets={key:key+'.css?v='+hashlib.sha256((base+css+common_css).encode()).hexdigest()[:12] for key,css in styles.items()}
theme_preference='window.perceptionThemeAssets='+json.dumps(theme_assets)+';\n'+theme_preference
diff --git a/src/design-options/friend-challenge.js b/src/design-options/friend-challenge.js
new file mode 100644
index 0000000..c76efab
--- /dev/null
+++ b/src/design-options/friend-challenge.js
@@ -0,0 +1,50 @@
+// A challenge contains only a date and total, never answers or player identifiers.
+function readFriendChallenge(search){
+ const params=new URLSearchParams(search),day=params.get('day'),score=params.get('beat');
+ if(params.get('from')!=='challenge'||!validDay(day)||!/^\d{1,3}$/.test(score||'')||Number(score)>500)return null;
+ return {day:day.split('-').map(Number).join('-'),score:Number(score)};
+}
+const friendChallenge=typeof location!=='undefined'&&typeof URLSearchParams!=='undefined'?readFriendChallenge(location.search):null;
+function currentFriendChallenge(){return friendChallenge&&friendChallenge.day===calendarDay()&&mode==='daily'&&mini==='mix'&&!review;}
+function friendChallengeURL(total,day){
+ const url=new URL('https://perception.thedanktank.com/');
+ url.search=new URLSearchParams({from:'challenge',day,beat:String(total)}).toString();
+ return url.href;
+}
+function friendChallengeText(){
+ const total=results.reduce((sum,r)=>sum+r.score,0);
+ return `I scored ${total}/500 on Perception. Can you beat me?\nFive daily puzzles. No login.\n${friendChallengeURL(total,sessionDay)}`;
+}
+function renderFriendChallenge(){
+ const banner=$('friend-challenge');if(!banner)return;
+ banner.hidden=!friendChallenge||mode!=='daily'||mini!=='mix'||review;
+ if(banner.hidden)return;
+ if(!currentFriendChallenge()){
+ banner.textContent='That daily challenge has ended. Play today’s five and send a fresh challenge.';return;
+ }
+ if(results.length===5){
+ const total=results.reduce((sum,r)=>sum+r.score,0),delta=total-friendChallenge.score;
+ banner.textContent=delta>0?`You beat your friend by ${delta} ${delta===1?'point':'points'}! Send a challenge back.`:delta===0?`A tie at ${total}/500. Challenge someone else to break it.`:`Your friend scored ${friendChallenge.score}/500. You were ${-delta} points away. Try again tomorrow.`;
+ }else banner.textContent=`Your friend scored ${friendChallenge.score}/500. Can you beat them? Same five puzzles. Your own first attempt.`;
+}
+function bindFriendChallenge(){
+ renderFriendChallenge();
+ const button=$('challenge-friend');if(!button)return;
+ button.hidden=mode!=='daily'||mini!=='mix'||review||sessionDay!==calendarDay()||results.length!==5;
+ if(button.hidden)return;
+ button.addEventListener('click',async()=>{
+ if(button.disabled)return;
+ analyticsShareClicked();analyticsChallenge('share_clicked');
+ button.disabled=true;
+ const text=friendChallengeText(),status=$('share-status'),fallback=$('share-fallback');
+ status.textContent='';fallback.hidden=true;
+ try{
+ if(typeof navigator.share==='function'){
+ try{await navigator.share({text});status.textContent='Challenge shared.';return;}
+ catch(error){if(error?.name==='AbortError')return;}
+ }
+ try{await navigator.clipboard.writeText(text);status.textContent='Challenge copied. Send it to a friend.';}
+ catch{fallback.hidden=false;fallback.value=text;fallback.focus();fallback.select();status.textContent='Select and copy your challenge below.';}
+ }finally{button.disabled=false;}
+ });
+}
diff --git a/src/design-options/share-results.css b/src/design-options/share-results.css
index bb21225..9da49e4 100644
--- a/src/design-options/share-results.css
+++ b/src/design-options/share-results.css
@@ -1,3 +1,5 @@
.colour-adjust{display:block;text-align:left;margin:12px auto;max-width:320px}.colour-adjust>span:first-child{display:block;font-size:12px;color:var(--muted);margin-bottom:6px}.colour-adjust .chosen-swatch{display:block;width:100%;height:56px;border-radius:9px;border:1px solid var(--line);background:var(--canvas)}
.share-panel{margin:24px 0 12px}.share-panel>.primary{width:100%}.share-status{font-size:12px;color:var(--muted);min-height:18px;margin:8px 0 0;text-align:center}.share-fallback{display:block;width:100%;min-height:150px;padding:12px;margin-top:12px;background:var(--canvas);color:var(--ink);border:1px solid var(--line);border-radius:8px;font:13px/1.6 monospace;resize:vertical}.share-fallback[hidden]{display:none}
@media(max-width:760px){.colour-adjust{margin:8px auto}.colour-adjust .chosen-swatch{height:44px}.wheel-wrap{width:min(100%,clamp(140px,calc(100svh - 510px),260px))}}
+
+.share-panel>button{display:block;width:100%;margin-top:10px}.share-panel>button[hidden]{display:none}.friend-challenge{padding:12px 16px;margin:0 0 16px;border:1px solid var(--accent);border-radius:10px;background:var(--canvas);font-size:14px}.friend-challenge[hidden]{display:none}
diff --git a/src/design-options/share-results.js b/src/design-options/share-results.js
index 1d6b0f4..57f8c9f 100644
--- a/src/design-options/share-results.js
+++ b/src/design-options/share-results.js
@@ -8,6 +8,10 @@ function shareResultsText(rounds,day,game='mix',daily=true){
return `perception.thedanktank.com ${dateLabel}${daily?'':' · Practice '+gameLabel}\n${rounds.map(r=>(icons[r.q.type]||'•')+r.score).join(' ')}\nFinal score: ${total}/${rounds.length*100}`;
}
function bindShareResults(){
+ const panel=$('share-panel');
+ const allowed=mode==='daily'&&mini==='mix'&&!review;
+ if(panel)panel.hidden=!allowed;
+ if(!allowed)return;
const button=$('share-results'),status=$('share-status'),fallback=$('share-fallback');
const text=shareResultsText(results,sessionDay,mini,mode==='daily'&&!review);
button.addEventListener('click',async()=>{
diff --git a/tests/verify-analytics.cjs b/tests/verify-analytics.cjs
index c707bdd..7ac3a8b 100644
--- a/tests/verify-analytics.cjs
+++ b/tests/verify-analytics.cjs
@@ -15,8 +15,8 @@ assert.equal(h.calls.reduce((sum,c)=>sum+1+(c[0]==='visit'?0:Object.keys(c[1]||{
h.run('analyticsShareClicked();analyticsShareClicked()');assert.equal(h.calls.length,4);assert.equal(h.calls[3][0],'results_share_clicked');assert.equal(h.calls[3][1],undefined,'Sharing must use no quota-consuming properties');
const reload=harness({storage:h.store});reload.run('setupAnalytics();analyticsStarted();results='+JSON.stringify(['angle','time','colour','timeline','duration'].map(type=>({q:{type},score:80})))+';analyticsCompleted()');reload.scripts[0].load();assert.equal(reload.calls.length,0,'Reload/theme changes must not duplicate events');
reload.run("mode='practice';analyticsShareClicked();mini='angle';analyticsShareClicked()");assert.equal(reload.calls.length,0,'Sharing across modes/reloads must deduplicate');
-reload.run("calendarDay=()=> '2026-10-9';analyticsShareClicked();analyticsShareClicked()");assert.equal(reload.calls.length,1,'Next day may share again, even an older puzzle');
-const practiceShare=harness();practiceShare.run("setupAnalytics();mode='practice';analyticsShareClicked();analyticsShareClicked()");practiceShare.scripts[0].load();assert.deepEqual(practiceShare.calls.map(c=>c[0]),['visit','results_share_clicked']);
+reload.run("mode='daily';mini='mix';calendarDay=()=> '2026-10-9';analyticsShareClicked();analyticsShareClicked()");assert.equal(reload.calls.length,1,'Next day may share again, even an older puzzle');
+const practiceShare=harness();practiceShare.run("setupAnalytics();mode='practice';analyticsShareClicked();analyticsShareClicked()");practiceShare.scripts[0].load();assert.deepEqual(practiceShare.calls.map(c=>c[0]),['visit']);
for(const config of [{host:'localhost'},{host:'chipgpt.github.io'},{dnt:'1'},{configured:false}]){const x=harness(config);x.run('setupAnalytics();analyticsStarted();analyticsShareClicked()');assert.equal(x.scripts.length,0);assert.equal(x.calls.length,0);assert.equal(x.store.size,0);}
for(const mode of ["mode='practice'","mini='angle'","review=true"]){const x=harness();x.run('setupAnalytics();'+mode+';analyticsStarted()');x.scripts[0].load();assert.equal(x.calls.length,1,'Practice only contributes the deduplicated visit');}
const unavailable=harness({broken:true});unavailable.run('setupAnalytics();analyticsStarted();analyticsStarted()');unavailable.scripts[0].load();assert.equal(unavailable.calls.length,2,'Storage failure must be safe and deduplicate in memory');
@@ -25,6 +25,9 @@ const slow=harness();slow.run('setupAnalytics();analyticsStarted();');slow.scrip
const failed=harness();failed.run('setupAnalytics();analyticsStarted()');failed.scripts[0].error();assert.equal(failed.calls.length,0);
const queuedShare=harness();queuedShare.run('setupAnalytics();analyticsStarted();results='+JSON.stringify(['angle','time','colour','timeline','duration'].map(type=>({q:{type},score:80})))+';analyticsCompleted();analyticsShareClicked()');queuedShare.scripts[0].load();assert.equal(queuedShare.calls.length,4,'Share must survive loading alongside the other three requests');
const retention=harness();for(let d=1;d<=30;d++)retention.run(`analyticsClaim('2026-10-${d}','visit')`);assert.equal(Object.keys(JSON.parse(retention.store.get('perception-metrics-v1'))).length,7);
+const challenge=harness();challenge.run("const friendChallenge={day:'2026-10-8',score:300};function currentFriendChallenge(){return friendChallenge.day===calendarDay()&&mode==='daily'&&mini==='mix'&&!review;}setupAnalytics();analyticsStarted();results="+JSON.stringify(['angle','time','colour','timeline','duration'].map(type=>({q:{type},score:80})))+";analyticsCompleted();analyticsShareClicked();analyticsChallenge('share_clicked');analyticsChallenge('visit');analyticsChallenge('start');analyticsChallenge('complete');analyticsChallenge('share_clicked');");
+challenge.scripts[0].load();assert.deepEqual(challenge.calls.map(c=>c[0]),['visit','challenge_visit','daily_started','challenge_start','daily_completed','challenge_complete','results_share_clicked','challenge_share_clicked']);
+const stale=harness();stale.run("const friendChallenge={day:'2026-10-7',score:300};function currentFriendChallenge(){return false;}setupAnalytics();analyticsChallenge('start');analyticsChallenge('complete');");stale.scripts[0].load();assert.deepEqual(stale.calls.map(c=>c[0]),['visit']);
const generated=fs.readFileSync('src/design-options/game-themed.js','utf8');assert(generated.includes('analyticsStarted();results.push({q,guess:value,score});saveDailyGame();analyticsCompleted();'));
const shareHandler=generated.slice(generated.indexOf('function bindShareResults()'),generated.indexOf('function bindShareResults()')+2200);
assert(shareHandler.indexOf('analyticsShareClicked();') '2026-10-9',validDay(day){if(!/^\d{4}-\d{1,2}-\d{1,2}$/.test(day||''))return false;const [y,m,d]=day.split('-').map(Number),v=new Date(Date.UTC(y,m-1,d));return v.getUTCFullYear()===y&&v.getUTCMonth()+1===m&&v.getUTCDate()===d},mode:'daily',mini:'mix',review:false,sessionDay:'2026-10-9',results:[],navigator,$:id=>elements[id],analyticsShareClicked(){events.push('share')},analyticsChallenge(stage){events.push(stage)}});
+ vm.runInContext(source,c);return {elements,events,run:s=>vm.runInContext(s,c)};
+}
+(async()=>{
+ for(const query of ['?from=challenge&day=2026-2-30&beat=300','?from=challenge&day=2026-10-9&beat=501','?from=challenge&day=2026-10-9&beat=-1','?from=challenge&day=2026-10-9&beat=
','?day=2026-10-9&beat=300'])assert.equal(harness(query).run('friendChallenge'),null);
+ const c=harness('?from=challenge&day=2026-10-09&beat=300');
+ c.run('renderFriendChallenge()');assert(c.elements['friend-challenge'].textContent.includes('300/500'));
+ c.run('results=Array.from({length:5},()=>({score:80}));bindFriendChallenge()');assert(c.elements['friend-challenge'].textContent.includes('beat your friend by 100'));assert(!c.elements['challenge-friend'].hidden);
+ const text=c.run('friendChallengeText()'),url=text.split('\n').at(-1);assert.equal(new URL(url).searchParams.get('beat'),'400');assert.equal(harness(new URL(url).search).run('friendChallenge.score'),400);
+ c.run('results=Array.from({length:5},()=>({score:60}));renderFriendChallenge()');assert(c.elements['friend-challenge'].textContent.includes('tie'));
+ c.run('results=Array.from({length:5},()=>({score:40}));renderFriendChallenge()');assert(c.elements['friend-challenge'].textContent.includes('100 points away'));
+ c.run("mode='practice';bindFriendChallenge()");assert(c.elements['challenge-friend'].hidden);assert(c.elements['friend-challenge'].hidden);
+ const old=harness('?from=challenge&day=2026-10-8&beat=300');old.run('renderFriendChallenge()');assert(old.elements['friend-challenge'].textContent.includes('has ended'));assert(!old.run('currentFriendChallenge()'));
+ let payload;const native=harness('',{share:async data=>{payload=data}});native.run('results=Array.from({length:5},()=>({score:80}));bindFriendChallenge()');await native.elements['challenge-friend'].click();assert(payload.text.includes('400/500'));assert.deepEqual(native.events,['share','share_clicked']);
+ const cancel=harness('',{share:async()=>{throw {name:'AbortError'}}});cancel.run('results=Array.from({length:5},()=>({score:80}));bindFriendChallenge()');await cancel.elements['challenge-friend'].click();assert(cancel.elements['share-fallback'].hidden);assert.equal(cancel.elements['share-status'].textContent,'');
+ const fallback=harness();fallback.run('results=Array.from({length:5},()=>({score:80}));bindFriendChallenge()');await fallback.elements['challenge-friend'].click();assert(fallback.elements['share-fallback'].value.includes('beat=400'));assert(fallback.elements['share-fallback'].selected);
+ console.log('Verified challenge round trip, invalid dates/scores, expiry, win/tie/loss, practice isolation, native sharing, cancellation and manual copying.');
+})().catch(error=>{console.error(error);process.exitCode=1});
diff --git a/tests/verify-sharing.cjs b/tests/verify-sharing.cjs
index d47b86a..915fbe8 100644
--- a/tests/verify-sharing.cjs
+++ b/tests/verify-sharing.cjs
@@ -16,19 +16,21 @@ console.log('Verified share date, totals, game labels, practice labels, public l
(async()=>{
const shareSource=fs.readFileSync('src/design-options/share-results.js','utf8');
- function setup(nav){
- const elements={};for(const id of ['share-results','share-status','share-fallback'])elements[id]={textContent:'',hidden:true,disabled:false,addEventListener(type,fn){this.click=fn},focus(){this.focused=true},select(){this.selected=true}};
+ function setup(nav,state={}){
+ const elements={};for(const id of ['share-panel','share-results','share-status','share-fallback'])elements[id]={textContent:'',hidden:true,disabled:false,addEventListener(type,fn){this.click=fn},focus(){this.focused=true},select(){this.selected=true}};
let clicks=0;const copied=[];
const navigator={clipboard:{async writeText(t){copied.push(t)}},...nav};
- const c=vm.createContext({Date,calendarDay:()=> '2026-10-8',navigator,$:id=>elements[id],results:sample,mini:'mix',mode:'daily',review:false,analyticsShareClicked:()=>clicks++});
+ const c=vm.createContext({Date,calendarDay:()=> '2026-10-8',navigator,$:id=>elements[id],results:sample,mini:'mix',mode:'daily',review:false,analyticsShareClicked:()=>clicks++,...state});
vm.runInContext(shareSource+'\nbindShareResults();',c);
- return {button:elements['share-results'],status:elements['share-status'],fallback:elements['share-fallback'],copied,clicks:()=>clicks};
+ return {panel:elements['share-panel'],button:elements['share-results'],status:elements['share-status'],fallback:elements['share-fallback'],copied,clicks:()=>clicks};
}
let payload,resolve;const native=setup({share:data=>{payload=data;return new Promise(r=>resolve=r)}});
const pending=native.button.click();assert(payload,'Native share must be invoked immediately from the click');assert.equal(payload.text,run(`shareResultsText(${JSON.stringify(sample)},'2026-10-8')`));assert.equal(Object.keys(payload).length,1,'Avoid duplicating the URL in shared messages');assert(native.button.disabled);
await native.button.click();assert.equal(native.clicks(),1);resolve();await pending;assert(!native.button.disabled);assert.equal(native.copied.length,0);
const cancel=setup({share:async()=>{throw {name:'AbortError'}}});await cancel.button.click();assert.equal(cancel.copied.length,0);assert(cancel.fallback.hidden);assert.equal(cancel.status.textContent,'');assert(!cancel.button.disabled);
const desktop=setup({});await desktop.button.click();assert.equal(desktop.copied.length,1);assert.equal(desktop.button.textContent,'Copied!');
+ assert(!desktop.panel.hidden);
+ for(const state of [{mode:'practice'},{mode:'practice',mini:'angle'},{review:true}]){const practice=setup({},state);assert(practice.panel.hidden);assert.equal(practice.button.click,undefined);assert.equal(practice.clicks(),0);}
const rejected=setup({share:async()=>{throw {name:'NotAllowedError'}}});await rejected.button.click();assert.equal(rejected.copied.length,1);
const denied=setup({clipboard:{async writeText(){throw new Error('blocked')}}});await denied.button.click();assert(!denied.fallback.hidden);assert(denied.fallback.focused&&denied.fallback.selected);assert.equal(denied.fallback.value,payload.text);assert(!denied.button.disabled);
console.log('Verified native sharing on tap, cancellation, repeated taps, clipboard fallback, and manual copy when permissions fail.');