From 32fcacd5298fb7047a0c7cf98f48eb24a485f4b6 Mon Sep 17 00:00:00 2001 From: "Noam Y. Tenne" Date: Wed, 30 Sep 2026 17:24:28 +0100 Subject: [PATCH] Teach posts to recognize returning participants on open --- CHANGELOG.md | 8 ++++++ directory/differ/.claude-plugin/plugin.json | 2 +- directory/differ/.codex-plugin/plugin.json | 2 +- directory/differ/plugin.json | 2 +- directory/differ/skills.lock.json | 25 +++++++++++------- .../publish-content/examples/vote/README.md | 13 +++++++++- .../examples/vote/check-browser.mjs | 26 +++++++++++++++++++ .../examples/vote/content/app.mjs | 9 +++++-- .../examples/vote/content/index.html | 1 + .../examples/vote/content/vote.mjs | 5 ++-- .../references/participation.md | 12 +++++++-- package-lock.json | 4 +-- package.json | 2 +- plugins/differ/.claude-plugin/plugin.json | 2 +- plugins/differ/.codex-plugin/plugin.json | 2 +- plugins/differ/plugin.json | 2 +- plugins/differ/scripts/differ.cjs | 2 +- plugins/differ/skills.lock.json | 25 +++++++++++------- .../publish-content/examples/vote/README.md | 13 +++++++++- .../examples/vote/check-browser.mjs | 26 +++++++++++++++++++ .../examples/vote/content/app.mjs | 9 +++++-- .../examples/vote/content/index.html | 1 + .../examples/vote/content/vote.mjs | 5 ++-- .../references/participation.md | 12 +++++++-- 24 files changed, 166 insertions(+), 44 deletions(-) create mode 100644 directory/differ/skills/publish-content/examples/vote/check-browser.mjs create mode 100644 plugins/differ/skills/publish-content/examples/vote/check-browser.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 12f66a3..51464ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.5.1 + +- Teach posts to restore returning participants on open with + `pagelike.identity()`, while keeping first-time and signed-out viewers anonymous. +- Show an existing choice in the vote example before another click, and include + a browser check that opening the example writes no contribution. +- Refresh both distributions from Control's verified public skill snapshot. + ## 0.5.0 - Extract the creator plugin into its own public, MIT-licensed repository. diff --git a/directory/differ/.claude-plugin/plugin.json b/directory/differ/.claude-plugin/plugin.json index b8dd6d0..3819fa7 100644 --- a/directory/differ/.claude-plugin/plugin.json +++ b/directory/differ/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "differ", - "version": "0.5.0", + "version": "0.5.1", "description": "Create, preview, publish and revise interactive social posts.", "author": { "name": "Sky Valley", diff --git a/directory/differ/.codex-plugin/plugin.json b/directory/differ/.codex-plugin/plugin.json index e230fb1..eda9218 100644 --- a/directory/differ/.codex-plugin/plugin.json +++ b/directory/differ/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "differ", - "version": "0.5.0", + "version": "0.5.1", "description": "Create, preview, publish and revise interactive social posts.", "author": { "name": "Sky Valley", diff --git a/directory/differ/plugin.json b/directory/differ/plugin.json index 1459848..25ba32c 100644 --- a/directory/differ/plugin.json +++ b/directory/differ/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "differ", - "version": "0.5.0", + "version": "0.5.1", "description": "Create, preview, publish and revise interactive social posts.", "author": { "name": "Sky Valley", diff --git a/directory/differ/skills.lock.json b/directory/differ/skills.lock.json index 8eb1f11..83484b0 100644 --- a/directory/differ/skills.lock.json +++ b/directory/differ/skills.lock.json @@ -77,8 +77,13 @@ }, { "uri": "skill://publish-content/examples/vote/README.md", - "digest": "sha256:136357a5930e8e61fe1f3161db053435d81341526445b0edfda1e815b4cdc2d2", - "size": 1889 + "digest": "sha256:d291254b1d3fceea3b3b829a8ff8125b6ed1b1e729bc57576c682a5f9097a3e2", + "size": 2589 + }, + { + "uri": "skill://publish-content/examples/vote/check-browser.mjs", + "digest": "sha256:44f2d6c31a07f59ae8cb0ed7655d06309b6cffb38081d27baf27cb6b7d8f5b79", + "size": 1819 }, { "uri": "skill://publish-content/examples/vote/check.mjs", @@ -87,8 +92,8 @@ }, { "uri": "skill://publish-content/examples/vote/content/app.mjs", - "digest": "sha256:e1010cb228523ebdc499d008c15c201b290149dce297192253420dd0e9a3dfc6", - "size": 1952 + "digest": "sha256:237ee0de7f0968ec43f19ffc21fd5077e8d0778cf9f49b38d9102a68a167596e", + "size": 2321 }, { "uri": "skill://publish-content/examples/vote/content/data/votes.html", @@ -97,8 +102,8 @@ }, { "uri": "skill://publish-content/examples/vote/content/index.html", - "digest": "sha256:d44bec3347f4c4536a15fc9633ab9f9b4e255618edb6d498d9a8d08be0f99d1d", - "size": 1131 + "digest": "sha256:ce0aa62697568b6f1d683b21748f48d7cb9cc1436f87e07099aabfe03f5d1a57", + "size": 1168 }, { "uri": "skill://publish-content/examples/vote/content/rules.html", @@ -107,8 +112,8 @@ }, { "uri": "skill://publish-content/examples/vote/content/vote.mjs", - "digest": "sha256:3ce07820bfba8d8792bbea28671d7d835b3e3c5a1011e65ec7215d999bb22502", - "size": 894 + "digest": "sha256:1eeac58ec35de44f18b68064a0de2da6e30e13d6486a7c29f197397295f23fa1", + "size": 978 }, { "uri": "skill://publish-content/references/connector.md", @@ -122,8 +127,8 @@ }, { "uri": "skill://publish-content/references/participation.md", - "digest": "sha256:a4f05532cd9159907852fe1795e12705acd2deb0ec05acf2135fd2385ac43271", - "size": 7620 + "digest": "sha256:b8ff86ab2003b6ad5a4f044f420f428d190f2478c64a9576019d17197dc67513", + "size": 8108 }, { "uri": "skill://publish-content/references/setup.md", diff --git a/directory/differ/skills/publish-content/examples/vote/README.md b/directory/differ/skills/publish-content/examples/vote/README.md index 3845e40..99547ea 100644 --- a/directory/differ/skills/publish-content/examples/vote/README.md +++ b/directory/differ/skills/publish-content/examples/vote/README.md @@ -1,7 +1,7 @@ # The afternoon question A complete ordinary HTML/JS post: tea or coffee, with one effective choice per -signed-in identity, changeable over time. No SDK, generated rules or external +signed-in identity, changeable over time. Uses the managed identity SDK, with no generated rules or external database. Publish only `examples/vote/content/`. Read `examples/vote/content/index.html`, `app.mjs`, `vote.mjs`, `rules.html` and @@ -14,6 +14,11 @@ restricts choices. The browser counts the latest remaining choice for each trusted identity. Repeated clicks do not increase that identity's effective vote. This is one vote per identity, not a guarantee about physical people. +On load, `pagelike.identity()` quietly restores an existing post identity. The +UI then marks its latest remaining choice before any click. A new or signed-out +visitor stays anonymous. Opening does not append a vote; `participate()` and +the write remain in the button handler. + History is deliberate: removing the latest choice reveals an earlier remaining one. Player's Your participation removes individual contributions; removing all of your choices leaves no vote. The creator can moderate contributions. The UI @@ -26,6 +31,12 @@ reads and an actual SSE mutation, two identities, changes, forgery, multi-entry payloads, whole replacement, cross-person removal, own-removal and moderation. Run with Node 24+ and `PAGELIKE_BINARY=/absolute/path/to/pagelike`. +Also run `node examples/vote/check-browser.mjs` with that variable and Playwright +installed (or `PLAYWRIGHT_MODULE` pointing to its `index.mjs`). It verifies an +existing choice appears before interaction, opening adds no vote, and a +deliberate change updates the choice. It uses fixture identity with the real +runtime; the platform's separate integration verifies restoration across logins. + These checks have a specific contract. For a new activity, adapt its checks and prove them against deliberately defective variants before trusting green output. Browser and Player login checks remain separate. Never point mutation tests at diff --git a/directory/differ/skills/publish-content/examples/vote/check-browser.mjs b/directory/differ/skills/publish-content/examples/vote/check-browser.mjs new file mode 100644 index 0000000..63074f2 --- /dev/null +++ b/directory/differ/skills/publish-content/examples/vote/check-browser.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { readBundle, runtime } from '../../scripts/runtime.mjs'; +import { launchBrowser, viewer } from '../../scripts/browser.mjs'; +import { voteRequest } from './content/vote.mjs'; + +const host = await runtime(); let browser; +try { + browser = await launchBrowser(); + const site = await host.site(await readBundle(fileURLToPath(new URL('./content/', import.meta.url)))); + const alice = await site.participate('p_alice'); + const saved = await site.request('/data/votes.html', voteRequest('p_alice', 'tea'), alice); + assert.equal(saved.status, 206); await saved.text(); + const before = await (await site.request('/data/votes.html')).text(); + const returning = await viewer(site, browser, 'p_alice'), guest = await viewer(site, browser, null); + try { + await returning.page.locator('[data-choice="tea"][aria-pressed="true"]').waitFor(); + assert.equal(await returning.page.locator('#mine').textContent(), 'Your current choice: tea.'); + assert.equal(await guest.page.locator('[aria-pressed="true"]').count(), 0); + assert.equal(await (await site.request('/data/votes.html')).text(), before, 'opening must not add a vote'); + await returning.page.getByRole('button', { name: 'Coffee' }).click(); + await returning.page.locator('[data-choice="coffee"][aria-pressed="true"]').waitFor(); + assert.equal(await returning.page.locator('#totals').textContent(), 'Tea 0 · Coffee 1'); + console.log('PASS: returning identity displays its existing vote before interaction; opening writes nothing; an intentional change updates it. Fixture identity, real runtime and browser.'); + } finally { await returning.context.close(); await guest.context.close(); } +} finally { if (browser) await browser.close(); await host.close(); } diff --git a/directory/differ/skills/publish-content/examples/vote/content/app.mjs b/directory/differ/skills/publish-content/examples/vote/content/app.mjs index 7a42fa7..20dcaad 100644 --- a/directory/differ/skills/publish-content/examples/vote/content/app.mjs +++ b/directory/differ/skills/publish-content/examples/vote/content/app.mjs @@ -1,6 +1,7 @@ import { voteRequest, currentVotes } from './vote.mjs'; const status = document.querySelector('#status'); let reading = false, dirty = false; +let participant = null; async function refresh() { dirty = true; if (reading) return; @@ -10,8 +11,10 @@ async function refresh() { dirty = false; const response = await fetch('/data/votes.html', { cache: 'no-store' }); if (!response.ok) throw Error('The room could not be loaded. Reconnecting…'); - const votes = currentVotes(new DOMParser().parseFromString(await response.text(), 'text/html')); + const votes = currentVotes(new DOMParser().parseFromString(await response.text(), 'text/html'), participant); document.querySelector('#totals').textContent = `Tea ${votes.tea} · Coffee ${votes.coffee}`; + document.querySelector('#mine').textContent = votes.mine ? `Your current choice: ${votes.mine}.` : ''; + for (const button of document.querySelectorAll('[data-choice]')) button.setAttribute('aria-pressed', String(button.dataset.choice === votes.mine)); } } catch (error) { status.textContent = error.message; } finally { reading = false; } @@ -26,7 +29,7 @@ for (const button of document.querySelectorAll('[data-choice]')) button.addEvent const buttons = [...document.querySelectorAll('button')]; buttons.forEach(item => { item.disabled = true; }); try { - const participant = await pagelike.participate(); + participant = await pagelike.participate(); const response = await fetch('/data/votes.html', voteRequest(participant, button.dataset.choice)); if (!response.ok) throw Error(`Your choice was not saved (${response.status}). Please try again.`); status.textContent = 'Your latest choice counts.'; @@ -35,3 +38,5 @@ for (const button of document.querySelectorAll('[data-choice]')) button.addEvent finally { buttons.forEach(item => { item.disabled = false; }); } }); await refresh(); +participant = await pagelike.identity(); +if (participant) await refresh(); diff --git a/directory/differ/skills/publish-content/examples/vote/content/index.html b/directory/differ/skills/publish-content/examples/vote/content/index.html index d4bea73..b6220cd 100644 --- a/directory/differ/skills/publish-content/examples/vote/content/index.html +++ b/directory/differ/skills/publish-content/examples/vote/content/index.html @@ -4,6 +4,7 @@

