Fixed instructions, added color border based on closeness between guess

and target
This commit is contained in:
George Powell
2026-03-15 03:19:21 -04:00
parent 252edc3a6d
commit e878dea235
4 changed files with 390 additions and 297 deletions

View File

@@ -0,0 +1,41 @@
<script lang="ts">
let { guessCount }: { guessCount: number } = $props();
let promptText = $state("What book of the Bible is this verse from?");
let visible = $state(true);
$effect(() => {
let fadeOutId: ReturnType<typeof setTimeout>;
let fadeInId: ReturnType<typeof setTimeout>;
let changeId: ReturnType<typeof setTimeout>;
function animateTo(newText: string, delay = 0) {
fadeOutId = setTimeout(() => {
visible = false;
changeId = setTimeout(() => {
promptText = newText;
visible = true;
}, 300);
}, delay);
}
if (guessCount === 0) {
animateTo("What book of the Bible is this verse from?");
} else {
animateTo("Guess again", 2100);
}
return () => {
clearTimeout(fadeOutId);
clearTimeout(fadeInId);
clearTimeout(changeId);
};
});
</script>
<p
class="big-text text-center mb-6 px-4"
style="transition: opacity 0.3s ease; opacity: {visible ? 1 : 0};"
>
{promptText}
</p>

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { bibleBooks } from "$lib/types/bible";
import { getFirstLetter, type Guess } from "$lib/utils/game";
import Container from "./Container.svelte";
let {
guesses,
@@ -16,6 +15,19 @@
return "bg-red-500 border-red-600";
}
function getBookBoxStyle(guess: Guess): string {
if (guess.book.id === correctBookId) {
return "background-color: #22c55e; border-color: #16a34a;";
}
const correctBook = bibleBooks.find((b) => b.id === correctBookId);
if (!correctBook)
return "background-color: #ef4444; border-color: #dc2626;";
const t = Math.abs(guess.book.order - correctBook.order) / 65;
const hue = 120 * Math.pow(1 - t, 3);
const lightness = 55 - (hue / 120) * 15;
return `background-color: #ef4444; border-color: hsl(${hue}, 80%, ${lightness}%);`;
}
function getBoxContent(
guess: Guess,
column: "book" | "firstLetter" | "testament" | "section",
@@ -64,17 +76,7 @@
}
</script>
{#if !hasGuesses}
<Container class="p-6 text-center">
<h2 class="font-triodion text-xl italic mb-3 text-gray-800 dark:text-gray-100">
Instructions
</h2>
<p class="text-gray-700 dark:text-gray-300 leading-relaxed italic">
Guess what book of the bible you think the verse is from. You will
get clues to help you after each guess.
</p>
</Container>
{:else}
{#if hasGuesses}
<div class="space-y-3">
<!-- Column Headers -->
<div
@@ -146,10 +148,9 @@
<!-- Book Column -->
<div
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 border-opacity-100 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in {getBoxColor(
guess.book.id === correctBookId,
)}"
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in"
style="animation-delay: {rowIndex * 1000 +
3 * 500}ms; {getBookBoxStyle(guess)}"
>
<span class="text-center leading-tight px-1 text-shadow-lg"
>{getBoxContent(guess, "book")}</span

View File

@@ -1,309 +1,355 @@
<script lang="ts">
import { bibleBooks, type BibleBook, type BibleSection, type Testament } from "$lib/types/bible";
import { SvelteSet } from "svelte/reactivity";
import {
bibleBooks,
type BibleBook,
type BibleSection,
type Testament,
} from "$lib/types/bible";
import { SvelteSet } from "svelte/reactivity";
let {
searchQuery = $bindable(""),
guessedIds,
submitGuess,
guessCount = 0,
}: {
searchQuery: string;
guessedIds: SvelteSet<string>;
submitGuess: (id: string) => void;
guessCount: number;
} = $props();
let {
searchQuery = $bindable(""),
guessedIds,
submitGuess,
guessCount = 0,
}: {
searchQuery: string;
guessedIds: SvelteSet<string>;
submitGuess: (id: string) => void;
guessCount: number;
} = $props();
type DisplayMode = "simple" | "testament" | "sections";
type DisplayMode = "simple" | "testament" | "sections";
const displayMode = $derived<DisplayMode>(
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple"
);
const displayMode = $derived<DisplayMode>(
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple",
);
const filteredBooks = $derived(
bibleBooks.filter((book) =>
book.name.toLowerCase().includes(searchQuery.toLowerCase())
)
);
const filteredBooks = $derived(
bibleBooks.filter((book) =>
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
),
);
type SimpleGroup = { books: BibleBook[] };
type SimpleGroup = { books: BibleBook[] };
type TestamentGroup = {
testament: Testament;
label: string;
books: BibleBook[];
};
type TestamentGroup = {
testament: Testament;
label: string;
books: BibleBook[];
};
type SectionGroup = {
testament: Testament;
testamentLabel: string;
showTestamentHeader: boolean;
section: BibleSection;
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 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 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 }[] = [];
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 });
}
}
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;
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;
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;
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,
});
}
groups.push({
testament: pair.testament,
testamentLabel:
pair.testament === "old"
? "Old Testament"
: "New Testament",
showTestamentHeader,
section: pair.section,
books,
});
}
return groups;
});
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;
});
// 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) {
if (e.key === "Enter" && firstBookId) {
submitGuess(firstBookId);
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && firstBookId) {
submitGuess(firstBookId);
}
}
const showBanner = $derived(guessCount >= 3);
const bannerIsIndigo = $derived(guessCount >= 9);
// const showBanner = $derived(guessCount >= 3);
const showBanner = false;
const bannerIsIndigo = $derived(guessCount >= 9);
</script>
{#if showBanner}
<p
class="mb-3 text-xs font-medium text-gray-500 dark:text-gray-400"
role="status"
aria-live="polite"
>
{#if bannerIsIndigo}
Testament &amp; section groups now visible
{:else}
Old &amp; New Testament groups now visible
{/if}
</p>
<p
class="mb-3 text-xs font-medium text-gray-500 dark:text-gray-400"
role="status"
aria-live="polite"
>
{#if bannerIsIndigo}
Testament &amp; section groups now visible
{:else}
Old &amp; New Testament groups now visible
{/if}
</p>
{/if}
<div class="relative">
<div class="relative">
<svg
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"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
bind:value={searchQuery}
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 dark:border-gray-600 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 dark:focus:border-blue-400 focus:ring-4 focus:ring-blue-200 dark:focus:ring-blue-900/50 transition-all bg-white dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
onkeydown={handleKeydown}
autocomplete="off"
/>
{#if searchQuery}
<button
class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
onclick={() => (searchQuery = "")}
aria-label="Clear search"
>
<svg
class="w-5 h-5 sm:w-6 sm:h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
<div class="relative">
<svg
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"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
bind:value={searchQuery}
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
class="w-full pl-12 sm:pl-16 p-4 sm:p-6 border-2 border-gray-500 dark:border-gray-600 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 dark:focus:border-blue-400 focus:ring-4 focus:ring-blue-200 dark:focus:ring-blue-900/50 transition-all bg-white dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
onkeydown={handleKeydown}
autocomplete="off"
/>
{#if searchQuery}
<button
class="absolute right-4 sm:right-6 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
onclick={() => (searchQuery = "")}
aria-label="Clear search"
>
<svg
class="w-5 h-5 sm:w-6 sm:h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
{#if searchQuery && filteredBooks.length > 0}
<ul
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-2xl shadow-xl"
role="listbox"
>
{#if displayMode === "simple"}
{#each simpleGroup.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
{#if searchQuery && filteredBooks.length > 0}
<ul
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-2xl shadow-xl"
role="listbox"
>
{#if displayMode === "simple"}
{#each simpleGroup.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 dark:text-gray-100 {guessedIds.has(book.id)
? 'line-through text-gray-400 dark:text-gray-500'
: ''}"
>
{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 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
>
<span
class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-400"
>
{group.label}
</span>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></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 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
? '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 dark:text-gray-100 {guessedIds.has(
book.id,
)
? 'line-through text-gray-400 dark:text-gray-500'
: ''}"
>
{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 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
>
<span
class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-400"
>
{group.label}
</span>
<div
class="flex-1 h-px bg-gray-200 dark:bg-gray-600"
></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 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
{guessedIds.has(book.id)
? 'opacity-50 cursor-not-allowed pointer-events-none'
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
onclick={() => submitGuess(book.id)}
tabindex={guessedIds.has(book.id) ? -1 : 0}
>
<span
class="font-semibold {guessedIds.has(book.id)
? 'line-through text-gray-400 dark:text-gray-500'
: ''}"
>
{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 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
>
<span
class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
{group.testamentLabel}
</span>
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-600"></div>
</div>
{/if}
<div
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 dark:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700"
>
<span
class="text-[11px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500"
>
{group.section}
</span>
<div class="flex-1 h-px bg-gray-100 dark:bg-gray-600"></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 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
? 'opacity-50 cursor-not-allowed pointer-events-none'
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
onclick={() => submitGuess(book.id)}
tabindex={guessedIds.has(book.id)
? -1
: 0}
>
<span
class="font-semibold {guessedIds.has(
book.id,
)
? 'line-through text-gray-400 dark:text-gray-500'
: ''}"
>
{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 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
>
<span
class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
{group.testamentLabel}
</span>
<div
class="flex-1 h-px bg-gray-200 dark:bg-gray-600"
></div>
</div>
{/if}
<div
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 dark:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700"
>
<span
class="text-[11px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500"
>
{group.section}
</span>
<div
class="flex-1 h-px bg-gray-100 dark:bg-gray-600"
></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 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
{guessedIds.has(book.id)
? 'opacity-50 cursor-not-allowed pointer-events-none'
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
onclick={() => submitGuess(book.id)}
tabindex={guessedIds.has(book.id) ? -1 : 0}
>
<span
class="font-semibold {guessedIds.has(book.id)
? 'line-through text-gray-400 dark:text-gray-500'
: ''}"
>
{book.name}
</span>
</button>
</li>
{/each}
</ul>
</li>
{/each}
{/if}
</ul>
{:else if searchQuery}
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">No books found</p>
{/if}
? 'opacity-50 cursor-not-allowed pointer-events-none'
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
onclick={() => submitGuess(book.id)}
tabindex={guessedIds.has(book.id)
? -1
: 0}
>
<span
class="font-semibold {guessedIds.has(
book.id,
)
? 'line-through text-gray-400 dark:text-gray-500'
: ''}"
>
{book.name}
</span>
</button>
</li>
{/each}
</ul>
</li>
{/each}
{/if}
</ul>
{:else if searchQuery}
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">
No books found
</p>
{/if}
</div>

View File

@@ -9,6 +9,7 @@
import WinScreen from "$lib/components/WinScreen.svelte";
import Credits from "$lib/components/Credits.svelte";
import GamePrompt from "$lib/components/GamePrompt.svelte";
import DevButtons from "$lib/components/DevButtons.svelte";
import AuthModal from "$lib/components/AuthModal.svelte";
@@ -297,6 +298,8 @@
{#if !isWon}
<div class="animate-fade-in-up animate-delay-400">
<GamePrompt guessCount={persistence.guesses.length} />
<SearchInput
bind:searchQuery
{guessedIds}
@@ -335,8 +338,6 @@
<Credits />
</div>
{/if}
</div>
{#if isDev}
<div class="mt-8 flex flex-col items-stretch md:items-center gap-3">
@@ -373,7 +374,11 @@
<div>Daily Verse Date: {dailyVerse.date}</div>
<div>Streak: {streak}</div>
</div>
<DevButtons anonymousId={persistence.anonymousId} {user} onSignIn={() => (authModalOpen = true)} />
<DevButtons
anonymousId={persistence.anonymousId}
{user}
onSignIn={() => (authModalOpen = true)}
/>
</div>
{/if}