Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ Buttons across the app follow a ghost pattern: transparent background, 1px borde

## Testing

Vitest at root level. Config: `vitest.config.ts`. Tests: `tests/**/*.test.ts`. Currently 484 tests across 15 files. Shell check scripts (8) in `tests/*.sh` via `tests/run_all.sh`. Playwright E2E specs (5) in `e2e/`.
Vitest at root level. Config: `vitest.config.ts`. Tests: `tests/**/*.test.ts`. Currently 613 tests across 18 files. Shell check scripts (8) in `tests/*.sh` via `tests/run_all.sh`. Playwright E2E specs (5) in `e2e/`.

- `tests/artifact-config.test.ts` — artifact config resolution (`getResolvedConfig`, `buildAtRef`)
- `tests/artifact-runtime.test.ts` — artifact runtime helpers
Expand All @@ -318,8 +318,11 @@ Vitest at root level. Config: `vitest.config.ts`. Tests: `tests/**/*.test.ts`. C
- `tests/framework-css.test.ts` — CSS specificity, layout rules, variant correctness
- `tests/html-renderer-modules.test.ts` — HTML renderer output for all 14 module types
- `tests/module-type-parity.test.ts` — renderer/prompt/phantom type parity across shared package
- `tests/outline-parser.test.ts` — outline markdown parsing
- `tests/outline-pipeline-10.test.ts` — outline import pipeline (slide generation from parsed outline)
- `tests/resource-registry.test.ts` — resource registry validation
- `tests/rich-text.test.ts` — rich text pipeline (markdown, HTML, sanitization)
- `tests/slide-budget.test.ts` — slide budget estimation from outline
- `tests/slide-layout.test.ts` — layout zone validation
- `tests/ssrf-guard.test.ts` — SSRF protection for URL fetching
- `tests/system-prompt-*.test.ts` — system prompt docs and render diagnostics
Expand All @@ -343,6 +346,16 @@ Tests import directly from `packages/shared/src/` and `apps/web/src/lib/utils/`.
- `DELETE /api/themes/:id` — delete custom theme (owner only, not built-in)
- `GET /api/artifacts` — all artifact definitions

### Client-Side Resource Registry
All resources (templates, themes, artifacts) are managed through centralized Svelte stores with fetch-once guards:

- `apps/web/src/lib/stores/templates.ts` — `templatesStore`, `ensureTemplatesLoaded()`, `findTemplateById()`
- `apps/web/src/lib/stores/themes.ts` — `themesStore`, `ensureThemesLoaded()`, `createTheme()`, `deleteTheme()`
- `apps/web/src/lib/stores/artifacts.ts` — `artifactsStore`, `ensureArtifactsLoaded()`, `findArtifactByName()`
- `apps/web/src/lib/stores/resources.ts` — unified aggregator, re-exports all stores + `ensureAllResourcesLoaded()`

**Do not bypass these stores.** UI components should read via `$derived($store)` and call `ensure*Loaded()` in an `$effect`, not do their own `fetch()` calls. Mutations (create/delete theme) should use the store helpers, not inline fetch + `store.update()`. `mutations.ts` uses dynamic `import()` for store access to avoid circular dependencies.

## Known Issues / Tech Debt

