Compare commits

..

7 Commits

Author SHA1 Message Date
George Powell
fc674d6008 updated done list 2026-02-22 23:29:35 -05:00
George Powell
087a476df8 Remove verse reference from copied text
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 23:19:48 -05:00
George Powell
ba45cbdc37 Progressive disclosure in search dropdown based on guess count
Shows book names only (A-Z) for the first 3 guesses, reveals Old/New
Testament groupings after 3 guesses, and full section-level groupings
in canonical Bible order after 9 guesses. Adds a status banner above
the search bar to inform players when new structure becomes visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 23:19:00 -05:00
George Powell
1de436534c added copy verse text button 2026-02-22 22:50:28 -05:00
George Powell
3bcd7ea266 Fixed some things and added analytics events back 2026-02-22 22:29:35 -05:00
George Powell
7ecc84ffbc Centered main share button text 2026-02-22 19:30:35 -05:00
George Powell
3d78353a90 new share button design 2026-02-22 19:26:40 -05:00
5 changed files with 573 additions and 196 deletions

View File

@@ -1,29 +1,158 @@
<script lang="ts"> <script lang="ts">
import { bibleBooks, type BibleBook } from "$lib/types/bible"; import { bibleBooks, type BibleBook, type BibleSection, type Testament } from "$lib/types/bible";
import { SvelteSet } from "svelte/reactivity";
let { searchQuery = $bindable(""), guessedIds, submitGuess } = $props(); let {
searchQuery = $bindable(""),
guessedIds,
submitGuess,
guessCount = 0,
}: {
searchQuery: string;
guessedIds: SvelteSet<string>;
submitGuess: (id: string) => void;
guessCount: number;
} = $props();
let filteredBooks = $derived( type DisplayMode = "simple" | "testament" | "sections";
const displayMode = $derived<DisplayMode>(
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple"
);
const filteredBooks = $derived(
bibleBooks.filter((book) => bibleBooks.filter((book) =>
book.name.toLowerCase().includes(searchQuery.toLowerCase()) book.name.toLowerCase().includes(searchQuery.toLowerCase())
) )
); );
type SimpleGroup = { books: BibleBook[] };
type TestamentGroup = {
testament: Testament;
label: string;
books: BibleBook[];
};
type SectionGroup = {
testament: Testament;
testamentLabel: string;
showTestamentHeader: boolean;
section: BibleSection;
books: BibleBook[];
};
const simpleGroup = $derived.by<SimpleGroup>(() => {
const sorted = [...filteredBooks].sort((a, b) =>
a.name.localeCompare(b.name)
);
return { books: sorted };
});
const testamentGroups = $derived.by<TestamentGroup[]>(() => {
const old = filteredBooks
.filter((b) => b.testament === "old")
.sort((a, b) => a.name.localeCompare(b.name));
const newT = filteredBooks
.filter((b) => b.testament === "new")
.sort((a, b) => a.name.localeCompare(b.name));
const groups: TestamentGroup[] = [];
if (old.length > 0) {
groups.push({ testament: "old", label: "Old Testament", books: old });
}
if (newT.length > 0) {
groups.push({ testament: "new", label: "New Testament", books: newT });
}
return groups;
});
const sectionGroups = $derived.by<SectionGroup[]>(() => {
// Build an ordered list of (testament, section) pairs by iterating bibleBooks once
const seenKeys: Record<string, true> = {};
const orderedPairs: { testament: Testament; section: BibleSection }[] = [];
for (const book of bibleBooks) {
const key = `${book.testament}:${book.section}`;
if (!seenKeys[key]) {
seenKeys[key] = true;
orderedPairs.push({ testament: book.testament, section: book.section });
}
}
const groups: SectionGroup[] = [];
let lastTestament: Testament | null = null;
for (const pair of orderedPairs) {
const books = filteredBooks.filter(
(b) => b.testament === pair.testament && b.section === pair.section
);
if (books.length === 0) continue;
const showTestamentHeader = pair.testament !== lastTestament;
lastTestament = pair.testament;
groups.push({
testament: pair.testament,
testamentLabel:
pair.testament === "old" ? "Old Testament" : "New Testament",
showTestamentHeader,
section: pair.section,
books,
});
}
return groups;
});
// First book in display order for Enter key submission
const firstBookId = $derived.by<string | null>(() => {
if (filteredBooks.length === 0) return null;
if (displayMode === "simple") {
return simpleGroup.books[0]?.id ?? null;
}
if (displayMode === "testament") {
return testamentGroups[0]?.books[0]?.id ?? null;
}
return sectionGroups[0]?.books[0]?.id ?? null;
});
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && filteredBooks.length > 0) { if (e.key === "Enter" && firstBookId) {
submitGuess(filteredBooks[0].id); submitGuess(firstBookId);
} }
} }
const showBanner = $derived(guessCount >= 3);
const bannerIsIndigo = $derived(guessCount >= 9);
</script> </script>
{#if showBanner}
<div
class="mb-3 flex items-center gap-2 px-4 py-2 rounded-full text-xs font-medium border w-fit transition-all duration-300
{bannerIsIndigo
? 'bg-indigo-50 border-indigo-200 text-indigo-700'
: 'bg-amber-50 border-amber-200 text-amber-700'}"
role="status"
aria-live="polite"
>
<span aria-hidden="true" class="text-[10px] leading-none"></span>
{#if bannerIsIndigo}
Testament &amp; section groups now visible
{:else}
Old &amp; New Testament groups now visible
{/if}
</div>
{/if}
<div class="relative"> <div class="relative">
<div class="relative"> <div class="relative">
<svg <svg
class="absolute left-4 sm:left-6 top-1/2 transform -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400" class="absolute left-4 sm:left-6 top-1/2 -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
@@ -41,7 +170,7 @@
/> />
{#if searchQuery} {#if searchQuery}
<button <button
class="absolute right-4 sm:right-6 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors" class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
onclick={() => (searchQuery = "")} onclick={() => (searchQuery = "")}
aria-label="Clear search" aria-label="Clear search"
> >
@@ -51,6 +180,7 @@
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
@@ -62,29 +192,120 @@
</button> </button>
{/if} {/if}
</div> </div>
{#if searchQuery && filteredBooks.length > 0} {#if searchQuery && filteredBooks.length > 0}
<ul <ul
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-300 rounded-2xl shadow-xl" class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-300 rounded-2xl shadow-xl"
role="listbox"
> >
{#each filteredBooks as book (book.id)} {#if displayMode === "simple"}
<li> {#each simpleGroup.books as book (book.id)}
<button <li role="option" aria-selected={guessedIds.has(book.id)}>
class="w-full p-4 sm:p-5 text-left {guessedIds.has(book.id) <button
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600' class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center" {guessedIds.has(book.id)
onclick={() => submitGuess(book.id)} ? 'opacity-50 cursor-not-allowed pointer-events-none'
> : 'hover:bg-blue-50 hover:text-blue-700'}"
<span onclick={() => submitGuess(book.id)}
class="font-semibold {guessedIds.has(book.id) tabindex={guessedIds.has(book.id) ? -1 : 0}
? 'line-through text-gray-500'
: ''}">{book.name}</span
> >
<span class="ml-auto text-sm opacity-75" <span
>({book.testament.toUpperCase()})</span class="font-semibold {guessedIds.has(book.id)
? 'line-through text-gray-400'
: ''}"
>
{book.name}
</span>
</button>
</li>
{/each}
{:else if displayMode === "testament"}
{#each testamentGroups as group (group.testament)}
<li role="presentation">
<div
class="px-5 py-2 flex items-center gap-3 bg-gray-50 border-b border-gray-100"
> >
</button> <span
</li> class="text-xs font-semibold uppercase tracking-wider text-gray-400"
{/each} >
{group.label}
</span>
<div class="flex-1 h-px bg-gray-200"></div>
</div>
<ul>
{#each group.books as book (book.id)}
<li role="option" aria-selected={guessedIds.has(book.id)}>
<button
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
{guessedIds.has(book.id)
? 'opacity-50 cursor-not-allowed pointer-events-none'
: 'hover:bg-blue-50 hover:text-blue-700'}"
onclick={() => submitGuess(book.id)}
tabindex={guessedIds.has(book.id) ? -1 : 0}
>
<span
class="font-semibold {guessedIds.has(book.id)
? 'line-through text-gray-400'
: ''}"
>
{book.name}
</span>
</button>
</li>
{/each}
</ul>
</li>
{/each}
{:else}
{#each sectionGroups as group (`${group.testament}:${group.section}`)}
<li role="presentation">
{#if group.showTestamentHeader}
<div
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 border-b border-gray-100"
>
<span
class="text-xs font-bold uppercase tracking-wider text-gray-500"
>
{group.testamentLabel}
</span>
<div class="flex-1 h-px bg-gray-200"></div>
</div>
{/if}
<div
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 border-b border-gray-100"
>
<span
class="text-[11px] font-medium uppercase tracking-wider text-gray-400"
>
{group.section}
</span>
<div class="flex-1 h-px bg-gray-100"></div>
</div>
<ul>
{#each group.books as book (book.id)}
<li role="option" aria-selected={guessedIds.has(book.id)}>
<button
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
{guessedIds.has(book.id)
? 'opacity-50 cursor-not-allowed pointer-events-none'
: 'hover:bg-blue-50 hover:text-blue-700'}"
onclick={() => submitGuess(book.id)}
tabindex={guessedIds.has(book.id) ? -1 : 0}
>
<span
class="font-semibold {guessedIds.has(book.id)
? 'line-through text-gray-400'
: ''}"
>
{book.name}
</span>
</button>
</li>
{/each}
</ul>
</li>
{/each}
{/if}
</ul> </ul>
{:else if searchQuery} {:else if searchQuery}
<p class="mt-4 text-center text-gray-500 p-8">No books found</p> <p class="mt-4 text-center text-gray-500 p-8">No books found</p>

View File

@@ -1,73 +1,98 @@
<script lang="ts"> <script lang="ts">
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
import Container from "./Container.svelte"; import Container from "./Container.svelte";
let { let {
data, data,
isWon, isWon,
blurChapter = false, blurChapter = false,
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props(); }: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props();
let dailyVerse = $derived(data.dailyVerse); let dailyVerse = $derived(data.dailyVerse);
let displayReference = $derived( let displayReference = $derived(
blurChapter blurChapter
? dailyVerse.reference ? dailyVerse.reference
.replace(/^Psalms /, "Psalm ") .replace(/^Psalms /, "Psalm ")
.replace(/\s(\d+):/, " ?:") .replace(/\s(\d+):/, " ?:")
: dailyVerse.reference.replace(/^Psalms /, "Psalm ") : dailyVerse.reference.replace(/^Psalms /, "Psalm "),
); );
let displayVerseText = $derived( let displayVerseText = $derived(
dailyVerse.verseText dailyVerse.verseText
.replace(/^([a-z])/, (c) => c.toUpperCase()) .replace(/^([a-z])/, (c) => c.toUpperCase())
.replace(/[,:;-—]$/, "...") .replace(/[,:;-—]$/, "..."),
); );
let showReference = $state(false); let showReference = $state(false);
let copied = $state(false);
// Delay showing reference until GuessesTable animation completes // Delay showing reference until GuessesTable animation completes
$effect(() => { $effect(() => {
if (!isWon) { if (!isWon) {
showReference = false; showReference = false;
return; return;
} }
// Check if user already won today (page reload case) // Check if user already won today (page reload case)
const winTrackedKey = `bibdle-win-tracked-${dailyVerse.date}`; const winTrackedKey = `bibdle-win-tracked-${dailyVerse.date}`;
const alreadyWonToday = browser && localStorage.getItem(winTrackedKey) === "true"; const alreadyWonToday =
browser && localStorage.getItem(winTrackedKey) === "true";
if (alreadyWonToday) { if (alreadyWonToday) {
// User already won and is refreshing - show immediately // User already won and is refreshing - show immediately
showReference = true; showReference = true;
} else { } else {
// User just won this session - delay for animation // User just won this session - delay for animation
const animationDelay = 1800; const animationDelay = 1800;
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
showReference = true; showReference = true;
}, animationDelay); }, animationDelay);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
} }
}); });
function copyVerse() {
navigator.clipboard.writeText(displayVerseText).then(() => {
copied = true;
(window as any).rybbit?.event("Copy Verse");
setTimeout(() => {
copied = false;
}, 2000);
});
}
</script> </script>
<Container class="w-full p-8 sm:p-12 bg-white/70 overflow-hidden"> <Container class="w-full p-8 sm:p-12 bg-white/70 overflow-hidden">
<blockquote <blockquote
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center" class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
> >
{displayVerseText} {displayVerseText}
</blockquote> </blockquote>
<div <div
class="transition-all duration-500 ease-in-out overflow-hidden" class="transition-all duration-500 ease-in-out overflow-hidden"
style="max-height: {showReference ? '200px' : '0px'};" style="max-height: {showReference ? '200px' : '0px'};"
> >
{#if showReference} {#if showReference}
<p <p
transition:fade={{ duration: 400 }} transition:fade={{ duration: 400 }}
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2" class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
> >
{displayReference} {displayReference}
</p> </p>
{/if} <div
</div> transition:fade={{ duration: 300 }}
class="flex justify-center mt-3"
>
<button
onclick={copyVerse}
data-umami-event="Copy Verse"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs big-text text-gray-600 bg-white/50 hover:bg-white/70 border border-gray-300 rounded-lg transition-colors cursor-pointer"
>
{copied ? "✅" : "📋"}
{copied ? "Copied!" : "Copy verse to clipboard"}
</button>
</div>
{/if}
</div>
</Container> </Container>

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { fade, fly } from "svelte/transition"; import { fade, fly } from "svelte/transition";
import { getBookById, toOrdinal } from "$lib/utils/game"; import { getBookById, toOrdinal } from "$lib/utils/game";
import { onMount } from "svelte";
import Container from "./Container.svelte"; import Container from "./Container.svelte";
import CountdownTimer from "./CountdownTimer.svelte"; import CountdownTimer from "./CountdownTimer.svelte";
import ChapterGuess from "./ChapterGuess.svelte"; import ChapterGuess from "./ChapterGuess.svelte";
@@ -53,6 +52,7 @@
typeof navigator !== "undefined" && "share" in navigator, typeof navigator !== "undefined" && "share" in navigator,
); );
let copySuccess = $state(false); let copySuccess = $state(false);
let bubbleCopied = $state(false);
// List of congratulations messages with weights // List of congratulations messages with weights
const congratulationsMessages: WeightedMessage[] = [ const congratulationsMessages: WeightedMessage[] = [
@@ -109,19 +109,24 @@
</p> </p>
{#if streak > 1} {#if streak > 1}
<p class="big-text text-orange-500! text-lg! my-4"> <div class="flex flex-col gap-4 my-4">
🔥 {streak} days in a row! <p class="big-text text-orange-500! text-lg!">
</p> 🔥 {streak} days in a row!
{#if streak >= 7}
<p class="font-black text-lg font-triodion">
Thank you for making Bibdle part of your daily routine!
</p> </p>
{/if} {#if streak >= 7}
{#if streakPercentile !== null} <p class="font-black text-lg font-triodion">
<p class="text-sm mt-4 text-gray-700 font-triodion"> Thank you for making Bibdle part of your daily routine!
{streakPercentile <= 50 ? "Only " : ""}{streakPercentile}% of players have a streak of {streak} or greater. </p>
</p> {/if}
{/if} {#if streakPercentile !== null}
<p class="text-sm text-gray-700 font-triodion">
{streakPercentile <= 50
? "Only "
: ""}{streakPercentile}% of players have a streak of {streak}
or greater.
</p>
{/if}
</div>
{/if} {/if}
</Container> </Container>
@@ -205,34 +210,65 @@
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}> <div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
<div class="big-text font-black! text-center">Share your result</div> <div class="big-text font-black! text-center">Share your result</div>
<div class="share-buttons"> <div class="chat-window">
{#if hasWebShare} <!-- Received bubble: primary action (share / copy) -->
<div class="bubble-wrapper received-wrapper">
<button <button
onclick={() => { (window as any).rybbit?.event("Share"); handleShare(); }} class="bubble bubble-received"
data-umami-event="Share" class:success={copySuccess}
class="share-btn primary" aria-label={hasWebShare ? "Share" : "Copy to clipboard"}
data-umami-event={hasWebShare
? "Share"
: "Copy to Clipboard"}
onclick={() => {
if (hasWebShare) {
(window as any).rybbit?.event("Share");
handleShare();
} else {
(window as any).rybbit?.event("Copy to Clipboard");
copyToClipboard();
copySuccess = true;
setTimeout(() => {
copySuccess = false;
}, 3000);
}
}}
> >
📤 Click to share {#if hasWebShare}
📤 Tap here to share
{:else if copySuccess}
✅ Copied!
{:else}
📋 Copy to clipboard
{/if}
</button> </button>
{:else} </div>
<!-- Sent bubble: share text preview -->
<div class="bubble-wrapper">
<button <button
class="bubble bubble-sent"
aria-label="Copy to clipboard"
data-umami-event="Copy to Clipboard"
onclick={() => { onclick={() => {
(window as any).rybbit?.event("Copy to Clipboard"); (window as any).rybbit?.event("Copy to Clipboard");
copyToClipboard(); copyToClipboard();
copySuccess = true; bubbleCopied = true;
setTimeout(() => { setTimeout(() => {
copySuccess = false; bubbleCopied = false;
}, 3000); }, 2000);
}} }}>{shareText}</button
data-umami-event="Copy to Clipboard"
class={`share-btn primary ${copySuccess ? "success" : ""}`}
> >
{copySuccess ? "✅ Copied!" : "📋 Copy to clipboard"} {#if hasWebShare}
</button> <span class="copy-hint"
{/if} >{bubbleCopied ? "copied!" : "(tap to copy)"}</span
</div> >
<div class="chat-window"> {:else}
<p class="bubble">{shareText}</p> <span class="copy-hint"
>{bubbleCopied ? "copied!" : ""}</span
>
{/if}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -249,14 +285,13 @@
} }
} }
.fade-in { :global(.fade-in) {
animation: fadeIn 0.5s ease-out; animation: fadeIn 0.5s ease-out;
} }
/* ── Share card ── */ /* ── Share card ── */
.share-card { .share-card {
background: rgba(255, 255, 255, 0.5); background: oklch(94% 0.028 298.626);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 1.25rem; border-radius: 1.25rem;
padding: 1.25rem; padding: 1.25rem;
@@ -278,115 +313,167 @@
pointer-events: none; pointer-events: none;
} }
.share-card-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #8e8e93;
margin: 0;
}
/* ── Chat window ── */ /* ── Chat window ── */
.chat-window { .chat-window {
--sent-color: #0b93f6; --sent-color: #0b93f6;
--bg: oklch(93.996% 0.03041 300.209); --received-color: #3a3a3c;
--bg: oklch(94% 0.028 298.626);
display: flex; display: flex;
justify-content: center; flex-direction: column;
padding: 0 1.5rem 0; padding: 0 0.5rem 0;
gap: 0.6rem;
} }
/* ── Bubble (from article technique) ── */ /* ── Bubble wrappers ── */
.bubble-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.received-wrapper {
align-items: flex-start;
}
/* ── Shared bubble base ── */
.bubble { .bubble {
position: relative; position: relative;
max-width: 255px; max-width: 255px;
margin-bottom: 8px; margin-bottom: 0;
padding: 10px 20px; padding: 10px 20px;
line-height: 1.3; line-height: 1.3;
word-wrap: break-word; word-wrap: break-word;
border-radius: 25px; border-radius: 25px;
text-align: left; text-align: left;
white-space: pre-wrap; white-space: pre-wrap;
font-size: 0.9rem; font-size: 1rem;
transform: rotate(-2deg); cursor: pointer;
transition:
color: white; filter 80ms ease,
background: var(--sent-color); transform 80ms ease;
user-select: none;
} }
.bubble::before, /* ── Sent bubble (share text preview) ── */
.bubble::after { .bubble-sent {
color: white;
background: var(--sent-color);
transform: rotate(-2deg);
}
.bubble-sent:hover {
background-color: #2ea8ff;
transform: rotate(-2deg) translateY(-2px);
}
.bubble-sent:hover::before {
background-color: #2ea8ff;
}
.bubble-sent:active {
background-color: #0878d4;
transform: rotate(-2deg) scale(0.97);
}
.bubble-sent:active::before {
background-color: #0878d4;
}
/* Sent tail: bottom-right */
.bubble-sent::before,
.bubble-sent::after {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
height: 25px; height: 25px;
content: ""; content: "";
} }
.bubble::before { .bubble-sent::before {
width: 20px; width: 20px;
right: -7px; right: -7px;
background-color: var(--sent-color); background-color: var(--sent-color);
border-bottom-left-radius: 16px 14px; border-bottom-left-radius: 16px 14px;
} }
.bubble::after { .bubble-sent::after {
width: 26px; width: 26px;
right: -26px; right: -26px;
border-bottom-left-radius: 10px; border-bottom-left-radius: 10px;
background-color: var(--bg); background-color: var(--bg);
} }
/* ── Share buttons ── */ /* ── Received bubble (action button) ── */
.share-buttons { .bubble-received {
display: flex; color: #f5f5f7;
flex-direction: column; background: var(--received-color);
gap: 0.5rem; transform: rotate(2deg);
width: 100%; padding: 14px 24px;
}
.share-btn {
width: 100%;
padding: 1rem 1.5rem;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 700; font-weight: 700;
border: 3px solid rgba(0, 0, 0, 0.25); min-width: 14rem;
border-radius: 1rem; text-align: center;
cursor: pointer; }
transition:
transform 80ms ease, .bubble-received:hover {
box-shadow 80ms ease, background-color: #4a4a4e;
opacity 80ms ease; transform: rotate(2deg) translateY(-2px);
}
.bubble-received:hover::before {
background-color: #4a4a4e;
}
.bubble-received:active {
background-color: #2a2a2c;
transform: rotate(2deg) scale(0.97);
}
.bubble-received:active::before {
background-color: #2a2a2c;
}
.bubble-received.success {
background: #c7f7d4;
color: #155724;
}
/* Received tail: bottom-left (mirror of sent) */
.bubble-received::before,
.bubble-received::after {
position: absolute;
bottom: 0;
height: 25px;
content: "";
}
.bubble-received::before {
width: 20px;
left: -7px;
background-color: var(--received-color);
border-bottom-right-radius: 16px 14px;
}
.bubble-received::after {
width: 26px;
left: -26px;
border-bottom-right-radius: 10px;
background-color: var(--bg);
}
.bubble-received.success::before {
background-color: #c7f7d4;
}
/* ── Copy hints ── */
.copy-hint {
font-size: 0.68rem;
color: #444;
font-weight: 400;
letter-spacing: 0.01em; letter-spacing: 0.01em;
} padding-right: 32px;
transform: rotate(-2deg);
.share-btn:active { transform-origin: right center;
transform: scale(0.97); margin-top: -6px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
.share-btn.primary {
background: #7c3aed;
color: white;
box-shadow: 0 4px 14px rgba(124, 58, 237, 0.4);
}
.share-btn.primary:hover {
opacity: 0.92;
}
.share-btn.primary.success {
background: #636363;
color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.share-btn.secondary {
background: #f2f2f7;
color: #1c1c1e;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.share-btn.secondary:hover {
background: #e5e5ea;
} }
</style> </style>

View File

@@ -25,6 +25,7 @@
type StatsData, type StatsData,
} from "$lib/utils/stats-client"; } from "$lib/utils/stats-client";
import { createGamePersistence } from "$lib/stores/game-persistence.svelte"; import { createGamePersistence } from "$lib/stores/game-persistence.svelte";
import { SvelteSet } from "svelte/reactivity";
let { data }: PageProps = $props(); let { data }: PageProps = $props();
@@ -51,7 +52,7 @@
); );
let guessedIds = $derived( let guessedIds = $derived(
new Set(persistence.guesses.map((g) => g.book.id)), new SvelteSet(persistence.guesses.map((g) => g.book.id)),
); );
const currentDate = $derived( const currentDate = $derived(
@@ -302,7 +303,7 @@
{#if !isWon} {#if !isWon}
<div class="animate-fade-in-up animate-delay-400"> <div class="animate-fade-in-up animate-delay-400">
<SearchInput bind:searchQuery {guessedIds} {submitGuess} /> <SearchInput bind:searchQuery {guessedIds} {submitGuess} guessCount={persistence.guesses.length} />
</div> </div>
{:else if showWinScreen} {:else if showWinScreen}
<div class="animate-fade-in-up animate-delay-400"> <div class="animate-fade-in-up animate-delay-400">

45
todo.md
View File

@@ -59,10 +59,49 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
# done # done
## february 22nd
- New share button design; speech bubbles
- Share rate jumped from ~17% to ~27% (n=200) after share button redesign
- Updated streak-percentile to count all players from last 30 days and all streaks (in case there are streaks >30 days)
- Added copy verse button
- Refactored book search input to show progressively more info based on guess count
## february 21st
- Added streak counter and streak percentage
- Added Rybbit analytics alongside Umami
## february 18th-19th
- Refactored game logic into utility modules
- Small fixes to Sign In with Apple migrations
## february 13th
- Added Sign In with Apple
- Added animations on win and guess
- Various Apple auth bug fixes
## february 11th-12th
- Client-side timezone handling for daily verses (was using server time)
- Staggered page load animations
- Reordered guesses table with emphasis
- Redesigned stats page with dark theme and enhanced statistics
## february 5th-10th
- Added login modal and auth infrastructure
- Switched to `bun:sqlite`
- Support authenticated users in stats and page loading
- Anonymous stats migration on sign-in
- Test infrastructure and sign-in migration tests
## february 2nd ## february 2nd
- created rss feed - created rss feed
- fixed "first letter" clue edge cases - fixed "first letter" clue edge cases / easter egg
- updated ranking formula - updated ranking formula
## january 28th ## january 28th
@@ -82,6 +121,10 @@ I created Bibdle from a combination of two things. The first is my lifelong desi
- added "first letter" column - added "first letter" column
- added imposter mode, v0.1 (mom likes it) but needs work - added imposter mode, v0.1 (mom likes it) but needs work
## january 8th
- posted on Hacker News and LinkedIn, got 960 visitors in one day
## january 5th ## january 5th
- created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing... - created Imposter Mode with four options, identify the verse that is not in the same book as the other three. Needs more testing...