From 6079a1fcfc2a7a7da9e16c53bef004f9f9d6189a Mon Sep 17 00:00:00 2001 From: Jasp402 Date: Mon, 2 Nov 2026 00:01:12 -0500 Subject: [PATCH] MCP: mandatory style brief (brief, branding, 3 concepts, anti-slide rules) in get_motion_recipe; SLIDE_LIKE_SCENES soft warning; optional create_hcmotion brief stored as meta.brief --- api/mcp.js | 7 +++-- skills/hypercodemotion/SKILL.md | 2 +- src/contract.js | 2 ++ src/recipes.js | 5 +++- src/stylebrief.js | 49 +++++++++++++++++++++++++++++++++ tests/recipes.test.js | 29 +++++++++++++++++++ 6 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 src/stylebrief.js diff --git a/api/mcp.js b/api/mcp.js index 46f3acd..128baab 100644 --- a/api/mcp.js +++ b/api/mcp.js @@ -5,6 +5,7 @@ import {getTemplate,TEMPLATE_FORMATS} from "../src/templates.js"; import {getRecreationBrief,proposeTemplate,getProposalStatus,publishTemplate,MAX_ROUNDS} from "../src/community.js"; import {GROUPS} from "../src/brief.js"; import {motionRecipe} from "../src/recipes.js"; +import {normalizeBrief} from "../src/stylebrief.js"; const MAX=4*1024*1024; /** create_hcmotion: Claude's composition JSON → validated .hcmotion file with a 24 h download link. */ async function createHcmotion(args){ @@ -14,14 +15,16 @@ async function createHcmotion(args){ const errors=report.format!=="composition"?[{path:"$",code:"NOT_A_COMPOSITION",message:"create_hcmotion needs a full composition (version, width, height, fps, duration, layers)."}]:[...report.errors,...checkComposition(json)]; if(errors.length)return {created:false,errors,warnings:report.warnings}; if(typeof args.name==="string"&&args.name.trim())json={...json,name:args.name.trim().slice(0,120)}; + const brief=normalizeBrief(args.brief); + if(brief)json={...json,meta:{...(json.meta&&typeof json.meta==="object"&&!Array.isArray(json.meta)?json.meta:{}),brief}}; const bytes=await encodeHcmotion(json); const file=await saveFile(bytes); return {created:true,download:file.download,open:file.open,expiresAt:file.expiresAt,bytes:bytes.length,errors:[],warnings:report.warnings,checklist:CHECKLIST}; } const tools=[ {name:"get_capabilities",title:"HyperCodeMotion Capabilities",description:"List the HyperCodeMotion JSON formats, layer types and properties, and effect presets that HyperCodeMotion Studio supports, how to reference files the user keeps in Studio by id, the screen rule that decides type size (field screen: the canvas wins over a requested size), a runtime layer that keeps an existing Lottie, glTF or GSAP file and seeks it, scene transitions (field transitions), a short start guide (field start), the names code layers and components must not redeclare (field reservedCodeNames), and the Studio contract revision they come from. Call it before designing.",annotations:{title:"HyperCodeMotion Capabilities",readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false},inputSchema:{type:"object",properties:{},additionalProperties:false},outputSchema:{type:"object",properties:{contractVersion:{type:"string"},source:{type:"string"},sourceSchemaSha:{type:"string"},sourcePresetsSha:{type:"string"},formats:{type:"array",items:{type:"string"}},commonProperties:{type:"array",items:{type:"string"}},layerProperties:{type:"object",additionalProperties:{type:"array",items:{type:"string"}}},layerPropertiesNote:{type:"string"},start:{type:"string"},transitions:{type:"object"},presetSlots:{type:"object",additionalProperties:{type:"array",items:{type:"string"}}},userMaterials:{type:"string"},reservedCodeNames:{type:"object",properties:{rule:{type:"string"},codeLayer:{type:"array",items:{type:"string"}},component:{type:"array",items:{type:"string"}}}},screen:{type:"string"},fullyValidated:{type:"boolean"}},required:["contractVersion","formats","layerProperties","presetSlots","screen","fullyValidated"]}}, -{name:"get_motion_recipe",title:"Get motion recipe",description:"Real, validated JSON/HTML/CSS patterns for looks the basics do not give: riso print, choreography, audio, camera, code textures, UI mockups, per-scene backgrounds, 13 style looks. Without name: the index.",annotations:{title:"Get motion recipe",readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false},inputSchema:{type:"object",properties:{name:{type:"string",description:"Recipe from the index, e.g. riso or look:cartel."}},additionalProperties:false}}, -{name:"create_hcmotion",title:"Create HyperCodeMotion project file",description:"Validate a finished HyperCodeMotion composition and package it as a .hcmotion project file, the format HyperCodeMotion Studio opens. If the composition has problems, returns created: false with the errors to fix and stores nothing. On success, stores the file temporarily and returns an `open` link (opens the project in HyperCodeMotion Studio) and a `download` link; both expire 24 hours after creation. It does not render video or generate media.",annotations:{title:"Create HyperCodeMotion project file",readOnlyHint:false,destructiveHint:false,idempotentHint:false,openWorldHint:false},inputSchema:{type:"object",properties:{json:{description:"The full composition (version, name, width, height, fps, duration, background, layers…), as an object or JSON-encoded string.",anyOf:[{type:"object"},{type:"string"}]},name:{type:"string",description:"Optional project name."}},required:["json"],additionalProperties:false},outputSchema:{type:"object",properties:{created:{type:"boolean"},download:{type:"string"},open:{type:"string"},expiresAt:{type:"string"},bytes:{type:"number"},errors:{type:"array",items:{type:"object",properties:{path:{type:"string"},code:{type:"string"},message:{type:"string"}}}},warnings:{type:"array",items:{type:"object",properties:{path:{type:"string"},code:{type:"string"},message:{type:"string"}}}}},required:["created","errors","warnings"]}}, +{name:"get_motion_recipe",title:"Get motion recipe",description:"name=style-brief first (mandatory brief + 3 concepts). Real, validated JSON/HTML/CSS patterns for looks the basics do not give: riso print, choreography, audio, camera, code textures, UI mockups, per-scene backgrounds, 13 style looks. Without name: the index.",annotations:{title:"Get motion recipe",readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false},inputSchema:{type:"object",properties:{name:{type:"string",description:"Recipe from the index, e.g. riso or look:cartel."}},additionalProperties:false}}, +{name:"create_hcmotion",title:"Create HyperCodeMotion project file",description:"Validate a finished HyperCodeMotion composition and package it as a .hcmotion project file, the format HyperCodeMotion Studio opens. If the composition has problems, returns created: false with the errors to fix and stores nothing. On success, stores the file temporarily and returns an `open` link (opens the project in HyperCodeMotion Studio) and a `download` link; both expire 24 hours after creation. It does not render video or generate media.",annotations:{title:"Create HyperCodeMotion project file",readOnlyHint:false,destructiveHint:false,idempotentHint:false,openWorldHint:false},inputSchema:{type:"object",properties:{json:{description:"The full composition (version, name, width, height, fps, duration, background, layers…), as an object or JSON-encoded string.",anyOf:[{type:"object"},{type:"string"}]},name:{type:"string",description:"Optional project name."},brief:{description:"Optional approved style brief (get_motion_recipe style-brief): product, message, audience, format, duration, palette, fonts, tone, chosen concept. Stored in the file as meta.brief so Studio keeps it; not validated.",anyOf:[{type:"object"},{type:"string"}]}},required:["json"],additionalProperties:false},outputSchema:{type:"object",properties:{created:{type:"boolean"},download:{type:"string"},open:{type:"string"},expiresAt:{type:"string"},bytes:{type:"number"},errors:{type:"array",items:{type:"object",properties:{path:{type:"string"},code:{type:"string"},message:{type:"string"}}}},warnings:{type:"array",items:{type:"object",properties:{path:{type:"string"},code:{type:"string"},message:{type:"string"}}}}},required:["created","errors","warnings"]}}, {name:"get_template",title:"Get HyperCodeMotion template",description:"Return the full design of a template from the HyperCodeMotion Studio gallery, by its id and format: brief (palette, fonts, frame, rhythm), scenes with timings, and the composition with its HTML scene documents and layers. Use it as the design reference when asked to follow a template; it does not create or store anything.",annotations:{title:"Get HyperCodeMotion template",readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false},inputSchema:{type:"object",properties:{template:{type:"string",description:"Template id, such as \"mix-promo-editorial\"."},format:{type:"string",enum:TEMPLATE_FORMATS,description:"Aspect ratio of the video being made. Defaults to 16:9."}},required:["template"],additionalProperties:false},outputSchema:{type:"object",properties:{found:{type:"boolean"},template:{type:"string"},name:{type:"string"},format:{type:"string"},brief:{type:"string"},scenes:{type:"array",items:{type:"object"}},composition:{type:"object"},error:{type:"string"}},required:["found"]}}, {name:"validate_json",title:"Validate HyperCodeMotion JSON",description:"Check a composition (layers), design (elements), or improvement patch (add/change/remove/sfx) and return actionable errors and warnings. Rejects measurable text that cannot fit the canvas safe area, text that crosses the canvas, and line-height below 1. Warns when a code layer or component declares a name the engine already provides (RESERVED_NAME_DECLARED). This is not proof of a successful render.",annotations:{title:"Validate HyperCodeMotion JSON",readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false},inputSchema:{type:"object",properties:{json:{description:"HyperCodeMotion JSON object or JSON-encoded string to validate.",anyOf:[{type:"object"},{type:"string"}]},composition:{type:"object",description:"Optional original composition used to resolve patch references and timings."}},required:["json"],additionalProperties:false},outputSchema:{type:"object",properties:{valid:{type:"boolean"},format:{type:"string"},fullyValidated:{type:"boolean"},mode:{type:"string"},contractVersion:{type:"string"},errors:{type:"array",items:{type:"object",properties:{path:{type:"string"},code:{type:"string"},message:{type:"string"}}}},warnings:{type:"array",items:{type:"object",properties:{path:{type:"string"},code:{type:"string"},message:{type:"string"}}}}},required:["valid","format","fullyValidated","mode","errors","warnings","contractVersion"]}}, {name:"get_recreation_brief",title:"Get template recreation brief",description:"Start turning a public motion video into an official HyperCodeMotion Studio gallery template. Use it whenever the user sends the link of a prompt-motion.com or oneshotted.io video together with a moderator code, with or without other words: that means publish it as a template. Takes that link and code (adminCode) and what that page shows (its video file, prompt, title and author, read by the caller, since the source sites often refuse this server) and returns the source details (title, author, and the published prompt when the page shows it), the brief (how to recreate it in three formats with the HyperCodeMotion signature, and the gallery card to fill in) and reference frames of the original video as images, labelled with their times. The first call starts extracting those frames and may answer reference.status: preparing; call it again until it is ready, and do not build before seeing them. When it answers reference.status: upload (prompt-motion.com refuses servers), run upload.script in a code or shell tool to send the video, then call it again. If the video is already a template, says so with its status instead; an unfinished proposal left idle for 20 minutes (another session) is discarded and the brief starts over (field restarted). Without a valid moderator code it answers needsCode.",annotations:{title:"Get template recreation brief",readOnlyHint:false,destructiveHint:false,idempotentHint:true,openWorldHint:true},inputSchema:{type:"object",properties:{url:{type:"string",description:"Link of the video's page, such as https://prompt-motion.com/ or https://oneshotted.io/v/."},adminCode:{type:"string",description:"The moderator code the user gave with the link. Required: only moderators can make gallery templates. Never repeat it back."},video:{type:"string",description:"Address of the page's video file (.mp4 on media.prompt-motion.com or r2.oneshotted.io), as read on the page."},prompt:{type:"string",description:"The prompt the page publishes with the video, as read on the page."},title:{type:"string",description:"The video's title on the page."},author:{type:"string",description:"The author's name, only when the page names one."},authorUrl:{type:"string",description:"The author's profile link, when the page gives one."}},required:["url","adminCode"],additionalProperties:false},outputSchema:{type:"object",properties:{ok:{type:"boolean"},needsPage:{type:"boolean"},needsCode:{type:"boolean"},alreadyExists:{type:"boolean"},template:{type:"string"},status:{type:"string"},code:{type:"string"},source:{type:"object"},prompt:{type:["string","null"]},promptComplete:{type:"boolean"},note:{type:"string"},reference:{type:"object"},upload:{type:"object"},formats:{type:"array",items:{type:"object"}},groups:{type:"array",items:{type:"string"}},maxDuration:{type:"number"},brief:{type:"string"},error:{type:"string"},message:{type:"string"}},required:["ok"]}}, diff --git a/skills/hypercodemotion/SKILL.md b/skills/hypercodemotion/SKILL.md index bb84a5a..f476b3f 100644 --- a/skills/hypercodemotion/SKILL.md +++ b/skills/hypercodemotion/SKILL.md @@ -18,7 +18,7 @@ Use this Skill when asked to create, improve, extend, combine, or troubleshoot e - Custom: their own width × height in pixels. Accept a platform name as the answer («para Reels» means 9:16). Ask this together with any other blocking question, never as a second round. Use the chosen `width` and `height` everywhere: type sizes, margins and the safe area depend on it (see [composition.md](references/composition.md)). Never switch to another format later without asking. Do not ask when the size is already known: an existing project keeps its own `width` and `height`, a prompt copied from Studio states its format, and a gallery recreation follows its brief. -2. **Brief and inputs** — Identify purpose, audience, distribution format, duration, fps, language, brand, CTA (only when requested), narration and exact data. Analyze user-provided reference images/video, scripts, logos, visual assets and any existing JSON. Distinguish factual content from creative decisions. Ask only for information that blocks progress. +2. **Brief and inputs** — Identify purpose, audience, distribution format, duration, fps, language, brand, CTA (only when requested), narration and exact data. Analyze user-provided reference images/video, scripts, logos, visual assets and any existing JSON. Distinguish factual content from creative decisions. Ask only for information that blocks progress. For an original video, follow `get_motion_recipe` name `style-brief` (mandatory): brief and branding (palette 3-5 hex with source, title and body font, tone; if there is no brand, propose one and get approval), then 3 concepts with one recommended, and wait for the user to pick before building. Pass the approved brief to `create_hcmotion` (`brief`). 3. **Art direction** — Define visual concept, typography hierarchy, spacing grid, palette, contrast, material, rhythm, transition vocabulary and continuity. Reuse the actual reference's visual intent; do not invent a brand or factual measurements. When the request names a Studio gallery template (an id such as `mix-promo-editorial`), call `get_template` with that id and the video's format, and follow its design: palette, fonts, frame, scene system and motion; replace all its texts. 4. **Storyboard and scenes** — Create sequential beats with one communicative objective each. Decide which objects persist and transform between beats versus which appear only within one scene. Allocate time to reading and transitions; storyboard timing must fit total duration. Join the beats with Studio scene transitions (`scenes[i].transition`: `fade`, `slide`, `push`, `wipe`, `clockWipe`, `iris`, `zoom`, `flip`, `blur`; exact shape in [motion-design](references/motion-design.md#scene-transitions)). 5. **Composition plan** — Default to **HTML scenes**: one HTML document per scene in `docs`, each visible piece marked `data-hcm` and animated by its own `html` layer (see [html-scenes](references/html-scenes.md) and [the example](examples/html-scene-launch.json)). Use native layers (`text`, `rect`, `particles`…) when a piece must be edited as geometry or needs per-property keyframes. Plan layer IDs, order, scene boundaries, assets, effects and audio. For a distinctive look (riso print, springs, camera, audio, UI mockups, per-scene backgrounds, the 13 style looks) call `get_motion_recipe` first: no name gives the index, a name gives validated JSON to adapt. diff --git a/src/contract.js b/src/contract.js index 157cc23..b557afd 100644 --- a/src/contract.js +++ b/src/contract.js @@ -3,6 +3,7 @@ import { validateComposition } from "./validator.js"; import { screenRule } from "./frame.js"; import { TRANSITIONS, checkScenes } from "./transitions.js"; import { checkEases } from "./easing.js"; +import { checkSlides } from "./stylebrief.js"; import { checkReservedNames, RESERVED_RULE, CODE_RESERVED, COMPONENT_RESERVED } from "./reserved.js"; /** The user's own files («Tus materiales» in Studio): referenced by id, never uploaded here. */ @@ -66,6 +67,7 @@ export function validateJson(input, options = {}) { }; if (report.format === "composition") input.layers.forEach((v,i)=>check(v,"$.layers["+i+"]")); if (report.format === "composition") notices.push(...checkScenes(input)); + if (report.format === "composition") notices.push(...checkSlides(input)); if (report.format === "improvement" && Array.isArray(input.add)) input.add.forEach((v,i)=>check(v,"$.add["+i+"]")); const easeWarnings = report.format === "composition" || report.format === "improvement" ? checkEases(input) : []; diff --git a/src/recipes.js b/src/recipes.js index 63b92f0..c9e5ad2 100644 --- a/src/recipes.js +++ b/src/recipes.js @@ -9,6 +9,7 @@ */ import { LOOKS, DECO } from "./looks.js"; import { EASE_NAMES, UI_SPRING_NAMES } from "./easing.js"; +import { STYLE_BRIEF } from "./stylebrief.js"; const FONT_LINK = (family) => ``; const doc = (head, css, body) => `${head}${body}`; @@ -295,11 +296,13 @@ export function recipeFragment(name) { export function recipeIndex() { const rows = Object.entries(STATIC).map(([k, v]) => `${k}: ${v.summary}`); rows.push(`looks: ${looks.summary}`); - return `Motion recipes. Call get_motion_recipe with name=:\n${rows.join("\n")}\nlook: (${LOOKS.map((l) => l.id).join(", ")}): one look in full.`; + rows.unshift("style-brief: mandatory first step: brief, branding, 3 concepts, anti-slide rules (no fragment)."); + return `Before designing an original video: call get_motion_recipe with name=style-brief and follow it (brief, 3 concepts, wait for the user to pick).\n\nMotion recipes. Call get_motion_recipe with name=:\n${rows.join("\n")}\nlook: (${LOOKS.map((l) => l.id).join(", ")}): one look in full.`; } /** Text of a recipe, or null when the name is unknown. */ export function recipeText(name) { + if (name === "style-brief") return STYLE_BRIEF; if (STATIC[name]) { const r = STATIC[name]; return `# ${name}. ${r.summary}\n${r.notes.map((n) => "- " + n).join("\n")}\n${fragmentText(r.fragment)}`; diff --git a/src/stylebrief.js b/src/stylebrief.js new file mode 100644 index 0000000..fb38f06 --- /dev/null +++ b/src/stylebrief.js @@ -0,0 +1,49 @@ +/** + * Style brief: the mandatory step before designing an original video (get_motion_recipe, name "style-brief", + * and the head of the recipe index). Ideas adapted, not copied, from bangtutorial/bang-motion (MIT License, + * Copyright (c) its authors): SKILL.md style brief, references/anti-ppt.md and references/opener-konsep.md. + * Also the soft "looks like slides" check validate_json runs (warnings only, never errors). + */ +export const STYLE_BRIEF = `# style-brief. Mandatory before designing an original video (not for gallery recreations, which follow get_recreation_brief). +Talk to the user in their language (Spanish if they write in Spanish). Ask only for what you cannot deduce, in one message. + +1. Brief: gather or deduce +- product or theme, the one message, the audience; +- format: 1:1 1080x1080, 9:16 1080x1920 or 16:9 1920x1080; duration in seconds; +- branding: palette of 3-5 hex colors and where each came from (logo, website, what the user said); one title font and one body font (Google Fonts); tone (premium, playful, technical…). +If the user has no brand, propose one (palette, fonts, tone) and ask them to approve it before going on. + +2. Three concepts, then wait +Propose 3 concepts, each = a style direction + a structure, e.g. "editorial claro + afirmación → cómo → resultado", "UI en uso + problema → demo → CTA", "tipografía cinética + pregunta → giro → logo". One line each on why it fits this brief; mark one as recommended. Do not build or call create_hcmotion until the user picks one: a rejected full video costs far more tokens than three lines. + +3. Not a slide deck (validate_json warns SLIDE_LIKE_* when it can tell) +- One continuous world: scenes change because the camera or the world moves, not as isolated title + fade sections. +- A persistent brand element (logo, shape, color field, product object) stays on screen across scenes. +- The camera moves toward what matters: a camera layer with zoom/x/y changes (see get_motion_recipe camera), not a static frame. +- Text lives inside the scene (on objects, surfaces, UI), one short line per beat, not a floating title + subtitle + bullets. +- Nothing inherited from a base template or a previous project: palette, fonts and motion come from this brief. A reference video gives rhythm and energy, not its skin. + +Pass the approved brief to create_hcmotion (field brief) so Studio keeps it with the project.`; + +const isObj = (x) => !!x && typeof x === "object" && !Array.isArray(x); +const num = (x) => typeof x === "number" && Number.isFinite(x); + +/** Cheap, conservative heuristics; only on compositions with 3+ scenes. Never errors. */ +export function checkSlides(comp) { + const out = []; + if (!isObj(comp) || !Array.isArray(comp.scenes) || comp.scenes.length < 3 || !Array.isArray(comp.layers) || !num(comp.duration)) return out; + const layers = comp.layers.filter(isObj); + const hasCamera = layers.some((l) => l.type === "camera"); + const types = comp.scenes.slice(1).map((s) => (isObj(s) && isObj(s.transition) ? s.transition.type : "cut")); + const allFadeOrCut = types.every((t) => t === "fade" || t === "cut" || t === undefined); + if (!hasCamera && allFadeOrCut) + out.push({ path: "$.scenes", code: "SLIDE_LIKE_SCENES", message: "Every scene change is a fade or cut and there is no camera: it may read as slides. Consider camera moves toward the focus and varied transitions (get_motion_recipe style-brief). Keep it if deliberate." }); + return out; +} + +/** Normalizes the optional create_hcmotion brief: an object (kept as is) or a string (as {text}). Returns undefined when absent or unusable. */ +export function normalizeBrief(brief) { + if (typeof brief === "string") return brief.trim() ? { text: brief.trim().slice(0, 4000) } : undefined; + if (isObj(brief)) { const s = JSON.stringify(brief); return s.length <= 8000 ? brief : undefined; } + return undefined; +} diff --git a/tests/recipes.test.js b/tests/recipes.test.js index 3ca8374..345aaa8 100644 --- a/tests/recipes.test.js +++ b/tests/recipes.test.js @@ -128,3 +128,32 @@ test("create_hcmotion returns the checklist once the file is created, and none w const bad = await call("create_hcmotion", { json: { version: 1 } }); assert.equal(JSON.parse(bad.content[0].text).checklist, undefined); }); + +test("style-brief: in the index, returned by name, not a fragment recipe", async () => { + const { motionRecipe, RECIPE_NAMES } = await import("../src/recipes.js"); + assert.ok(motionRecipe().text.includes("style-brief")); + const r = motionRecipe("style-brief"); + assert.ok(r.found && r.text.includes("3 concepts") && r.text.includes("create_hcmotion")); + assert.ok(!RECIPE_NAMES.includes("style-brief")); +}); + +test("checkSlides warns (never errors) on 3+ scenes with only fades and no camera", async () => { + const { validateJson } = await import("../src/contract.js"); + const L = (id, s, e) => ({ id, type: "rect", start: s, end: e, props: { width: 100, height: 100, fill: "#fff" } }); + const base = { version: 1, name: "s", width: 1080, height: 1920, fps: 30, duration: 9, background: "#000", params: {}, + scenes: [{ id: "a", name: "a", start: 0, end: 3 }, { id: "b", name: "b", start: 3, end: 6, transition: { type: "fade", dur: 0.5 } }, { id: "c", name: "c", start: 6, end: 9, transition: { type: "fade", dur: 0.5 } }], + layers: [L("a", 0, 3), L("b", 3, 6), L("c", 6, 9)] }; + const r = validateJson(base); + assert.ok(r.valid); + assert.ok(r.warnings.some((w) => w.code === "SLIDE_LIKE_SCENES")); + const withCam = validateJson({ ...base, layers: [...base.layers, { id: "cam", type: "camera", start: 0, end: 9, props: { zoom: 1 } }] }); + assert.ok(!withCam.warnings.some((w) => w.code === "SLIDE_LIKE_SCENES")); +}); + +test("create_hcmotion brief is optional and stored as meta.brief", async () => { + const { normalizeBrief } = await import("../src/stylebrief.js"); + assert.equal(normalizeBrief(undefined), undefined); + assert.equal(normalizeBrief(" "), undefined); + assert.deepEqual(normalizeBrief("marca X"), { text: "marca X" }); + assert.deepEqual(normalizeBrief({ palette: ["#000"] }), { palette: ["#000"] }); +});