Compare commits

..

No commits in common. "dev" and "main" have entirely different histories.
dev ... main

3 changed files with 86 additions and 1399 deletions

View file

@ -1,5 +1,5 @@
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { access, mkdir, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { inflateSync } from "node:zlib"; import { inflateSync } from "node:zlib";
@ -11,28 +11,13 @@ import multer from "multer";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import pdfParse from "pdf-parse"; import pdfParse from "pdf-parse";
import { z } from "zod"; import { z } from "zod";
import { loadStylePack, planDeckFromSource, SourceDocument, StylePack } from "@slide-factory/core"; import { loadStylePack, planDeckFromSource, SourceDocument } from "@slide-factory/core";
import { renderPptx } from "@slide-factory/render-pptx"; import { renderPptx } from "@slide-factory/render-pptx";
const DesignTemplateSchema = z.object({
id: z.string().optional(),
name: z.string().optional(),
description: z.string().optional(),
summary: z.string().optional(),
colors: z.array(z.string()).optional(),
layoutRules: z.array(z.string()).optional(),
componentRules: z.array(z.string()).optional(),
slideFamilies: z.array(z.string()).optional(),
prompt: z.string().optional(),
analysisSource: z.string().optional(),
sourceName: z.string().optional()
});
const CreateDeckRequestSchema = z.object({ const CreateDeckRequestSchema = z.object({
style: z.string().default("incorta"), style: z.string().default("incorta"),
instructions: z.string().optional(), instructions: z.string().optional(),
audience: z.string().default("executives"), audience: z.string().default("executives"),
designTemplate: DesignTemplateSchema.optional(),
input: z.object({ input: z.object({
type: z.enum(["text", "markdown"]).default("markdown"), type: z.enum(["text", "markdown"]).default("markdown"),
title: z.string().optional(), title: z.string().optional(),
@ -40,42 +25,6 @@ const CreateDeckRequestSchema = z.object({
}) })
}); });
const TemplateAnalyzeRequestSchema = z.object({
name: z.string().optional(),
description: z.string().optional()
});
type DesignTemplateInput = z.infer<typeof DesignTemplateSchema>;
type SourceFacts = {
sourceName?: string;
mimeType?: string;
extension?: string;
sizeKb?: number;
imageWidth?: number;
imageHeight?: number;
extractedText?: string;
};
type TemplateAnalysis = {
id: string;
name: string;
description: string;
summary: string;
colors: string[];
layoutRules: string[];
componentRules: string[];
slideFamilies: string[];
prompt: string;
sourceName?: string;
updatedAt: string;
analysisSource: "model-service-gateway" | "heuristic";
analysisStatus: "ai-analyzed" | "fallback";
analysisNote: string;
gatewayRequestId?: string;
sourceFacts?: SourceFacts;
};
const app = express(); const app = express();
const port = Number(process.env.SLIDE_FACTORY_PORT || 3025); const port = Number(process.env.SLIDE_FACTORY_PORT || 3025);
const outputDir = path.resolve(process.env.SLIDE_FACTORY_OUTPUT_DIR || "outputs"); const outputDir = path.resolve(process.env.SLIDE_FACTORY_OUTPUT_DIR || "outputs");
@ -103,8 +52,7 @@ app.post("/api/decks", async (req, res, next) => {
source: body.input, source: body.input,
instructions: body.instructions, instructions: body.instructions,
audience: body.audience, audience: body.audience,
styleId: body.style, styleId: body.style
designTemplate: body.designTemplate
}); });
res.status(201).json(result); res.status(201).json(result);
@ -120,13 +68,9 @@ app.post("/api/decks/from-source", upload.single("source"), async (req, res, nex
const source = file ? await sourceFromUpload(file) : sourceFromText(pastedText); const source = file ? await sourceFromUpload(file) : sourceFromText(pastedText);
const result = await createDeckArtifacts({ const result = await createDeckArtifacts({
source, source,
instructions: combineInstructions( instructions: stringField(req.body.instructions),
stringField(req.body.instructions),
file && pastedText.trim() ? `Attached slide/deck update instructions:\n${pastedText.trim()}` : undefined
),
audience: stringField(req.body.audience) || "executives", audience: stringField(req.body.audience) || "executives",
styleId: stringField(req.body.style) || "incorta", styleId: stringField(req.body.style) || "incorta"
designTemplate: parseDesignTemplateField(req.body.designTemplate)
}); });
res.status(201).json({ res.status(201).json({
@ -142,21 +86,6 @@ app.post("/api/decks/from-source", upload.single("source"), async (req, res, nex
} }
}); });
app.post("/api/templates/analyze", upload.single("source"), async (req, res, next) => {
try {
const body = TemplateAnalyzeRequestSchema.parse(req.body);
const template = await analyzeTemplate({
name: body.name,
description: body.description,
file: req.file || undefined
});
res.status(201).json({ template });
} catch (error) {
next(error);
}
});
if (existsSync(webDistDir)) { if (existsSync(webDistDir)) {
app.use(express.static(webDistDir)); app.use(express.static(webDistDir));
app.get("*", (req, res, next) => { app.get("*", (req, res, next) => {
@ -199,12 +128,10 @@ async function createDeckArtifacts(options: {
instructions?: string; instructions?: string;
audience: string; audience: string;
styleId: string; styleId: string;
designTemplate?: DesignTemplateInput;
}) { }) {
const id = nanoid(10); const id = nanoid(10);
const stylePath = await resolveStylePath(options.styleId); const stylePath = await resolveStylePath(options.styleId);
const baseStyle = await loadStylePack(stylePath); const style = await loadStylePack(stylePath);
const style = applyDesignTemplateToStyle(baseStyle, options.designTemplate);
const deck = planDeckFromSource({ const deck = planDeckFromSource({
source: options.source, source: options.source,
style, style,
@ -228,373 +155,6 @@ async function createDeckArtifacts(options: {
}; };
} }
async function analyzeTemplate(options: {
name?: string;
description?: string;
file?: Express.Multer.File;
}): Promise<TemplateAnalysis> {
const name = options.name?.trim() || "Untitled design standard";
const description = options.description?.trim() || "";
const sourceFacts = await buildSourceFacts(options.file);
const gateway = await analyzeTemplateWithGateway({ name, description, sourceFacts });
if (gateway) return gateway;
return heuristicTemplateAnalysis({ name, description, sourceFacts });
}
async function analyzeTemplateWithGateway(options: {
name: string;
description: string;
sourceFacts: SourceFacts;
}): Promise<TemplateAnalysis | null> {
const enabled = process.env.MODEL_SERVICE_GATEWAY_ENABLED === "true";
const baseUrl = process.env.MODEL_SERVICE_BASE_URL;
const appId = process.env.MODEL_SERVICE_APP_ID || "slide-factory";
const model = process.env.MODEL_SERVICE_GATEWAY_MODEL || "gpt-4o-mini";
if (!enabled || !baseUrl) return null;
const token = await readGatewayToken();
if (!token) return null;
try {
const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/api/gateway/chat`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
appId,
model,
temperature: 0.2,
maxOutputTokens: 1800,
attribution: {
feature: "slide-template-analysis",
environment: process.env.NODE_ENV || process.env.ENV || "local"
},
messages: [
{
role: "system",
content: [
"You convert presentation screenshots, slide decks, and design notes into reusable slide design standards.",
"Return strict JSON only. Do not include markdown fences.",
"The current gateway input is text-only, so base visual claims on extracted source facts, filenames, and user-provided notes. Do not invent pixel-specific observations."
].join(" ")
},
{
role: "user",
content: JSON.stringify(
{
task: "Create a reusable design template for consistent slide/deck generation.",
templateName: options.name,
userDescription: options.description,
sourceFacts: options.sourceFacts,
requiredJsonShape: {
summary: "one short paragraph",
colors: ["#112233", "#ffffff", "#..."],
layoutRules: ["rule", "rule"],
componentRules: ["rule", "rule"],
slideFamilies: ["title", "two-column", "metric", "roadmap"],
prompt: "detailed reusable design prompt"
}
},
null,
2
)
}
]
}),
signal: AbortSignal.timeout(120000)
});
if (!response.ok) return null;
const data = (await response.json()) as { id?: string; content?: string };
const parsed = parseJsonFromText(data.content || "");
if (!parsed || typeof parsed !== "object") return null;
const record = parsed as Record<string, unknown>;
return normalizeTemplateAnalysis({
id: `template-${nanoid(10)}`,
name: options.name,
description: options.description,
sourceFacts: options.sourceFacts,
source: "model-service-gateway",
status: "ai-analyzed",
note:
"Analyzed through Model Service Gateway from your description and extracted source metadata. Raw image pixels are not yet sent as multimodal vision input.",
gatewayRequestId: data.id,
record
});
} catch {
return null;
}
}
function heuristicTemplateAnalysis(options: {
name: string;
description: string;
sourceFacts: SourceFacts;
}): TemplateAnalysis {
const record = {
summary: options.description || "Reusable executive presentation standard with consistent hierarchy, spacing, and visual rhythm.",
colors: inferColors(options.name, options.description, options.sourceFacts),
layoutRules: inferLayoutRules(options.description),
componentRules: [
"Use repeated title, content, evidence, and takeaway regions across slides.",
"Keep icons minimal, line-based, and functional.",
"Use one accent system for dividers, section labels, status markers, and callouts."
],
slideFamilies: ["title", "single-column", "two-column", "three-column", "four-column", "metric-callout", "roadmap"],
prompt: [
`Create slides in the ${options.name} design system.`,
options.description || "Use consistent spacing, hierarchy, and reusable slide sections.",
"Preserve a clear executive narrative, use restrained decoration, and keep visual choices repeatable across a deck."
].join(" ")
};
return normalizeTemplateAnalysis({
id: `template-${nanoid(10)}`,
name: options.name,
description: options.description,
sourceFacts: options.sourceFacts,
source: "heuristic",
status: "fallback",
note: "Model Service Gateway was not configured or did not return a usable response, so Slide Factory saved a deterministic local template draft.",
record
});
}
function normalizeTemplateAnalysis(options: {
id: string;
name: string;
description: string;
sourceFacts: SourceFacts;
source: "model-service-gateway" | "heuristic";
status: "ai-analyzed" | "fallback";
note: string;
gatewayRequestId?: string;
record: Record<string, unknown>;
}): TemplateAnalysis {
const summary = stringValue(options.record.summary) || options.description || "Reusable slide design standard.";
const colors = stringArray(options.record.colors).map(normalizeHexColor).filter(Boolean).slice(0, 6);
return {
id: options.id,
name: options.name,
description: options.description || summary,
summary,
colors: colors.length ? colors : inferColors(options.name, options.description, options.sourceFacts),
layoutRules: stringArray(options.record.layoutRules).slice(0, 8),
componentRules: stringArray(options.record.componentRules).slice(0, 8),
slideFamilies: stringArray(options.record.slideFamilies).slice(0, 10),
prompt: stringValue(options.record.prompt) || `${options.description}\n\n${summary}`.trim(),
sourceName: options.sourceFacts.sourceName,
updatedAt: new Date().toISOString(),
analysisSource: options.source,
analysisStatus: options.status,
analysisNote: options.note,
gatewayRequestId: options.gatewayRequestId,
sourceFacts: options.sourceFacts
};
}
async function buildSourceFacts(file?: Express.Multer.File): Promise<SourceFacts> {
if (!file) return {};
const extension = path.extname(file.originalname).toLowerCase();
const facts: SourceFacts = {
sourceName: file.originalname,
mimeType: file.mimetype,
extension,
sizeKb: Math.round(file.size / 1024)
};
const dimensions = imageDimensions(file.buffer, file.mimetype);
if (dimensions) {
facts.imageWidth = dimensions.width;
facts.imageHeight = dimensions.height;
}
try {
if (extension === ".pptx" || file.mimetype.includes("presentation")) {
facts.extractedText = truncateForPrompt(await extractPptxText(file.buffer));
} else if (extension === ".pdf" || file.mimetype === "application/pdf") {
facts.extractedText = truncateForPrompt(await extractPdfText(file.buffer));
} else if (file.mimetype.startsWith("text/")) {
facts.extractedText = truncateForPrompt(file.buffer.toString("utf8"));
}
} catch {
// Source facts are helpful but should not block template creation.
}
return facts;
}
async function readGatewayToken(): Promise<string | null> {
if (process.env.MODEL_SERVICE_GATEWAY_TOKEN) return process.env.MODEL_SERVICE_GATEWAY_TOKEN;
const tokenFile = process.env.MODEL_SERVICE_GATEWAY_TOKEN_FILE;
if (!tokenFile) return null;
try {
const parsed = JSON.parse(await readFile(tokenFile, "utf8")) as { token?: unknown };
return typeof parsed.token === "string" && parsed.token.trim() ? parsed.token.trim() : null;
} catch {
return null;
}
}
function parseDesignTemplateField(value: unknown): DesignTemplateInput | undefined {
if (!value) return undefined;
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
return DesignTemplateSchema.parse(parsed);
} catch {
return undefined;
}
}
function parseJsonFromText(value: string): unknown {
const trimmed = value.trim().replace(/^```json\s*/i, "").replace(/^```\s*/i, "").replace(/```$/i, "").trim();
try {
return JSON.parse(trimmed);
} catch {
const start = trimmed.indexOf("{");
const end = trimmed.lastIndexOf("}");
if (start < 0 || end <= start) return null;
try {
return JSON.parse(trimmed.slice(start, end + 1));
} catch {
return null;
}
}
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function stringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map((item) => String(item || "").trim()).filter(Boolean);
}
if (typeof value === "string" && value.trim()) {
return value
.split(/\n|;/)
.map((item) => item.replace(/^[-*]\s+/, "").trim())
.filter(Boolean);
}
return [];
}
function inferColors(name: string, description: string, sourceFacts: SourceFacts): string[] {
const text = `${name} ${description} ${sourceFacts.sourceName || ""}`.toLowerCase();
if (text.includes("starbucks")) return ["#006241", "#d4e9e2", "#1e3932", "#ffffff", "#00754a", "#cba258"];
if (text.includes("incorta")) return ["#ffffff", "#f05a28", "#1e6bff", "#172033", "#596579"];
if (text.includes("qbr") || text.includes("sprint") || text.includes("green")) {
return ["#07382f", "#eaf4ef", "#0f4a3c", "#ffffff", "#2d6a4f", "#8fb9a8"];
}
return ["#ffffff", "#f05a28", "#1e6bff", "#172033", "#596579"];
}
function inferLayoutRules(description: string): string[] {
const rules = [
"Keep slide chrome, title placement, section spacing, and footer behavior consistent across every slide.",
"Use a repeatable grid so content can move between one-column, two-column, and multi-column layouts without redesign."
];
if (/1,\s*2,\s*3,\s*(or\s*)?4|column/i.test(description)) {
rules.push("Support one-, two-, three-, and four-column content modules.");
}
if (/row/i.test(description)) {
rules.push("Allow one or two stacked rows inside each content column while preserving shared gutters.");
}
return rules;
}
function applyDesignTemplateToStyle(style: StylePack, designTemplate?: DesignTemplateInput): StylePack {
const colors = (designTemplate?.colors || []).map(normalizeHexColor).filter(Boolean);
if (!colors.length) return style;
const lightColors = colors.filter((color) => colorLuminance(color) > 0.78);
const darkColors = colors.filter((color) => colorLuminance(color) < 0.42);
const saturated = colors.filter((color) => colorLuminance(color) <= 0.78 && colorLuminance(color) >= 0.12);
const background = lightColors[0] || style.colors.background;
const surface = lightColors[1] || lightColors[0] || style.colors.surface;
const text = darkColors[0] || style.colors.text;
const primary = saturated[0] || darkColors[0] || style.colors.primary;
const secondary = saturated[1] || primary || style.colors.secondary;
const accent = saturated[2] || secondary || style.colors.accent;
return {
...style,
id: designTemplate?.id || style.id,
name: designTemplate?.name ? `${style.name} / ${designTemplate.name}` : style.name,
colors: {
...style.colors,
background: stripHash(background),
surface: stripHash(surface),
text: stripHash(text),
mutedText: stripHash(darkColors[1] || style.colors.mutedText),
primary: stripHash(primary),
secondary: stripHash(secondary),
accent: stripHash(accent),
line: stripHash(lightColors[2] || style.colors.line)
}
};
}
function normalizeHexColor(value: string): string {
const trimmed = String(value || "").trim();
const hex = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
if (/^[0-9a-f]{3}$/i.test(hex)) {
return `#${hex
.split("")
.map((char) => `${char}${char}`)
.join("")}`.toLowerCase();
}
if (/^[0-9a-f]{6}$/i.test(hex)) return `#${hex.toLowerCase()}`;
return "";
}
function stripHash(value: string): string {
const normalized = normalizeHexColor(value);
return normalized ? normalized.slice(1).toUpperCase() : value.replace(/^#/, "").toUpperCase();
}
function colorLuminance(value: string): number {
const normalized = normalizeHexColor(value);
if (!normalized) return 0.5;
const channels = [1, 3, 5].map((index) => Number.parseInt(normalized.slice(index, index + 2), 16) / 255);
const [r, g, b] = channels.map((channel) =>
channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function imageDimensions(buffer: Buffer, mimeType: string): { width: number; height: number } | null {
if (mimeType === "image/png" || buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
if (buffer.length >= 24) {
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
}
}
if (mimeType === "image/jpeg" || (buffer[0] === 0xff && buffer[1] === 0xd8)) {
let offset = 2;
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) break;
const marker = buffer[offset + 1];
const size = buffer.readUInt16BE(offset + 2);
if (marker >= 0xc0 && marker <= 0xc3) {
return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
}
offset += 2 + size;
}
}
return null;
}
function truncateForPrompt(value: string): string | undefined {
const trimmed = value.replace(/\s+/g, " ").trim();
if (!trimmed) return undefined;
return trimmed.slice(0, 4000);
}
function sourceFromText(content: string): SourceDocument { function sourceFromText(content: string): SourceDocument {
const trimmed = content.trim(); const trimmed = content.trim();
if (!trimmed) { if (!trimmed) {
@ -808,11 +368,6 @@ function stringField(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined; return typeof value === "string" && value.trim() ? value.trim() : undefined;
} }
function combineInstructions(...values: Array<string | undefined>): string | undefined {
const parts = values.map((value) => value?.trim()).filter(Boolean);
return parts.length ? parts.join("\n\n") : undefined;
}
async function exists(targetPath: string): Promise<boolean> { async function exists(targetPath: string): Promise<boolean> {
try { try {
await access(targetPath); await access(targetPath);

View file

@ -1,25 +1,8 @@
import React, { DragEvent, useEffect, useMemo, useRef, useState } from "react"; import React, { DragEvent, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { import { Download, ExternalLink, FileText, Image as ImageIcon, Loader2, Paperclip, Upload, WandSparkles, X } from "lucide-react";
Download,
ExternalLink,
FileText,
Image as ImageIcon,
Layers,
LayoutTemplate,
Loader2,
Palette,
Paperclip,
Pencil,
Save,
Upload,
WandSparkles,
X
} from "lucide-react";
import "./styles.css"; import "./styles.css";
type Mode = "template-build" | "deck-build" | "template-edit" | "deck-edit";
type DeckResult = { type DeckResult = {
id: string; id: string;
title: string; title: string;
@ -33,25 +16,6 @@ type DeckResult = {
}; };
}; };
type DesignTemplate = {
id: string;
name: string;
description: string;
summary?: string;
sourceName?: string;
colors: string[];
layoutRules?: string[];
componentRules?: string[];
slideFamilies?: string[];
prompt?: string;
analysisSource?: "built-in" | "model-service-gateway" | "heuristic";
analysisStatus?: "built-in" | "ai-analyzed" | "fallback";
analysisNote?: string;
gatewayRequestId?: string;
updatedAt: string;
locked?: boolean;
};
const initialContent = `# Customer Discovery Notes const initialContent = `# Customer Discovery Notes
Paste notes, transcript excerpts, or brief content here. Paste notes, transcript excerpts, or brief content here.
@ -60,125 +24,16 @@ Paste notes, transcript excerpts, or brief content here.
- Build an executive narrative. - Build an executive narrative.
- Render a PPTX for review.`; - Render a PPTX for review.`;
const starterTemplate: DesignTemplate = {
id: "incorta",
name: "Incorta Starter",
description:
"White executive deck, orange brand accent, restrained blue secondary accents, Aptos typography, compact business density, fixed title/body/two-column/roadmap layouts.",
summary: "Built-in starter style pack for concise executive decks.",
colors: ["#ffffff", "#f05a28", "#1e6bff", "#172033", "#596579"],
layoutRules: ["Use consistent title placement, compact panels, and restrained executive slide density."],
componentRules: ["Use simple callouts, two-column evidence panels, roadmap cards, and a small footer."],
slideFamilies: ["title", "executive-summary", "two-column", "metric-callout", "roadmap", "recommendation"],
prompt:
"Use the Incorta Starter system: white executive slides, orange accent, restrained blue secondary accents, Aptos typography, and compact business density.",
analysisSource: "built-in",
analysisStatus: "built-in",
updatedAt: "2026-06-09T00:00:00.000Z",
locked: true
};
const modeTabs: Array<{ id: Mode; label: string; icon: React.ElementType }> = [
{ id: "template-build", label: "Build Template", icon: LayoutTemplate },
{ id: "deck-build", label: "Build Slide / Deck", icon: Layers },
{ id: "template-edit", label: "Edit Template", icon: Palette },
{ id: "deck-edit", label: "Edit Slide / Deck", icon: Pencil }
];
function App() { function App() {
const [activeMode, setActiveMode] = useState<Mode>("template-build");
const [templates, setTemplates] = useState<DesignTemplate[]>([starterTemplate]);
const [selectedTemplateId, setSelectedTemplateId] = useState(starterTemplate.id);
const [content, setContent] = useState(initialContent); const [content, setContent] = useState(initialContent);
const [instructions, setInstructions] = useState("Create a concise executive deck."); const [instructions, setInstructions] = useState("Create a concise executive deck.");
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [templateName, setTemplateName] = useState("New design standard");
const [templateDescription, setTemplateDescription] = useState(
"Executive presentation system with clear hierarchy, reusable slide sections, consistent spacing, and restrained visual emphasis."
);
const [templateFile, setTemplateFile] = useState<File | null>(null);
const [templatePreviewUrl, setTemplatePreviewUrl] = useState<string | null>(null);
const [templateDragging, setTemplateDragging] = useState(false);
const [editTemplateId, setEditTemplateId] = useState(starterTemplate.id);
const [editTemplateName, setEditTemplateName] = useState(starterTemplate.name);
const [editTemplateDescription, setEditTemplateDescription] = useState(starterTemplate.description);
const [result, setResult] = useState<DeckResult | null>(null); const [result, setResult] = useState<DeckResult | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const templateInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
try {
const stored = window.localStorage.getItem("slideFactory.designTemplates.v1");
if (!stored) return;
const parsed = JSON.parse(stored) as DesignTemplate[];
const customTemplates = parsed.filter((template) => template.id !== starterTemplate.id);
setTemplates([starterTemplate, ...customTemplates]);
} catch {
setTemplates([starterTemplate]);
}
}, []);
useEffect(() => {
const customTemplates = templates.filter((template) => !template.locked);
window.localStorage.setItem("slideFactory.designTemplates.v1", JSON.stringify(customTemplates));
}, [templates]);
const selectedTemplate = useMemo(
() => templates.find((template) => template.id === selectedTemplateId) || starterTemplate,
[selectedTemplateId, templates]
);
const editableTemplate = useMemo(
() => templates.find((template) => template.id === editTemplateId) || starterTemplate,
[editTemplateId, templates]
);
const activeTemplatePreview = useMemo<DesignTemplate>(() => {
if (activeMode === "template-build") {
return {
id: "draft-template",
name: templateName.trim() || "Untitled design standard",
description: templateDescription.trim(),
summary: templateDescription.trim(),
sourceName: templateFile?.name,
colors: starterTemplate.colors,
layoutRules: inferDraftLayoutRules(templateDescription),
componentRules: ["Use repeated slide sections, restrained accents, and consistent icon treatment."],
slideFamilies: ["single-column", "two-column", "three-column", "four-column"],
analysisSource: "heuristic",
analysisStatus: "fallback",
updatedAt: new Date().toISOString()
};
}
if (activeMode === "template-edit") {
return {
...editableTemplate,
name: editTemplateName.trim() || editableTemplate.name,
description: editTemplateDescription.trim()
};
}
return selectedTemplate;
}, [
activeMode,
editableTemplate,
editTemplateDescription,
editTemplateName,
selectedTemplate,
templateDescription,
templateFile,
templateName
]);
const sourceLabel = useMemo(() => { const sourceLabel = useMemo(() => {
if (file) return `${file.name} · ${formatBytes(file.size)}`; if (file) return `${file.name} · ${formatBytes(file.size)}`;
@ -186,19 +41,11 @@ function App() {
return chars ? `${chars.toLocaleString()} characters pasted` : "No source selected"; return chars ? `${chars.toLocaleString()} characters pasted` : "No source selected";
}, [content, file]); }, [content, file]);
const templateSourceLabel = useMemo(() => {
if (templateFile) return `${templateFile.name} · ${formatBytes(templateFile.size)}`;
return "No design source selected";
}, [templateFile]);
function setSourceFile(nextFile: File | null) { function setSourceFile(nextFile: File | null) {
if (previewUrl) URL.revokeObjectURL(previewUrl); if (previewUrl) URL.revokeObjectURL(previewUrl);
setFile(nextFile); setFile(nextFile);
setResult(null); setResult(null);
setError(null); setError(null);
if (nextFile && content.trim() === initialContent.trim()) {
setContent("");
}
if (nextFile?.type.startsWith("image/")) { if (nextFile?.type.startsWith("image/")) {
setPreviewUrl(URL.createObjectURL(nextFile)); setPreviewUrl(URL.createObjectURL(nextFile));
} else { } else {
@ -206,32 +53,13 @@ function App() {
} }
} }
function setTemplateSourceFile(nextFile: File | null) { function onDrop(event: DragEvent<HTMLLabelElement>) {
if (templatePreviewUrl) URL.revokeObjectURL(templatePreviewUrl);
setTemplateFile(nextFile);
setNotice(null);
setError(null);
if (nextFile?.type.startsWith("image/")) {
setTemplatePreviewUrl(URL.createObjectURL(nextFile));
} else {
setTemplatePreviewUrl(null);
}
}
function onSourceDrop(event: DragEvent<HTMLLabelElement>) {
event.preventDefault(); event.preventDefault();
setDragging(false); setDragging(false);
const dropped = event.dataTransfer.files?.[0]; const dropped = event.dataTransfer.files?.[0];
if (dropped) setSourceFile(dropped); if (dropped) setSourceFile(dropped);
} }
function onTemplateDrop(event: DragEvent<HTMLLabelElement>) {
event.preventDefault();
setTemplateDragging(false);
const dropped = event.dataTransfer.files?.[0];
if (dropped) setTemplateSourceFile(dropped);
}
async function onPaste(event: React.ClipboardEvent<HTMLTextAreaElement>) { async function onPaste(event: React.ClipboardEvent<HTMLTextAreaElement>) {
const image = Array.from(event.clipboardData.files).find((item) => item.type.startsWith("image/")); const image = Array.from(event.clipboardData.files).find((item) => item.type.startsWith("image/"));
if (image) { if (image) {
@ -240,93 +68,9 @@ function App() {
} }
} }
async function saveTemplate() {
const name = templateName.trim() || "Untitled design standard";
setBusy(true);
setError(null);
setNotice("Analyzing template...");
try {
const response = await analyzeTemplate(name);
const json = (await response.json()) as { template?: DesignTemplate; error?: string };
if (!response.ok || !json.template) throw new Error(json.error || "Template analysis failed");
const template = json.template;
setTemplates((current) => [
starterTemplate,
template,
...current.filter((item) => item.id !== starterTemplate.id && item.id !== template.id)
]);
setSelectedTemplateId(template.id);
setEditTemplateId(template.id);
setEditTemplateName(template.name);
setEditTemplateDescription(template.description);
setActiveMode("deck-build");
setNotice(
template.analysisStatus === "ai-analyzed"
? `${template.name} analyzed by AI and selected for deck building`
: `${template.name} saved with a local fallback and selected for deck building`
);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
setNotice(null);
} finally {
setBusy(false);
}
}
function loadEditableTemplate(templateId: string) {
const template = templates.find((item) => item.id === templateId) || starterTemplate;
setEditTemplateId(template.id);
setEditTemplateName(template.name);
setEditTemplateDescription(template.description);
setNotice(null);
setError(null);
}
function updateTemplate() {
if (editableTemplate.locked) {
const copy: DesignTemplate = {
...editableTemplate,
id: `template-${Date.now()}`,
name: `${editTemplateName.trim() || editableTemplate.name} Copy`,
description: editTemplateDescription.trim(),
locked: false,
updatedAt: new Date().toISOString()
};
setTemplates((current) => [starterTemplate, copy, ...current.filter((item) => item.id !== starterTemplate.id)]);
setSelectedTemplateId(copy.id);
setEditTemplateId(copy.id);
setEditTemplateName(copy.name);
setNotice(`${copy.name} saved`);
return;
}
setTemplates((current) =>
current.map((template) =>
template.id === editTemplateId
? {
...template,
name: editTemplateName.trim() || template.name,
description: editTemplateDescription.trim(),
updatedAt: new Date().toISOString()
}
: template
)
);
setNotice(`${editTemplateName.trim() || editableTemplate.name} updated`);
}
function removeEditableTemplate() {
if (editableTemplate.locked) return;
setTemplates((current) => current.filter((template) => template.id !== editableTemplate.id));
setSelectedTemplateId(starterTemplate.id);
loadEditableTemplate(starterTemplate.id);
setNotice(`${editableTemplate.name} removed`);
}
async function createDeck() { async function createDeck() {
setBusy(true); setBusy(true);
setError(null); setError(null);
setNotice(null);
setResult(null); setResult(null);
try { try {
const response = file ? await createDeckFromFile(file) : await createDeckFromText(); const response = file ? await createDeckFromFile(file) : await createDeckFromText();
@ -340,43 +84,12 @@ function App() {
} }
} }
function designAwareInstructions() {
return [
instructions,
"",
"Use the selected design template as a binding design standard.",
`Template: ${selectedTemplate.name}.`,
selectedTemplate.description,
selectedTemplate.summary,
selectedTemplate.layoutRules?.length ? `Layout rules:\n- ${selectedTemplate.layoutRules.join("\n- ")}` : "",
selectedTemplate.componentRules?.length ? `Component rules:\n- ${selectedTemplate.componentRules.join("\n- ")}` : "",
selectedTemplate.slideFamilies?.length ? `Supported slide families: ${selectedTemplate.slideFamilies.join(", ")}.` : "",
selectedTemplate.prompt ? `Reusable design prompt: ${selectedTemplate.prompt}` : "",
file && content.trim() ? `Attached slide/deck update instructions:\n${content.trim()}` : ""
]
.filter(Boolean)
.join("\n");
}
function analyzeTemplate(name: string) {
const formData = new FormData();
formData.set("name", name);
formData.set("description", templateDescription.trim());
if (templateFile) formData.set("source", templateFile);
return fetch("/api/templates/analyze", {
method: "POST",
body: formData
});
}
function createDeckFromFile(sourceFile: File) { function createDeckFromFile(sourceFile: File) {
const formData = new FormData(); const formData = new FormData();
formData.set("source", sourceFile); formData.set("source", sourceFile);
formData.set("style", "incorta"); formData.set("style", "incorta");
formData.set("audience", "executives"); formData.set("audience", "executives");
formData.set("instructions", designAwareInstructions()); formData.set("instructions", instructions);
formData.set("designTemplate", JSON.stringify(templateForRequest(selectedTemplate)));
if (content.trim()) formData.set("content", content.trim());
return fetch("/api/decks/from-source", { return fetch("/api/decks/from-source", {
method: "POST", method: "POST",
body: formData body: formData
@ -389,520 +102,116 @@ function App() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
style: "incorta", style: "incorta",
instructions: designAwareInstructions(), instructions,
designTemplate: templateForRequest(selectedTemplate),
input: { type: "markdown", content } input: { type: "markdown", content }
}) })
}); });
} }
const title = modeTabs.find((mode) => mode.id === activeMode)?.label || "Slide Factory";
return ( return (
<main className="app-shell"> <main className="app-shell">
<section className="workspace"> <section className="workspace">
<header className="topbar"> <header className="topbar">
<div> <div>
<p className="eyebrow">Slide Factory</p> <p className="eyebrow">Slide Factory</p>
<h1>{title}</h1> <h1>Source to deck</h1>
</div> </div>
<div className="topbar-actions"> <div className="topbar-actions">
<a className="secondary-action" href="/google-slides-workflow.html"> <a className="secondary-action" href="/google-slides-workflow.html">
<ExternalLink size={16} /> <ExternalLink size={16} />
Google Slides workflow Google Slides workflow
</a> </a>
{activeMode === "template-build" && (
<button className="primary-action" onClick={saveTemplate} disabled={busy}>
{busy ? <Loader2 className="spin" size={18} /> : <WandSparkles size={18} />}
{busy ? "Analyzing" : "Analyze template"}
</button>
)}
{activeMode === "deck-build" && (
<button className="primary-action" onClick={createDeck} disabled={busy}> <button className="primary-action" onClick={createDeck} disabled={busy}>
{busy ? <Loader2 className="spin" size={18} /> : <WandSparkles size={18} />} {busy ? <Loader2 className="spin" size={18} /> : <WandSparkles size={18} />}
{busy ? "Building" : "Build deck"} {busy ? "Building" : "Build deck"}
</button> </button>
)}
{activeMode === "template-edit" && (
<button className="primary-action" onClick={updateTemplate}>
<Save size={18} />
Save edits
</button>
)}
{activeMode === "deck-edit" && (
<button className="primary-action" onClick={createDeck} disabled={busy}>
{busy ? <Loader2 className="spin" size={18} /> : <WandSparkles size={18} />}
{busy ? "Rebuilding" : "Build revision"}
</button>
)}
</div> </div>
</header> </header>
<nav className="mode-tabs" aria-label="Slide Factory modes">
{modeTabs.map((mode) => {
const Icon = mode.icon;
return (
<button
key={mode.id}
type="button"
className={`mode-tab ${activeMode === mode.id ? "is-active" : ""}`}
onClick={() => setActiveMode(mode.id)}
>
<Icon size={16} />
{mode.label}
</button>
);
})}
</nav>
<div className="main-grid"> <div className="main-grid">
<section className="source-pane"> <section className="source-pane">
{activeMode === "template-build" && (
<TemplateBuildPane
dragging={templateDragging}
inputRef={templateInputRef}
name={templateName}
previewUrl={templatePreviewUrl}
sourceLabel={templateSourceLabel}
description={templateDescription}
busy={busy}
onDrop={onTemplateDrop}
onDragging={setTemplateDragging}
onFile={setTemplateSourceFile}
onName={setTemplateName}
onDescription={setTemplateDescription}
onSave={saveTemplate}
/>
)}
{activeMode === "deck-build" && (
<DeckBuildPane
content={content}
dragging={dragging}
file={file}
fileInputRef={fileInputRef}
instructions={instructions}
previewUrl={previewUrl}
selectedTemplateId={selectedTemplateId}
sourceLabel={sourceLabel}
templates={templates}
onContent={setContent}
onDragging={setDragging}
onDrop={onSourceDrop}
onFile={setSourceFile}
onInstructions={setInstructions}
onPaste={onPaste}
onTemplate={setSelectedTemplateId}
/>
)}
{activeMode === "template-edit" && (
<TemplateEditPane
description={editTemplateDescription}
editableTemplate={editableTemplate}
name={editTemplateName}
templateId={editTemplateId}
templates={templates}
onDescription={setEditTemplateDescription}
onName={setEditTemplateName}
onRemove={removeEditableTemplate}
onTemplate={loadEditableTemplate}
/>
)}
{activeMode === "deck-edit" && (
<DeckEditPane
content={content}
dragging={dragging}
file={file}
fileInputRef={fileInputRef}
instructions={instructions}
previewUrl={previewUrl}
selectedTemplateId={selectedTemplateId}
sourceLabel={sourceLabel}
templates={templates}
onContent={setContent}
onDragging={setDragging}
onDrop={onSourceDrop}
onFile={setSourceFile}
onInstructions={setInstructions}
onPaste={onPaste}
onTemplate={setSelectedTemplateId}
/>
)}
</section>
<OutputPanel
activeMode={activeMode}
error={error}
notice={notice}
result={result}
template={activeTemplatePreview}
templates={templates}
/>
</div>
</section>
</main>
);
}
function TemplateBuildPane(props: {
busy: boolean;
dragging: boolean;
inputRef: React.RefObject<HTMLInputElement>;
name: string;
previewUrl: string | null;
sourceLabel: string;
description: string;
onDrop: (event: DragEvent<HTMLLabelElement>) => void;
onDragging: (dragging: boolean) => void;
onFile: (file: File | null) => void;
onName: (value: string) => void;
onDescription: (value: string) => void;
onSave: () => void;
}) {
return (
<>
<label <label
className={`drop-zone ${props.dragging ? "is-dragging" : ""}`} className={`drop-zone ${dragging ? "is-dragging" : ""}`}
onDragOver={(event) => { onDragOver={(event) => {
event.preventDefault(); event.preventDefault();
props.onDragging(true); setDragging(true);
}} }}
onDragLeave={() => props.onDragging(false)} onDragLeave={() => setDragging(false)}
onDrop={props.onDrop} onDrop={onDrop}
> >
<input <input
ref={props.inputRef} ref={fileInputRef}
type="file" type="file"
accept=".ppt,.pptx,.pdf,image/*" accept=".md,.markdown,.txt,.pdf,.pptx,image/*"
onChange={(event) => props.onFile(event.target.files?.[0] || null)} onChange={(event) => setSourceFile(event.target.files?.[0] || null)}
/> />
<Upload size={20} /> <Upload size={20} />
<span>Drop design source</span> <span>Drop source</span>
<button type="button" className="secondary-action" onClick={() => props.inputRef.current?.click()}> <button type="button" className="secondary-action" onClick={() => fileInputRef.current?.click()}>
<Paperclip size={16} /> <Paperclip size={16} />
Choose file Choose file
</button> </button>
</label> </label>
<div className="source-status"> <div className="source-status">
<span>{props.sourceLabel}</span> <span>{sourceLabel}</span>
{props.previewUrl && ( {file && (
<button type="button" className="icon-action" onClick={() => props.onFile(null)} aria-label="Clear design source"> <button type="button" className="icon-action" onClick={() => setSourceFile(null)} aria-label="Clear file">
<X size={16} /> <X size={16} />
</button> </button>
)} )}
</div> </div>
{props.previewUrl && ( {previewUrl && (
<div className="image-preview"> <div className="image-preview">
<img src={props.previewUrl} alt="" /> <img src={previewUrl} alt="" />
</div>
)}
<label className="field">
<span>Template name</span>
<input value={props.name} onChange={(event) => props.onName(event.target.value)} />
</label>
<label className="field content compact">
<span>Design description</span>
<textarea value={props.description} onChange={(event) => props.onDescription(event.target.value)} />
</label>
<button type="button" className="secondary-action wide-action" onClick={props.onSave} disabled={props.busy}>
{props.busy ? <Loader2 className="spin" size={16} /> : <WandSparkles size={16} />}
{props.busy ? "Analyzing template" : "Analyze and save template"}
</button>
</>
);
}
function DeckBuildPane(props: DeckPaneProps) {
return (
<>
<TemplateSelect templates={props.templates} value={props.selectedTemplateId} onChange={props.onTemplate} />
<SourceInput {...props} dropLabel="Drop source" textLabel="Paste source" />
</>
);
}
function DeckEditPane(props: DeckPaneProps) {
return (
<>
<TemplateSelect templates={props.templates} value={props.selectedTemplateId} onChange={props.onTemplate} />
<SourceInput {...props} dropLabel="Drop slide / deck" textLabel="Revision notes" />
</>
);
}
type DeckPaneProps = {
content: string;
dragging: boolean;
file: File | null;
fileInputRef: React.RefObject<HTMLInputElement>;
instructions: string;
previewUrl: string | null;
selectedTemplateId: string;
sourceLabel: string;
templates: DesignTemplate[];
onContent: (value: string) => void;
onDragging: (dragging: boolean) => void;
onDrop: (event: DragEvent<HTMLLabelElement>) => void;
onFile: (file: File | null) => void;
onInstructions: (value: string) => void;
onPaste: (event: React.ClipboardEvent<HTMLTextAreaElement>) => void;
onTemplate: (id: string) => void;
};
function TemplateSelect(props: { templates: DesignTemplate[]; value: string; onChange: (id: string) => void }) {
const active = props.templates.find((template) => template.id === props.value) || starterTemplate;
return (
<div className="template-card">
<div className="template-card-header">
<label className="field">
<span>Template to apply</span>
<select value={props.value} onChange={(event) => props.onChange(event.target.value)}>
{props.templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</label>
<Swatches colors={active.colors} />
</div>
<p className="template-summary">{active.summary || active.description}</p>
<div className="template-meta-row">
<AnalysisBadge template={active} />
{active.sourceName && <span className="source-chip">{active.sourceName}</span>}
</div>
</div>
);
}
function SourceInput(props: DeckPaneProps & { dropLabel: string; textLabel: string }) {
return (
<>
<label
className={`drop-zone ${props.dragging ? "is-dragging" : ""}`}
onDragOver={(event) => {
event.preventDefault();
props.onDragging(true);
}}
onDragLeave={() => props.onDragging(false)}
onDrop={props.onDrop}
>
<input
ref={props.fileInputRef}
type="file"
accept=".md,.markdown,.txt,.pdf,.ppt,.pptx,image/*"
onChange={(event) => props.onFile(event.target.files?.[0] || null)}
/>
<Upload size={20} />
<span>{props.dropLabel}</span>
<button type="button" className="secondary-action" onClick={() => props.fileInputRef.current?.click()}>
<Paperclip size={16} />
Choose file
</button>
</label>
<div className="source-status">
<span>{props.sourceLabel}</span>
{props.file && (
<button type="button" className="icon-action" onClick={() => props.onFile(null)} aria-label="Clear file">
<X size={16} />
</button>
)}
</div>
{props.previewUrl && (
<div className="image-preview">
<img src={props.previewUrl} alt="" />
</div> </div>
)} )}
<label className="field"> <label className="field">
<span>Instructions</span> <span>Instructions</span>
<input value={props.instructions} onChange={(event) => props.onInstructions(event.target.value)} /> <input value={instructions} onChange={(event) => setInstructions(event.target.value)} />
</label> </label>
<label className="field content"> <label className="field content">
<span>{props.file ? "Update instructions for attached slide / deck" : props.textLabel}</span> <span>Paste source</span>
<textarea <textarea
value={props.content} value={content}
onPaste={props.onPaste} onPaste={onPaste}
onChange={(event) => props.onContent(event.target.value)} onChange={(event) => {
setContent(event.target.value);
if (file) setSourceFile(null);
}}
/> />
</label> </label>
</> </section>
);
}
function TemplateEditPane(props: {
description: string;
editableTemplate: DesignTemplate;
name: string;
templateId: string;
templates: DesignTemplate[];
onDescription: (value: string) => void;
onName: (value: string) => void;
onRemove: () => void;
onTemplate: (id: string) => void;
}) {
return (
<>
<div className="template-select">
<label className="field">
<span>Template</span>
<select value={props.templateId} onChange={(event) => props.onTemplate(event.target.value)}>
{props.templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</label>
<Swatches colors={props.editableTemplate.colors} />
</div>
<label className="field">
<span>Template name</span>
<input value={props.name} onChange={(event) => props.onName(event.target.value)} />
</label>
<label className="field content compact">
<span>Design description</span>
<textarea value={props.description} onChange={(event) => props.onDescription(event.target.value)} />
</label>
<div className="button-row">
<button type="button" className="secondary-action" onClick={props.onRemove} disabled={props.editableTemplate.locked}>
<X size={16} />
Remove
</button>
</div>
</>
);
}
function OutputPanel(props: {
activeMode: Mode;
error: string | null;
notice: string | null;
result: DeckResult | null;
template: DesignTemplate;
templates: DesignTemplate[];
}) {
const templateMode = props.activeMode === "template-build" || props.activeMode === "template-edit";
return (
<aside className="result-panel"> <aside className="result-panel">
<div className="result-title"> <div className="result-title">
{templateMode ? <LayoutTemplate size={18} /> : <FileText size={18} />} {file?.type.startsWith("image/") ? <ImageIcon size={18} /> : <FileText size={18} />}
<span>{templateMode ? "Template" : "Output"}</span> <span>Output</span>
</div> </div>
{error && <p className="error">{error}</p>}
{props.error && <p className="error">{props.error}</p>} {!result && !error && <p className="muted">Ready for a source.</p>}
{props.notice && <p className="notice">{props.notice}</p>} {result && (
{templateMode ? (
<div className="result"> <div className="result">
<strong>{props.template.name}</strong> <strong>{result.title}</strong>
<Swatches colors={props.template.colors} /> <span>{result.slides} slides</span>
<AnalysisBadge template={props.template} /> {result.source?.sourceName && <span className="source-chip">{result.source.sourceName}</span>}
{props.template.description && <p className="template-summary">{props.template.description}</p>} <a href={result.pptxUrl}>
{props.template.summary && props.template.summary !== props.template.description && (
<p className="template-summary">{props.template.summary}</p>
)}
{props.template.layoutRules?.length ? (
<ul className="template-rule-list">
{props.template.layoutRules.slice(0, 4).map((rule) => (
<li key={rule}>{rule}</li>
))}
</ul>
) : null}
<span>{props.templates.length} templates</span>
{props.template.sourceName && <span className="source-chip">{props.template.sourceName}</span>}
{props.template.gatewayRequestId && <span className="source-chip">Gateway {props.template.gatewayRequestId}</span>}
</div>
) : (
<>
{!props.result && !props.error && (
<div className="result">
<p className="muted">Ready for a source.</p>
<strong>Using {props.template.name}</strong>
<AnalysisBadge template={props.template} />
<p className="template-summary">{props.template.summary || props.template.description}</p>
</div>
)}
{props.result && (
<div className="result">
<strong>{props.result.title}</strong>
<span>{props.result.slides} slides</span>
{props.result.source?.sourceName && <span className="source-chip">{props.result.source.sourceName}</span>}
<a href={props.result.pptxUrl}>
<Download size={16} /> <Download size={16} />
PPTX PPTX
</a> </a>
<a href={props.result.specUrl}>DeckSpec JSON</a> <a href={result.specUrl}>DeckSpec JSON</a>
</div> </div>
)} )}
</>
)}
</aside> </aside>
);
}
function Swatches(props: { colors: string[] }) {
return (
<div className="swatches" aria-label="Template colors">
{props.colors.map((color) => (
<span key={color} className="swatch" style={{ background: color }} />
))}
</div> </div>
</section>
</main>
); );
} }
function AnalysisBadge(props: { template: DesignTemplate }) {
const status = props.template.analysisStatus || props.template.analysisSource || "saved";
const label =
status === "ai-analyzed"
? "AI analyzed"
: status === "fallback"
? "Local fallback"
: status === "built-in"
? "Built in"
: "Saved";
return <span className={`analysis-badge ${status === "ai-analyzed" ? "is-ai" : ""}`}>{label}</span>;
}
function templateForRequest(template: DesignTemplate) {
return {
id: template.id,
name: template.name,
description: template.description,
summary: template.summary,
colors: template.colors,
layoutRules: template.layoutRules,
componentRules: template.componentRules,
slideFamilies: template.slideFamilies,
prompt: template.prompt,
analysisSource: template.analysisSource,
sourceName: template.sourceName
};
}
function inferDraftLayoutRules(description: string): string[] {
const rules = [
"Keep title placement, spacing, and section hierarchy consistent across slides.",
"Use a repeatable grid for each slide family."
];
if (/column/i.test(description)) rules.push("Support one-, two-, three-, and four-column compositions.");
if (/row/i.test(description)) rules.push("Support one- and two-row modules inside content columns.");
return rules;
}
function formatBytes(value: number) { function formatBytes(value: number) {
if (value < 1024) return `${value} B`; if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`; if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;

View file

@ -14,7 +14,6 @@ body {
button, button,
input, input,
select,
textarea { textarea {
font: inherit; font: inherit;
} }
@ -23,18 +22,12 @@ button {
cursor: pointer; cursor: pointer;
} }
button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
padding: 24px; padding: 24px;
} }
.workspace { .workspace {
width: 100%;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
@ -79,14 +72,6 @@ h1 {
border: 0; border: 0;
} }
.primary-action,
.secondary-action,
.mode-tab {
min-width: 0;
text-align: center;
white-space: normal;
}
.primary-action { .primary-action {
min-height: 40px; min-height: 40px;
padding: 0 16px; padding: 0 16px;
@ -116,33 +101,6 @@ h1 {
border: 1px solid #d8dee9; border: 1px solid #d8dee9;
} }
.mode-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 16px;
}
.mode-tab {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 42px;
padding: 0 12px;
border: 1px solid #d8dee9;
border-radius: 6px;
color: #172033;
background: #fff;
font-weight: 750;
}
.mode-tab.is-active {
color: #fff;
background: #172033;
border-color: #172033;
}
.main-grid { .main-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 320px; grid-template-columns: minmax(0, 1fr) 320px;
@ -159,7 +117,6 @@ h1 {
grid-template-columns: auto auto 1fr auto; grid-template-columns: auto auto 1fr auto;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
width: 100%;
min-height: 68px; min-height: 68px;
padding: 14px; padding: 14px;
border: 1px dashed #aab4c4; border: 1px dashed #aab4c4;
@ -199,19 +156,15 @@ h1 {
} }
input, input,
select,
textarea, textarea,
.result-panel, .result-panel,
.image-preview { .image-preview {
width: 100%;
max-width: 100%;
border: 1px solid #d8dee9; border: 1px solid #d8dee9;
border-radius: 8px; border-radius: 8px;
background: #fff; background: #fff;
} }
input, input {
select {
height: 42px; height: 42px;
padding: 0 12px; padding: 0 12px;
} }
@ -223,64 +176,6 @@ textarea {
line-height: 1.45; line-height: 1.45;
} }
.content.compact textarea {
min-height: 260px;
}
.template-select {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 12px;
}
.template-card {
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid #d8dee9;
border-radius: 8px;
background: #fff;
}
.template-card-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 12px;
}
.template-meta-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.swatches {
display: inline-flex;
align-items: center;
min-height: 42px;
gap: 6px;
}
.swatch {
width: 22px;
height: 22px;
border-radius: 999px;
border: 1px solid #d8dee9;
}
.wide-action {
width: fit-content;
}
.button-row {
display: flex;
align-items: center;
gap: 10px;
}
.image-preview { .image-preview {
display: flex; display: flex;
align-items: center; align-items: center;
@ -316,10 +211,6 @@ textarea {
color: #b42318; color: #b42318;
} }
.notice {
color: #137333;
}
.result { .result {
display: grid; display: grid;
gap: 10px; gap: 10px;
@ -337,40 +228,6 @@ textarea {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.analysis-badge {
display: inline-flex;
width: fit-content;
align-items: center;
padding: 4px 8px;
border-radius: 999px;
color: #596579;
background: #f4f6fa;
font-size: 12px;
font-weight: 750;
}
.analysis-badge.is-ai {
color: #0b6b38;
background: #e9f7ef;
}
.template-summary {
margin: 0;
color: #596579;
font-size: 13px;
line-height: 1.45;
}
.template-rule-list {
display: grid;
gap: 6px;
margin: 0;
padding-left: 18px;
color: #596579;
font-size: 13px;
line-height: 1.35;
}
.result a { .result a {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -401,26 +258,13 @@ textarea {
} }
.topbar-actions { .topbar-actions {
width: 100%;
justify-content: flex-start; justify-content: flex-start;
} }
.mode-tabs {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.main-grid { .main-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.template-select {
grid-template-columns: 1fr;
}
.template-card-header {
grid-template-columns: 1fr;
}
.drop-zone { .drop-zone {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
} }
@ -430,24 +274,3 @@ textarea {
justify-self: start; justify-self: start;
} }
} }
@media (max-width: 520px) {
.topbar-actions {
display: grid;
grid-template-columns: 1fr;
}
.topbar-actions > * {
width: 100%;
}
.mode-tab {
min-height: 48px;
padding: 8px;
line-height: 1.2;
}
.mode-tabs {
grid-template-columns: 1fr;
}
}