Next-Level Lyrix Bench Blueprint | SvelteKit + Skeleton v3 + OpenRouter
π§ Quick Nav
| π | Section |
|---|---|
| π | Current State Analysis |
| ποΈ | Target Stack |
| ποΈ | Project Architecture |
| π | OpenRouter Integration |
| π | Libraries System |
| βοΈ | Editor Strategy |
| π§© | Skeleton v3 Components |
| βΏ | A11y + UX |
| π | Genius Improvements |
π Current State Analysis
π§± Existing Stack (/home/dv/lrx)
framework: React 19 + Vite 7 + TypeScript
styling: Tailwind CSS v4
editor: CodeMirror 6 (@uiw/react-codemirror)
llm: OpenRouter (client-side β οΈ)
persistence: localStorage
architecture: Monolithic (Studio.tsx ~1180 lines)β Solid Foundations (Keep)
- Section-aware workflow (full song / section / rewrite)
/tag completion at line start- Prompt profiles (
classic,rr,shine,pro) - LLM param presets (balanced/creative/focused/wild)
- Decent baseline a11y
β οΈ Critical Gaps
| Gap | Impact | Priority |
|---|---|---|
| π Client-side API key | Security breach risk | π΄ Critical |
| π§± Monolithic UI | Unmaintainable | π΄ Critical |
| π No prompt library CRUD | Limited reuse | π High |
| π·οΈ No tag skeletons | Manual structure | π High |
| π§ͺ No bench mode | Canβt compare models | π High |
| πΈ No cost tracking | Blind spending | π‘ Medium |
| πΎ No project files | No versioning | π‘ Medium |
ποΈ Target Stack
π¦ Core Dependencies
# SvelteKit + Svelte 5
npx sv create lyrix-bench
cd lyrix-bench
# Skeleton v3 (Svelte + Core)
npm i @skeletonlabs/skeleton @skeletonlabs/skeleton-svelte
# Tailwind v4
npm i -D tailwindcss @tailwindcss/vite
# Editor
npm i @codemirror/view @codemirror/state @codemirror/lang-markdown
npm i @codemirror/autocomplete @codemirror/commands
# SSE Streaming
npm i -D sveltekit-sse
# Storage
npm i idb # IndexedDB wrapperβοΈ Skeleton v3 Setup
/* src/app.css */
@import "tailwindcss";
@import "@skeletonlabs/skeleton";
@import "@skeletonlabs/skeleton-svelte";
@import "@skeletonlabs/skeleton/themes/cerberus";<!-- src/app.html -->
<html data-theme="cerberus"></html>// vite.config.ts
import { sveltekit } from "@sveltejs/kit/vite"
import tailwindcss from "@tailwindcss/vite"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit(), // Must come after Tailwind
],
server: { port: 51120, strictPort: true },
})ποΈ Project Architecture
π Route Structure
src/routes/
βββ (app)/
β βββ +layout.svelte # App shell + nav
β βββ studio/+page.svelte # Main writing studio
β βββ bench/+page.svelte # A/B compare grid
β βββ library/
β β βββ +page.svelte # Library overview
β β βββ prompts/+page.svelte
β β βββ presets/+page.svelte
β β βββ skeletons/+page.svelte
β βββ projects/
β β βββ +page.svelte # Project list
β β βββ [id]/+page.svelte # Single project
β βββ settings/+page.svelte
βββ api/
β βββ openrouter/
β βββ chat/+server.ts # Non-streaming
β βββ stream/+server.ts # SSE streaming
β βββ generation/+server.ts # Cost/stats
βββ +layout.svelte # Root layout
π§ State Modules (src/state/)
src/state/
βββ projects.svelte.ts # Project CRUD, drafts, versions
βββ editor.svelte.ts # Cursor, selection, outline
βββ bench.svelte.ts # Matrix runs, ratings
βββ library.svelte.ts # Prompts, presets, skeletons
βββ generation.svelte.ts # Active gen, history, costs
βββ settings.svelte.ts # UI prefs, theme, limits
π Svelte 5 State Pattern
// src/state/library.svelte.ts
import type { PromptTemplate, Preset, TagSkeleton } from "$lib/types"
// β
Export object with $state properties (cross-module reactive)
export const library = $state({
prompts: [] as PromptTemplate[],
presets: [] as Preset[],
skeletons: [] as TagSkeleton[],
loading: false,
})
// β
Export actions that mutate properties
export function addPrompt(prompt: PromptTemplate) {
library.prompts = [...library.prompts, prompt]
}
export function updatePrompt(id: string, updates: Partial<PromptTemplate>) {
library.prompts = library.prompts.map((p) => (p.id === id ? { ...p, ...updates } : p))
}
// β
Derived values
export const systemPrompts = $derived(library.prompts.filter((p) => p.type === "system"))<!-- Usage in component -->
<script>
import { library, addPrompt, systemPrompts } from '$state/library.svelte';
</script>
{#each systemPrompts as prompt}
<PromptCard {prompt} />
{/each}π OpenRouter Integration
π Server-Side Security
// src/routes/api/openrouter/stream/+server.ts
import { OPENROUTER_API_KEY } from "$env/static/private"
import { produce } from "sveltekit-sse"
import type { RequestHandler } from "./$types"
export const POST: RequestHandler = async ({ request }) => {
const { model, messages, params, maxTokens } = await request.json()
return produce(async function start({ emit, lock }) {
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://lyrix-bench.local",
"X-Title": "Lyrix Bench",
},
body: JSON.stringify({
model,
messages,
stream: true,
max_tokens: maxTokens ?? 2000,
temperature: params.temp,
top_p: params.topP,
frequency_penalty: params.freqP,
presence_penalty: params.presP,
// Include usage in final SSE chunk (alternative to /generation endpoint)
usage: { include: true },
}),
})
if (!response.ok) {
emit("error", JSON.stringify({ status: response.status }))
lock.set(false)
return
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let generationId = ""
while (reader) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
const lines = chunk.split("\n").filter((l) => l.startsWith("data: "))
for (const line of lines) {
const data = line.slice(6)
if (data === "[DONE]") continue
try {
const parsed = JSON.parse(data)
generationId = parsed.id ?? generationId
const content = parsed.choices?.[0]?.delta?.content
if (content) emit("content", content)
} catch {
/* ignore parse errors */
}
}
}
// Emit generation ID for cost lookup
if (generationId) emit("generation_id", generationId)
lock.set(false)
})
}πΈ Cost Tracking
// src/routes/api/openrouter/generation/+server.ts
import { OPENROUTER_API_KEY } from "$env/static/private"
import { json } from "@sveltejs/kit"
export const GET: RequestHandler = async ({ url }) => {
const id = url.searchParams.get("id")
const response = await fetch(`https://openrouter.ai/api/v1/generation?id=${id}`, {
headers: { Authorization: `Bearer ${OPENROUTER_API_KEY}` },
})
const { data } = await response.json() // Response wrapped in `data`
return json({
tokens_prompt: data.tokens_prompt,
tokens_completion: data.tokens_completion,
native_tokens_prompt: data.native_tokens_prompt,
native_tokens_completion: data.native_tokens_completion,
total_cost: data.total_cost,
model: data.model,
})
}π‘ Client SSE Consumer
<script lang="ts">
import { source } from 'sveltekit-sse';
import { generation } from '$state/generation.svelte';
async function generateStreaming(payload: GeneratePayload) {
generation.loading = true;
generation.text = '';
const connection = source('/api/openrouter/stream', {
options: {
method: 'POST',
body: JSON.stringify(payload)
}
});
const content = connection.select('content');
const genId = connection.select('generation_id');
const error = connection.select('error');
// Subscribe to content chunks
content.subscribe(chunk => {
if (chunk) generation.text += chunk;
});
// Fetch cost after completion
genId.subscribe(async id => {
if (id) {
const stats = await fetch(`/api/openrouter/generation?id=${id}`).then(r => r.json());
generation.lastRun = { ...generation.lastRun, ...stats };
}
});
error.subscribe(err => {
if (err) generation.error = JSON.parse(err);
generation.loading = false;
});
}
</script>π Libraries System
𧬠Entity Types
// src/lib/types.ts
export interface PromptTemplate {
id: string
name: string
type: "system" | "user" | "assistant"
body: string
tags: string[]
variables: string[] // e.g., ['{{genre}}', '{{mood}}']
version: number
createdAt: Date
updatedAt: Date
}
export interface PromptStack {
id: string
name: string
baseSystemId: string
profileId?: string
overrides: { systemExtra?: string; userExtra?: string }
}
export interface Preset {
id: string
name: string
description: string
model: string
params: LlmParams
maxTokens: number
tags: string[]
}
export interface TagSkeleton {
id: string
name: string
genre: string
sections: SkeletonSection[]
constraints: SkeletonConstraints
tags: string[]
}
export interface SkeletonSection {
tag: string // '[Verse 1]'
lines: [number, number] // [min, max] lines
rhymeScheme?: string // 'ABAB', 'AABB'
notes?: string
}
export interface SkeletonConstraints {
totalLines?: [number, number]
hookDensity?: "low" | "medium" | "high"
rhymeDensity?: "sparse" | "moderate" | "dense"
}
export interface Project {
id: string
name: string
content: string
genre: string
mood: string
theme?: string
presetId?: string
promptStackId?: string
skeletonId?: string
runs: Run[]
createdAt: Date
updatedAt: Date
}
export interface Run {
id: string
type: "full" | "section" | "rewrite"
input: { promptStackId: string; presetId: string; context: any }
output: string
generationId?: string
stats?: { tokens: number; cost: number; latency: number }
rating?: 1 | 2 | 3 | 4 | 5
createdAt: Date
}𧬠Variable Interpolation
// src/lib/prompts/interpolate.ts
export const VARIABLES = {
"{{genre}}": (ctx: StyleContext) => ctx.genre,
"{{mood}}": (ctx: StyleContext) => ctx.mood,
"{{theme}}": (ctx: StyleContext) => ctx.theme ?? "unspecified",
"{{section}}": (ctx: SectionContext) => ctx.section,
"{{selection}}": (ctx: RewriteContext) => ctx.selection,
"{{full_lyrics}}": (ctx: FullContext) => ctx.lyrics,
"{{constraints}}": (ctx: any) => formatConstraints(ctx.constraints),
"{{structure}}": (ctx: any) => formatStructure(ctx.skeleton),
} as const
export function interpolate(template: string, ctx: Record<string, any>): string {
return Object.entries(VARIABLES).reduce(
(text, [key, fn]) => text.replaceAll(key, fn(ctx) ?? ""),
template,
)
}πΎ IndexedDB Persistence
// src/lib/storage/db.ts
import { openDB, type IDBPDatabase } from "idb"
interface LyrixDB {
prompts: PromptTemplate
presets: Preset
skeletons: TagSkeleton
projects: Project
runs: Run
}
let db: IDBPDatabase<LyrixDB>
export async function initDB() {
db = await openDB<LyrixDB>("lyrix-bench", 1, {
upgrade(db) {
db.createObjectStore("prompts", { keyPath: "id" })
db.createObjectStore("presets", { keyPath: "id" })
db.createObjectStore("skeletons", { keyPath: "id" })
db.createObjectStore("projects", { keyPath: "id" })
db.createObjectStore("runs", { keyPath: "id" })
},
})
return db
}
export const storage = {
prompts: {
getAll: () => db.getAll("prompts"),
get: (id: string) => db.get("prompts", id),
put: (item: PromptTemplate) => db.put("prompts", item),
delete: (id: string) => db.delete("prompts", id),
},
// ... similar for presets, skeletons, projects, runs
}βοΈ Editor Strategy
π― Recommendation: CodeMirror 6
| Criterion | CodeMirror 6 | Monaco |
|---|---|---|
| Bundle size | ~100KB | ~2MB+ |
| Multi-instance | β Easy | β οΈ Complex |
| Tailwind theming | β Native CSS | β οΈ Override wars |
| Svelte integration | β Direct | β οΈ Wrapper needed |
| Mobile | β Good | β οΈ Heavy |
π§ CodeMirror Svelte Wrapper
<!-- src/lib/components/LyrixEditor.svelte -->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { EditorView, keymap } from '@codemirror/view';
import { EditorState, type Extension } from '@codemirror/state';
import { markdown } from '@codemirror/lang-markdown';
import { autocompletion, startCompletion } from '@codemirror/autocomplete';
import { history, historyKeymap } from '@codemirror/commands';
interface Props {
value: string;
onchange?: (value: string) => void;
extensions?: Extension[];
class?: string;
}
let { value = $bindable(), onchange, extensions = [], class: className }: Props = $props();
let container: HTMLDivElement;
let view: EditorView;
const baseExtensions: Extension[] = [
markdown(),
history(),
keymap.of(historyKeymap),
EditorView.lineWrapping,
EditorView.updateListener.of(update => {
if (update.docChanged) {
value = update.state.doc.toString();
onchange?.(value);
}
}),
sectionCompletion(), // Custom section tag completion
];
onMount(() => {
view = new EditorView({
state: EditorState.create({
doc: value,
extensions: [...baseExtensions, ...extensions]
}),
parent: container
});
});
onDestroy(() => view?.destroy());
// Sync external value changes
$effect(() => {
if (view && value !== view.state.doc.toString()) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value }
});
}
});
export function getView() { return view; }
</script>
<div bind:this={container} class="lyrix-editor {className}"></div>
<style>
.lyrix-editor :global(.cm-editor) {
height: 100%;
font-family: 'JetBrains Mono', monospace;
}
.lyrix-editor :global(.cm-content) {
padding: 1rem;
}
</style>β¨οΈ Section Tag Completion
// src/lib/editor/completions.ts
import { autocompletion, type CompletionContext } from "@codemirror/autocomplete"
import { SECTIONS } from "$lib/config"
export function sectionCompletion() {
return autocompletion({
override: [sectionCompletionSource],
})
}
function sectionCompletionSource(ctx: CompletionContext) {
const pos = ctx.pos
const line = ctx.state.doc.lineAt(pos)
const before = ctx.state.sliceDoc(line.from, pos)
// Trigger on `/` at line start
const slashIndex = before.indexOf("/")
if (slashIndex === -1) return null
if (before.slice(0, slashIndex).trim().length !== 0) return null
const typed = before.slice(slashIndex + 1).toLowerCase()
const options = SECTIONS.filter(
(s) => !typed || s.label.toLowerCase().includes(typed) || s.key.toLowerCase() === typed,
).map((s) => ({
label: s.label,
detail: s.description,
info: s.tag,
type: "keyword",
apply: (view, _completion, from, to) => {
view.dispatch({
changes: { from: line.from + slashIndex, to, insert: `${s.tag}\n` },
selection: { anchor: line.from + slashIndex + s.tag.length + 1 },
})
},
}))
return options.length ? { from: line.from + slashIndex, options, filter: false } : null
}πͺ Genius Editor Features
// Command palette integration
export function commandPaletteExtension(actions: CommandAction[]) {
return keymap.of([
{
key: "Mod-k",
run: () => {
openCommandPalette(actions)
return true
},
},
])
}
// Section outline/jump
export function sectionOutline(doc: string): OutlineItem[] {
const sections: OutlineItem[] = []
const regex = /^\[([^\]]+)\]/gm
let match
while ((match = regex.exec(doc)) !== null) {
sections.push({
label: match[1],
pos: match.index,
line: doc.slice(0, match.index).split("\n").length,
})
}
return sections
}
// Ghost text suggestions (Copilot-style)
export function ghostTextExtension(getSuggestion: () => Promise<string>) {
// Implementation uses StateField + Decoration.widget
}π§© Skeleton v3 Components
π¦ Available Components (Svelte)
Accordion Avatar Combobox Navigation
Pagination Progress ProgressRing Ratings
Segment Slider Switch Tabs
TagsInput Toast Tooltip TreeView
π¨ Component Usage Pattern
<script>
import { Accordion, Tabs } from '@skeletonlabs/skeleton-svelte';
</script>
<!-- Accordion (v3 composition API) -->
<Accordion collapsible>
<Accordion.Item value="prompts">
<Accordion.ItemTrigger class="hover:preset-filled-primary-500">
System Prompts
<Accordion.ItemIndicator />
</Accordion.ItemTrigger>
<Accordion.ItemContent>
<PromptList type="system" />
</Accordion.ItemContent>
</Accordion.Item>
</Accordion>
<!-- Tabs (v3 composition API) -->
<Tabs defaultValue="studio" class="w-full">
<Tabs.List>
<Tabs.Trigger value="studio">Studio</Tabs.Trigger>
<Tabs.Trigger value="bench">Bench</Tabs.Trigger>
<Tabs.Trigger value="library">Library</Tabs.Trigger>
<Tabs.Indicator />
</Tabs.List>
<Tabs.Content value="studio"><StudioView /></Tabs.Content>
<Tabs.Content value="bench"><BenchView /></Tabs.Content>
<Tabs.Content value="library"><LibraryView /></Tabs.Content>
</Tabs>π¨ Skeleton Design Tokens
/* Using Skeleton presets and utilities */
.btn {
@apply preset-filled-primary-500;
}
.card {
@apply preset-outlined-surface-500;
}
.input {
@apply preset-filled-surface-100-900;
}
/* Color utilities */
.text-primary {
@apply text-primary-500;
}
.bg-surface {
@apply bg-surface-100-900;
}π§© Blind Spot Components (Add These)
| Component | Use Case | Implementation |
|---|---|---|
| πΉ Command Palette | Quick actions, search | Custom + Skeleton Modal |
| π Diff Viewer | Compare outputs | Custom CodeMirror |
| π Cost Dashboard | Usage analytics | Custom + Progress |
| π·οΈ Tag Input | Library tagging | Skeleton TagsInput |
| π Split Pane | Bench comparisons | Custom resizable |
| π Toast Stack | Notifications | Skeleton Toast |
| π¨ Theme Switcher | Dark/light/custom | Skeleton + Context |
βΏ A11y + UX
β¨οΈ Keyboard-First Design
// Global keyboard shortcuts
const SHORTCUTS = {
"Mod-Enter": "generate",
"Mod-k": "command-palette",
"Mod-s": "save-project",
"Mod-Shift-s": "save-as",
Escape: "cancel-generation",
"/": "section-completion", // In editor
"Mod-z": "undo",
"Mod-Shift-z": "redo",
}π£ Live Regions
<div
role="status"
aria-live="polite"
aria-atomic="true"
class="sr-only"
>
{#if generation.loading}
Generating {generation.type}...
{:else if generation.error}
Generation failed: {generation.error.message}
{:else if generation.lastRun}
Generation complete. {generation.lastRun.tokens} tokens used.
{/if}
</div>π― Focus Management
<script>
import { tick } from 'svelte';
let dialogRef: HTMLDialogElement;
let previousFocus: HTMLElement;
async function openDialog() {
previousFocus = document.activeElement as HTMLElement;
dialogRef.showModal();
await tick();
dialogRef.querySelector<HTMLElement>('[autofocus]')?.focus();
}
function closeDialog() {
dialogRef.close();
previousFocus?.focus();
}
</script>π Motion Preferences
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}π Genius Improvements
π Prioritized Roadmap
| UID | π·οΈ Feature | π Implementation | π‘ Benefit |
|---|---|---|---|
| A1 | π Server-side OpenRouter | SvelteKit +server.ts + $env/static/private | Keys safe, deployable |
| A2 | π SSE Streaming | sveltekit-sse + ReadableStream | Live typing UX, cancel |
| A3 | πΈ Cost Tracking | /api/v1/generation stats endpoint | True spend per run |
| B1 | π Prompt Library | IndexedDB + CRUD UI + tagging | Reusable, versioned |
| B2 | 𧬠Variable System | {{var}} interpolation | Programmable prompts |
| B3 | π·οΈ Tag Skeletons | Genre templates + constraints | One-click structure |
| C1 | π§ͺ Bench Mode | Side-by-side grid + ratings | Rapid A/B testing |
| C2 | π Projects | IDB storage + import/export | Portable, versioned |
| C3 | π§Ύ Run History | Every gen tracked | Reproducibility |
| D1 | β¨οΈ Command Palette | Mod+K actions | Power-user speed |
| D2 | π Section Outline | Jump to [Section] | Navigation |
| D3 | π Diff Preview | Compare before apply | Safe edits |
| E1 | π¨ Theme System | Skeleton themes + custom | User preference |
| E2 | βΏ A11y Audit | WCAG 2.1 AA compliance | Inclusive |
| E3 | π± Responsive | Mobile-friendly panels | Anywhere access |
π― Phase Breakdown
Phase 1: Foundation (A1-A3)
- Migrate to SvelteKit + Skeleton v3
- Server-side OpenRouter with streaming
- Basic state management
Phase 2: Libraries (B1-B3)
- Prompt/preset/skeleton CRUD
- Variable interpolation
- IndexedDB persistence
Phase 3: Bench (C1-C3)
- Multi-output comparison grid
- Rating + filtering
- Run history + reproduction
Phase 4: Polish (D1-E3)
- Command palette
- Section outline
- Theme system
- Full a11y audit
π Reference Links
π Documentation
- Skeleton v3 Docs
- Skeleton v3 Migration
- SvelteKit Docs
- Svelte 5 Runes
- OpenRouter API
- CodeMirror 6
- sveltekit-sse
π οΈ Tools
π΅ Quick Start Template
# Create project
npx sv create lyrix-bench --template minimal --types ts
cd lyrix-bench
# Install deps
npm i @skeletonlabs/skeleton @skeletonlabs/skeleton-svelte idb
npm i @codemirror/view @codemirror/state @codemirror/lang-markdown
npm i @codemirror/autocomplete @codemirror/commands
npm i -D sveltekit-sse tailwindcss @tailwindcss/vite
# Configure port (vite.config.ts)
server: { port: 51120, strictPort: true }
# Add env
echo "OPENROUTER_API_KEY=sk-or-..." >> .env
# Run
npm run devBlueprint crafted for Lyrix Bench Next-Level Revamp Stack: SvelteKit + Svelte 5 + Skeleton v3 + Tailwind v4 + OpenRouter