Redesign the library UI and add scenario cover art

Rework the home page into a single landing surface and give the app a
consistent visual language, chosen as "illuminated tome" over two other
pitched directions because it builds on the existing Cinzel + gold identity
instead of replacing it.

Home is now Continue (up to 4 in-progress stories, each showing where you
left off) over a scenario shelf, each section with a "See all" link. The
full adventure list moves to /adventures.

Scenario cover art has three tiers, in precedence order: an uploaded picture
(downscaled client-side to 400px WebP before storing), an emoji, or gradient
art generated from a hash of the title so no card is ever an empty box.
Adventures inherit their scenario's art. Images live in the row rather than
on disk because Render's free tier has no persistent volume, and it keeps
export bundles self-contained; list responses carry a cacheable
/api/scenarios/{id}/image URL rather than the base64.

Also: ambient drifting motes behind the app, loading skeletons, staggered
card entrance, ornamental scene breaks and a drop cap in the story, a
"Weaving" thinking indicator, and a toast system replacing every alert().

Two fixes found along the way:

- Importing a scenario bundle with no "tags" key returned a 500. Column
  defaults are not applied until flush, so the attribute was still None
  when the width clamp sliced it.
- Anything meaning "the story's latest narration" was missing action type
  "start", which is the only text a freshly created adventure has, so new
  adventures looked empty. Collected as NARRATION_TYPES.

Migrations 30 and 31 add scenarios.image and scenarios.icon; both are
additive with a '' default and were verified against a database stamped at
29. vite.config.js now reads AIDND_API_PORT so the recurring port-8000
clash with another local app needs no file edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FGY1yvzSeKgTtRfeVtDmx
This commit is contained in:
parththakkar106
2026-07-26 13:17:46 +05:30
co-authored by Claude Opus 5
parent 970d71a5b6
commit 57c24a07d8
25 changed files with 1879 additions and 158 deletions
+145 -1
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { api } from './api'
export function downloadJSON(obj, filename) {
@@ -31,6 +31,150 @@ export function pickJSONFile() {
})
}
// ---------- Scenario art ----------
// FNV-1a. Any stable hash works; the point is that a given title always maps to
// the same plate, so the library looks the same on every visit and every device.
function hashString(str) {
let hash = 2166136261
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i)
hash = Math.imul(hash, 16777619)
}
return hash >>> 0
}
// Deep jewel ramps that sit under gold without competing with it — the accent
// stays the brightest thing on the card. Ordered so adjacent library entries
// rarely land on neighbouring hues.
const ART_RAMPS = [
['#14424a', '#0b2328'], // drowned teal
['#4d2130', '#250f19'], // wine
['#2b2a5c', '#13122c'], // indigo
['#4a2a17', '#231309'], // rust
['#24401f', '#0f1c0c'], // moss
['#3a2b52', '#1b1328'], // violet ash
['#1f3346', '#0f1a25'], // cold slate
['#4a3a16', '#231b09'], // ochre
]
// Up to two letters from the title's most significant words.
function monogram(title) {
const words = (title || '')
.replace(/^\[[^\]]*\]\s*/, '') // drop a leading "[Demo]" style label
.split(/\s+/)
.filter((word) => word.length > 2 && !/^(the|and|of|a|an|from|for)$/i.test(word))
const letters = (words.length ? words : ['?']).slice(0, 2).map((w) => w[0])
return letters.join('').toUpperCase()
}
/** A scenario's plate: uploaded picture, else emoji, else generated art.
*
* `large` is for the Continue cards, where the plate carries more weight. The
* generated tier means no card is ever an empty box, so a fresh library still
* reads as a shelf of distinct things.
*/
export function ScenarioArt({ image, icon, title, large = false }) {
const [failed, setFailed] = useState(false)
const ramp = ART_RAMPS[hashString(title || '') % ART_RAMPS.length]
const className = `art${large ? ' art-lg' : ''}`
// A stored image can 404 (scenario deleted its art in another tab, or an
// external URL rotted) — fall through to the generated tier rather than
// showing a broken-image glyph.
if (image && !failed) {
return (
<span className={`${className} art-photo`}>
<img src={image} alt="" loading="lazy" onError={() => setFailed(true)} />
</span>
)
}
const style = { background: `linear-gradient(150deg, ${ramp[0]}, ${ramp[1]})` }
if (icon) {
return <span className={`${className} art-icon`} style={style} aria-hidden="true">{icon}</span>
}
return (
<span className={`${className} art-mono`} style={style} aria-hidden="true">
{monogram(title)}
</span>
)
}
// ---------- Loading skeletons ----------
/** Placeholder cards shown while a list loads, so the grid doesn't pop in. */
export function CardSkeleton({ count = 3, lines = 2 }) {
return (
<div className="card-grid" aria-hidden="true">
{Array.from({ length: count }, (_, i) => (
<div key={i} className="card skeleton-card" style={{ animationDelay: `${i * 70}ms` }}>
<div className="sk sk-plate" />
<div className="sk sk-title" />
{Array.from({ length: lines }, (_, line) => (
<div key={line} className="sk sk-line" style={{ width: line === lines - 1 ? '62%' : '100%' }} />
))}
</div>
))}
</div>
)
}
// ---------- Toasts ----------
const ToastContext = createContext(() => {})
/** `const toast = useToast()` then `toast('Imported')` or `toast(msg, 'error')`. */
export function useToast() {
return useContext(ToastContext)
}
const TOAST_MS = 4200
export function ToastHost({ children }) {
const [toasts, setToasts] = useState([])
const nextId = useRef(1)
const timers = useRef(new Map())
const dismiss = useCallback((id) => {
setToasts((current) => current.filter((t) => t.id !== id))
const timer = timers.current.get(id)
if (timer) {
clearTimeout(timer)
timers.current.delete(id)
}
}, [])
const push = useCallback((message, kind = 'info') => {
const id = nextId.current++
setToasts((current) => [...current, { id, message: String(message), kind }])
timers.current.set(id, setTimeout(() => dismiss(id), TOAST_MS))
}, [dismiss])
// Clear pending timers if the host ever unmounts mid-flight.
useEffect(() => () => timers.current.forEach(clearTimeout), [])
return (
<ToastContext.Provider value={push}>
{children}
<div className="toast-host" role="status" aria-live="polite">
{toasts.map((toast) => (
<button
key={toast.id}
type="button"
className={`toast toast-${toast.kind}`}
onClick={() => dismiss(toast.id)}
title="Dismiss"
>
<span className="toast-mark" aria-hidden="true">{toast.kind === 'error' ? '✕' : '❖'}</span>
<span>{toast.message}</span>
</button>
))}
</div>
</ToastContext.Provider>
)
}
// Unique ${Placeholder} names, in order of first appearance, across the given texts.
export function extractPlaceholders(...texts) {
const names = []