+
+
+
+
+ Welcome back to HiveLore.
+
+
+ Sign in to contribute, vote, and build trusted Hive knowledge.
+
+
+ Hive accounts are the canonical identity. Signing a challenge proves account control and
+ never gives HiveLore permission to publish, transfer, or hold private keys.
+
+
+
+
+ {user ? (
+
+
+
Signed in as
+
@{user.hiveUsername}
- ) : (
-
-
- Hive username
- setUsername(event.target.value)}
- placeholder="alice"
- value={username}
- />
-
-
-
- Sign in with Hive Keychain
+
+
+ Refresh Session
-
-
-
- or
-
-
-
-
- Continue with HiveSigner
+
+ Log Out
-
- {env.googleAuthEnabled ? (
- Continue with Google
- ) : null}
-
-
- No password is stored by HiveLore.
-
- )}
-
- {challenge ? (
-
-
- Challenge message
-
-
-
- Signature
- setManualSignature(event.target.value)}
- placeholder="Paste HiveSigner signature"
- value={manualSignature}
- />
-
-
- Public key
- setManualPublicKey(event.target.value)}
- placeholder="Optional recovered public key"
- value={manualPublicKey}
- />
-
-
- Verify Signature
-
+
+ ) : (
+
+
+ Hive username
+ setUsername(event.target.value)}
+ placeholder="alice"
+ value={username}
+ />
+
+
+
+ Sign in with Hive Keychain
+
+
+
+
+ or
+
- ) : null}
-
- {error ? (
-
- Authentication failed
- {error}
-
- ) : null}
-
-
+
+
+ Continue with HiveSigner
+
+
+ }
+ title={
+ env.googleAuthEnabled
+ ? 'Continue with Google'
+ : 'Google onboarding is not enabled in this environment.'
+ }
+ variant="secondary"
+ >
+ Continue with Google
+
+
+
+ Google creates or connects a Hive account behind the scenes.
+
+
+
+ No password is stored by HiveLore.
+
+
+ )}
+
+ {challenge ? (
+
+
+ Challenge message
+
+
+
+ Signature
+ setManualSignature(event.target.value)}
+ placeholder="Paste HiveSigner signature"
+ value={manualSignature}
+ />
+
+
+ Public key
+ setManualPublicKey(event.target.value)}
+ placeholder="Optional recovered public key"
+ value={manualPublicKey}
+ />
+
+
+ Verify Signature
+
+
+ ) : null}
+
+ {error ? (
+
+ Authentication failed
+ {error}
+
+ ) : null}
+
-
+
);
}
diff --git a/apps/web/src/components/auth/lore-atlas-background.tsx b/apps/web/src/components/auth/lore-atlas-background.tsx
new file mode 100644
index 0000000..507ecac
--- /dev/null
+++ b/apps/web/src/components/auth/lore-atlas-background.tsx
@@ -0,0 +1,179 @@
+'use client';
+
+import { useEffect, useRef, type CSSProperties } from 'react';
+
+const nodes = [
+ { id: 'ember-crown', x: 22, y: 39, r: 2.2, type: 'hub' },
+ { id: 'salt-banner', x: 33, y: 31, r: 1.45, type: 'region' },
+ { id: 'ash-vault', x: 42, y: 43, r: 0.95, type: 'lore' },
+ { id: 'oracle', x: 29, y: 55, r: 1.05, type: 'lore' },
+ { id: 'neon-archives', x: 55, y: 28, r: 1.75, type: 'region' },
+ { id: 'null-choir', x: 64, y: 45, r: 0.95, type: 'lore' },
+ { id: 'memory-court', x: 49, y: 58, r: 1.05, type: 'lore' },
+ { id: 'river-names', x: 24, y: 74, r: 1.55, type: 'region' },
+ { id: 'ferryman', x: 39, y: 78, r: 0.85, type: 'lore' },
+ { id: 'founder-record', x: 12, y: 61, r: 0.8, type: 'lore' },
+ { id: 'hidden-era', x: 70, y: 66, r: 1.35, type: 'region' },
+ { id: 'artifact-veil', x: 80, y: 38, r: 0.8, type: 'lore' },
+] as const;
+
+const connections = [
+ 'M22 39 C27 32 30 30 33 31',
+ 'M22 39 C29 40 36 44 42 43',
+ 'M22 39 C19 48 22 53 29 55',
+ 'M29 55 C23 59 17 61 12 61',
+ 'M29 55 C27 64 25 69 24 74',
+ 'M24 74 C29 79 34 80 39 78',
+ 'M42 43 C47 34 51 29 55 28',
+ 'M55 28 C62 31 69 35 80 38',
+ 'M55 28 C61 35 64 39 64 45',
+ 'M64 45 C58 51 53 55 49 58',
+ 'M49 58 C56 64 63 67 70 66',
+ 'M70 66 C73 55 77 46 80 38',
+ 'M39 78 C48 73 58 68 70 66',
+] as const;
+
+const contours = [
+ 'M5 47 C11 37 19 32 30 34 C39 35 43 25 53 22 C64 19 70 27 82 24 C89 22 94 16 99 11',
+ 'M3 62 C12 56 21 58 31 52 C43 45 52 51 63 45 C73 40 80 31 94 30',
+ 'M9 80 C19 85 31 84 42 78 C55 72 67 76 84 67 C91 63 96 58 100 55',
+ 'M8 25 C18 19 28 12 43 14 C55 16 62 12 75 9 C83 7 90 8 96 5',
+ 'M15 70 C22 66 27 65 33 61 C45 52 55 56 66 50 C74 46 83 44 92 41',
+ 'M19 38 C24 45 33 49 43 48 C54 47 58 37 67 36 C76 35 82 43 90 42',
+] as const;
+
+const coastlines = [
+ 'M8 66 C12 54 22 50 26 42 C31 32 42 32 48 26 C56 18 68 22 73 31 C77 39 72 47 79 55 C87 64 79 75 68 76 C58 77 53 84 43 81 C34 78 30 72 22 73 C15 74 10 72 8 66Z',
+ 'M55 64 C61 58 68 58 74 52 C81 46 90 50 93 59 C96 69 90 78 79 81 C69 84 62 78 55 64Z',
+ 'M19 24 C24 17 34 17 39 23 C44 29 40 36 31 37 C23 38 15 32 19 24Z',
+] as const;
+
+const particles = [
+ { left: '9%', top: '22%', delay: '0s', distance: '22px' },
+ { left: '17%', top: '83%', delay: '6s', distance: '-18px' },
+ { left: '30%', top: '18%', delay: '11s', distance: '16px' },
+ { left: '44%', top: '72%', delay: '3s', distance: '-20px' },
+ { left: '58%', top: '21%', delay: '8s', distance: '18px' },
+ { left: '72%', top: '56%', delay: '13s', distance: '-16px' },
+ { left: '84%', top: '33%', delay: '4s', distance: '20px' },
+ { left: '91%', top: '78%', delay: '10s', distance: '-14px' },
+] as const;
+
+export function LoreAtlasBackground() {
+ const rootRef = useRef
(null);
+
+ useEffect(() => {
+ const root = rootRef.current;
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
+ const canUseParallax = window.matchMedia('(pointer: fine) and (min-width: 768px)');
+
+ if (!root || reducedMotion.matches || !canUseParallax.matches) {
+ return;
+ }
+
+ let animationFrame = 0;
+
+ const handlePointerMove = (event: PointerEvent) => {
+ window.cancelAnimationFrame(animationFrame);
+ animationFrame = window.requestAnimationFrame(() => {
+ const x = (event.clientX / window.innerWidth - 0.5).toFixed(3);
+ const y = (event.clientY / window.innerHeight - 0.5).toFixed(3);
+
+ root.style.setProperty('--atlas-parallax-x', x);
+ root.style.setProperty('--atlas-parallax-y', y);
+ });
+ };
+
+ window.addEventListener('pointermove', handlePointerMove, { passive: true });
+
+ return () => {
+ window.cancelAnimationFrame(animationFrame);
+ window.removeEventListener('pointermove', handlePointerMove);
+ };
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {contours.map((path) => (
+
+ ))}
+
+
+
+ {coastlines.map((path) => (
+
+ ))}
+
+
+
+ {connections.map((path) => (
+
+ ))}
+
+
+
+ {connections.slice(0, 7).map((path, index) => (
+
+ ))}
+
+
+
+ {nodes.map((node, index) => (
+
+
+
+
+ ))}
+
+
+
+
+ {particles.map((particle) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/theme-switcher.tsx b/apps/web/src/components/theme-switcher.tsx
index a903cd8..f184fed 100644
--- a/apps/web/src/components/theme-switcher.tsx
+++ b/apps/web/src/components/theme-switcher.tsx
@@ -1,27 +1,38 @@
'use client';
-import { useId } from 'react';
import { useTheme } from '@/providers/theme-provider';
export function ThemeSwitcher() {
- const id = useId();
- const { preference, setPreference } = useTheme();
+ const { resolvedTheme, setPreference } = useTheme();
+ const isDark = resolvedTheme === 'dark';
+ const nextTheme = isDark ? 'light' : 'dark';
+ const label = isDark ? 'Switch to light mode' : 'Switch to dark mode';
return (
-
-
- Theme
-
- setPreference(event.target.value as 'system' | 'light' | 'dark')}
- >
- System
- Light
- Dark
-
-
+ setPreference(nextTheme)}
+ title={label}
+ type="button"
+ >
+ {label}
+ {isDark ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
);
}
From 9ee38ce8369bb671b19a703660eeadbc53557910 Mon Sep 17 00:00:00 2001
From: Kareem Naous <62202089+KareemN1936@users.noreply.github.com>
Date: Thu, 6 Aug 2026 21:16:44 +0300
Subject: [PATCH 2/2] feat(ui): restore responsive worlds frontend experience
---
apps/web/src/app/globals.css | 51 ++++
apps/web/src/app/layout.tsx | 19 --
apps/web/src/app/worlds/[worldId]/page.tsx | 288 +++++++++++++++++-
apps/web/src/app/worlds/new/page.tsx | 181 ++++++++++-
apps/web/src/app/worlds/page.tsx | 201 +++++++++++-
.../components/layout/mobile-navigation.tsx | 90 ++++--
.../web/src/components/layout/site-header.tsx | 4 +-
apps/web/src/components/theme-switcher.tsx | 53 ++--
apps/web/src/lib/worlds/mock-worlds.ts | 285 +++++++++++++++++
apps/web/src/providers/theme-provider.tsx | 96 +++---
10 files changed, 1150 insertions(+), 118 deletions(-)
create mode 100644 apps/web/src/lib/worlds/mock-worlds.ts
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index b408ba6..75c71c3 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -192,6 +192,57 @@ textarea {
height: 1.25rem;
}
+.mobile-navigation-backdrop {
+ position: fixed;
+ inset: 4rem 0 0;
+ z-index: 70;
+ border: 0;
+ background:
+ linear-gradient(rgb(0 0 0 / 0.92), rgb(0 0 0 / 0.92)),
+ color-mix(in srgb, var(--background) 35%, black);
+ backdrop-filter: blur(8px);
+ cursor: default;
+ padding: 0;
+}
+
+.mobile-navigation-panel {
+ position: fixed;
+ top: 4.75rem;
+ right: 1rem;
+ z-index: 80;
+ display: flex;
+ width: calc(100vw - 2rem);
+ max-width: 24rem;
+ transform-origin: top right;
+ flex-direction: column;
+ gap: 1.25rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-panel);
+ background: color-mix(in srgb, var(--surface) 98%, transparent);
+ box-shadow: var(--shadow-elevated);
+ padding: 1rem;
+}
+
+@keyframes mobile-overlay-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes mobile-menu-in {
+ from {
+ opacity: 0;
+ transform: translateY(-0.5rem) scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
.hive-brand {
display: inline-flex;
align-items: center;
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index b345951..eb5c2bd 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -16,22 +16,6 @@ const geistMono = Geist_Mono({
subsets: ['latin'],
});
-const themeInitScript = `
-(() => {
- try {
- const stored = localStorage.getItem('hivelore-theme');
- const preference = stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system';
- const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
- const theme = preference === 'system' ? (systemDark ? 'dark' : 'light') : preference;
- document.documentElement.dataset.theme = theme;
- document.documentElement.style.colorScheme = theme;
- } catch {
- document.documentElement.dataset.theme = 'light';
- document.documentElement.style.colorScheme = 'light';
- }
-})();
-`;
-
export const metadata: Metadata = {
title: 'HiveLore',
description: 'Collaborative worldbuilding platform built on Hive.',
@@ -48,9 +32,6 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable}`}
suppressHydrationWarning
>
-
-
-
{children}
diff --git a/apps/web/src/app/worlds/[worldId]/page.tsx b/apps/web/src/app/worlds/[worldId]/page.tsx
index 64454ea..5941418 100644
--- a/apps/web/src/app/worlds/[worldId]/page.tsx
+++ b/apps/web/src/app/worlds/[worldId]/page.tsx
@@ -1,4 +1,11 @@
-import { PlaceholderPage } from '@/components/layout/placeholder-page';
+import Link from 'next/link';
+import { notFound } from 'next/navigation';
+
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { getWorldById, loreCategories } from '@/lib/worlds/mock-worlds';
type WorldPageProps = {
params: Promise<{ worldId: string }>;
@@ -6,6 +13,283 @@ type WorldPageProps = {
export default async function WorldPage({ params }: WorldPageProps) {
const { worldId } = await params;
+ const world = getWorldById(worldId);
+
+ if (!world) {
+ notFound();
+ }
+
+ return (
+
+
+
+
+ {world.genre}
+ {world.canonEntries} canon entries
+
+ {world.activeProposals} active proposals
+
+
+
+ {world.title}
+
+
{world.summary}
+
+
+
Tone
+ {world.tone}
+
+
+
Founder
+ @{world.founder}
+
+
+
Main conflict
+ {world.mainConflict}
+
+
+
+
+
+ Next actions
+ Read, vote, or contribute inside this world.
+
+
+
+ Create Lore Entry
+
+
+ Write Story Contribution
+
+
+ View Timeline
+ View Map
+
+
+
+
+
+
+
+
+ World Seed
+ Foundation lore accepted when the world was created.
+
+
+
+
+
Starting location
+ {world.seed.location}
+
+
+
+ First historical event
+
+ {world.seed.firstEvent}
+
+
+
First characters
+
+ {world.seed.characters.map((character) => (
+ {character}
+ ))}
+
+
+
+
First factions
+
+ {world.seed.factions.map((faction) => (
+ {faction}
+ ))}
+
+
+
+
+
+
+
+
+ Canon status
+ Quick health read before users contribute.
+
+
+ {world.canonHealth.map((item) => (
+
+
+
{item.label}
+ {item.value}
+
+
{item.detail}
+
+ ))}
+
+
+
+
+
+
+
+ World Codex preview
+ Contributor-facing rules that ground AI checks.
+
+
+
+ {world.codexPreview.map((rule) => (
+
+
+ {rule}
+
+ ))}
+
+
+
+
+
+
+ Ask about this world
+
+ AI guide panel placeholder for summaries, contradictions, and contribution guidance.
+
+
+
+
+ Ask AI Guide
+
+
+
+
+
+
+
+ Canon entries
+
+
+
+ {loreCategories.map((category) => (
+
+ {category.label}
+
+ ))}
+
+ {loreCategories.map((category) => (
+
+
+
+
+
+
{category.label}
+
+ {world.categoryCounts[category.id]} entries in this world.
+
+
+
+ Add {category.label}
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+
Featured canon
+
+ Reading endpoints that show the world identity quickly.
+
+
+
+ {world.featuredCanon.map((entry) => (
+
+
+
+ {entry.type}
+ {entry.status?.replaceAll('-', ' ')}
+
+ {entry.title}
+
+
+ {entry.summary}
+
+ Open entry
+
+
+
+ ))}
+
+
- return ;
+
+
+
Pending proposals
+
+ Contributions that need review or votes.
+
+
+
+ {world.pendingProposals.length > 0 ? (
+ world.pendingProposals.map((proposal) => (
+
+
+
+ Proposal
+ {proposal.hasAiWarning ? (
+ AI Warning
+ ) : null}
+
+ {proposal.title}
+ By @{proposal.author}
+
+
+
+
+
Votes
+ {proposal.voteCount}
+
+
+
Approval
+ {proposal.approvalPercent}%
+
+
+
+ Review proposal
+
+
+
+ ))
+ ) : (
+
+
+
+ No active proposals. This is a good moment to add structured lore.
+
+
+
+ )}
+
+
+
+
+ );
}
diff --git a/apps/web/src/app/worlds/new/page.tsx b/apps/web/src/app/worlds/new/page.tsx
index d8cb39b..1967f88 100644
--- a/apps/web/src/app/worlds/new/page.tsx
+++ b/apps/web/src/app/worlds/new/page.tsx
@@ -1,5 +1,182 @@
-import { PlaceholderPage } from '@/components/layout/placeholder-page';
+import Link from 'next/link';
+
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Select } from '@/components/ui/select';
+import { Textarea } from '@/components/ui/textarea';
+
+const starterCharacters = ['Founder heir', 'Rival witness', 'Guide or chronicler'];
+const starterFactions = ['Founder circle', 'Opposing faction', 'Neutral civic group'];
export default function NewWorldPage() {
- return ;
+ return (
+
+
+
+
+ Founder setup
+
+
+ Create a world seed and bible.
+
+
+ Give contributors enough structure to understand the world, write inside its rules, and
+ start building canon through the community loop.
+
+
+
+
+ Starter canon
+
+ The founder creates the first layer, then canon decisions move through votes and AI
+ checks.
+
+
+
+
+
+
+
+
+
+ World Codex
+
+ Placeholder for the rich World Bible editor that will guide contributors and feed AI
+ consistency checks.
+
+
+
+
+
+ Save Draft World
+ Create World
+
+ Back to Worlds
+
+
+
+
+
+ );
}
diff --git a/apps/web/src/app/worlds/page.tsx b/apps/web/src/app/worlds/page.tsx
index 95001df..5d597fd 100644
--- a/apps/web/src/app/worlds/page.tsx
+++ b/apps/web/src/app/worlds/page.tsx
@@ -1,5 +1,202 @@
-import { PlaceholderPage } from '@/components/layout/placeholder-page';
+'use client';
+
+import Link from 'next/link';
+import { useMemo, useState } from 'react';
+
+import { Badge } from '@/components/ui/badge';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { SearchInput } from '@/components/ui/search-input';
+import { Select } from '@/components/ui/select';
+import { cn } from '@/lib/styles';
+import { quickFilters, worlds, type WorldSort } from '@/lib/worlds/mock-worlds';
+
+const sortLabels: Record = {
+ newest: 'Newest',
+ 'most-active': 'Most active',
+ 'highest-reputation': 'Highest reputation',
+ 'most-canon': 'Most canon entries',
+};
+
+function sortWorlds(sort: WorldSort) {
+ return [...worlds].sort((left, right) => {
+ if (sort === 'newest') {
+ return new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime();
+ }
+
+ if (sort === 'most-active') {
+ return right.activityScore - left.activityScore;
+ }
+
+ if (sort === 'highest-reputation') {
+ return right.founderReputation - left.founderReputation;
+ }
+
+ return right.canonEntries - left.canonEntries;
+ });
+}
export default function WorldsPage() {
- return ;
+ const [activeFilter, setActiveFilter] = useState('all');
+ const [query, setQuery] = useState('');
+ const [sort, setSort] = useState('most-active');
+
+ const filteredWorlds = useMemo(() => {
+ const normalizedQuery = query.trim().toLowerCase();
+
+ return sortWorlds(sort).filter((world) => {
+ const matchesFilter = activeFilter === 'all' || world.tags.includes(activeFilter);
+ const searchableText = [
+ world.title,
+ world.genre,
+ world.tone,
+ world.founder,
+ world.summary,
+ ...world.tags,
+ ]
+ .join(' ')
+ .toLowerCase();
+
+ return matchesFilter && (!normalizedQuery || searchableText.includes(normalizedQuery));
+ });
+ }, [activeFilter, query, sort]);
+
+ return (
+
+
+
+
+ World discovery
+
+
+ Browse active HiveLore worlds.
+
+
+ Compare tone, activity, canon size, and current proposal queues before choosing where to
+ read, vote, or contribute.
+
+
+
+
+ Create New World
+
+
+
+
+
+
+
+
+ Search worlds
+ setQuery(event.target.value)}
+ placeholder="Search by title, genre, founder, or tone"
+ value={query}
+ />
+
+
+ Sort
+ setSort(event.target.value as WorldSort)}
+ value={sort}
+ >
+ {Object.entries(sortLabels).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+
+ setActiveFilter('all')}
+ type="button"
+ >
+ All
+
+ {quickFilters.map((filter) => (
+ setActiveFilter(filter)}
+ type="button"
+ >
+ {filter}
+
+ ))}
+
+
+
+
+
+
+
+ Worlds
+
+
+ Showing {filteredWorlds.length} of {worlds.length} worlds.
+
+
+
+
+
+ {filteredWorlds.map((world) => (
+
+
+
+
+ {world.genre}
+ {world.activeProposals > 0 ? (
+ {world.activeProposals} active votes
+ ) : null}
+
+ {world.title}
+ {world.tone}
+
+
+ {world.summary}
+
+
+
Founder
+ @{world.founder}
+
+
+
Canon
+ {world.canonEntries}
+
+
+
Rep
+ {world.founderReputation}
+
+
+
+
+
+ ))}
+
+
+
+ );
}
diff --git a/apps/web/src/components/layout/mobile-navigation.tsx b/apps/web/src/components/layout/mobile-navigation.tsx
index d75b1c6..4a5f499 100644
--- a/apps/web/src/components/layout/mobile-navigation.tsx
+++ b/apps/web/src/components/layout/mobile-navigation.tsx
@@ -9,59 +9,84 @@ import { SearchInput } from '@/components/ui/search-input';
export function MobileNavigation() {
const [isOpen, setIsOpen] = useState(false);
- const closeButtonRef = useRef(null);
+ const menuButtonRef = useRef(null);
useEffect(() => {
if (!isOpen) return;
- closeButtonRef.current?.focus();
-
+ const menuButton = menuButtonRef.current;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
document.addEventListener('keydown', onKeyDown, true);
- return () => document.removeEventListener('keydown', onKeyDown, true);
+ return () => {
+ document.body.style.overflow = previousOverflow;
+ document.removeEventListener('keydown', onKeyDown, true);
+ menuButton?.focus();
+ };
}, [isOpen]);
return (
-
+
+
+
+
setIsOpen(true)}
+ aria-label={isOpen ? 'Close navigation menu' : 'Open navigation menu'}
+ className="relative z-[90] h-10 w-10 px-0"
+ onClick={() => setIsOpen((current) => !current)}
variant="outline"
>
- Menu
+ {isOpen ? 'Close menu' : 'Open menu'}
+ {isOpen ? (
+
+
+
+ ) : (
+
+
+
+ )}
{isOpen ? (
-
{
- if (event.key === 'Escape') {
- setIsOpen(false);
- }
- }}
- role="dialog"
- >
-
-
-
Navigation
-
setIsOpen(false)}
- variant="ghost"
- >
- Close
-
-
+ <>
+
setIsOpen(false)}
+ type="button"
+ />
+ {
+ if (event.key === 'Escape') {
+ setIsOpen(false);
+ }
+ }}
+ role="dialog"
+ >
{navigationLinks.map((link) => (
@@ -83,7 +108,6 @@ export function MobileNavigation() {
disabled
placeholder="Search coming soon"
/>
-
-
+ >
) : null}
);
diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx
index 09984e4..95bacf5 100644
--- a/apps/web/src/components/layout/site-header.tsx
+++ b/apps/web/src/components/layout/site-header.tsx
@@ -7,7 +7,7 @@ import { SearchInput } from '@/components/ui/search-input';
export function SiteHeader() {
return (
-
+
-
+
{
+ setHasMounted(true);
+ }, []);
return (
setPreference(nextTheme)}
- title={label}
+ onClick={() => {
+ if (hasMounted) {
+ setPreference(nextTheme);
+ }
+ }}
+ title={buttonLabel}
type="button"
>
- {label}
- {isDark ? (
-
-
-
- ) : (
-
-
-
- )}
+ {buttonLabel}
+
+ {hasMounted && isDark ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
);
}
diff --git a/apps/web/src/lib/worlds/mock-worlds.ts b/apps/web/src/lib/worlds/mock-worlds.ts
new file mode 100644
index 0000000..933b68b
--- /dev/null
+++ b/apps/web/src/lib/worlds/mock-worlds.ts
@@ -0,0 +1,285 @@
+import type { ComponentProps } from 'react';
+import { Badge } from '@/components/ui/badge';
+
+export type WorldSort = 'newest' | 'most-active' | 'highest-reputation' | 'most-canon';
+
+export type LoreCategory =
+ 'characters' | 'cities' | 'factions' | 'quests' | 'events' | 'stories' | 'artifacts';
+
+export type WorldCard = {
+ id: string;
+ title: string;
+ genre: string;
+ tone: string;
+ founder: string;
+ summary: string;
+ createdAt: string;
+ activityScore: number;
+ founderReputation: number;
+ canonEntries: number;
+ activeProposals: number;
+ tags: string[];
+};
+
+export type CanonEntry = {
+ id: string;
+ title: string;
+ type: string;
+ summary: string;
+ status: NonNullable
['variant']>;
+};
+
+export type PendingProposal = {
+ id: string;
+ title: string;
+ author: string;
+ voteCount: number;
+ approvalPercent: number;
+ hasAiWarning: boolean;
+};
+
+export type WorldDetail = WorldCard & {
+ mainConflict: string;
+ seed: {
+ location: string;
+ characters: string[];
+ factions: string[];
+ firstEvent: string;
+ };
+ codexPreview: string[];
+ canonHealth: {
+ label: string;
+ value: string;
+ detail: string;
+ }[];
+ featuredCanon: CanonEntry[];
+ pendingProposals: PendingProposal[];
+ categoryCounts: Record;
+};
+
+export const quickFilters = [
+ 'fantasy',
+ 'sci-fi',
+ 'political fantasy',
+ 'dark fantasy',
+ 'mystery',
+ 'adventure',
+];
+
+export const loreCategories: { id: LoreCategory; label: string }[] = [
+ { id: 'characters', label: 'Characters' },
+ { id: 'cities', label: 'Cities/Kingdoms' },
+ { id: 'factions', label: 'Factions' },
+ { id: 'quests', label: 'Quests' },
+ { id: 'events', label: 'Historical Events' },
+ { id: 'stories', label: 'Stories' },
+ { id: 'artifacts', label: 'Artifacts' },
+];
+
+export const worlds: WorldDetail[] = [
+ {
+ id: 'ember-crown',
+ title: 'The Ember Crown',
+ genre: 'Political Fantasy',
+ tone: 'Tense, mythic, courtly',
+ founder: 'kareem',
+ summary:
+ 'A fractured kingdom chooses its next ruler through prophecy, public memory, and dangerous bargains with old fire.',
+ createdAt: '2026-08-01',
+ activityScore: 96,
+ founderReputation: 410,
+ canonEntries: 42,
+ activeProposals: 9,
+ tags: ['fantasy', 'political fantasy', 'adventure'],
+ mainConflict: 'Three houses claim the throne while an exiled oracle rewrites the royal line.',
+ seed: {
+ location: 'Veyrhold, a mountain capital built around a sleeping volcanic shrine.',
+ characters: ['Mara Veyr', 'Orren Blackglass', 'The Ash Oracle'],
+ factions: ['The Red Synod', 'House Veyr', 'The Salt Banner'],
+ firstEvent: 'The crown cracked during the public coronation and named no heir.',
+ },
+ codexPreview: [
+ 'Fire magic is inherited through vows, not blood.',
+ 'No ruler may enter the Ash Vault without three living witnesses.',
+ 'Prophecies are legal evidence, but only when recorded by a neutral scribe.',
+ ],
+ canonHealth: [
+ { label: 'Canon status', value: 'Stable', detail: '7 entries added this week' },
+ { label: 'Vote queue', value: '9', detail: '3 close to threshold' },
+ { label: 'AI warnings', value: '2', detail: 'Both are timeline conflicts' },
+ ],
+ categoryCounts: {
+ characters: 14,
+ cities: 5,
+ factions: 6,
+ quests: 3,
+ events: 8,
+ stories: 5,
+ artifacts: 1,
+ },
+ featuredCanon: [
+ {
+ id: 'mara-veyr',
+ title: 'Mara Veyr',
+ type: 'Character',
+ summary: 'Disgraced heir and founder of the public witness compact.',
+ status: 'canon',
+ },
+ {
+ id: 'ash-vault',
+ title: 'The Ash Vault',
+ type: 'Artifact',
+ summary: 'A sealed chamber that remembers every broken oath made near the crown.',
+ status: 'published-on-hive',
+ },
+ {
+ id: 'salt-banner',
+ title: 'The Salt Banner',
+ type: 'Faction',
+ summary: 'Coastal rebels who reject prophecy as a tool of royal control.',
+ status: 'canon',
+ },
+ ],
+ pendingProposals: [
+ {
+ id: 'oracle-return',
+ title: 'The Oracle Returns in Year 411',
+ author: 'perla',
+ voteCount: 6,
+ approvalPercent: 67,
+ hasAiWarning: true,
+ },
+ {
+ id: 'salt-banner-heir',
+ title: 'A Salt Banner Heir Claims Witness Rights',
+ author: 'noura',
+ voteCount: 4,
+ approvalPercent: 75,
+ hasAiWarning: false,
+ },
+ ],
+ },
+ {
+ id: 'neon-archives',
+ title: 'Neon Archives',
+ genre: 'Sci-Fi Mystery',
+ tone: 'Noir, precise, paranoid',
+ founder: 'perla',
+ summary:
+ 'Memory archivists investigate impossible crimes in a city where every public act is supposedly recorded.',
+ createdAt: '2026-07-28',
+ activityScore: 84,
+ founderReputation: 275,
+ canonEntries: 31,
+ activeProposals: 6,
+ tags: ['sci-fi', 'mystery'],
+ mainConflict: 'A murder appears in every archive except the victim never existed.',
+ seed: {
+ location: 'Lumen Quay, an orbital city with transparent streets and sealed memory banks.',
+ characters: ['Ivo Senn', 'Archivist Halden', 'Mira-9'],
+ factions: ['The Index Court', 'Null Choir', 'Quay Security'],
+ firstEvent: 'A dead person filed a memory appeal from inside a corrupted archive.',
+ },
+ codexPreview: [
+ 'Public memories can be inspected, but private memories require consent or court order.',
+ 'Synthetic witnesses must declare model lineage before testimony.',
+ 'The Null Choir never appears on camera, only in missing timestamps.',
+ ],
+ canonHealth: [
+ { label: 'Canon status', value: 'Growing', detail: 'Strong entity coverage' },
+ { label: 'Vote queue', value: '6', detail: '1 needs revision' },
+ { label: 'AI warnings', value: '1', detail: 'Identity contradiction' },
+ ],
+ categoryCounts: {
+ characters: 9,
+ cities: 3,
+ factions: 5,
+ quests: 4,
+ events: 6,
+ stories: 3,
+ artifacts: 1,
+ },
+ featuredCanon: [
+ {
+ id: 'lumen-quay',
+ title: 'Lumen Quay',
+ type: 'City',
+ summary: 'Orbital city governed through public memory law.',
+ status: 'canon',
+ },
+ {
+ id: 'null-choir',
+ title: 'Null Choir',
+ type: 'Faction',
+ summary: 'Anonymous saboteurs who remove events from public recall.',
+ status: 'under-review',
+ },
+ ],
+ pendingProposals: [
+ {
+ id: 'mira-nine-testimony',
+ title: 'Mira-9 Gives Contradictory Testimony',
+ author: 'kareem',
+ voteCount: 5,
+ approvalPercent: 80,
+ hasAiWarning: true,
+ },
+ ],
+ },
+ {
+ id: 'river-of-names',
+ title: 'River of Names',
+ genre: 'Dark Fantasy',
+ tone: 'Quiet, haunted, ritual',
+ founder: 'sara',
+ summary:
+ 'Villages survive by sending names downriver to bargain with spirits that remember every debt.',
+ createdAt: '2026-07-18',
+ activityScore: 62,
+ founderReputation: 188,
+ canonEntries: 18,
+ activeProposals: 3,
+ tags: ['fantasy', 'dark fantasy'],
+ mainConflict:
+ 'The river returns a forbidden name that should have been erased generations ago.',
+ seed: {
+ location: 'Namar Ford, a fog-bound crossing where names are traded for safe passage.',
+ characters: ['Talin Reed', 'Mother Vess', 'The Nameless Ferryman'],
+ factions: ['Ford Keepers', 'Debt Priests'],
+ firstEvent: 'A child was born with three names already written on their palms.',
+ },
+ codexPreview: [
+ 'Names are living contracts.',
+ 'A person with no name cannot be harmed by spirits, but cannot be remembered by people.',
+ 'The river accepts songs, copper, and confessions as partial payment.',
+ ],
+ canonHealth: [
+ { label: 'Canon status', value: 'Sparse', detail: 'Needs more locations' },
+ { label: 'Vote queue', value: '3', detail: 'Low risk' },
+ { label: 'AI warnings', value: '0', detail: 'No open contradictions' },
+ ],
+ categoryCounts: {
+ characters: 6,
+ cities: 2,
+ factions: 2,
+ quests: 2,
+ events: 3,
+ stories: 2,
+ artifacts: 1,
+ },
+ featuredCanon: [
+ {
+ id: 'nameless-ferryman',
+ title: 'The Nameless Ferryman',
+ type: 'Character',
+ summary: 'A river guide who can remember everyone except himself.',
+ status: 'canon',
+ },
+ ],
+ pendingProposals: [],
+ },
+];
+
+export function getWorldById(worldId: string) {
+ return worlds.find((world) => world.id === worldId);
+}
diff --git a/apps/web/src/providers/theme-provider.tsx b/apps/web/src/providers/theme-provider.tsx
index cde53d1..ec0cefd 100644
--- a/apps/web/src/providers/theme-provider.tsx
+++ b/apps/web/src/providers/theme-provider.tsx
@@ -1,6 +1,14 @@
'use client';
-import { createContext, useContext, useMemo, useSyncExternalStore, type ReactNode } from 'react';
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from 'react';
type ThemePreference = 'system' | 'light' | 'dark';
type ResolvedTheme = 'light' | 'dark';
@@ -12,7 +20,6 @@ type ThemeContextValue = {
};
const STORAGE_KEY = 'hivelore-theme';
-const CHANGE_EVENT = 'hivelore-theme-change';
const ThemeContext = createContext(null);
@@ -32,7 +39,13 @@ function applyTheme(preference: ThemePreference) {
}
function readPreference(): ThemePreference {
- const stored = window.localStorage.getItem(STORAGE_KEY);
+ let stored: string | null = null;
+
+ try {
+ stored = window.localStorage.getItem(STORAGE_KEY);
+ } catch {
+ stored = null;
+ }
if (stored === 'light' || stored === 'dark' || stored === 'system') {
return stored;
@@ -41,45 +54,52 @@ function readPreference(): ThemePreference {
return 'system';
}
-function getSnapshot() {
- const preference = readPreference();
- const resolvedTheme = applyTheme(preference);
- return `${preference}:${resolvedTheme}`;
-}
-
-function getServerSnapshot() {
- return 'system:light';
-}
-
-function subscribe(callback: () => void) {
- const media = window.matchMedia('(prefers-color-scheme: dark)');
- const handleChange = () => callback();
-
- media.addEventListener('change', handleChange);
- window.addEventListener(CHANGE_EVENT, handleChange);
- window.addEventListener('storage', handleChange);
-
- return () => {
- media.removeEventListener('change', handleChange);
- window.removeEventListener(CHANGE_EVENT, handleChange);
- window.removeEventListener('storage', handleChange);
- };
-}
-
export function ThemeProvider({ children }: { children: ReactNode }) {
- const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
-
- const [preference, resolvedTheme] = snapshot.split(':') as [ThemePreference, ResolvedTheme];
-
- const setPreference = (nextPreference: ThemePreference) => {
- window.localStorage.setItem(STORAGE_KEY, nextPreference);
- applyTheme(nextPreference);
- window.dispatchEvent(new Event(CHANGE_EVENT));
- };
+ const [preference, setPreferenceState] = useState('system');
+ const [resolvedTheme, setResolvedTheme] = useState('light');
+
+ const setPreference = useCallback((nextPreference: ThemePreference) => {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, nextPreference);
+ } catch {
+ // Theme still updates for this session if localStorage is unavailable.
+ }
+
+ setPreferenceState(nextPreference);
+ setResolvedTheme(applyTheme(nextPreference));
+ }, []);
+
+ useEffect(() => {
+ const storedPreference = readPreference();
+ setPreferenceState(storedPreference);
+ setResolvedTheme(applyTheme(storedPreference));
+ }, []);
+
+ useEffect(() => {
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
+
+ const handleSystemChange = () => {
+ setResolvedTheme(applyTheme(preference));
+ };
+
+ const handleStorageChange = () => {
+ const nextPreference = readPreference();
+ setPreferenceState(nextPreference);
+ setResolvedTheme(applyTheme(nextPreference));
+ };
+
+ media.addEventListener('change', handleSystemChange);
+ window.addEventListener('storage', handleStorageChange);
+
+ return () => {
+ media.removeEventListener('change', handleSystemChange);
+ window.removeEventListener('storage', handleStorageChange);
+ };
+ }, [preference]);
const value = useMemo(
() => ({ preference, resolvedTheme, setPreference }),
- [preference, resolvedTheme],
+ [preference, resolvedTheme, setPreference],
);
return {children} ;