From 24a5dcf94faffd76d721feb78267b7d85176d0d4 Mon Sep 17 00:00:00 2001 From: zmuhls Date: Sat, 11 Apr 2026 23:36:06 -0400 Subject: [PATCH 1/3] feat: centralized resource registry, fix 18 pre-existing type errors add templates store, theme crud helpers, unified resources aggregator. rewire 5 consumers to use stores instead of scattered fetches. rename tmpl to templates in resource panel. fix type errors across zonedrop, slidecard, richtexteditor, chatpanel, mutations, and debug page. --- .../components/canvas/CanvasToolbar.svelte | 37 +++++---------- .../src/lib/components/canvas/ZoneDrop.svelte | 3 +- .../src/lib/components/chat/ChatInput.svelte | 16 ++----- .../src/lib/components/chat/ChatPanel.svelte | 2 +- .../lib/components/outline/SlideCard.svelte | 7 +-- .../renderers/RichTextEditor.svelte | 2 +- .../components/resources/ArtifactsTab.svelte | 39 ++++++---------- .../components/resources/ResourcePanel.svelte | 2 +- .../components/resources/TemplatesTab.svelte | 34 ++++---------- apps/web/src/lib/stores/resources.ts | 30 +++++++++++++ apps/web/src/lib/stores/templates.ts | 37 +++++++++++++++ apps/web/src/lib/stores/themes.ts | 45 +++++++++++++++++++ apps/web/src/lib/utils/mutations.ts | 14 +++--- apps/web/src/routes/(app)/debug/+page.svelte | 10 ++++- 14 files changed, 174 insertions(+), 104 deletions(-) create mode 100644 apps/web/src/lib/stores/resources.ts create mode 100644 apps/web/src/lib/stores/templates.ts diff --git a/apps/web/src/lib/components/canvas/CanvasToolbar.svelte b/apps/web/src/lib/components/canvas/CanvasToolbar.svelte index ff20bd2..6735b5c 100644 --- a/apps/web/src/lib/components/canvas/CanvasToolbar.svelte +++ b/apps/web/src/lib/components/canvas/CanvasToolbar.svelte @@ -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' @@ -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 = '' } @@ -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) @@ -453,7 +438,7 @@ class="tp-delete-btn" onclick={() => { if (confirmDelete === variant.id) { - deleteTheme(variant.id) + handleDeleteTheme(variant.id) } else { confirmDelete = variant.id } diff --git a/apps/web/src/lib/components/canvas/ZoneDrop.svelte b/apps/web/src/lib/components/canvas/ZoneDrop.svelte index fca0487..8ebd507 100644 --- a/apps/web/src/lib/components/canvas/ZoneDrop.svelte +++ b/apps/web/src/lib/components/canvas/ZoneDrop.svelte @@ -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' diff --git a/apps/web/src/lib/components/chat/ChatInput.svelte b/apps/web/src/lib/components/chat/ChatInput.svelte index 66e778e..1768661 100644 --- a/apps/web/src/lib/components/chat/ChatInput.svelte +++ b/apps/web/src/lib/components/chat/ChatInput.svelte @@ -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' @@ -56,26 +55,19 @@ let mentionAtPos = $state(-1) // ProseMirror position of the @ let mentionIndex = $state(0) let mentionDataLoaded = $state(false) - let mentionTemplates = $state([]) 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 }) diff --git a/apps/web/src/lib/components/chat/ChatPanel.svelte b/apps/web/src/lib/components/chat/ChatPanel.svelte index f779c83..b46732a 100644 --- a/apps/web/src/lib/components/chat/ChatPanel.svelte +++ b/apps/web/src/lib/components/chat/ChatPanel.svelte @@ -83,7 +83,7 @@ ) ) const successful = downloads - .filter((d): d is PromiseFulfilledResult => d.status === 'fulfilled' && d.value?.file) + .filter((d): d is PromiseFulfilledResult => d.status === 'fulfilled' && !!d.value?.file) .map(d => d.value) if (successful.length) { diff --git a/apps/web/src/lib/components/outline/SlideCard.svelte b/apps/web/src/lib/components/outline/SlideCard.svelte index 5fc40d3..1015aee 100644 --- a/apps/web/src/lib/components/outline/SlideCard.svelte +++ b/apps/web/src/lib/components/outline/SlideCard.svelte @@ -37,7 +37,8 @@ let deleting = $state(false) let expanded = $state(false) - let blockItems = $state([]) + type Block = typeof slide.blocks[number] + let blockItems = $state([]) let draggingBlocks = false // plain boolean — invisible to reactive system $effect(() => { @@ -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 { diff --git a/apps/web/src/lib/components/renderers/RichTextEditor.svelte b/apps/web/src/lib/components/renderers/RichTextEditor.svelte index 7baf42f..0318184 100644 --- a/apps/web/src/lib/components/renderers/RichTextEditor.svelte +++ b/apps/web/src/lib/components/renderers/RichTextEditor.svelte @@ -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 }) } }) diff --git a/apps/web/src/lib/components/resources/ArtifactsTab.svelte b/apps/web/src/lib/components/resources/ArtifactsTab.svelte index 2345c43..eb1c7a2 100644 --- a/apps/web/src/lib/components/resources/ArtifactsTab.svelte +++ b/apps/web/src/lib/components/resources/ArtifactsTab.svelte @@ -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([]) + let artifacts = $derived($artifactsStore) let loading = $state(true) let error = $state(null) let inserting = $state(null) @@ -35,22 +30,14 @@ let expandedGroups = $state>(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 = {} + const byType: Record = {} for (const a of artifacts) { ;(byType[a.type] ??= []).push(a) } @@ -93,7 +80,7 @@ expandedGroups = next } - function openConfigEditor(artifact: Artifact) { + function openConfigEditor(artifact: ArtifactDef) { if (editingArtifactId === artifact.id) { editingArtifactId = null return @@ -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 @@ -156,24 +143,24 @@ let copied = $state(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 | null return cfg != null && typeof cfg === 'object' && Object.keys(cfg).length > 0 } - function buildArtifactReferenceData(artifact: Artifact, config: Record) { + function buildArtifactReferenceData(artifact: ArtifactDef, config: Record) { return { registryId: artifact.id, config, diff --git a/apps/web/src/lib/components/resources/ResourcePanel.svelte b/apps/web/src/lib/components/resources/ResourcePanel.svelte index 4f3d0c0..763272c 100644 --- a/apps/web/src/lib/components/resources/ResourcePanel.svelte +++ b/apps/web/src/lib/components/resources/ResourcePanel.svelte @@ -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' }, ] diff --git a/apps/web/src/lib/components/resources/TemplatesTab.svelte b/apps/web/src/lib/components/resources/TemplatesTab.svelte index 70973f3..200f3d0 100644 --- a/apps/web/src/lib/components/resources/TemplatesTab.svelte +++ b/apps/web/src/lib/components/resources/TemplatesTab.svelte @@ -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; stepOrder?: number }[] - thumbnail: string | null - builtIn: boolean - } - - let templates = $state([]) + let templates = $derived($templatesStore) let loading = $state(true) let error = $state(null) @@ -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 = {} + const groups: Record = {} for (const t of templates) { if (!groups[t.layout]) groups[t.layout] = [] groups[t.layout].push(t) @@ -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 diff --git a/apps/web/src/lib/stores/resources.ts b/apps/web/src/lib/stores/resources.ts new file mode 100644 index 0000000..ab4f83c --- /dev/null +++ b/apps/web/src/lib/stores/resources.ts @@ -0,0 +1,30 @@ +import { derived } from 'svelte/store' +import { templatesStore, ensureTemplatesLoaded, findTemplateById } from './templates' +import { themesStore, ensureThemesLoaded, createTheme, deleteTheme } from './themes' +import { artifactsStore, ensureArtifactsLoaded, findArtifactByName } from './artifacts' + +export { + // Templates + templatesStore, ensureTemplatesLoaded, findTemplateById, + // Themes + themesStore, ensureThemesLoaded, createTheme, deleteTheme, + // Artifacts + artifactsStore, ensureArtifactsLoaded, findArtifactByName, +} + +export const allResources = derived( + [templatesStore, themesStore, artifactsStore], + ([$templates, $themes, $artifacts]) => ({ + templates: $templates, + themes: $themes, + artifacts: $artifacts, + }), +) + +export async function ensureAllResourcesLoaded() { + await Promise.all([ + ensureTemplatesLoaded(), + ensureThemesLoaded(), + ensureArtifactsLoaded(), + ]) +} diff --git a/apps/web/src/lib/stores/templates.ts b/apps/web/src/lib/stores/templates.ts new file mode 100644 index 0000000..ae96fde --- /dev/null +++ b/apps/web/src/lib/stores/templates.ts @@ -0,0 +1,37 @@ +import { writable, get } from 'svelte/store' +import { API_URL } from '$lib/api' + +export interface TemplateData { + id: string + name: string + layout: string + modules: { type: string; zone: string; data: Record; stepOrder?: number }[] + thumbnail: string | null + builtIn: boolean +} + +export const templatesStore = writable([]) + +let fetched = false + +export async function ensureTemplatesLoaded(): Promise { + const existing = get(templatesStore) + if (fetched && existing.length > 0) return existing + + fetched = true + try { + const res = await fetch(`${API_URL}/api/templates`, { credentials: 'include' }) + const data = await res.json() + const templates = data.templates ?? [] + templatesStore.set(templates) + return templates + } catch (err) { + console.error('Failed to fetch templates:', err) + return [] + } +} + +/** Find a template by ID */ +export function findTemplateById(id: string): TemplateData | undefined { + return get(templatesStore).find((t) => t.id === id) +} diff --git a/apps/web/src/lib/stores/themes.ts b/apps/web/src/lib/stores/themes.ts index e6330c6..f337e5b 100644 --- a/apps/web/src/lib/stores/themes.ts +++ b/apps/web/src/lib/stores/themes.ts @@ -56,3 +56,48 @@ export function isDark(hex: string): boolean { const b = parseInt(hex.slice(5, 7), 16) return (r * 0.299 + g * 0.587 + b * 0.114) < 128 } + +export interface CreateThemeInput { + name: string + colors: { primary: string; secondary: string; accent: string; bg: string } + fonts: { heading: string; body: string } +} + +/** Create a new custom theme and add it to the store */ +export async function createTheme(input: CreateThemeInput): Promise { + try { + const res = await fetch(`${API_URL}/api/themes`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }) + if (res.ok) { + const data = await res.json() + if (data.theme) { + themesStore.update((t) => [...t, data.theme]) + return data.theme + } + } + } catch (err) { + console.error('Failed to create theme:', err) + } + return null +} + +/** Delete a custom theme and remove it from the store */ +export async function deleteTheme(themeId: string): Promise { + 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)) + return true + } + } catch (err) { + console.error('Failed to delete theme:', err) + } + return false +} diff --git a/apps/web/src/lib/utils/mutations.ts b/apps/web/src/lib/utils/mutations.ts index 5fb044d..fd1abfd 100644 --- a/apps/web/src/lib/utils/mutations.ts +++ b/apps/web/src/lib/utils/mutations.ts @@ -530,8 +530,9 @@ export async function applyMutation(mutation: Record): Promise< const resolvedSlideId = slideId ? (resolveSlideRef(slideId) || slideId) : undefined // Fetch template details - const templateData = await apiCall('/api/templates', 'GET') - const template = (templateData?.templates ?? []).find((t: any) => t.id === templateId) + const { ensureTemplatesLoaded, findTemplateById } = await import('../stores/templates') + await ensureTemplatesLoaded() + const template = findTemplateById(templateId) if (!template) { console.error('Template not found:', templateId) break @@ -549,7 +550,7 @@ export async function applyMutation(mutation: Record): Promise< await apiCall(`/api/decks/${deck.id}/slides/${resolvedSlideId}/blocks/${block.id}`, 'DELETE') } await apiCall(`/api/decks/${deck.id}/slides/${resolvedSlideId}`, 'PATCH', { layout: template.layout }) - const newBlocks = [] + const newBlocks: any[] = [] for (const mod of template.modules) { const result = await apiCall(`/api/decks/${deck.id}/slides/${resolvedSlideId}/blocks`, 'POST', { type: mod.type, @@ -985,8 +986,9 @@ async function applyMutationSilent(mutation: Record): Promise t.id === templateId) + const { ensureTemplatesLoaded, findTemplateById } = await import('../stores/templates') + await ensureTemplatesLoaded() + const template = findTemplateById(templateId) if (!template) break if (slideId) { const slide = deck.slides.find((s) => s.id === slideId) @@ -1027,7 +1029,7 @@ async function applyMutationSilent(mutation: Record): Promise() + interface Transcript { + id: string; model: string; deckId: string; userMessage: string; assistantMessage: string + timestamp: string; userEmail: string; durationMs: number; inputTokens: number; outputTokens: number + error: string | null; provider: string; systemPromptChars: number; historyLength: number + mutations: string[]; [key: string]: unknown + } + let { data } = $props<{ data: { transcripts: Transcript[] } }>() let user = $state(null) let accessDenied = $state(false) @@ -34,7 +40,7 @@ // Live streams indexed by id let streams = $state>({}) let streamOrder = $state([]) - let transcripts = $derived(data?.transcripts ?? []) + let transcripts = $state(data?.transcripts ?? []) // Filters let filterModel = $state('') From 61a30d23664b1aa1f5e37868a71488283c785554 Mon Sep 17 00:00:00 2001 From: zmuhls Date: Sat, 11 Apr 2026 23:49:55 -0400 Subject: [PATCH 2/3] docs: update claude.md with resource registry, fix test counts in readme --- CLAUDE.md | 15 ++++++++++++++- README.md | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d011907..3d313f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 @@ -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. diff --git a/README.md b/README.md index 3b47a89..97d3071 100644 --- a/README.md +++ b/README.md @@ -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) ``` From ec498f61bd882918a4313b96bbfa22cdb4632531 Mon Sep 17 00:00:00 2001 From: zmuhls Date: Sun, 12 Apr 2026 16:14:44 -0400 Subject: [PATCH 3/3] fix: use sveltekit base path for api url, allow cloudflare beacon in csp API_URL now derives from $app/paths base, auto-detecting local dev (empty, via vite proxy) and staging (/slide-maker, via nginx). Removes dependency on PUBLIC_API_URL env var for client-side routing. Adds cloudflareinsights.com to CSP script-src and connect-src to stop beacon block errors on staging. --- apps/web/src/hooks.server.ts | 4 ++-- apps/web/src/lib/api.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index 981e1af..3e5b0a3 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -13,11 +13,11 @@ export const handle: Handle = async ({ event, resolve }) => { 'Content-Security-Policy', [ "default-src 'self'", - "script-src 'self' 'unsafe-inline' https://*.qzz.io", + "script-src 'self' 'unsafe-inline' https://*.qzz.io https://static.cloudflareinsights.com", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "font-src 'self' https://fonts.gstatic.com", `img-src 'self' data: blob: https://*.qzz.io https://*.cuny.edu https://images.pexels.com https://*.pexels.com https://*.tile.openstreetmap.org https://*.basemaps.cartocdn.com https://server.arcgisonline.com${localhost}`, - `connect-src 'self'${localhost} https://*.cuny.edu https://*.qzz.io`, + `connect-src 'self'${localhost} https://*.cuny.edu https://*.qzz.io https://*.cloudflareinsights.com`, "frame-src 'self' blob: https://www.youtube.com https://player.vimeo.com https://www.loom.com", "object-src 'none'", "base-uri 'self'", diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index a1b650c..e043a01 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,6 @@ -export const API_URL = import.meta.env.DEV ? '' : (import.meta.env.PUBLIC_API_URL ?? 'http://localhost:3001') +import { base } from '$app/paths' + +export const API_URL = base async function request(path: string, options?: RequestInit): Promise { const res = await fetch(`${API_URL}${path}`, {