- PreTeXtBook/pretext is a server-side Python toolchain, NOT a browser JS library. `@chenglou/pretext` was previously used for text measurement but was removed — its inline styles were overridden by `framework-preview.css` `!important` rules, making it entirely inert. All text sizing uses CSS `clamp()` with `cqi` units.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ apps/api/ — Hono API (Node, SQLite via better-sqlite3 + Drizzle, Lucia
apps/web/ — SvelteKit frontend (Svelte 5 runes, TipTap editor)
packages/shared/ — Shared TypeScript types, constants, framework CSS
templates/ — Seeded slide and artifact template JSON (35 files)
tests/ — Vitest unit tests (484 tests, 15 files) + 8 shell check scripts
tests/ — Vitest unit tests (613 tests, 18 files) + 8 shell check scripts
e2e/ — Playwright E2E tests (5 specs)
```

Expand Down
37 changes: 11 additions & 26 deletions apps/web/src/lib/components/canvas/CanvasToolbar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { currentDeck } from '$lib/stores/deck'
import { activeSlideId, setActiveSlide } from '$lib/stores/ui'
import { editorDarkMode } from '$lib/stores/editor-theme'
import { themesStore, themesLoaded, ensureThemesLoaded, isDark, type ThemeData } from '$lib/stores/themes'
import { themesStore, themesLoaded, ensureThemesLoaded, isDark, createTheme, deleteTheme, type ThemeData } from '$lib/stores/themes'
import { API_URL } from '$lib/api'
import ShareDeckDialog from '$lib/components/gallery/ShareDeckDialog.svelte'

Expand Down Expand Up @@ -203,21 +203,12 @@
if (!formName.trim() || saving) return
saving = true
try {
const res = await fetch(`${API_URL}/api/themes`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formName.trim(),
colors: { primary: formPrimary, secondary: formSecondary, accent: formAccent, bg: formBg },
fonts: { heading: formHeadingFont, body: formBodyFont },
}),
const result = await createTheme({
name: formName.trim(),
colors: { primary: formPrimary, secondary: formSecondary, accent: formAccent, bg: formBg },
fonts: { heading: formHeadingFont, body: formBodyFont },
})
if (res.ok) {
const data = await res.json()
if (data.theme) {
themesStore.update((t) => [...t, data.theme])
}
if (result) {
showCreateForm = false
formName = ''
}
Expand All @@ -228,19 +219,13 @@
}
}

async function deleteTheme(themeId: string) {
async function handleDeleteTheme(themeId: string) {
if (deleting) return
deleting = themeId
try {
const res = await fetch(`${API_URL}/api/themes/${themeId}`, {
method: 'DELETE',
credentials: 'include',
})
if (res.ok) {
themesStore.update((t) => t.filter((th) => th.id !== themeId))
if (deckThemeId === themeId) {
await applyTheme('cuny-ai-lab-default')
}
const ok = await deleteTheme(themeId)
if (ok && deckThemeId === themeId) {
await applyTheme('cuny-ai-lab-default')
}
} catch (err) {
console.error('Failed to delete theme:', err)
Expand Down Expand Up @@ -453,7 +438,7 @@
class="tp-delete-btn"
onclick={() => {
if (confirmDelete === variant.id) {
deleteTheme(variant.id)
handleDeleteTheme(variant.id)
} else {
confirmDelete = variant.id
}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/lib/components/canvas/ZoneDrop.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@
return () => ro.disconnect()
})

function transformDragPreview(el: HTMLElement) {
function transformDragPreview(el?: HTMLElement) {
if (!el) return
el.style.opacity = '0.9'
el.style.boxShadow = '0 8px 32px rgba(0,0,0,0.3)'
el.style.borderRadius = '8px'
Expand Down
16 changes: 4 additions & 12 deletions apps/web/src/lib/components/chat/ChatInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
import { chatStreaming, chatDraft } from '$lib/stores/chat'
import { currentDeck } from '$lib/stores/deck'
import { api } from '$lib/api'
import { API_URL } from '$lib/api'
import { get } from 'svelte/store'
import { activeSlideId } from '$lib/stores/ui'
import { artifactsStore, ensureArtifactsLoaded } from '$lib/stores/artifacts'
import { artifactsStore, templatesStore, ensureAllResourcesLoaded } from '$lib/stores/resources'
import ChatRichTextEditor from './ChatRichTextEditor.svelte'
import ChatFormattingToolbar from './ChatFormattingToolbar.svelte'
import type { Editor } from '@tiptap/core'
Expand Down Expand Up @@ -56,26 +55,19 @@
let mentionAtPos = $state(-1) // ProseMirror position of the @
let mentionIndex = $state(0)
let mentionDataLoaded = $state(false)
let mentionTemplates = $state<MentionItem[]>([])

async function loadMentionData() {
if (mentionDataLoaded) return
mentionDataLoaded = true
await ensureArtifactsLoaded()
try {
const res = await fetch(`${API_URL}/api/templates`, { credentials: 'include' })
if (res.ok) {
const data = await res.json()
mentionTemplates = (data.templates ?? []).map((t: any) => ({ name: t.name, prefix: 'template' as const }))
}
} catch { /* non-fatal */ }
await ensureAllResourcesLoaded()
}

let mentionItems = $derived.by((): MentionItem[] => {
if (mentionQuery === null) return []
const q = mentionQuery.toLowerCase()
const artifacts = ($artifactsStore ?? []).map((a) => ({ name: a.name, prefix: 'artifact' as const }))
const all: MentionItem[] = [...artifacts, ...mentionTemplates]
const templates = ($templatesStore ?? []).map((t) => ({ name: t.name, prefix: 'template' as const }))
const all: MentionItem[] = [...artifacts, ...templates]
return q ? all.filter((m) => m.name.toLowerCase().includes(q)) : all
})

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/components/chat/ChatPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
)
)
const successful = downloads
.filter((d): d is PromiseFulfilledResult<any> => d.status === 'fulfilled' && d.value?.file)
.filter((d): d is PromiseFulfilledResult<any> => d.status === 'fulfilled' && !!d.value?.file)
.map(d => d.value)

if (successful.length) {
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/lib/components/outline/SlideCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@

let deleting = $state(false)
let expanded = $state(false)
let blockItems = $state([])
type Block = typeof slide.blocks[number]
let blockItems = $state<Block[]>([])
let draggingBlocks = false // plain boolean — invisible to reactive system

$effect(() => {
Expand Down Expand Up @@ -73,9 +74,9 @@
if (active) expanded = true
})

function handleClick(e: MouseEvent) {
function handleClick(e?: MouseEvent) {
// Ignore clicks on the delete button (Svelte 5 event delegation)
if ((e.target as HTMLElement).closest('.delete-btn')) return
if (e && (e.target as HTMLElement).closest('.delete-btn')) return
if (active) {
expanded = !expanded
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@
// Guard against external content changes (e.g., undo/redo, AI mutations)
$effect(() => {
if (editor && content !== lastEmittedHtml && content !== editor.getHTML()) {
editor.commands.setContent(content, false)
editor.commands.setContent(content, { emitUpdate: false })
}
})

Expand Down
39 changes: 13 additions & 26 deletions apps/web/src/lib/components/resources/ArtifactsTab.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,20 @@
import { activeSlideId } from '$lib/stores/ui'
import { applyMutation } from '$lib/utils/mutations'
import { get } from 'svelte/store'
import { API_URL } from '$lib/api'
import { chatDraft, switchToChat } from '$lib/stores/chat'
import { artifactsStore, ensureArtifactsLoaded, type ArtifactDef } from '$lib/stores/artifacts'
import {
getResolvedConfig,
type ArtifactRef,
} from '$lib/utils/artifact-config'

interface Artifact extends ArtifactRef {
builtIn: boolean
}

interface ArtifactGroup {
key: string
label: string
badge: string
items: Artifact[]
items: ArtifactDef[]
}

let artifacts = $state<Artifact[]>([])
let artifacts = $derived($artifactsStore)
let loading = $state(true)
let error = $state<string | null>(null)
let inserting = $state<string | null>(null)
Expand All @@ -35,22 +30,14 @@
let expandedGroups = $state<Set<string>>(new Set(['chart', 'diagram', 'map', 'visualization']))

$effect(() => {
fetch(`${API_URL}/api/artifacts`, { credentials: 'include' })
.then((res) => res.json())
.then((data) => {
artifacts = data.artifacts ?? []
loading = false
})
.catch((err) => {
console.error('Failed to fetch artifacts:', err)
error = 'Failed to load artifacts'
loading = false
})
ensureArtifactsLoaded()
.then(() => { loading = false })
.catch(() => { error = 'Failed to load artifacts'; loading = false })
})

// Group artifacts by type
let groups = $derived.by(() => {
const byType: Record<string, Artifact[]> = {}
const byType: Record<string, ArtifactDef[]> = {}
for (const a of artifacts) {
;(byType[a.type] ??= []).push(a)
}
Expand Down Expand Up @@ -93,7 +80,7 @@
expandedGroups = next
}

function openConfigEditor(artifact: Artifact) {
function openConfigEditor(artifact: ArtifactDef) {
if (editingArtifactId === artifact.id) {
editingArtifactId = null
return
Expand All @@ -115,7 +102,7 @@
return 'main'
}

async function insertArtifact(artifact: Artifact, useConfig: boolean = false) {
async function insertArtifact(artifact: ArtifactDef, useConfig: boolean = false) {
const slideId = get(activeSlideId)
if (!slideId || inserting) return

Expand Down Expand Up @@ -156,24 +143,24 @@

let copied = $state<string | null>(null)

async function copyConfig(artifact: Artifact) {
async function copyConfig(artifact: ArtifactDef) {
const config = getResolvedConfig(artifact)
await navigator.clipboard.writeText(JSON.stringify(config, null, 2))
copied = artifact.id
setTimeout(() => { if (copied === artifact.id) copied = null }, 1500)
}

function injectAtRef(artifact: Artifact) {
function injectAtRef(artifact: ArtifactDef) {
chatDraft.set(`@artifact:${artifact.name}`)
switchToChat.set(true)
}

function hasConfig(artifact: Artifact): boolean {
function hasConfig(artifact: ArtifactDef): boolean {
const cfg = artifact.config as Record<string, unknown> | null
return cfg != null && typeof cfg === 'object' && Object.keys(cfg).length > 0
}

function buildArtifactReferenceData(artifact: Artifact, config: Record<string, unknown>) {
function buildArtifactReferenceData(artifact: ArtifactDef, config: Record<string, unknown>) {
return {
registryId: artifact.id,
config,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/components/resources/ResourcePanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

const tabs: { key: 'files' | 'templates' | 'artifacts'; label: string }[] = [
{ key: 'files', label: 'Files' },
{ key: 'templates', label: 'Tmpl' },
{ key: 'templates', label: 'Templates' },
{ key: 'artifacts', label: 'Visuals' },
]

Expand Down
34 changes: 9 additions & 25 deletions apps/web/src/lib/components/resources/TemplatesTab.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,11 @@
import { get } from 'svelte/store'
import { currentDeck, addSlideToDeck } from '$lib/stores/deck'
import { setActiveSlide } from '$lib/stores/ui'
import { API_URL } from '$lib/api'
import { chatDraft } from '$lib/stores/chat'
import { API_URL } from '$lib/api'
import { templatesStore, ensureTemplatesLoaded, type TemplateData } from '$lib/stores/templates'

interface Template {
id: string
name: string
layout: string
modules: { type: string; zone: string; data: Record<string, unknown>; stepOrder?: number }[]
thumbnail: string | null
builtIn: boolean
}

let templates = $state<Template[]>([])
let templates = $derived($templatesStore)
let loading = $state(true)
let error = $state<string | null>(null)

Expand All @@ -31,7 +23,7 @@
const groupOrder = ['title-slide', 'layout-split', 'layout-content', 'layout-grid', 'layout-full-dark', 'layout-divider', 'closing-slide']

let grouped = $derived.by(() => {
const groups: Record<string, Template[]> = {}
const groups: Record<string, TemplateData[]> = {}
for (const t of templates) {
if (!groups[t.layout]) groups[t.layout] = []
groups[t.layout].push(t)
Expand Down Expand Up @@ -100,24 +92,16 @@
let previewMode = $derived<'dark' | 'light'>($editorDarkMode ? 'dark' : 'light')

$effect(() => {
fetch(`${API_URL}/api/templates`, { credentials: 'include' })
.then((res) => res.json())
.then((data) => {
templates = data.templates ?? []
loading = false
})
.catch((err) => {
console.error('Failed to fetch templates:', err)
error = 'Failed to load templates'
loading = false
})
ensureTemplatesLoaded()
.then(() => { loading = false })
.catch(() => { error = 'Failed to load templates'; loading = false })
})

function injectTemplateRef(template: Template) {
function injectTemplateRef(template: TemplateData) {
chatDraft.set(`@template:${template.name}`)
}

async function applyTemplate(template: Template) {
async function applyTemplate(template: TemplateData) {
const deck = get(currentDeck)
if (!deck) return

Expand Down
Loading