fuzzyworld

word-waltz / commit

stylistic changes, as well as requiring a word when starting a game

4a3e918a4d2f3349042cc8f282b954211e6524e1 · Pavel Safronov · 2026-03-26 15:03:29 -0700 · parents 6a27222f6ab9

stylistic changes, as well as requiring a word when starting a game
 .vscode/settings.json        |   4 +
 src/assets/url.png           | Bin 0 -> 17468 bytes
 src/components/Scoreboard.ts |  85 ++++++++++--------
 src/index.html               |   2 +
 src/main.ts                  | 204 ++++++++++++++++++++++++++++++-------------
 src/styles/main.css          | 117 ++++++++++++++++++-------
 src/utils/shareLink.ts       |  17 ++--
 7 files changed, 295 insertions(+), 134 deletions(-)

diff --git a/.vscode/settings.json b/.vscode/settings.json
index 9a49189..a4d7ac6 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -3,6 +3,10 @@
         "/^npx tsc --noEmit 2>&1 && echo \"TS OK\"$/": {
             "approve": true,
             "matchCommandLine": true
+        },
+        "/^npx tsc --noEmit 2>&1$/": {
+            "approve": true,
+            "matchCommandLine": true
         }
     }
 }
\ No newline at end of file
diff --git a/src/assets/url.png b/src/assets/url.png
new file mode 100644
index 0000000..59fa946
Binary files /dev/null and b/src/assets/url.png differ
diff --git a/src/components/Scoreboard.ts b/src/components/Scoreboard.ts
index 5fd6ab4..f260ddf 100644
--- a/src/components/Scoreboard.ts
+++ b/src/components/Scoreboard.ts
@@ -13,18 +13,24 @@ interface ScoreboardProps {
   players: { a: Player; b: Player };
   rounds: Round[];
   myRole: PlayerId;
+  turn: 'pick' | 'solve';
+  currentRound: number;
   onNameChange: (newName: string) => void;
 }
 
 const COLLAPSED_COUNT = 2;
 
 export function mountScoreboard(container: HTMLElement, props: ScoreboardProps): () => void {
-  const { players, rounds, myRole, onNameChange } = props;
+  const { players, rounds, myRole, turn, currentRound, onNameChange } = props;
 
   const root = document.createElement('div');
   root.className = 'scoreboard';
 
-  const completedRounds = rounds.filter((r) => r.guesses.length > 0);
+  // Exclude a live round (currently being solved) from the completed list.
+  const liveRoundIdx = turn === 'solve' ? currentRound : -1;
+  const completedRounds = rounds
+    .map((r, i) => ({ r, i }))
+    .filter(({ r, i }) => i !== liveRoundIdx && r.guesses.length > 0);
   let expanded = false;
 
   function render(): void {
@@ -47,30 +53,20 @@ export function mountScoreboard(container: HTMLElement, props: ScoreboardProps):
       const card = document.createElement('div');
       card.className = 'scoreboard__player';
 
+      const nameEl = document.createElement('span');
+      nameEl.className = `scoreboard__name scoreboard__name--${id}`;
+      nameEl.textContent = displayName;
+
       if (id === myRole) {
-        // Editable name
-        const nameRow = document.createElement('div');
-        nameRow.className = 'scoreboard__name-row';
-
-        const nameEl = document.createElement('span');
-        nameEl.className = `scoreboard__name scoreboard__name--${id}`;
-        nameEl.textContent = displayName;
-        nameRow.appendChild(nameEl);
-
-        const editBtn = document.createElement('button');
-        editBtn.className = 'scoreboard__edit-btn';
-        editBtn.title = 'Change name';
-        editBtn.textContent = '✎';
-        editBtn.addEventListener('click', () => {
-          const row = nameEl.parentElement!;
-          // Replace with inline input
+        nameEl.classList.add('scoreboard__name--editable');
+        nameEl.title = 'Click to rename';
+        nameEl.addEventListener('click', () => {
           const input = document.createElement('input');
           input.type = 'text';
           input.className = 'scoreboard__name-input';
           input.value = p.name;
           input.maxLength = 20;
-          row.innerHTML = '';
-          row.appendChild(input);
+          nameEl.replaceWith(input);
           input.focus();
           input.select();
 
@@ -85,15 +81,10 @@ export function mountScoreboard(container: HTMLElement, props: ScoreboardProps):
             if (e.key === 'Escape') { input.removeEventListener('blur', commit); render(); }
           });
         });
-        nameRow.appendChild(editBtn);
-        card.appendChild(nameRow);
-      } else {
-        const nameEl = document.createElement('span');
-        nameEl.className = `scoreboard__name scoreboard__name--${id}`;
-        nameEl.textContent = displayName;
-        card.appendChild(nameEl);
       }
 
+      card.appendChild(nameEl);
+
       const scoreEl = document.createElement('span');
       scoreEl.className = `scoreboard__score scoreboard__score--${id}`;
       scoreEl.textContent = String(p.score);
@@ -104,11 +95,7 @@ export function mountScoreboard(container: HTMLElement, props: ScoreboardProps):
     root.appendChild(playersEl);
 
     // --- History ---
-    if (completedRounds.length === 0) return;
-
-    const visible = expanded
-      ? completedRounds
-      : completedRounds.slice(-COLLAPSED_COUNT);
+    if (completedRounds.length === 0 && liveRoundIdx < 0) return;
 
     const historyEl = document.createElement('div');
     historyEl.className = 'scoreboard__history-wrap';
@@ -125,8 +112,11 @@ export function mountScoreboard(container: HTMLElement, props: ScoreboardProps):
 
     const ol = document.createElement('ol');
     ol.className = 'scoreboard__history';
-    ol.start = expanded ? 1 : completedRounds.length - visible.length + 1;
-    visible.forEach((r) => {
+    const visible = expanded
+      ? completedRounds
+      : completedRounds.slice(-COLLAPSED_COUNT);
+    visible.forEach(({ r, i }) => {
+      const roundNum = i + 1;
       const solver: PlayerId = r.challenger === 'a' ? 'b' : 'a';
       const solverName = players[solver].name || (solver === 'a' ? 'Player A' : 'Player B');
       const result = r.solved
@@ -135,9 +125,13 @@ export function mountScoreboard(container: HTMLElement, props: ScoreboardProps):
       const li = document.createElement('li');
       li.className = 'scoreboard__round';
       const textEl = document.createElement('span');
+      const numSpan = document.createElement('span');
+      numSpan.className = 'scoreboard__round-num';
+      numSpan.textContent = `${roundNum}.`;
       const nameSpan = document.createElement('span');
       nameSpan.className = `scoreboard__name--${solver}`;
       nameSpan.textContent = solverName;
+      textEl.appendChild(numSpan);
       textEl.appendChild(nameSpan);
       textEl.appendChild(document.createTextNode(`: ${result}`));
       li.appendChild(textEl);
@@ -149,6 +143,29 @@ export function mountScoreboard(container: HTMLElement, props: ScoreboardProps):
       }
       ol.appendChild(li);
     });
+
+    // Live round — show pending entry to the challenger only.
+    if (liveRoundIdx >= 0) {
+      const liveRound = rounds[liveRoundIdx];
+      if (liveRound && myRole === liveRound.challenger) {
+        const roundNum = liveRoundIdx + 1;
+        const solver: PlayerId = liveRound.challenger === 'a' ? 'b' : 'a';
+        const solverName = players[solver].name || (solver === 'a' ? 'Player A' : 'Player B');
+        const liveLi = document.createElement('li');
+        liveLi.className = 'scoreboard__round scoreboard__round--live';
+        const numSpan = document.createElement('span');
+        numSpan.className = 'scoreboard__round-num';
+        numSpan.textContent = `${roundNum}.`;
+        const nameSpan = document.createElement('span');
+        nameSpan.className = `scoreboard__name--${solver}`;
+        nameSpan.textContent = solverName;
+        liveLi.appendChild(numSpan);
+        liveLi.appendChild(nameSpan);
+        liveLi.appendChild(document.createTextNode(` is trying to solve ${liveRound.word}…`));
+        ol.appendChild(liveLi);
+      }
+    }
+
     historyEl.appendChild(ol);
     root.appendChild(historyEl);
   }
diff --git a/src/index.html b/src/index.html
index f4b594e..342cbba 100644
--- a/src/index.html
+++ b/src/index.html
@@ -19,6 +19,8 @@
             <span class="header__title">Word Waltz</span>
         </a>
         <nav class="header__links">
+            <button id="share-btn" class="header__share-btn" type="button" aria-label="Share game link"
+                title="Share invite link" hidden></button>
             <button id="notif-btn" class="header__notif-btn" type="button" aria-label="Notification settings"
                 title="Enable notifications"></button>
             <a href="https://www.flaticon.com/search?author_id=296&style_id=1001&type=standard" target="_blank"
diff --git a/src/main.ts b/src/main.ts
index c55a878..a7870aa 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -21,6 +21,7 @@ import { mountShareDialog } from './components/ShareDialog.js';
 import { notifyMyTurn, clearNotification, registerServiceWorker, subscribeToPush, isNotifOptedOut, setNotifOptOut, unsubscribeFromPush } from './utils/notifications.js';
 import speakerIconUrl from './assets/speaker.png';
 import muteIconUrl from './assets/mute.png';
+import urlIconUrl from './assets/url.png';
 import { getRandomWord } from './utils/wordList.js';
 import type { GameState, PlayerId, Round, SoloGameState, SoloRound, PendingNotification } from './types.js';
 // Waiting status helper
@@ -98,6 +99,60 @@ function navigateToGame(gameId: string, key: string): void {
     if (fragment) window.location.hash = fragment;
 }
 
+// ---------------------------------------------------------------------------
+// Share button — shown in the header while a game is active
+// ---------------------------------------------------------------------------
+
+let shareModal: HTMLElement | null = null;
+
+function setShareButton(inviteUrl: string | null): void {
+    const btn = document.getElementById('share-btn') as HTMLButtonElement | null;
+    if (!btn) return;
+
+    if (!inviteUrl) {
+        btn.hidden = true;
+        btn.onclick = null;
+        return;
+    }
+
+    btn.textContent = '';
+    const shareImg = document.createElement('img');
+    shareImg.src = urlIconUrl;
+    shareImg.alt = 'Share';
+    btn.appendChild(shareImg);
+    btn.hidden = false;
+    btn.onclick = () => {
+        if (shareModal) { shareModal.remove(); shareModal = null; return; }
+
+        const overlay = document.createElement('div');
+        overlay.className = 'share-modal';
+        shareModal = overlay;
+
+        const box = document.createElement('div');
+        box.className = 'share-modal__box';
+
+        const closeBtn = document.createElement('button');
+        closeBtn.className = 'share-modal__close';
+        closeBtn.textContent = '×';
+        closeBtn.setAttribute('aria-label', 'Close');
+        closeBtn.onclick = () => { overlay.remove(); shareModal = null; };
+        box.appendChild(closeBtn);
+
+        const inner = document.createElement('div');
+        box.appendChild(inner);
+        overlay.appendChild(box);
+        document.body.appendChild(overlay);
+
+        import('./components/ShareDialog.js').then(({ mountShareDialog }) => {
+            mountShareDialog(inner, { shareUrl: inviteUrl });
+        }).catch(console.error);
+
+        overlay.addEventListener('click', (e) => {
+            if (e.target === overlay) { overlay.remove(); shareModal = null; }
+        });
+    };
+}
+
 // ---------------------------------------------------------------------------
 // Firestore write helper
 // ---------------------------------------------------------------------------
@@ -152,6 +207,8 @@ function render(
         players: state.players,
         rounds: state.rounds,
         myRole,
+        turn: state.turn,
+        currentRound: state.currentRound,
         onNameChange: async (newName: string) => {
             saveName(newName);
             await saveState(gameId, key, {
@@ -177,7 +234,8 @@ function render(
     if (state.players.b.name === '') {
         const shareEl = document.createElement('div');
         mainEl.appendChild(shareEl);
-        cleanupMain = mountShareDialog(shareEl, { shareUrl });
+        const inviteUrl = buildLink(gameId, base64urlKey, 'b');
+        cleanupMain = mountShareDialog(shareEl, { shareUrl: inviteUrl });
         mainEl.appendChild(createWaitingEl('Share the link above with your opponent, then wait for them to join…'));
     } else if (state.turn === 'pick') {
         const challenger = nextChallenger(state);
@@ -532,67 +590,86 @@ function showHomeScreen(app: HTMLElement): void {
 
     root.appendChild(newGameSection);
 
-    form.addEventListener('submit', async (e) => {
+    form.addEventListener('submit', (e) => {
         e.preventDefault();
         const name = nameInput.value.trim();
         if (!name) return;
 
-        submitBtn.disabled = true;
-        submitBtn.textContent = 'Creating…';
         saveName(name);
 
-        try {
-            const gameId = crypto.randomUUID();
-            const cryptoKey = await generateKey();
-            const base64urlKey = await exportKey(cryptoKey);
+        // Step 2: pick a word before creating the game
+        newGameSection.innerHTML = '';
+        const pickDesc = document.createElement('p');
+        pickDesc.textContent = 'Pick a secret word to challenge your opponent with:';
+        newGameSection.appendChild(pickDesc);
 
-            const initialState: GameState = {
-                players: {
-                    a: { name, score: 0 },
-                    b: { name: '', score: 0 },
-                },
-                rounds: [],
-                currentRound: 0,
-                turn: 'pick',
-                createdAt: Date.now(),
-                updatedAt: Date.now(),
-            };
+        const wordPickerEl = document.createElement('div');
+        newGameSection.appendChild(wordPickerEl);
 
-            const envelope = await encrypt(JSON.stringify(initialState), cryptoKey);
+        mountWordPicker(wordPickerEl, {
+            onWordChosen: async (word: string) => {
+                wordPickerEl.innerHTML = '<p class="status">Creating game…</p>';
 
-            // Race the Firestore write against an 8 s timeout so we surface
-            // errors (e.g. database not created, network issue) instead of
-            // hanging on "Creating…" forever.
-            const timeout = new Promise<never>((_, reject) =>
-                setTimeout(() => reject(new Error('Timed out connecting to Firestore. Make sure your Firestore security rules allow read/write (see the Rules tab in the Firebase console).')), 8_000),
-            );
-            await Promise.race([createGame(gameId, envelope), timeout]);
+                try {
+                    const gameId = crypto.randomUUID();
+                    const cryptoKey = await generateKey();
+                    const base64urlKey = await exportKey(cryptoKey);
 
-            setRole(gameId, 'a');
-            upsertStoredGame({
-                gameId,
-                key: base64urlKey,
-                role: 'a',
-                myName: name,
-                opponentName: '',
-                status: 'Waiting for opponent to join',
-                myTurn: false,
-                createdAt: initialState.createdAt,
-                lastUpdated: Date.now(),
-            });
+                    const now = Date.now();
+                    const firstRound: Round = {
+                        challenger: 'a',
+                        word,
+                        guesses: [],
+                        solved: false,
+                        score: 0,
+                    };
+                    const initialState: GameState = {
+                        players: {
+                            a: { name, score: 0 },
+                            b: { name: '', score: 0 },
+                        },
+                        rounds: [firstRound],
+                        currentRound: 0,
+                        turn: 'solve',
+                        createdAt: now,
+                        updatedAt: now,
+                    };
 
-            // Navigate — key is in the fragment and is never sent to the server
-            navigateToGame(gameId, base64urlKey);
-        } catch (err) {
-            console.error('Game creation failed:', err);
-            submitBtn.disabled = false;
-            submitBtn.textContent = 'Create game';
-            const raw = err instanceof Error ? err.message : String(err);
-            const msg = raw.toLowerCase().includes('permission')
-                ? 'Permission denied by Firestore. Open the Firebase console → Firestore → Rules and set: allow read, write: if true; then publish.'
-                : raw || 'Failed to create game. Please try again.';
-            showError(app, msg);
-        }
+                    const envelope = await encrypt(JSON.stringify(initialState), cryptoKey);
+
+                    // Race the Firestore write against an 8 s timeout so we surface
+                    // errors (e.g. database not created, network issue) instead of
+                    // hanging on "Creating game…" forever.
+                    const timeout = new Promise<never>((_, reject) =>
+                        setTimeout(() => reject(new Error('Timed out connecting to Firestore. Make sure your Firestore security rules allow read/write (see the Rules tab in the Firebase console).')), 8_000),
+                    );
+                    await Promise.race([createGame(gameId, envelope), timeout]);
+
+                    setRole(gameId, 'a');
+                    upsertStoredGame({
+                        gameId,
+                        key: base64urlKey,
+                        role: 'a',
+                        myName: name,
+                        opponentName: '',
+                        status: 'Waiting for opponent to join',
+                        myTurn: false,
+                        createdAt: initialState.createdAt,
+                        lastUpdated: Date.now(),
+                    });
+
+                    // Navigate — key is in the fragment and is never sent to the server
+                    navigateToGame(gameId, base64urlKey);
+                } catch (err) {
+                    console.error('Game creation failed:', err);
+                    const raw = err instanceof Error ? err.message : String(err);
+                    const msg = raw.toLowerCase().includes('permission')
+                        ? 'Permission denied by Firestore. Open the Firebase console → Firestore → Rules and set: allow read, write: if true; then publish.'
+                        : raw || 'Failed to create game. Please try again.';
+                    showError(app, msg);
+                }
+            },
+        });
     });
 }
 
@@ -654,7 +731,7 @@ async function bootstrap(): Promise<void> {
     const app = document.getElementById('app');
     if (!app) throw new Error('#app element not found');
 
-    const { gameId, key } = parseLink();
+    const { gameId, key, role: urlRole } = parseLink();
 
     if (!gameId || !key) {
         showHomeScreen(app);
@@ -684,10 +761,11 @@ async function bootstrap(): Promise<void> {
     }
 
     // Determine player role.
-    // Heuristic for new devices: if player B's name slot is empty this is B's first visit.
+    // Priority: localStorage (same device / browser) → role encoded in the URL
+    // → heuristic (B slot empty means this is B's first visit).
     let myRole = getRole(gameId);
     if (!myRole) {
-        if (state.players.b.name === '') {
+        if (urlRole === 'b' || (!urlRole && state.players.b.name === '')) {
             // Player B's first visit — prompt for name then join
             await new Promise<void>((resolve) => {
                 promptPlayerBName(app, async (name) => {
@@ -699,7 +777,7 @@ async function bootstrap(): Promise<void> {
                     const updatedEnvelope = await encrypt(JSON.stringify(updated), cryptoKey);
                     const joinNotify: PendingNotification = {
                         player: 'a',
-                        message: `${name} has joined! Pick a word to challenge them.`,
+                        message: `${name} has joined and is solving your word!`,
                         gameUrl: buildLink(gameId, key).split('#')[0],
                     };
                     await updateGame(gameId, updatedEnvelope, joinNotify);
@@ -710,7 +788,8 @@ async function bootstrap(): Promise<void> {
                 });
             });
         } else {
-            // Fallback: treat as player A (e.g. creator on a new device)
+            // URL says role=a, or no URL role and B already joined → this is player A
+            // (e.g. creator rejoining on a new device).
             setRole(gameId, 'a');
             myRole = 'a';
         }
@@ -718,6 +797,8 @@ async function bootstrap(): Promise<void> {
 
     const role = myRole as PlayerId;
     const shareUrl = buildLink(gameId, key);
+    const inviteUrl = buildLink(gameId, key, 'b');
+    setShareButton(inviteUrl);
 
     // Register the service worker and subscribe to push notifications.
     // Fire-and-forget: push is a best-effort enhancement.
@@ -764,6 +845,7 @@ document.getElementById('home-link')?.addEventListener('click', (e) => {
     cleanupView?.();
     cleanupView = null;
     clearNotification();
+    setShareButton(null);
     history.replaceState(null, '', window.location.pathname);
     const app = document.getElementById('app');
     if (app) showHomeScreen(app);
@@ -781,6 +863,7 @@ window.addEventListener('hashchange', () => {
     unsubscribeGame = null;
     cleanupView?.();
     cleanupView = null;
+    setShareButton(null);
     bootstrap().catch((err: unknown) => {
         console.error('Bootstrap failed:', err);
         const app = document.getElementById('app');
@@ -820,21 +903,20 @@ function initNotifButton(): void {
             img.src = speakerIconUrl;
             btn!.title = 'Notifications enabled — click to disable';
             btn!.classList.add('header__notif-btn--on');
-            btn!.classList.remove('header__notif-btn--off', 'header__notif-btn--blocked');
+            btn!.classList.remove('header__notif-btn--blocked');
         } else if (perm === 'denied') {
             img.src = muteIconUrl;
             btn!.title = 'Notifications blocked by browser';
-            btn!.classList.remove('header__notif-btn--on', 'header__notif-btn--off');
+            btn!.classList.remove('header__notif-btn--on');
             btn!.classList.add('header__notif-btn--blocked');
         } else if (perm === 'granted' && optedOut) {
             img.src = muteIconUrl;
             btn!.title = 'Notifications disabled — click to enable';
             btn!.classList.remove('header__notif-btn--on', 'header__notif-btn--blocked');
-            btn!.classList.add('header__notif-btn--off');
         } else {
-            img.src = speakerIconUrl;
+            img.src = muteIconUrl;
             btn!.title = 'Enable notifications';
-            btn!.classList.remove('header__notif-btn--on', 'header__notif-btn--off', 'header__notif-btn--blocked');
+            btn!.classList.remove('header__notif-btn--on', 'header__notif-btn--blocked');
         }
     }
 
diff --git a/src/styles/main.css b/src/styles/main.css
index 4286db6..5853ef8 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -118,7 +118,7 @@ header {
     padding: 2px 4px;
     border-radius: 4px;
     line-height: 1;
-    opacity: 0.35;
+    opacity: 0.75;
     transition: opacity 0.15s;
     display: flex;
     align-items: center;
@@ -131,19 +131,15 @@ header {
 }
 
 .header__notif-btn:hover {
-    opacity: 0.8;
+    opacity: 1;
 }
 
 .header__notif-btn--on {
     opacity: 1;
 }
 
-.header__notif-btn--off {
-    opacity: 0.8;
-}
-
 .header__notif-btn--blocked {
-    opacity: 0.4;
+    opacity: 0.75;
     cursor: default;
 }
 
@@ -231,6 +227,7 @@ header {
     align-items: center;
     gap: 2px;
     flex: 1;
+    min-width: 0;
 }
 
 .scoreboard__vs {
@@ -243,7 +240,9 @@ header {
     font-size: 0.9rem;
     font-weight: 600;
     text-align: center;
-    word-break: break-word;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
 }
 
 .scoreboard__name--a {
@@ -269,11 +268,16 @@ header {
 }
 
 .scoreboard__history {
-    list-style: decimal;
-    padding-left: 20px;
+    list-style: none;
+    padding-left: 0;
     display: flex;
     flex-direction: column;
-    gap: 3px;
+    gap: 8px;
+}
+
+.scoreboard__round-num {
+    color: var(--color-muted);
+    margin-right: 3px;
 }
 
 .scoreboard__history-wrap {
@@ -296,25 +300,11 @@ header {
     color: var(--color-text);
 }
 
-.scoreboard__name-row {
-    display: flex;
-    align-items: center;
-    gap: 4px;
-}
-
-.scoreboard__edit-btn {
-    background: none;
-    border: none;
-    padding: 0;
-    font-size: 0.8rem;
+.scoreboard__name--editable {
     cursor: pointer;
-    color: var(--color-muted);
-    line-height: 1;
-    flex-shrink: 0;
-}
-
-.scoreboard__edit-btn:hover {
-    color: var(--color-text);
+    text-decoration: underline;
+    text-decoration-style: dotted;
+    text-underline-offset: 2px;
 }
 
 .scoreboard__name-input {
@@ -335,6 +325,10 @@ header {
     gap: 3px;
 }
 
+.scoreboard__round--live {
+    font-style: italic;
+}
+
 .scoreboard__emoji {
     font-size: 0.7rem;
     line-height: 1.4;
@@ -342,7 +336,70 @@ header {
     white-space: pre;
 }
 
-/* --- Wordle board ----------------------------------------- */
+.header__share-btn {
+    background: none;
+    border: none;
+    cursor: pointer;
+    padding: 2px 4px;
+    border-radius: 4px;
+    line-height: 1;
+    opacity: 0.75;
+    transition: opacity 0.15s;
+    display: flex;
+    align-items: center;
+}
+
+.header__share-btn img {
+    height: 22px;
+    width: 22px;
+    display: block;
+}
+
+.header__share-btn:hover {
+    opacity: 1;
+}
+
+/* --- Share modal ------------------------------------------ */
+.share-modal {
+    position: fixed;
+    inset: 0;
+    background: rgba(0, 0, 0, 0.4);
+    display: flex;
+    align-items: flex-start;
+    justify-content: center;
+    padding-top: 80px;
+    z-index: 100;
+}
+
+.share-modal__box {
+    background: var(--color-surface);
+    border: 1px solid var(--color-border);
+    border-radius: var(--radius);
+    padding: 20px;
+    max-width: 420px;
+    width: 90%;
+    position: relative;
+    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
+}
+
+.share-modal__close {
+    position: absolute;
+    top: 8px;
+    right: 10px;
+    background: none;
+    border: none;
+    font-size: 1.4rem;
+    line-height: 1;
+    cursor: pointer;
+    color: var(--color-muted);
+    padding: 0 4px;
+}
+
+.share-modal__close:hover {
+    color: var(--color-text);
+}
+
+
 .wordle-board {
     display: flex;
     flex-direction: column;
diff --git a/src/utils/shareLink.ts b/src/utils/shareLink.ts
index f45447a..e2a683a 100644
--- a/src/utils/shareLink.ts
+++ b/src/utils/shareLink.ts
@@ -9,15 +9,8 @@
  * The fragment is parsed on the client to extract both values.
  */
 
-// Production URL used when generating share links from localhost so that QR
-// codes are scannable from devices that can't reach localhost.
-// If you deploy to a different host, update this constant.
-const PRODUCTION_URL = 'https://pavelsafronov.gitlab.io/word-waltz/';
 
 function shareBase(): string {
-  if (['localhost', '127.0.0.1'].includes(window.location.hostname)) {
-    return PRODUCTION_URL;
-  }
   return window.location.origin + window.location.pathname;
 }
 
@@ -27,21 +20,27 @@ function shareBase(): string {
 export function buildLink(
   gameId: string,
   base64urlKey: string,
+  role?: 'a' | 'b',
   base: string = shareBase(),
 ): string {
-  const fragment = new URLSearchParams({ gameId, key: base64urlKey }).toString();
+  const params: Record<string, string> = { gameId, key: base64urlKey };
+  if (role) params.role = role;
+  const fragment = new URLSearchParams(params).toString();
   return `${base}#${fragment}`;
 }
 
-/** Parse a game URL fragment to extract gameId and key. */
+/** Parse a game URL fragment to extract gameId, key, and optional role. */
 export function parseLink(url: string = window.location.hash): {
   gameId: string | null;
   key: string | null;
+  role: 'a' | 'b' | null;
 } {
   const fragment = url.startsWith('#') ? url.slice(1) : url;
   const params = new URLSearchParams(fragment);
+  const role = params.get('role');
   return {
     gameId: params.get('gameId'),
     key: params.get('key'),
+    role: role === 'a' || role === 'b' ? role : null,
   };
 }