Add AI template analysis workflow
This commit is contained in:
parent
9855b92605
commit
6dc8e47eb6
3 changed files with 661 additions and 41 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { access, mkdir, writeFile } from "node:fs/promises";
|
import { access, mkdir, readFile, 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,13 +11,28 @@ 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 } from "@slide-factory/core";
|
import { loadStylePack, planDeckFromSource, SourceDocument, StylePack } 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(),
|
||||||
|
|
@ -25,6 +40,42 @@ 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");
|
||||||
|
|
@ -52,7 +103,8 @@ 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);
|
||||||
|
|
@ -70,7 +122,8 @@ app.post("/api/decks/from-source", upload.single("source"), async (req, res, nex
|
||||||
source,
|
source,
|
||||||
instructions: stringField(req.body.instructions),
|
instructions: stringField(req.body.instructions),
|
||||||
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({
|
||||||
|
|
@ -86,6 +139,21 @@ 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) => {
|
||||||
|
|
@ -128,10 +196,12 @@ 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 style = await loadStylePack(stylePath);
|
const baseStyle = await loadStylePack(stylePath);
|
||||||
|
const style = applyDesignTemplateToStyle(baseStyle, options.designTemplate);
|
||||||
const deck = planDeckFromSource({
|
const deck = planDeckFromSource({
|
||||||
source: options.source,
|
source: options.source,
|
||||||
style,
|
style,
|
||||||
|
|
@ -155,6 +225,373 @@ 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) {
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,17 @@ type DesignTemplate = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
summary?: string;
|
||||||
sourceName?: string;
|
sourceName?: string;
|
||||||
colors: 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;
|
updatedAt: string;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
};
|
};
|
||||||
|
|
@ -56,7 +65,15 @@ const starterTemplate: DesignTemplate = {
|
||||||
name: "Incorta Starter",
|
name: "Incorta Starter",
|
||||||
description:
|
description:
|
||||||
"White executive deck, orange brand accent, restrained blue secondary accents, Aptos typography, compact business density, fixed title/body/two-column/roadmap layouts.",
|
"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"],
|
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",
|
updatedAt: "2026-06-09T00:00:00.000Z",
|
||||||
locked: true
|
locked: true
|
||||||
};
|
};
|
||||||
|
|
@ -131,8 +148,14 @@ function App() {
|
||||||
id: "draft-template",
|
id: "draft-template",
|
||||||
name: templateName.trim() || "Untitled design standard",
|
name: templateName.trim() || "Untitled design standard",
|
||||||
description: templateDescription.trim(),
|
description: templateDescription.trim(),
|
||||||
|
summary: templateDescription.trim(),
|
||||||
sourceName: templateFile?.name,
|
sourceName: templateFile?.name,
|
||||||
colors: starterTemplate.colors,
|
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()
|
updatedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -214,22 +237,37 @@ function App() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveTemplate() {
|
async function saveTemplate() {
|
||||||
const name = templateName.trim() || "Untitled design standard";
|
const name = templateName.trim() || "Untitled design standard";
|
||||||
const template: DesignTemplate = {
|
setBusy(true);
|
||||||
id: `template-${Date.now()}`,
|
setError(null);
|
||||||
name,
|
setNotice("Analyzing template...");
|
||||||
description: templateDescription.trim(),
|
try {
|
||||||
sourceName: templateFile?.name,
|
const response = await analyzeTemplate(name);
|
||||||
colors: starterTemplate.colors,
|
const json = (await response.json()) as { template?: DesignTemplate; error?: string };
|
||||||
updatedAt: new Date().toISOString()
|
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)]);
|
setTemplates((current) => [
|
||||||
|
starterTemplate,
|
||||||
|
template,
|
||||||
|
...current.filter((item) => item.id !== starterTemplate.id && item.id !== template.id)
|
||||||
|
]);
|
||||||
setSelectedTemplateId(template.id);
|
setSelectedTemplateId(template.id);
|
||||||
setEditTemplateId(template.id);
|
setEditTemplateId(template.id);
|
||||||
setEditTemplateName(template.name);
|
setEditTemplateName(template.name);
|
||||||
setEditTemplateDescription(template.description);
|
setEditTemplateDescription(template.description);
|
||||||
setNotice(`${template.name} saved`);
|
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) {
|
function loadEditableTemplate(templateId: string) {
|
||||||
|
|
@ -303,19 +341,37 @@ function App() {
|
||||||
return [
|
return [
|
||||||
instructions,
|
instructions,
|
||||||
"",
|
"",
|
||||||
`Selected design template: ${selectedTemplate.name}.`,
|
"Use the selected design template as a binding design standard.",
|
||||||
selectedTemplate.description
|
`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}` : ""
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n");
|
.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", designAwareInstructions());
|
||||||
|
formData.set("designTemplate", JSON.stringify(templateForRequest(selectedTemplate)));
|
||||||
return fetch("/api/decks/from-source", {
|
return fetch("/api/decks/from-source", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData
|
body: formData
|
||||||
|
|
@ -329,6 +385,7 @@ function App() {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
style: "incorta",
|
style: "incorta",
|
||||||
instructions: designAwareInstructions(),
|
instructions: designAwareInstructions(),
|
||||||
|
designTemplate: templateForRequest(selectedTemplate),
|
||||||
input: { type: "markdown", content }
|
input: { type: "markdown", content }
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
@ -350,9 +407,9 @@ function App() {
|
||||||
Google Slides workflow
|
Google Slides workflow
|
||||||
</a>
|
</a>
|
||||||
{activeMode === "template-build" && (
|
{activeMode === "template-build" && (
|
||||||
<button className="primary-action" onClick={saveTemplate}>
|
<button className="primary-action" onClick={saveTemplate} disabled={busy}>
|
||||||
<Save size={18} />
|
{busy ? <Loader2 className="spin" size={18} /> : <WandSparkles size={18} />}
|
||||||
Save template
|
{busy ? "Analyzing" : "Analyze template"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{activeMode === "deck-build" && (
|
{activeMode === "deck-build" && (
|
||||||
|
|
@ -403,6 +460,7 @@ function App() {
|
||||||
previewUrl={templatePreviewUrl}
|
previewUrl={templatePreviewUrl}
|
||||||
sourceLabel={templateSourceLabel}
|
sourceLabel={templateSourceLabel}
|
||||||
description={templateDescription}
|
description={templateDescription}
|
||||||
|
busy={busy}
|
||||||
onDrop={onTemplateDrop}
|
onDrop={onTemplateDrop}
|
||||||
onDragging={setTemplateDragging}
|
onDragging={setTemplateDragging}
|
||||||
onFile={setTemplateSourceFile}
|
onFile={setTemplateSourceFile}
|
||||||
|
|
@ -484,6 +542,7 @@ function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplateBuildPane(props: {
|
function TemplateBuildPane(props: {
|
||||||
|
busy: boolean;
|
||||||
dragging: boolean;
|
dragging: boolean;
|
||||||
inputRef: React.RefObject<HTMLInputElement>;
|
inputRef: React.RefObject<HTMLInputElement>;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -547,9 +606,9 @@ function TemplateBuildPane(props: {
|
||||||
<textarea value={props.description} onChange={(event) => props.onDescription(event.target.value)} />
|
<textarea value={props.description} onChange={(event) => props.onDescription(event.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="button" className="secondary-action wide-action" onClick={props.onSave}>
|
<button type="button" className="secondary-action wide-action" onClick={props.onSave} disabled={props.busy}>
|
||||||
<Save size={16} />
|
{props.busy ? <Loader2 className="spin" size={16} /> : <WandSparkles size={16} />}
|
||||||
Save template
|
{props.busy ? "Analyzing template" : "Analyze and save template"}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -595,9 +654,10 @@ type DeckPaneProps = {
|
||||||
function TemplateSelect(props: { templates: DesignTemplate[]; value: string; onChange: (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;
|
const active = props.templates.find((template) => template.id === props.value) || starterTemplate;
|
||||||
return (
|
return (
|
||||||
<div className="template-select">
|
<div className="template-card">
|
||||||
|
<div className="template-card-header">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>Design template</span>
|
<span>Template to apply</span>
|
||||||
<select value={props.value} onChange={(event) => props.onChange(event.target.value)}>
|
<select value={props.value} onChange={(event) => props.onChange(event.target.value)}>
|
||||||
{props.templates.map((template) => (
|
{props.templates.map((template) => (
|
||||||
<option key={template.id} value={template.id}>
|
<option key={template.id} value={template.id}>
|
||||||
|
|
@ -608,6 +668,12 @@ function TemplateSelect(props: { templates: DesignTemplate[]; value: string; onC
|
||||||
</label>
|
</label>
|
||||||
<Swatches colors={active.colors} />
|
<Swatches colors={active.colors} />
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -742,13 +808,32 @@ function OutputPanel(props: {
|
||||||
<div className="result">
|
<div className="result">
|
||||||
<strong>{props.template.name}</strong>
|
<strong>{props.template.name}</strong>
|
||||||
<Swatches colors={props.template.colors} />
|
<Swatches colors={props.template.colors} />
|
||||||
|
<AnalysisBadge template={props.template} />
|
||||||
{props.template.description && <p className="template-summary">{props.template.description}</p>}
|
{props.template.description && <p className="template-summary">{props.template.description}</p>}
|
||||||
|
{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>
|
<span>{props.templates.length} templates</span>
|
||||||
{props.template.sourceName && <span className="source-chip">{props.template.sourceName}</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{!props.result && !props.error && <p className="muted">Ready for a source.</p>}
|
{!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 && (
|
{props.result && (
|
||||||
<div className="result">
|
<div className="result">
|
||||||
<strong>{props.result.title}</strong>
|
<strong>{props.result.title}</strong>
|
||||||
|
|
@ -777,6 +862,45 @@ function Swatches(props: { colors: string[] }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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`;
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@ 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;
|
||||||
|
|
@ -229,6 +234,29 @@ textarea {
|
||||||
gap: 12px;
|
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 {
|
.swatches {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -309,6 +337,23 @@ 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 {
|
.template-summary {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #596579;
|
color: #596579;
|
||||||
|
|
@ -316,6 +361,16 @@ textarea {
|
||||||
line-height: 1.45;
|
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;
|
||||||
|
|
@ -362,6 +417,10 @@ textarea {
|
||||||
grid-template-columns: 1fr;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue