Next-Level Lyrix Bench Blueprint | SvelteKit + Skeleton v3 + OpenRouter

🧭 Quick Nav


πŸ“Š 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

GapImpactPriority
πŸ” Client-side API keySecurity breach riskπŸ”΄ Critical
🧱 Monolithic UIUnmaintainableπŸ”΄ Critical
πŸ“š No prompt library CRUDLimited reuse🟠 High
🏷️ No tag skeletonsManual structure🟠 High
πŸ§ͺ No bench modeCan’t compare models🟠 High
πŸ’Έ No cost trackingBlind spending🟑 Medium
πŸ’Ύ No project filesNo 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

CriterionCodeMirror 6Monaco
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)

ComponentUse CaseImplementation
🎹 Command PaletteQuick actions, searchCustom + Skeleton Modal
πŸ“Š Diff ViewerCompare outputsCustom CodeMirror
πŸ“ˆ Cost DashboardUsage analyticsCustom + Progress
🏷️ Tag InputLibrary taggingSkeleton TagsInput
πŸ”€ Split PaneBench comparisonsCustom resizable
πŸ“‹ Toast StackNotificationsSkeleton Toast
🎨 Theme SwitcherDark/light/customSkeleton + 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 OpenRouterSvelteKit +server.ts + $env/static/privateKeys safe, deployable
A2🌊 SSE Streamingsveltekit-sse + ReadableStreamLive typing UX, cancel
A3πŸ’Έ Cost Tracking/api/v1/generation stats endpointTrue spend per run
B1πŸ“š Prompt LibraryIndexedDB + CRUD UI + taggingReusable, versioned
B2🧬 Variable System{{var}} interpolationProgrammable prompts
B3🏷️ Tag SkeletonsGenre templates + constraintsOne-click structure
C1πŸ§ͺ Bench ModeSide-by-side grid + ratingsRapid A/B testing
C2πŸ“ ProjectsIDB storage + import/exportPortable, versioned
C3🧾 Run HistoryEvery gen trackedReproducibility
D1⌨️ Command PaletteMod+K actionsPower-user speed
D2πŸ“Š Section OutlineJump to [Section]Navigation
D3πŸ”€ Diff PreviewCompare before applySafe edits
E1🎨 Theme SystemSkeleton themes + customUser preference
E2β™Ώ A11y AuditWCAG 2.1 AA complianceInclusive
E3πŸ“± ResponsiveMobile-friendly panelsAnywhere 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

πŸ“š Documentation

πŸ› οΈ 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 dev

Blueprint crafted for Lyrix Bench Next-Level Revamp Stack: SvelteKit + Svelte 5 + Skeleton v3 + Tailwind v4 + OpenRouter