diff --git a/templates/slides/.agents/skills/create-deck/SKILL.md b/templates/slides/.agents/skills/create-deck/SKILL.md index 47b4998d031..bdd4ffc07e3 100644 --- a/templates/slides/.agents/skills/create-deck/SKILL.md +++ b/templates/slides/.agents/skills/create-deck/SKILL.md @@ -157,7 +157,7 @@ pnpm action add-slide --deckId= --layout content --content "..." Every slide's `content` must use this exact outer div: ```html -
+
``` @@ -171,8 +171,8 @@ Poppins, cyan, white, black, or a stock radius into new markup. ## Fit budget The canvas is fixed at its aspect-ratio dimensions. With the standard 16:9 -canvas (960x540) and `padding: 80px 110px`, the usable content area is only -740x380px. Treat that as a hard budget for the main flow: use at most two title +canvas (960x540) and `padding: 64px 80px`, the usable content area is only +800x412px. Treat that as a hard budget for the main flow: use at most two title lines, three short bullets or cards, and two or three short items per column. Split dense source material across slides instead of shrinking it into a dense stack. Keep body text at or above 16px. Never hide overflow with zoom, @@ -180,13 +180,16 @@ stack. Keep body text at or above 16px. Never hide overflow with zoom, may reduce the slide's explicit padding, and that padding must remain intact when the saved HTML is rendered. -Background is pure black (`bg-[#000000]`) — set by the renderer, not the slide HTML. - -When no reference deck or hydrated design system is available, use a restrained, -content-first visual language. Do not invent colorful cards, boxes, or -decorative rectangles behind or over text. Add a colored shape only when it has -a clear semantic role and leaves the text unobscured; prefer typography, -spacing, alignment, and one restrained accent. +When no reference deck or hydrated design system is available, use the fallback +direction: warm neutral paper (`#F5F2EA`), ink text (`#1F2933`), Inter or a +close sans-serif, 64px vertical and 80px horizontal padding, and one restrained +blue or coral accent. Build an intentional composition beyond a text dump: use +a title block, two-column split, metric treatment, rule, callout, visual +placeholder, or simple diagram where it fits the message. Never use a black +background with white text as the generic fallback, never omit the padded +wrapper, and never let body copy touch the canvas edge. Do not add decorative +cards or shapes without a semantic role, and keep any accent treatment away +from text so contrast stays readable. ## Bounded visual QA @@ -200,149 +203,46 @@ was rendered and compared. ## Ready-to-Use Templates -Copy and fill in the bracketed values. Use `\` to escape quotes inside the JSON string. - ---- +Copy and fill in the bracketed values. Keep the wrapper styles and replace +tokens with the hydrated design-system values when available. ### Title Slide ```html -
-
[LABEL OR DATE]
-

[TITLE]

-

[SUBTITLE OR PRESENTER]

+
+
[LABEL OR DATE]
+

[TITLE]

+

[SUBTITLE OR PRESENTER]

``` ---- - -### Section Divider +### Content or Two-Column Slide ```html -
-
[SECTION NUMBER, e.g. 01]
-

[SECTION TITLE]

-
-``` - ---- - -### Content Slide (bullets) - -```html -
-
[SECTION LABEL]
-

[SLIDE HEADING]

-
-
- - [BULLET TEXT] -
-
- - [BULLET TEXT] -
-
- - [BULLET TEXT] +
+
[SECTION LABEL]
+

[SLIDE HEADING]

+
+
+
[KEY POINT]
+
[KEY POINT]
+
[VISUAL OR IMAGE DESCRIPTION]
``` ---- - -### Two-Column Slide (text left, image/visual right) - -```html -
-
[SECTION LABEL]
-

[HEADING]

-
-
-
- - [BULLET] -
-
- - [BULLET] -
-
-
[IMAGE DESCRIPTION]
-
-
-``` - ---- - -### Statement / Quote Slide - -```html -
-
-

“[STATEMENT OR QUOTE]”

-

[SOURCE OR ATTRIBUTION]

-
-``` - ---- - -### Metrics / Stats Slide - -```html -
-
[SECTION LABEL]
-

[HEADING]

-
-
-
[METRIC]
-
[LABEL]
-
-
-
[METRIC]
-
[LABEL]
-
-
-
[METRIC]
-
[LABEL]
-
-
-
-``` - ---- - -### Closing / CTA Slide - -```html -
-
[LABEL, e.g. GET STARTED]
-

[CLOSING STATEMENT]

-

[CONTACT OR NEXT STEP]

-
-``` +Use the same wrapper and tokens for section, statement, metrics, and closing +slides, changing only the composition. An image placeholder, metric row, +short rule, or callout should support the message, not fill empty space. ## Image Placeholders -When a slide needs a visual, use this div — it renders as a styled placeholder and can later be replaced with a generated image: - -```html -
[Description of what image should show]
-``` - -## Bold Terms with Description - -For definition-style bullets: +When a slide needs a visual, use this div. It renders as a styled placeholder +and can later be replaced with a generated image: ```html -
- - - [Term] - — [description] - -
+
[Description of what image should show]
``` ## Bulk Replacement Only @@ -351,24 +251,8 @@ Use a non-empty `create-deck --slides '[...]'` payload only for imports or an intentional atomic bulk replacement. For normal AI-generated decks, use the empty-deck plus sequential `add-slide` workflow above. -A bulk payload looks like this: - -```bash -pnpm action create-deck --title "Product Vision 2025" --slides '[ - { - "id": "slide-1", - "layout": "title", - "content": "
ANNUAL STRATEGY

Product Vision 2025

Engineering Leadership — Q1 2025

" - }, - { - "id": "slide-2", - "layout": "content", - "content": "
OVERVIEW

Three Core Priorities

Ship the agent platform by March
Grow to 10k active teams
Reduce time-to-value to under 5 minutes
" - } -]' -``` - -After creating, navigate to the deck: +For a bulk replacement, pass the same fully styled HTML templates in the +`slides` array. After creating, navigate to the deck: ```bash pnpm action navigate --deckId= ``` diff --git a/templates/slides/actions/add-slide.ts b/templates/slides/actions/add-slide.ts index 092d9406342..eb7d2b70a4c 100644 --- a/templates/slides/actions/add-slide.ts +++ b/templates/slides/actions/add-slide.ts @@ -115,6 +115,7 @@ export default defineAction({ "For an agent-generated deck with a persisted target slide count, stop once that count is reached. If the user explicitly asks for more slides after the target, re-read the deck and set targetSlideCountOverride to the new total on the first add-slide call. " + "Before the first slide you add to an existing deck, call `get-deck` with compact=true once and use its `designSystem`, `deckStyle`, and `representativeSlideId`; if designSystem.scope is summary, call `get-design-system` once with its id. Reuse that context for every following slide. Never use generic slide styling from an id alone. " + "Pass presenter-only speaker notes in `notes`; keep them out of the slide HTML. " + + "Every new slide must be a fully styled composition with the exact padded `fmd-slide` wrapper, a clear type hierarchy, intentional alignment, readable contrast, and at least one visual or structural treatment beyond plain text. If no design system is linked, use a light neutral canvas with ink text and one or two restrained accents; never default to a black canvas with white text. " + "For a single-call bulk append, use `patch-deck` with add-slide operations. " + "Returns the new slide ID, 1-based slideNumber, updated slide count, and pending layoutFit identity that can be checked later with get-layout-overflows.", schema: z.object({ diff --git a/templates/slides/actions/create-deck.ts b/templates/slides/actions/create-deck.ts index 4b2a493c011..757443240ea 100644 --- a/templates/slides/actions/create-deck.ts +++ b/templates/slides/actions/create-deck.ts @@ -123,7 +123,7 @@ export default defineAction({ "For longer decks or live in-app generation, create the deck with slides: [] and then use add-slide sequentially so progress appears live; the new deck is also opened in the connected Slides UI. " + "Pass presenter-only speaker notes in each slide's `notes` field; keep them out of slide HTML. " + "Pass deckId to replace an existing deck. " + - "Returns the deck id, title, effective designSystemId, linked designSystem.agentContext when readable, and slide count. Apply that context before authoring slides.", + "Returns the deck id, title, effective designSystemId, linked designSystem.agentContext when readable, and slide count. Apply that context before authoring slides. Every generated slide must be a fully styled composition with the exact padded `fmd-slide` wrapper, a clear type hierarchy, intentional alignment, readable contrast, and at least one visual or structural treatment beyond plain text. If no design system is linked, use a light neutral canvas with ink text and one or two restrained accents; never default to a black canvas with white text.", schema: z.object({ title: z.string().describe("Deck title"), slides: SlidesSchema.describe( diff --git a/templates/slides/actions/export-html.ts b/templates/slides/actions/export-html.ts index f83bb010fd5..4569a8b785b 100644 --- a/templates/slides/actions/export-html.ts +++ b/templates/slides/actions/export-html.ts @@ -2,20 +2,31 @@ import fs from "fs"; import path from "path"; import { defineAction, fail } from "@agent-native/core/action"; +import { + hydrateBuilderDesignSystemReference, + parseBuilderDesignSystemProxyReference, +} from "@agent-native/core/server"; import { getRequestUserEmail } from "@agent-native/core/server/request-context"; import { resolveAccess } from "@agent-native/core/sharing"; import { z } from "zod"; import "../server/db/index.js"; // ensure registerShareableResource runs +import { sanitizeCssValue } from "../app/lib/sanitize-slide-html.js"; import { safeGeneratedFilename, tenantExportDir, } from "../server/lib/tenant-files.js"; +import type { DesignSystemData } from "../shared/api.js"; import { type AspectRatio, getAspectRatioDims, ASPECT_RATIO_VALUES, } from "../shared/aspect-ratios.js"; +import { + backgroundCssValue, + DEFAULT_SLIDE_BACKGROUND, + resolveSlideBackground, +} from "../shared/slide-background.js"; /** * Minimal server-side HTML sanitizer for exported slide content. @@ -37,17 +48,254 @@ function sanitizeSlideContent(html: string): string { .replace(/\s+srcdoc\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, ""); } +function safeCssToken( + value: unknown, + fallback: string, + builderTokenValues?: Record, +): string { + if (typeof value !== "string") return fallback; + const sanitized = sanitizeCssValue(value); + if (!sanitized) return fallback; + const resolved = sanitized + .replace(/[{}<>;]/g, "") + .replace(/var\(\s*(--[a-zA-Z][\w-]*)\s*\)/g, (_, name: string) => { + const replacement = builderTokenValues?.[name]; + const safeReplacement = replacement + ? sanitizeCssValue(replacement) + : null; + return safeReplacement && !/[{}<>;]/.test(safeReplacement) + ? safeReplacement + : `var(${name})`; + }) + .trim(); + const finalValue = sanitizeCssValue(resolved)?.replace(/[{}<>;]/g, ""); + return !finalValue || /var\(\s*--/i.test(finalValue) + ? fallback + : finalValue.slice(0, 240) || fallback; +} + +const STANDALONE_TAILWIND_BACKGROUNDS: Record = { + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-black": "#000000", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-white": "#FFFFFF", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-slate-900": "#0F172A", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-slate-950": "#020617", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-gray-900": "#111827", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-zinc-900": "#18181B", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-neutral-900": "#171717", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-stone-900": "#1C1917", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-red-500": "#EF4444", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-orange-500": "#F97316", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-amber-500": "#F59E0B", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-yellow-400": "#FACC15", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-lime-500": "#84CC16", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-green-500": "#22C55E", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-emerald-500": "#10B981", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-teal-500": "#14B8A6", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-cyan-500": "#06B6D4", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-sky-500": "#0EA5E9", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-blue-500": "#3B82F6", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-indigo-500": "#6366F1", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-indigo-950": "#1E1B4B", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-violet-500": "#8B5CF6", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-purple-600": "#9333EA", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-fuchsia-500": "#D946EF", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-pink-500": "#EC4899", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-rose-500": "#F43F5E", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-slate-700": "#334155", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-slate-800": "#1E293B", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-zinc-50": "#FAFAFA", + // guard:allow-raw-color - standalone Tailwind background compatibility + "bg-gray-100": "#F3F4F6", +}; + +const TAILWIND_GRADIENT_DIRECTIONS: Record = { + "bg-gradient-to-t": "to top", + "bg-gradient-to-tr": "to top right", + "bg-gradient-to-r": "to right", + "bg-gradient-to-br": "to bottom right", + "bg-gradient-to-b": "to bottom", + "bg-gradient-to-bl": "to bottom left", + "bg-gradient-to-l": "to left", + "bg-gradient-to-tl": "to top left", +}; + +function standaloneBackgroundCssValue(value: string): string { + const cssValue = backgroundCssValue(value); + if (cssValue) return cssValue; + const classes = value.split(/\s+/); + const solidBackground = classes.find( + (className) => STANDALONE_TAILWIND_BACKGROUNDS[className], + ); + if (solidBackground) return STANDALONE_TAILWIND_BACKGROUNDS[solidBackground]; + const direction = classes.find( + (className) => TAILWIND_GRADIENT_DIRECTIONS[className], + ); + if (direction) { + const stops = classes + .filter((className) => /^(?:from|via|to)-/.test(className)) + .map((className) => { + const [, color, shade] = + className.match(/^(?:from|via|to)-([\w]+)-([\d]+)$/) ?? []; + return color && shade + ? STANDALONE_TAILWIND_BACKGROUNDS[`bg-${color}-${shade}`] + : undefined; + }) + .filter((stop): stop is string => Boolean(stop)); + if (stops.length >= 2) { + return `linear-gradient(${TAILWIND_GRADIENT_DIRECTIONS[direction]}, ${stops.join(", ")})`; + } + } + return STANDALONE_TAILWIND_BACKGROUNDS[value] ?? DEFAULT_SLIDE_BACKGROUND; +} + +function isDarkStandaloneBackground(value: string): boolean { + const colors = [ + ...value.matchAll(/#([\da-f]{3,8})\b/gi), + ...value.matchAll(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/gi), + ]; + if (colors.length === 0) return false; + return colors.every((match) => { + const channels = match[0].startsWith("#") + ? match[1].length === 3 || match[1].length === 4 + ? match[1] + .slice(0, 3) + .split("") + .map((channel) => parseInt(channel + channel, 16)) + : [ + match[1].slice(0, 2), + match[1].slice(2, 4), + match[1].slice(4, 6), + ].map((channel) => parseInt(channel, 16)) + : match.slice(1, 4).map(Number); + return ( + channels[0] * 0.299 + channels[1] * 0.587 + channels[2] * 0.114 < 128 + ); + }); +} + +function googleFontHref(font: unknown): string | undefined { + if (typeof font !== "string") return undefined; + const family = font.split(",", 1)[0]?.replace(/["']/g, "").trim(); + const supported = [ + "DM Sans", + "Inter", + "Manrope", + "Montserrat", + "Open Sans", + "Poppins", + "Plus Jakarta Sans", + "Roboto", + "Space Grotesk", + "Work Sans", + ]; + if ( + !family || + !supported.some( + (candidate) => candidate.toLowerCase() === family.toLowerCase(), + ) + ) { + return undefined; + } + return `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family).replace(/%20/g, "+")}:wght@400;700&display=swap`; +} + +function standaloneDesignSystemVars( + designSystem: DesignSystemData | undefined, + slideBackground: string, + builderTokenValues?: Record, +): string { + const colors = designSystem?.colors; + const typography = designSystem?.typography; + const borders = designSystem?.borders; + const standaloneBackground = standaloneBackgroundCssValue(slideBackground); + const safeBackground = safeCssToken( + standaloneBackground, + DEFAULT_SLIDE_BACKGROUND, + builderTokenValues, + ); + const darkBackground = isDarkStandaloneBackground(safeBackground); + return [ + `--ds-bg: ${safeBackground}`, + // guard:allow-raw-color - standalone export fallback palette + // guard:allow-raw-color - standalone export dark-background fallback + `--ds-text: ${safeCssToken(colors?.text, darkBackground ? "#FFFFFF" : "#1F2933", builderTokenValues)}`, + // guard:allow-raw-color - standalone export fallback palette + // guard:allow-raw-color - standalone export dark-background fallback + `--ds-text-muted: ${safeCssToken(colors?.textMuted, darkBackground ? "rgba(255, 255, 255, 0.72)" : "#667085", builderTokenValues)}`, + // guard:allow-raw-color - standalone export fallback palette + `--ds-accent: ${safeCssToken(colors?.accent, "#2457D6", builderTokenValues)}`, + // guard:allow-raw-color - standalone export fallback palette + `--ds-primary: ${safeCssToken(colors?.primary, "#2457D6", builderTokenValues)}`, + // guard:allow-raw-color - standalone export fallback palette + `--ds-secondary: ${safeCssToken(colors?.secondary, "#C85C3A", builderTokenValues)}`, + // guard:allow-raw-color - standalone export fallback palette + `--ds-surface: ${safeCssToken(colors?.surface, "#FFFFFF", builderTokenValues)}`, + `--ds-heading-font: ${safeCssToken(typography?.headingFont, "Inter, sans-serif", builderTokenValues)}`, + `--ds-body-font: ${safeCssToken(typography?.bodyFont, "Inter, sans-serif", builderTokenValues)}`, + `--ds-radius: ${safeCssToken(borders?.radius, "14px", builderTokenValues)}`, + ...Object.entries(builderTokenValues ?? {}) + .filter( + ([name, value]) => + /^--[a-zA-Z][\w-]*$/.test(name) && typeof value === "string", + ) + .map( + ([name, value]) => + `${name}: ${safeCssToken(value, "initial", builderTokenValues)}`, + ), + ].join("; "); +} + function buildStandaloneHtml( title: string, - slides: Array<{ id: string; content: string; notes?: string }>, + slides: Array<{ + id: string; + content: string; + notes?: string; + background?: string; + }>, aspectRatio?: AspectRatio, + designSystem?: DesignSystemData, + builderTokenValues?: Record, ): string { const dims = getAspectRatioDims(aspectRatio); const slideHtmlSections = slides - .map( - (slide, i) => - `
${sanitizeSlideContent(slide.content)}
`, - ) + .map((slide, i) => { + const slideBackground = resolveSlideBackground( + slide.background, + designSystem, + ); + const style = `display: ${i === 0 ? "flex" : "none"}; background: ${safeCssToken(standaloneBackgroundCssValue(slideBackground), DEFAULT_SLIDE_BACKGROUND, builderTokenValues)}; ${standaloneDesignSystemVars(designSystem, slideBackground, builderTokenValues)}`; + return `
${sanitizeSlideContent(slide.content)}
`; + }) .join("\n"); return ` @@ -56,9 +304,16 @@ function buildStandaloneHtml( ${escapeHtml(title)} - - - + ${[ + googleFontHref(designSystem?.typography?.headingFont), + googleFontHref(designSystem?.typography?.bodyFont), + ] + .filter( + (href, index, all): href is string => + Boolean(href) && all.indexOf(href) === index, + ) + .map((href) => ``) + .join("\n ")}