diff --git a/crates/op-ai-skills/skills/domains/cjk-typography.md b/crates/op-ai-skills/skills/domains/cjk-typography.md index e36fc247e..7ea9696b6 100644 --- a/crates/op-ai-skills/skills/domains/cjk-typography.md +++ b/crates/op-ai-skills/skills/domains/cjk-typography.md @@ -5,7 +5,7 @@ phase: [generation] trigger: keywords: - "/[\\u4e00-\\u9fff\\u3040-\\u309f\\u30a0-\\u30ff\\uac00-\\ud7af]/" -priority: 25 +priority: 21 budget: 500 category: domain --- diff --git a/crates/op-ai-skills/skills/domains/deck-patterns.md b/crates/op-ai-skills/skills/domains/deck-patterns.md new file mode 100644 index 000000000..ea58d20cf --- /dev/null +++ b/crates/op-ai-skills/skills/domains/deck-patterns.md @@ -0,0 +1,108 @@ +--- +name: deck-patterns +description: Slide-pattern skeletons for presentation decks — board placement, cover/data/timeline/table/points/quote/closing structures with measured numbers +phase: [generation] +trigger: + keywords: [slide, slides, deck, presentation, pitch deck, keynote, ppt, 幻灯片, 演示, 演示文稿, 路演, 课件, 汇报] +priority: 23 +budget: 1900 +category: domain +--- + +DECK PATTERNS — SLIDE SKELETONS + +Companion to the `slides` skill: that one picks the tier and the layout contract, this one is the structure to emit. Colour names below are roles from the chosen tier (`bg` / `surface` / `ink` / `muted` / `accent` / `accent-soft` / `border`). The numbers are measured from shipped decks — copy them, do not re-derive. + +## Board placement + +Each slide is its own top-level 1920×1080 frame and MUST carry explicit `x`/`y`; without them every board lands at the origin and the deck renders as one slide with the rest hidden underneath. 3 boards per row: `x = (i % 3) * (1920 + 120)`, `y = (i / 3) * (1080 + 360)`. The row gap is 360, not 120, because the canvas paints each frame's NAME above it at a fixed screen-space offset — at the zoom where a 3-wide deck fits the screen, 120px is ~16 screen px and every second-row label collides with the row above. + +## Slide frame + +``` +frame(name="01 封面", x, y, width=1920, height=1080, layout="vertical", + padding=[120,120], gap=48..56, justifyContent="start", + alignItems="start", fill=[bg], clipContent=true) +``` + +Never `fit_content` on a board — the artboard is the projector. Padding 120 satisfies the ≥100 safe area on all four sides. + +## Keep page titles on one line across the deck + +A page-title block followed by a transparent `height="fill_container"`, `justifyContent="center"` wrapper holding the body. The title sits at the same y on every slide (repetition), while the body stays vertically centred instead of being stretched. + +``` +frame(slide) ├── 页头 (vertical, gap=18) ├── text(title, 64, 700, ink, lh=1.15) + │ └── text(subtitle, 30, 400, muted, lh=1.45) + └── frame(fill_container, justifyContent="center", gap=0) └──
+``` + +## Cover — three forms, pick one + +- **Left-aligned + sign-off** (default): slide `justifyContent="space_between"`, children = eyebrow pill, lede column, meta row. Lede = title 100–112/700 lh 1.12 (`\n` for the intended break) + accent bar + subtitle 32–34/400 lh 1.45, gap 36. Meta row `alignItems="center"`, `justifyContent="space_between"`: speaker 28/500 ink, occasion/date 28/400 muted. +- **Centred claim**: slide `justifyContent="center"`, one column gap 32 — eyebrow, title 104/700, subtitle 34/400 muted, accent bar. +- **Oversized statement** (S4 only): title 120–140/700 and nothing but one 30–34/400 line. + +Eyebrow pill: `frame(fit_content, padding=[8..10, 18..22], cornerRadius=999, fill=[accent-soft])` wrapping `text(24..26, 600, accent, letterSpacing=1.0..1.2)`. Accent bar: `rect(width=120..160, height=8..10, cornerRadius=999, fill=[accent])`. Use at most ONE pill per slide. + +## Data page — the number is the slide + +3 KPI cards in a row, `alignItems="stretch"`, gap 36. + +``` +card = frame(vertical, height="fill_container", justifyContent="center", + padding=[56,44], cornerRadius=24, gap=18..20, fill=[surface]) + ├── row(gap=10, alignItems="end", width="fit_content") + │ ├── text(value, 140, 700, accent, lh=1.0, width="fit_content") + │ └── frame(fit_content, padding=[0,0,19,0]) └── text(unit, 44, 600, accent, lh=1.0) + ├── text(label, 32, 600, ink, lh=1.25) + └── text(note, 26, 400, muted, lh=1.45) +``` + +The unit's `19` bottom padding is a baseline compensation: `round((valueSize - unitSize) * 0.2)`. There is no real baseline alignment (`alignItems:"baseline"` folds to `end`), so without it a 44px unit sits below the 140px digits. Recompute it whenever either size changes. One KPI → 120–200/700 centred; two → 80–120. + +## Points page — number, title, description + +Three layers per item, never two: `序号 + 标题 + 说明`. + +- **Numbered circle**: `frame(width=size, height=size, cornerRadius=size/2, layout="horizontal", alignItems="center", justifyContent="center", fill=[accent])` + `text(digit, round(size*0.46), 700, "#FFFFFF", lh=1.0)`. size 52 inline, 64 as a card leading. Planned-but-not-done → `fill=[surface]`, ink `accent`, `stroke(accent, 3)`. +- **Bullet dot**: `rect(14×14, cornerRadius=7, fill=[accent])` inside a `fit_content` column with `padding=[15,0,0,0]` — the lift is `round(fontSize * lineHeight / 2 - dotSize / 2)`, which drops the dot onto the first line's optical centre instead of its top edge. +- Card variant: each item is `padding=[36..48, 36..44], cornerRadius=20, fill=[surface]`, item gap 24, text column gap 12. Title 34–40/600 lh 1.2–1.25, description 27–28/400 lh 1.5–1.55. +- 3 items per row when horizontal (`alignItems="stretch"`, gap 36, each `height="fill_container"`), 3–4 stacked when vertical. + +## Timeline + +A row of equal columns with **gap 0** — the axis is assembled from each column's own segment, and any column gap breaks it into disconnected pieces. Breathing room comes from a right inset on the text, never from the row gap. + +``` +column(gap=28) + ├── 节点日期 frame(padding=[0,56,0,0]) └── text(date, 28, 600, accent, lh=1.2) + ├── 轴段 row(gap=0, alignItems="center") + │ ├── rect(28×28, cornerRadius=14, fill=[accent]) -- done + │ │ (planned: fill=[bg] + stroke(accent, 4)) + │ └── rect(width="fill_container", height=4, fill=[border]) + └── 节点文案 frame(padding=[0,56,0,0], gap=10) + ├── text(title, 34, 600, ink, lh=1.25) + └── text(desc, 26, 400, muted, lh=1.5) +``` + +Never put padding on the axis row itself. 4–5 nodes maximum. + +## Comparison table + +Rules only, via per-side stroke — a full box turns the table into a grid and hides the row relationships. + +- Header row: `padding=[26,32]`, `stroke={thickness:{bottom:3}, fill:[accent]}`, column headings 34/700 accent. +- Body row: same padding, `stroke={thickness:{bottom:2}, fill:[border]}`. **The last row carries no stroke** — a trailing rule dangles under nothing. +- Row-label column: fixed `width=260`, 27/600 muted. Content columns fill, 28/400 ink lh 1.5. Row gap 40 between cells, 0 between rows. +- Wrapper: `frame(vertical, gap=0, cornerRadius=20, fill=[surface], clipContent=true)`. The header needs a blank label cell (`text(" ")`) so its columns line up with the body. + +## Chart placeholder + +`frame(horizontal, gap=28, alignItems="end", padding=[40,40], cornerRadius=24, fill=[surface], height="fill_container")` holding 5 `rect(width="fill_container", cornerRadius=12)` bars at heights 180/250/220/330/420. Exactly ONE bar — the one the takeaway is about — uses `accent`, the rest `accent-soft`. Pair it with a `width=520` notes column of 2–3 title 30/600 + body 26/400 pairs: the insight goes in words, never left for the audience to find. + +## Closing / CTA + +`justifyContent="space_between"`: a lede column (accent bar, headline 88–96/700 lh 1.15, one 30–32/400 muted line) and a contact card `row(padding=[48,56], cornerRadius=24, fill=[surface], justifyContent="space_between", alignItems="center")`. Left = name 32/600 over a nested column of 30/500 accent + 30/400 muted lines (nesting keeps each level's sizes internally consistent). Right = a `200×200` `frame` placeholder, `cornerRadius=16`, `fill=[accent-soft]`, `stroke(accent, 2)`, with a centred 26/600 label inside. + +Placeholders for a QR code, a photo or a chart must be `frame`, never `rectangle`: a rectangle does not render its children, so the label inside a rectangle placeholder is invisible. diff --git a/crates/op-ai-skills/skills/domains/slides.md b/crates/op-ai-skills/skills/domains/slides.md index cc14022a2..44713d8e7 100644 --- a/crates/op-ai-skills/skills/domains/slides.md +++ b/crates/op-ai-skills/skills/domains/slides.md @@ -4,8 +4,8 @@ description: Presentation slide / deck design — 16:9 layout contracts, slide t phase: [generation] trigger: keywords: [slide, slides, deck, presentation, pitch deck, keynote, ppt, 幻灯片, 演示, 演示文稿, 路演] -priority: 28 -budget: 1800 +priority: 24 +budget: 2400 category: domain --- @@ -17,6 +17,53 @@ You design slides readable in real conditions (projector, Zoom, mobile). Priorit The selected style guide is a brand/product palette — it is NOT slide-optimized. ALWAYS adapt it for slides: scale type up to the sizes below, widen spacing, raise contrast, simplify. If the guide's body size or contrast would hurt readability on a projector, override it. Readability beats brand fidelity every time. Pull core/accent/neutral from the guide, then enforce the slide sizes regardless of the guide's own scale. +## Style tiers — pick ONE for the whole deck, then hold it on every slide + +Route the tier from the request (rules below), then use that tier's palette, type scale and element cap verbatim. When a style guide IS selected, keep its accent HUE but move the values onto the tier's roles so the contrast floors below still hold. Hex values and contrast ratios here are measured, not estimates — the listed floor pair is the one that fails first when you swap the accent, so re-measure THAT pair, never the title. + +S1 WARM-WHITE BUSINESS — default. Reports, quarterly reviews, corporate updates, 汇报. + +- bg `#FFFFFF` · surface `#F4F6F9` · ink `#0B1220` · muted `#5A6B85` · accent `#2F5BEA` · accent-soft `#E4EAFD` · border `#DCE2EC` +- Contrast: ink/bg 18.72 · muted/bg 5.42 · accent/bg 5.52 · muted/accent-soft 4.51 (FLOOR) +- Type: cover 104/700 · page title 64/700 · card title 34–40/600 · body 26–32/400 · KPI 140/700 · eyebrow 24/600 +- ≤6 elements per slide. Cards carry the `surface` fill; the slide background stays `bg`. +- NEVER: a second accent hue; a coloured page background on anything but the closing slide. + +S2 DARK PITCH — 深色 / 暗色 / dark / 科技感 / tech / 路演 / investor pitch. + +- bg `#0B1220` · surface `#18263F` · ink `#F2F6FF` · muted `#93A4C4` · accent `#4D8DFF` · accent-soft `#16233D` · border `#24314D` +- Contrast: ink/bg 17.30 · muted/bg 7.44 · accent/bg 5.86 · accent/surface 4.73 (FLOOR) +- Type: cover 88–112/700 lh 1.12 · page title 64/700 · card title 38/600 · body 26–30/400 · KPI 140/700 with its unit at 44/600 +- EXACTLY TWO background levels (bg + surface). ≤5 elements per slide. +- NEVER: a third grey plane — on dark, hierarchy comes from weight and the accent, not from stacking more greys. NEVER body text lighter-weight than `muted`, drop shadows, or a second accent. + +S3 LIGHT LECTURE — 课件 / 教学 / 培训 / lecture / course / tutorial / workshop. + +- bg `#F2EEE2` (paper, deliberately not pure white — pure white lights the whole room) · surface `#FFFFFF` · ink `#17211C` · muted `#55635A` · accent `#1B6B4C` · accent-soft `#D8E9DE` · border `#D9D2C2` +- Contrast: ink/bg 14.25 · accent/bg 5.56 · white-on-accent 6.45 · muted/accent-soft 5.01 (FLOOR) +- Type: cover 100/700 · page title 64/700 · step/objective title 30–38/600 · body 26–30/400 at lineHeight 1.5–1.6 (denser than a pitch — the audience is taking notes) +- ≤8 elements per slide. The numbered circle is this tier's signature: a filled accent circle with a white digit is the highest-contrast point on the page. +- NEVER: a procedure slide with no visible step order; body below 26. + +S4 MINIMAL KEYNOTE — 极简 / 简约 / minimal / keynote / "one big idea". + +- Pick ONE ground and never mix: S1's `#FFFFFF`/`#0B1220` pair, or S2's `#0B1220`/`#F2F6FF` pair. Exactly one accent, used at most once per slide. +- Type: statement 88–140/700 lh 1.12 · one supporting line 30–34/400. Nothing else. +- ≤4 elements per slide, counting the accent bar. +- NEVER: bullet lists, cards, borders, tables, icons, or a slide carrying title + subtitle + body + footer at once. + +## Route the tier from the request + +Scan the user's words in this order and take the FIRST hit; scan the deck's subject only if no style word appears: + +1. 深色/暗色/黑色/dark/night/科技感/tech/cyber/neon → S2. 路演/pitch/投资人/investor/融资 also → S2. +2. 极简/简约/minimal/keynote/性冷淡/one big idea/less is more → S4. +3. 课件/教学/讲义/培训/lecture/course/tutorial/workshop/教程 → S3. +4. 浅色/明亮/light/白底/商务/汇报/季度/年度/report/review/corporate → S1. +5. No style word at all → S1. + +An explicit brand colour in the request overrides the tier's accent (keep every other role). A tier is a whole-deck decision — never switch tiers between slides of one deck. + ## Format - Each slide is a 16:9 frame, 1920×1080. Keep all content ≥100px from the edges. diff --git a/crates/op-ai-skills/skills/knowledge/shapes-and-decks.md b/crates/op-ai-skills/skills/knowledge/shapes-and-decks.md index e04cf7818..07171b950 100644 --- a/crates/op-ai-skills/skills/knowledge/shapes-and-decks.md +++ b/crates/op-ai-skills/skills/knowledge/shapes-and-decks.md @@ -3,7 +3,7 @@ name: shapes-and-decks description: Full worked examples for concentric progress rings/donuts/gauges and stacked-card decks (companion depth to the layout skill's compact rules) phase: [generation] trigger: - keywords: [ring, donut, gauge, pie chart, progress ring, activity ring, deck, stacked card, card stack, swipeable, flashcard, 进度环, 甜甜圈, 仪表盘, 堆叠卡] + keywords: [ring, donut, gauge, pie chart, progress ring, activity ring, card deck, stacked deck, stacked card, card stack, swipeable, flashcard, 进度环, 甜甜圈, 仪表盘, 堆叠卡] priority: 28 budget: 1200 category: knowledge diff --git a/crates/op-ai-skills/skills/phases/planning/decomposition.md b/crates/op-ai-skills/skills/phases/planning/decomposition.md index 2dce4d614..ac4764cd1 100644 --- a/crates/op-ai-skills/skills/phases/planning/decomposition.md +++ b/crates/op-ai-skills/skills/phases/planning/decomposition.md @@ -4,7 +4,7 @@ description: Orchestrator task decomposition — splits UI requests into cohesiv phase: [planning] trigger: null priority: 0 -budget: 3000 +budget: 3500 category: base --- @@ -44,7 +44,15 @@ OTHERWISE classify by purpose: - Every subtask MUST carry a `screen` field naming that slide, and no two may share a value. This field is what makes each slide its own frame; without it the whole deck collapses onto one board. - Each subtask's region is the full slide: {"width":1920,"height":1080} - Structure per slide: one idea — a takeaway title plus its supporting content. NO status bar, NO navigation bar, NO footer. - - Honour an explicitly requested slide count ("6 页"/"5-slide"); otherwise plan 5-8 slides covering cover, agenda, the argument, evidence, and a close. + - Honour an explicitly requested slide count ("6 页"/"5-slide"); otherwise plan 5-8 slides. + - OUTLINE MODE — pick the running order from the deck's PURPOSE, then name each subtask after the slide it produces (that name is the board's title, so it must read as a slide title, not as "Section 3"): + - Pitch / 路演 / 融资: cover - the problem (3 pains) - the solution - proof data (3 KPIs) - milestones/roadmap - the ask + contact. + - Lecture / 课件 / 培训: cover - learning objectives - the concept - a worked example (numbered steps) - a comparison of the two things students confuse - summary + homework. + - Report / 汇报 / 季度: cover - agenda - what we did (3 points) - results (3 KPIs) - a trend chart with its takeaway - next steps. + - Product launch / 发布: cover - the change in the market - the product - three capabilities - evidence/benchmark - availability + CTA. + - Anything else: cover - agenda - 2-4 argument slides - one evidence slide - closing. + - COPY LIMITS (a slide is a visual aid, not a document — plan the amount, do not leave it to the generator): slide title <= 14 CJK chars / ~10 English words. Bullet or card item <= 20 CJK chars / ~14 English words. Total body copy per slide <= 80 CJK chars / ~55 English words. At most 3 cards, 3 KPIs, 5 timeline nodes, or 5 bullets on one slide — if the content needs more, plan another slide instead. + - The "elements" field for a slide names the slide's ONE takeaway plus its supporting parts (e.g. "takeaway title, 3 KPI cards each with value + unit + label + note"), never a list of paragraphs. CRITICAL — "MOBILE" MEANS MOBILE-SIZED SCREEN, NOT A PHONE MOCKUP: When the user says "mobile"/"移动端"/"手机" + a screen type (login, profile, settings, etc.), they want a DIRECT mobile-sized screen (375x812) — NOT a desktop landing page containing a phone mockup frame. A "mobile login page" = type 2 (375x812 login screen). Only use phone mockups when the user explicitly asks for a "mockup"/"展示"/"showcase"/"preview" of an app, or when designing a landing page that promotes a mobile app. diff --git a/crates/op-ai-skills/src/deck_corpus_tests.rs b/crates/op-ai-skills/src/deck_corpus_tests.rs new file mode 100644 index 000000000..a549ab860 --- /dev/null +++ b/crates/op-ai-skills/src/deck_corpus_tests.rs @@ -0,0 +1,266 @@ +//! Corpus guards for the deck skills (`slides` + `deck-patterns`). +//! +//! Delivery into the assembled system prompt is guarded on the orchestrator +//! side (`prompt_deck_skill_tests`); this file guards the corpus itself — +//! registration, trigger wiring, and the tier/pattern content the deck +//! generation path depends on. + +use crate::loader::{get_skill_by_name, get_skills_by_phase}; +use crate::resolver::match_keyword; +use crate::types::{Phase, SkillCategory, SkillTrigger}; + +#[test] +fn deck_patterns_registers_as_a_keyword_gated_generation_domain_skill() { + let skill = get_skill_by_name("deck-patterns").expect("deck-patterns must be registered"); + assert_eq!(skill.meta.category, SkillCategory::Domain); + assert!(skill.meta.phase.contains(&Phase::Generation)); + // Priority below every other generation Domain skill that a deck prompt can + // also trigger (`dashboard` 28, `web-app` 30, `landing-page` 35): the budget + // filler walks Domain skills in ascending priority, so a deck prompt that + // also says "数据"/"product" must spend its remaining tokens on the deck + // teaching first. Measured: at the old priority 28 `slides` lost its tail — + // or was dropped entirely — on "做一份季度数据汇报 PPT,要有仪表盘和数据表格". + assert!( + skill.meta.priority < 28, + "deck-patterns must outrank the generic page domains, got {}", + skill.meta.priority + ); + match &skill.meta.trigger { + SkillTrigger::Keywords(keywords) => { + for word in ["deck", "ppt", "幻灯片", "课件", "路演"] { + assert!( + keywords.iter().any(|k| k == word), + "deck-patterns must trigger on {word:?}" + ); + } + } + other => panic!("deck-patterns must be keyword-gated, got {other:?}"), + } +} + +#[test] +fn a_presentation_deck_prompt_no_longer_pulls_the_stacked_card_worked_example() { + // `shapes-and-decks` teaches concentric rings and STACKED CARD decks. Its + // bare `deck` keyword also fired on every presentation deck, spending ~1000 + // tokens of the generation budget on a ring/card-stack worked example that + // has nothing to do with slides — and those are tokens `slides` then could + // not have. The narrowed keywords must still cover the real stacked-card + // intents while leaving presentation decks alone. + let shapes = get_skill_by_name("shapes-and-decks").expect("shapes-and-decks registered"); + let SkillTrigger::Keywords(keywords) = &shapes.meta.trigger else { + panic!("shapes-and-decks must be keyword-gated"); + }; + let fires = |message: &str| { + let lowered = message.to_lowercase(); + keywords + .iter() + .any(|k| match_keyword(&lowered, &k.to_lowercase())) + }; + for slides_prompt in [ + "generate a 6-slide pitch deck about our launch", + "帮我做一个融资路演 PPT", + "a keynote deck for the all-hands", + ] { + assert!( + !fires(slides_prompt), + "the stacked-card worked example must not load for {slides_prompt:?}" + ); + } + for stack_prompt in [ + "a swipeable card deck of testimonials", + "show a stacked card stack", + "堆叠卡组", + "an activity ring for the fitness screen", + ] { + assert!( + fires(stack_prompt), + "shapes-and-decks must still fire for {stack_prompt:?}" + ); + } +} + +#[test] +fn slides_teaches_four_routable_style_tiers_with_measured_contrast_floors() { + let slides = get_skill_by_name("slides").expect("slides registered"); + let body = &slides.content; + for tier in [ + "S1 WARM-WHITE BUSINESS", + "S2 DARK PITCH", + "S3 LIGHT LECTURE", + "S4 MINIMAL KEYNOTE", + ] { + assert!(body.contains(tier), "slides must define {tier}"); + } + // Every tier states the pair that fails first, so the model re-measures the + // right one after swapping an accent. These ratios are copied from the + // shipped template generators (`templates/step0/_generators/*.py`) — a tier + // without a FLOOR line is a tier whose palette can be silently broken. + assert_eq!( + body.matches("(FLOOR)").count(), + 3, + "S1/S2/S3 each declare their floor pair; S4 reuses S1/S2's ground" + ); + for floor in [ + "muted/accent-soft 4.51 (FLOOR)", // S1, computed from tpl_slides.py's palette + "accent/surface 4.73 (FLOOR)", // S2, tpl_pitch_dark.py's own measurement + "muted/accent-soft 5.01 (FLOOR)", // S3, tpl_lecture_light.py's own measurement + ] { + assert!( + body.contains(floor), + "slides must carry the measured {floor}" + ); + } + // Routing must be executable: each tier is reachable from words a user + // actually types, in both languages. + assert!(body.contains("## Route the tier from the request")); + for word in ["深色", "极简", "课件", "minimal", "lecture", "dark"] { + assert!(body.contains(word), "tier routing must cover {word:?}"); + } + // Per-tier element caps — the rule that keeps a generated slide from + // becoming a document. + assert_eq!( + body.matches("elements per slide").count(), + 4, + "every tier must cap its element count" + ); +} + +#[test] +fn deck_patterns_carries_the_structural_numbers_the_templates_proved() { + let body = &get_skill_by_name("deck-patterns") + .expect("deck-patterns registered") + .content; + for rule in [ + // Board placement — the "six slides stacked at the origin" bug. + "x = (i % 3) * (1920 + 120)", + "y = (i / 3) * (1080 + 360)", + // KPI unit baseline compensation — there is no real baseline align. + "round((valueSize - unitSize) * 0.2)", + // Timeline axis continuity. + "**gap 0**", + // Table rules. + "**The last row carries no stroke**", + // Bullet-dot optical centring. + "round(fontSize * lineHeight / 2 - dotSize / 2)", + // Numbered circle digit size. + "round(size*0.46)", + // Placeholders must be frames, not rectangles. + "a rectangle does not render its children", + ] { + assert!(body.contains(rule), "deck-patterns must teach {rule:?}"); + } +} + +#[test] +fn decomposition_carries_the_deck_outline_templates_and_copy_caps() { + let body = &get_skill_by_name("decomposition") + .expect("decomposition registered") + .content; + assert!(body.contains("OUTLINE MODE")); + for outline in [ + "Pitch / 路演 / 融资", + "Lecture / 课件 / 培训", + "Report / 汇报 / 季度", + "Product launch / 发布", + ] { + assert!(body.contains(outline), "type-4 must plan {outline}"); + } + assert!(body.contains("COPY LIMITS")); + assert!(body.contains("slide title <= 14 CJK chars")); +} + +#[test] +fn every_deck_prompt_resolves_both_deck_skills_untruncated() { + // The generation-phase total is what actually decides this; these prompts + // are the ones that used to lose `slides` (each pulls a second, larger + // domain skill: dashboard / web-app / landing-page / mobile-app). + for prompt in [ + "帮我做一个 8 页的融资路演 PPT,深色科技感", + "做一份季度数据汇报 PPT,要有仪表盘和数据表格", + "generate a 10-slide deck for our SaaS admin console product with analytics data tables", + "极简 keynote 演示,讲移动端 app 的设计", + "帮我做一个教学课件幻灯片,讲解表单设计和登录页", + "pitch deck landing page marketing homepage slides", + ] { + let ctx = + crate::resolve_skills(Phase::Generation, prompt, &crate::ResolveOptions::default()); + for name in ["slides", "deck-patterns"] { + let entry = ctx + .report + .included + .iter() + .find(|e| e.name == name) + .unwrap_or_else(|| { + panic!( + "{prompt:?} dropped {name}; kept {:?} at {}/{} tokens", + ctx.report + .included + .iter() + .map(|e| e.name.as_str()) + .collect::