openvibers/openvibe.games Commits

Share: one Share sheet on worlds, results and scores (#289)

OP OpenVibers committed 7 days ago · 85342026fa · 11 files, +88 −30

* Share: the one Share sheet on worlds, results and scores

A creator world's page, a round's result page, the leaderboard and the
in-game round result now share through data-ov-share-sheet with the
page's public address (and the round's words), replacing the old kit
menu item and navigator.share. The result page now starts the shared
navbar itself, which is what loads the sheet on the first press.

* Share: the shared kit button, one look everywhere

Render the one Share button with openvibe-shared 4.114.0's kit.share
(or its exact markup where the page is built in the browser), so every
site shows the same quiet button with the share icon. Remove the
site-specific Share styles.

* Share: let the kit button keep its quiet look in a round

Drop the card's own filled Share rule; the shared kit button keeps its
look and only the row's sizing applies.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex ea63f38..b208332 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
++### Added
++
++- Share: a creator world's page, a round's result page, the leaderboard and the in-game round result now open the network's one Share sheet (`data-ov-share-sheet`, openvibe-shared 4.114.0) with the page's public address. The result page now starts the shared navbar itself, which is what loads the sheet on the first press; `navigator.share` is gone.
++
### Fixed
- **A shared /worlds link had no preview.** The creator worlds page carried no Open Graph or Twitter tags, so a
ddiff --git a/apps/client/index.html b/apps/client/index.html
iindex b57f957..2353e18 100644
---- a/apps/client/index.html
++++ b/apps/client/index.html
@@@ -13,6 +13,7 @@
/>
<!-- The OpenVibe Frame: the user's theme before first paint, then navbar + footer. -->
<!-- Deferred: a slow or unreachable openvibe.network never holds the first paint (the inline colours above cover it). -->
++ <link rel="stylesheet" href="/shared/kit.css" />
<script src="/shared/theme-loader.js" defer></script>
<script src="/shared/navbar.js" defer></script>
<script src="/shared/footer.js" defer></script>
ddiff --git a/apps/client/leaderboard.html b/apps/client/leaderboard.html
iindex 5b265f4..4c6980d 100644
---- a/apps/client/leaderboard.html
++++ b/apps/client/leaderboard.html
@@@ -250,6 +250,16 @@
letter-spacing: 0.03em;
text-decoration: none;
}
++ .cta-row {
++ display: flex;
++ flex-wrap: wrap;
++ align-items: center;
++ gap: 10px;
++ margin-top: 22px;
++ }
++ .cta-row .play {
++ margin-top: 0;
++ }
.note {
margin-top: 14px;
font-size: 14px;
@@@ -259,6 +269,7 @@
<script type="module" src="/src/pages/leaderboard.ts"></script>
<!-- The OpenVibe Frame (navbar + footer), as on every other page of the site: this page had only its own row of
links (phone sweep, 2026-11-01). -->
++ <link rel="stylesheet" href="/shared/kit.css" />
<script src="/shared/navbar.js" defer></script>
<script src="/shared/footer.js" defer></script>
<script>
@@@ -311,7 +322,10 @@
><button id="next" type="button">Next</button>
</div>
</section>
-- <a class="play" id="play-link" href="/play?game=salvage-rush">▶ Play a round</a>
++ <div class="cta-row">
++ <button class="ovk-btn ovk-btn--quiet" type="button" id="share" data-ov-share-sheet data-title="Salvage Rush leaderboard" data-url="https://openvibe.games/leaderboard.html?game=salvage-rush"><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20V9M7 14l5-5 5 5M5 4h14"/></svg><span>Share</span></button>
++ <a class="play" id="play-link" href="/play?game=salvage-rush">▶ Play a round</a>
++ </div>
<p class="note">
"This week" starts on Monday, 00:00 UTC. These boards are kept by
<a href="https://play.openvibe.network/">OpenVibe.Play</a>, with the achievements you have
ddiff --git a/apps/client/src/pages/leaderboard.ts b/apps/client/src/pages/leaderboard.ts
iindex 757dc0b..828e097 100644
---- a/apps/client/src/pages/leaderboard.ts
++++ b/apps/client/src/pages/leaderboard.ts
@@@ -31,6 +31,7 @@ const lede = document.getElementById('lede') as HTMLElement
const scoreHeader = document.getElementById('score-header') as HTMLElement
const playLink = document.getElementById('play-link') as HTMLAnchorElement
const youLine = document.getElementById('you') as HTMLElement
++const shareButton = document.getElementById('share') as HTMLButtonElement
const COPY: Record<
Game,
@@@ -86,9 +87,20 @@ function cell(text: string, className?: string): HTMLTableCellElement {
return td
}
++/** The one Share sheet sends the board on screen: its game and period, in its public address. */
++function applyShare(): void {
++ const copy = COPY[game]
++ const query = new URLSearchParams({ game })
++ if (period === 'week') query.set('period', 'week')
++ shareButton.dataset.title = `${copy.title} leaderboard`
++ shareButton.dataset.text = copy.lede
++ shareButton.dataset.url = `https://openvibe.games/leaderboard.html?${query.toString()}`
++}
++
function applyGame(): void {
for (const tab of gameTabs) tab.setAttribute('aria-pressed', String(tab.dataset.game === game))
const copy = COPY[game]
++ applyShare()
title.textContent = copy.title
lede.textContent = copy.lede
scoreHeader.textContent = copy.score
@@@ -123,6 +135,7 @@ function markMine(mine: Mine | null): void {
}
async function load(): Promise<void> {
++ applyShare()
for (const tab of periodTabs)
tab.setAttribute('aria-pressed', String(tab.dataset.period === period))
const copy = COPY[game]
ddiff --git a/apps/client/src/ui/rushResult.css b/apps/client/src/ui/rushResult.css
iindex 5511358..bf396cd 100644
---- a/apps/client/src/ui/rushResult.css
++++ b/apps/client/src/ui/rushResult.css
@@@ -67,6 +67,9 @@
.rush-result-actions > * {
flex: 1 1 8rem;
min-height: 2.75rem;
++}
++.rush-result-actions > :not(.ovk-btn) {
++ min-height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
@@@ -81,11 +84,6 @@
text-decoration: none;
cursor: pointer;
}
--.rush-result-share {
-- background: var(--accent-strong, var(--accent));
-- border-color: transparent;
-- color: var(--on-accent-strong, var(--on-accent, #fff));
--}
.rush-result-board {
background: transparent;
}
ddiff --git a/apps/client/src/ui/rushResult.test.ts b/apps/client/src/ui/rushResult.test.ts
iindex e374d42..256084b 100644
---- a/apps/client/src/ui/rushResult.test.ts
++++ b/apps/client/src/ui/rushResult.test.ts
@@@ -39,7 +39,15 @@ describe('the result card of a round', () => {
it('carries the buttons a player needs', () => {
const card = buildResultCard(result, 'https://openvibe.games')
-- expect(card.querySelector('.rush-result-share')?.textContent).toBe('Share')
++ const share = card.querySelector('.rush-result-share') as HTMLButtonElement
++ expect(share.className).toContain('ovk-btn ovk-btn--quiet')
++ expect(share.textContent).toBe('Share')
++ expect(share.dataset.ovShareSheet).toBe('')
++ expect(share.dataset.url).toBe('https://openvibe.games/result/0%3A1700000000000-12')
++ expect(share.dataset.title).toBe('A Salvage Rush round')
++ expect(share.dataset.text).toBe(
++ 'I scored 12 in Salvage Rush (rank 2 of 7). My best round: 12.',
++ )
expect(card.querySelector('.rush-result-board')?.getAttribute('href')).toBe(
'/leaderboard.html?game=salvage-rush',
)
ddiff --git a/apps/client/src/ui/rushResult.ts b/apps/client/src/ui/rushResult.ts
iindex f52a34c..de6b285 100644
---- a/apps/client/src/ui/rushResult.ts
++++ b/apps/client/src/ui/rushResult.ts
@@@ -65,22 +65,16 @@ export function buildResultCard(result: RoundResult, origin: string): HTMLElemen
const actions = document.createElement('div')
actions.className = 'rush-result-actions'
++ // The network's one Share button, exactly as openvibe-shared 4.114.0's kit.share() builds it: the navbar loads
++ // ov-share.js on the first press and opens it from these data-* attributes.
const share = document.createElement('button')
share.type = 'button'
-- share.className = 'rush-result-share'
-- share.textContent = 'Share'
-- share.addEventListener('click', () => {
-- const text = resultShareText(result)
-- if (navigator.share) {
-- navigator.share({ title: 'A Salvage Rush round', text, url }).catch(() => undefined)
-- return
-- }
-- void navigator.clipboard?.writeText(`${text} ${url}`).catch(() => undefined)
-- share.textContent = 'Copied'
-- window.setTimeout(() => {
-- share.textContent = 'Share'
-- }, 1500)
-- })
++ share.className = 'ovk-btn ovk-btn--quiet rush-result-share'
++ share.dataset.ovShareSheet = ''
++ share.dataset.title = 'A Salvage Rush round'
++ share.dataset.text = resultShareText(result)
++ share.dataset.url = url
++ share.innerHTML = '<svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20V9M7 14l5-5 5 5M5 4h14"/></svg><span>Share</span>'
const board = document.createElement('a')
board.className = 'rush-result-board'
board.href = '/leaderboard.html?game=salvage-rush'
ddiff --git a/apps/server/src/net/resultPage.test.ts b/apps/server/src/net/resultPage.test.ts
iindex 3f4e641..b8ae860 100644
---- a/apps/server/src/net/resultPage.test.ts
++++ b/apps/server/src/net/resultPage.test.ts
@@@ -52,8 +52,13 @@ describe('the result page of a round', () => {
expect(resultPageUrl('https://openvibe.games/', '0:1700000000000-12')).toBe(
'https://openvibe.games/result/0%3A1700000000000-12',
)
-- expect(html).toContain('data-ovk-share')
++ expect(html).toContain('data-ov-share-sheet')
++ expect(html).toContain('data-url="https://openvibe.games/result/0%3A1700000000000-12"')
++ expect(html).toContain('class="ovk-btn ovk-btn--quiet"')
++ expect(html).toContain('data-title="A Salvage Rush round"')
expect(html).toContain('I scored 12 in Salvage Rush')
++ // The navbar is what loads ov-share.js on the first press, so the page must start it.
++ expect(html).toContain('OpenVibeNavbar.init(')
})
it('carries the one line every game page carries', () => {
ddiff --git a/apps/server/src/net/resultPage.ts b/apps/server/src/net/resultPage.ts
iindex 557c602..150108b 100644
---- a/apps/server/src/net/resultPage.ts
++++ b/apps/server/src/net/resultPage.ts
@@@ -26,6 +26,7 @@ interface Kit {
tool: Builder
page: Builder
button: Builder
++ share: Builder
badge: (text: string, tone?: string) => string
}
const sharedRequire = createRequire(import.meta.url)
@@@ -98,13 +99,14 @@ export function resultPageHtml(input: ResultPageInput): string {
]
: [['Players', n(players)]],
})
-- const share = kit.button({
-- href: url,
++ const share = kit.share({
++ title: 'A Salvage Rush round',
++ url,
++ text: shareText(input, mine),
label: 'Share this result',
-- icon: 'send',
-- attrs: { 'data-ovk-share': shareText(input, mine) },
})
const body = [
++ `<p>${share}</p>`,
kit.tool({
head: kit.toolHead({
icon: 'trophy',
@@@ -120,7 +122,6 @@ export function resultPageHtml(input: ResultPageInput): string {
}),
kit.section({
body:
-- `<div class="ovk-tool-card">${share}</div>` +
`<p class="ovk-note">${kit.esc(GAME_PROMISE)}</p>` +
kit.button({ href: '/leaderboard.html?game=salvage-rush', label: 'Full leaderboard', kind: 'ghost', icon: 'trophy' }),
}),
@@@ -133,7 +134,15 @@ export function resultPageHtml(input: ResultPageInput): string {
}),
body,
})
-- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>A Salvage Rush round · OpenVibe.Games</title><meta name="robots" content="noindex,follow"><meta name="description" content="A scored round of Salvage Rush on OpenVibe.Games."><script src="/shared/theme-loader.js"></script><link rel="stylesheet" href="/shared/kit.css"><link rel="stylesheet" href="/shared/kit-data.css"></head><body class="ovk-app"><div id="navbar-mount"></div>${html}<script src="/shared/navbar.js" defer></script><script src="/shared/kit-web.js" defer></script></body></html>`
++ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>A Salvage Rush round · OpenVibe.Games</title><meta name="robots" content="noindex,follow"><meta name="description" content="A scored round of Salvage Rush on OpenVibe.Games."><style>body{margin:0}:where(body){font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif)}</style><script src="/shared/theme-loader.js"></script><link rel="stylesheet" href="/shared/kit.css"><link rel="stylesheet" href="/shared/kit-data.css"></head><body class="ovk-app"><div id="navbar-mount"></div>${html}<script src="/shared/web-runtime.js" defer></script><script src="/shared/navbar.js" defer></script><script src="/shared/kit-web.js" defer></script><script>window.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init(${JSON.stringify({
++ service: 'games',
++ apiBase: 'https://openvibe.network',
++ links: [
++ { label: 'Leaderboard', href: '/leaderboard.html' },
++ { label: 'Play Scraplandia', href: '/play' },
++ ],
++ history: { type: 'page', title: 'A Salvage Rush round' },
++ })}); });</script></body></html>`
}
export interface ResultRouteOptions {
ddiff --git a/apps/server/src/worlds/searchIndex.test.ts b/apps/server/src/worlds/searchIndex.test.ts
iindex 4b52e03..4f2bdac 100644
---- a/apps/server/src/worlds/searchIndex.test.ts
++++ b/apps/server/src/worlds/searchIndex.test.ts
@@@ -142,6 +142,10 @@ describe('creator worlds in OpenVibe.Search', () => {
expect(html).toContain('<link rel="canonical" href="https://openvibe.games/worlds/wk3j9x0a2bq"')
expect(html).toContain('1 visit')
expect(html).toContain('href="/worlds?remix=wk3j9x0a2bq"')
++ expect(html).toContain('class="ovk-btn ovk-btn--quiet"')
++ expect(html).toContain('data-ov-share-sheet')
++ expect(html).toContain('data-url="https://openvibe.games/worlds/wk3j9x0a2bq"')
++ expect(html).toContain('data-title="Moon &lt;script&gt;"')
expect(html).not.toContain('Remix of')
const remix = worldPageHtml(
{
ddiff --git a/apps/server/src/worlds/worldPage.ts b/apps/server/src/worlds/worldPage.ts
iindex f41168a..1a14814 100644
---- a/apps/server/src/worlds/worldPage.ts
++++ b/apps/server/src/worlds/worldPage.ts
@@@ -14,6 +14,13 @@ interface Shell {
page(o: Record<string, unknown>): string
}
++interface Kit {
++ share(o: Record<string, unknown>): string
++}
++
++const kit = (opts: { require?: NodeRequire } = {}) =>
++ (opts.require ?? createRequire(import.meta.url))('openvibe-shared/kit') as Kit
++
const SITE_NAME = 'OpenVibe.Games'
const NETWORK_URL = 'https://openvibe.network'
const LINKS: NotFoundLink[] = [
@@@ -33,7 +40,7 @@ const STYLE = `<style>
.world .note{margin-top:22px;font-size:14px;color:var(--text-faint,#7386a3)}
</style>`
--export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell): string {
++export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell, kitLib: Kit = kit()): string {
const url = `${siteUrl}/worlds/${world.id}`
const description =
world.summary ||
@@@ -56,7 +63,7 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell
description: description.slice(0, 300),
canonical: url,
robots: 'index, follow',
-- head: `<link rel="icon" type="image/svg+xml" href="/favicon.svg" />\n${STYLE}`,
++ head: `<link rel="icon" type="image/svg+xml" href="/favicon.svg" />\n<link rel="stylesheet" href="/shared/kit.css">\n${STYLE}`,
navbar: {
service: 'games',
apiBase: NETWORK_URL,
@@@ -82,6 +89,7 @@ export function worldPageHtml(world: CreatorWorld, siteUrl: string, shell: Shell
<p class="stats">${escapeHtml(visits)}</p>
<div class="row">
<a class="play" href="/play?world=${world.id}">▶ Join this world</a>
++ ${kitLib.share({ title: world.title, url, text: description })}
<a class="more" href="/worlds?remix=${world.id}">Remix it</a>
<a class="more" href="/worlds">More worlds</a>
</div>