The afternoon question

Tea or coffee?

Loading the room… +

One current choice per signed-in identity. Change your mind whenever you like. Your latest remaining choice counts. Use Player’s Your participation to remove your contributions.
diff --git a/directory/differ/skills/publish-content/examples/vote/content/vote.mjs b/directory/differ/skills/publish-content/examples/vote/content/vote.mjs index 120eaac..68543c2 100644 --- a/directory/differ/skills/publish-content/examples/vote/content/vote.mjs +++ b/directory/differ/skills/publish-content/examples/vote/content/vote.mjs @@ -6,9 +6,10 @@ export function voteRequest(participant, choice) { body: `
  • ${choice}
  • ` }; } -export function currentVotes(document) { +export function currentVotes(document, participant = null) { const latest = new Map(); for (const entry of document.querySelectorAll('#votes > li')) latest.set(entry.dataset.voter, entry.dataset.choice); return { tea: [...latest.values()].filter(choice => choice === 'tea').length, - coffee: [...latest.values()].filter(choice => choice === 'coffee').length }; + coffee: [...latest.values()].filter(choice => choice === 'coffee').length, + mine: participant ? latest.get(participant) ?? null : null }; } diff --git a/directory/differ/skills/publish-content/references/participation.md b/directory/differ/skills/publish-content/references/participation.md index 9a1526b..0079123 100644 --- a/directory/differ/skills/publish-content/references/participation.md +++ b/directory/differ/skills/publish-content/references/participation.md @@ -14,9 +14,17 @@ authored files, rules and seeds. Never download live visitor data into a remix. Use relative authored asset URLs to stay on the selected version; root URLs address the current post. Live data always uses root `/data/...` URLs. -Load ``. When someone explicitly chooses +Load ``. On load, call +`await pagelike.identity()` to quietly restore a returning participant's opaque +post identity, including on another device signed into the same account. It +returns `null` for first-time or signed-out visitors and when identity is +unavailable, without a popup. Use the returned identity to read and mark their +existing entries; restoration never adds a contribution. Keep public reads and +local play usable while it resolves. Do not infer ownership from localStorage. + +When someone explicitly chooses to contribute, call `await pagelike.participate()`. Player handles sign-in and -consent, and the runtime sets its host-only HttpOnly partitioned cookie. The +participation, and the runtime sets its host-only HttpOnly partitioned cookie. The result is an opaque identity string stable within this post. No email, handle or cross-post identity is disclosed. Then use same-origin `fetch`: diff --git a/package-lock.json b/package-lock.json index ed307fb..3f9740c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "differ-plugin", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "differ-plugin", - "version": "0.5.0", + "version": "0.5.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", diff --git a/package.json b/package.json index a14e68c..6fd68c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "differ-plugin", - "version": "0.5.0", + "version": "0.5.1", "private": true, "type": "module", "engines": { diff --git a/plugins/differ/.claude-plugin/plugin.json b/plugins/differ/.claude-plugin/plugin.json index b8dd6d0..3819fa7 100644 --- a/plugins/differ/.claude-plugin/plugin.json +++ b/plugins/differ/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "differ", - "version": "0.5.0", + "version": "0.5.1", "description": "Create, preview, publish and revise interactive social posts.", "author": { "name": "Sky Valley", diff --git a/plugins/differ/.codex-plugin/plugin.json b/plugins/differ/.codex-plugin/plugin.json index e230fb1..eda9218 100644 --- a/plugins/differ/.codex-plugin/plugin.json +++ b/plugins/differ/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "differ", - "version": "0.5.0", + "version": "0.5.1", "description": "Create, preview, publish and revise interactive social posts.", "author": { "name": "Sky Valley", diff --git a/plugins/differ/plugin.json b/plugins/differ/plugin.json index 1459848..25ba32c 100644 --- a/plugins/differ/plugin.json +++ b/plugins/differ/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "differ", - "version": "0.5.0", + "version": "0.5.1", "description": "Create, preview, publish and revise interactive social posts.", "author": { "name": "Sky Valley", diff --git a/plugins/differ/scripts/differ.cjs b/plugins/differ/scripts/differ.cjs index 828fd5c..00d2c3a 100644 --- a/plugins/differ/scripts/differ.cjs +++ b/plugins/differ/scripts/differ.cjs @@ -37138,7 +37138,7 @@ function inspectVideo(input2) { // package.json var package_default = { name: "differ-plugin", - version: "0.5.0", + version: "0.5.1", private: true, type: "module", engines: { diff --git a/plugins/differ/skills.lock.json b/plugins/differ/skills.lock.json index 8eb1f11..83484b0 100644 --- a/plugins/differ/skills.lock.json +++ b/plugins/differ/skills.lock.json @@ -77,8 +77,13 @@ }, { "uri": "skill://publish-content/examples/vote/README.md", - "digest": "sha256:136357a5930e8e61fe1f3161db053435d81341526445b0edfda1e815b4cdc2d2", - "size": 1889 + "digest": "sha256:d291254b1d3fceea3b3b829a8ff8125b6ed1b1e729bc57576c682a5f9097a3e2", + "size": 2589 + }, + { + "uri": "skill://publish-content/examples/vote/check-browser.mjs", + "digest": "sha256:44f2d6c31a07f59ae8cb0ed7655d06309b6cffb38081d27baf27cb6b7d8f5b79", + "size": 1819 }, { "uri": "skill://publish-content/examples/vote/check.mjs", @@ -87,8 +92,8 @@ }, { "uri": "skill://publish-content/examples/vote/content/app.mjs", - "digest": "sha256:e1010cb228523ebdc499d008c15c201b290149dce297192253420dd0e9a3dfc6", - "size": 1952 + "digest": "sha256:237ee0de7f0968ec43f19ffc21fd5077e8d0778cf9f49b38d9102a68a167596e", + "size": 2321 }, { "uri": "skill://publish-content/examples/vote/content/data/votes.html", @@ -97,8 +102,8 @@ }, { "uri": "skill://publish-content/examples/vote/content/index.html", - "digest": "sha256:d44bec3347f4c4536a15fc9633ab9f9b4e255618edb6d498d9a8d08be0f99d1d", - "size": 1131 + "digest": "sha256:ce0aa62697568b6f1d683b21748f48d7cb9cc1436f87e07099aabfe03f5d1a57", + "size": 1168 }, { "uri": "skill://publish-content/examples/vote/content/rules.html", @@ -107,8 +112,8 @@ }, { "uri": "skill://publish-content/examples/vote/content/vote.mjs", - "digest": "sha256:3ce07820bfba8d8792bbea28671d7d835b3e3c5a1011e65ec7215d999bb22502", - "size": 894 + "digest": "sha256:1eeac58ec35de44f18b68064a0de2da6e30e13d6486a7c29f197397295f23fa1", + "size": 978 }, { "uri": "skill://publish-content/references/connector.md", @@ -122,8 +127,8 @@ }, { "uri": "skill://publish-content/references/participation.md", - "digest": "sha256:a4f05532cd9159907852fe1795e12705acd2deb0ec05acf2135fd2385ac43271", - "size": 7620 + "digest": "sha256:b8ff86ab2003b6ad5a4f044f420f428d190f2478c64a9576019d17197dc67513", + "size": 8108 }, { "uri": "skill://publish-content/references/setup.md", diff --git a/plugins/differ/skills/publish-content/examples/vote/README.md b/plugins/differ/skills/publish-content/examples/vote/README.md index 3845e40..99547ea 100644 --- a/plugins/differ/skills/publish-content/examples/vote/README.md +++ b/plugins/differ/skills/publish-content/examples/vote/README.md @@ -1,7 +1,7 @@ # The afternoon question A complete ordinary HTML/JS post: tea or coffee, with one effective choice per -signed-in identity, changeable over time. No SDK, generated rules or external +signed-in identity, changeable over time. Uses the managed identity SDK, with no generated rules or external database. Publish only `examples/vote/content/`. Read `examples/vote/content/index.html`, `app.mjs`, `vote.mjs`, `rules.html` and @@ -14,6 +14,11 @@ restricts choices. The browser counts the latest remaining choice for each trusted identity. Repeated clicks do not increase that identity's effective vote. This is one vote per identity, not a guarantee about physical people. +On load, `pagelike.identity()` quietly restores an existing post identity. The +UI then marks its latest remaining choice before any click. A new or signed-out +visitor stays anonymous. Opening does not append a vote; `participate()` and +the write remain in the button handler. + History is deliberate: removing the latest choice reveals an earlier remaining one. Player's Your participation removes individual contributions; removing all of your choices leaves no vote. The creator can moderate contributions. The UI @@ -26,6 +31,12 @@ reads and an actual SSE mutation, two identities, changes, forgery, multi-entry payloads, whole replacement, cross-person removal, own-removal and moderation. Run with Node 24+ and `PAGELIKE_BINARY=/absolute/path/to/pagelike`. +Also run `node examples/vote/check-browser.mjs` with that variable and Playwright +installed (or `PLAYWRIGHT_MODULE` pointing to its `index.mjs`). It verifies an +existing choice appears before interaction, opening adds no vote, and a +deliberate change updates the choice. It uses fixture identity with the real +runtime; the platform's separate integration verifies restoration across logins. + These checks have a specific contract. For a new activity, adapt its checks and prove them against deliberately defective variants before trusting green output. Browser and Player login checks remain separate. Never point mutation tests at diff --git a/plugins/differ/skills/publish-content/examples/vote/check-browser.mjs b/plugins/differ/skills/publish-content/examples/vote/check-browser.mjs new file mode 100644 index 0000000..63074f2 --- /dev/null +++ b/plugins/differ/skills/publish-content/examples/vote/check-browser.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { readBundle, runtime } from '../../scripts/runtime.mjs'; +import { launchBrowser, viewer } from '../../scripts/browser.mjs'; +import { voteRequest } from './content/vote.mjs'; + +const host = await runtime(); let browser; +try { + browser = await launchBrowser(); + const site = await host.site(await readBundle(fileURLToPath(new URL('./content/', import.meta.url)))); + const alice = await site.participate('p_alice'); + const saved = await site.request('/data/votes.html', voteRequest('p_alice', 'tea'), alice); + assert.equal(saved.status, 206); await saved.text(); + const before = await (await site.request('/data/votes.html')).text(); + const returning = await viewer(site, browser, 'p_alice'), guest = await viewer(site, browser, null); + try { + await returning.page.locator('[data-choice="tea"][aria-pressed="true"]').waitFor(); + assert.equal(await returning.page.locator('#mine').textContent(), 'Your current choice: tea.'); + assert.equal(await guest.page.locator('[aria-pressed="true"]').count(), 0); + assert.equal(await (await site.request('/data/votes.html')).text(), before, 'opening must not add a vote'); + await returning.page.getByRole('button', { name: 'Coffee' }).click(); + await returning.page.locator('[data-choice="coffee"][aria-pressed="true"]').waitFor(); + assert.equal(await returning.page.locator('#totals').textContent(), 'Tea 0 · Coffee 1'); + console.log('PASS: returning identity displays its existing vote before interaction; opening writes nothing; an intentional change updates it. Fixture identity, real runtime and browser.'); + } finally { await returning.context.close(); await guest.context.close(); } +} finally { if (browser) await browser.close(); await host.close(); } diff --git a/plugins/differ/skills/publish-content/examples/vote/content/app.mjs b/plugins/differ/skills/publish-content/examples/vote/content/app.mjs index 7a42fa7..20dcaad 100644 --- a/plugins/differ/skills/publish-content/examples/vote/content/app.mjs +++ b/plugins/differ/skills/publish-content/examples/vote/content/app.mjs @@ -1,6 +1,7 @@ import { voteRequest, currentVotes } from './vote.mjs'; const status = document.querySelector('#status'); let reading = false, dirty = false; +let participant = null; async function refresh() { dirty = true; if (reading) return; @@ -10,8 +11,10 @@ async function refresh() { dirty = false; const response = await fetch('/data/votes.html', { cache: 'no-store' }); if (!response.ok) throw Error('The room could not be loaded. Reconnecting…'); - const votes = currentVotes(new DOMParser().parseFromString(await response.text(), 'text/html')); + const votes = currentVotes(new DOMParser().parseFromString(await response.text(), 'text/html'), participant); document.querySelector('#totals').textContent = `Tea ${votes.tea} · Coffee ${votes.coffee}`; + document.querySelector('#mine').textContent = votes.mine ? `Your current choice: ${votes.mine}.` : ''; + for (const button of document.querySelectorAll('[data-choice]')) button.setAttribute('aria-pressed', String(button.dataset.choice === votes.mine)); } } catch (error) { status.textContent = error.message; } finally { reading = false; } @@ -26,7 +29,7 @@ for (const button of document.querySelectorAll('[data-choice]')) button.addEvent const buttons = [...document.querySelectorAll('button')]; buttons.forEach(item => { item.disabled = true; }); try { - const participant = await pagelike.participate(); + participant = await pagelike.participate(); const response = await fetch('/data/votes.html', voteRequest(participant, button.dataset.choice)); if (!response.ok) throw Error(`Your choice was not saved (${response.status}). Please try again.`); status.textContent = 'Your latest choice counts.'; @@ -35,3 +38,5 @@ for (const button of document.querySelectorAll('[data-choice]')) button.addEvent finally { buttons.forEach(item => { item.disabled = false; }); } }); await refresh(); +participant = await pagelike.identity(); +if (participant) await refresh(); diff --git a/plugins/differ/skills/publish-content/examples/vote/content/index.html b/plugins/differ/skills/publish-content/examples/vote/content/index.html index d4bea73..b6220cd 100644 --- a/plugins/differ/skills/publish-content/examples/vote/content/index.html +++ b/plugins/differ/skills/publish-content/examples/vote/content/index.html @@ -4,6 +4,7 @@

    The afternoon question

    Tea or coffee?

    Loading the room… +

    One current choice per signed-in identity. Change your mind whenever you like. Your latest remaining choice counts. Use Player’s Your participation to remove your contributions.
    diff --git a/plugins/differ/skills/publish-content/examples/vote/content/vote.mjs b/plugins/differ/skills/publish-content/examples/vote/content/vote.mjs index 120eaac..68543c2 100644 --- a/plugins/differ/skills/publish-content/examples/vote/content/vote.mjs +++ b/plugins/differ/skills/publish-content/examples/vote/content/vote.mjs @@ -6,9 +6,10 @@ export function voteRequest(participant, choice) { body: `
  • ${choice}
  • ` }; } -export function currentVotes(document) { +export function currentVotes(document, participant = null) { const latest = new Map(); for (const entry of document.querySelectorAll('#votes > li')) latest.set(entry.dataset.voter, entry.dataset.choice); return { tea: [...latest.values()].filter(choice => choice === 'tea').length, - coffee: [...latest.values()].filter(choice => choice === 'coffee').length }; + coffee: [...latest.values()].filter(choice => choice === 'coffee').length, + mine: participant ? latest.get(participant) ?? null : null }; } diff --git a/plugins/differ/skills/publish-content/references/participation.md b/plugins/differ/skills/publish-content/references/participation.md index 9a1526b..0079123 100644 --- a/plugins/differ/skills/publish-content/references/participation.md +++ b/plugins/differ/skills/publish-content/references/participation.md @@ -14,9 +14,17 @@ authored files, rules and seeds. Never download live visitor data into a remix. Use relative authored asset URLs to stay on the selected version; root URLs address the current post. Live data always uses root `/data/...` URLs. -Load ``. When someone explicitly chooses +Load ``. On load, call +`await pagelike.identity()` to quietly restore a returning participant's opaque +post identity, including on another device signed into the same account. It +returns `null` for first-time or signed-out visitors and when identity is +unavailable, without a popup. Use the returned identity to read and mark their +existing entries; restoration never adds a contribution. Keep public reads and +local play usable while it resolves. Do not infer ownership from localStorage. + +When someone explicitly chooses to contribute, call `await pagelike.participate()`. Player handles sign-in and -consent, and the runtime sets its host-only HttpOnly partitioned cookie. The +participation, and the runtime sets its host-only HttpOnly partitioned cookie. The result is an opaque identity string stable within this post. No email, handle or cross-post identity is disclosed. Then use same-origin `fetch`: