Search by

concept24 / nova-ai-pagebuilder

mihaictinescu

AI Page Builder tool for Laravel Nova — conversational agent that builds landing pages (Page + blockText bands) with product selection, generated copy and images.

Package info

bitbucket.org/concept24/nova-ai-pagebuilder

pkg:composer/concept24/nova-ai-pagebuilder

Statistics

Installs: 39

Dependents: 0

Suggesters: 0

v0.9.6 2026-10-30 11:53 UTC

This package is auto-updated.

Last update: 2026-10-30 11:55:12 UTC


README

AI Page Builder tool for Concept24 CMS — conversational multi-model agent that turns a natural-language brief into a complete draft landing page (Page + blockText bands, product selection, multi-locale copy, generated images), built on Laravel Nova.

Requirements

  • PHP >= 8.4
  • Laravel 12 or 13
  • Laravel Nova 4.12+ / 5.x
  • OpenRouter API key
  • A C24CMS application — the package writes into the host app's Page / BlockText models and product catalog (not portable to other Laravel apps, same tradeoff as nova-ai-report)

Installation

composer require concept24/nova-ai-pagebuilder

The service provider is auto-discovered. Migrations run automatically via loadMigrationsFrom. Publish config manually:

php artisan vendor:publish --tag=nova-ai-pagebuilder-config

Host columns. The builder writes into the host's site_block_texts.html / .css, site_block_texts_trans.strings and site_pages.campaign_skin. The package ships database/migrations/2026_10_13_120000_sync_pagebuilder_freeform_schema.php, which adds each of these columns only if it is missing — so an older shop forked before the freeform work gets them with a plain php artisan migrate, and an up-to-date one is untouched. To copy the package migrations into the project's own database/migrations: php artisan vendor:publish --tag=nova-ai-pagebuilder-migrations (same file names, so they never run twice).

Register the tool in NovaServiceProvider::tools() and add a MenuSection pointing to /nova-ai-pagebuilder.

Access. The tool page and every nova-vendor/nova-ai-pagebuilder/* route require a Nova login, the viewNova gate and the tool's own canSee — restrict it when registering, e.g. (new NovaAiPagebuilder)->canSee(fn ($request) => (bool) $request->user()?->hasRole('Super Admin')). Guests get a redirect to the Nova login (page) or 401 (API); Nova users failing canSee get 403. tests/Feature/RouteAccessTest.php walks all registered routes, so new routes are covered automatically.

Configuration

config/nova-ai-pagebuilder.php:

KeyEnv varDefault
openrouter.api_keyOPENROUTER_API_KEY—
models.coordinatorNOVA_AI_PB_MODEL_COORDINATOR~anthropic/claude-opus-latest (0.9.5; was google/gemini-3.8-flash, still in the options list)
models.sqlNOVA_AI_PB_MODEL_SQL~anthropic/claude-sonnet-latest
models.copyNOVA_AI_PB_MODEL_COPY~openai/gpt-astra-latest
models.htmlNOVA_AI_PB_MODEL_HTML~anthropic/claude-opus-latest
models.coordinator_fallback—~anthropic/claude-sonnet-latest
models.startersNOVA_AI_PB_MODEL_STARTERS~openai/gpt-mini-latest — campaign ideas; «Other ideas» may pick any model_options.copy model
starters_warm_atNOVA_AI_PB_STARTERS_WARM_AT05:30 — daily nova-ai-pagebuilder:warm-starters run (needs the host's schedule:run cron); null = off
black_fridayNOVA_AI_PB_BLACK_FRIDAY— the shop's Black Friday (Y-m-d) as announced on its market (eMAG 2026: 2026-11-06); empty = ideas say «Black Friday» with no date
models.video / video.modelNOVA_AI_PB_MODEL_VIDEObytedance/seedance-2.5
model_labels—dropdown labels for the ~…-latest aliases (not in the live /models catalog)
freeform.visual_qaNOVA_AI_PB_FREEFORM_VISUAL_QAtrue
models.imageNOVA_AI_PB_MODEL_IMAGEgoogle/gemini-3.1-flash-image
model_options.*—per-role allowlists for the UI dropdowns. Text roles carry the OpenRouter latest aliases (~anthropic/claude-{sonnet,opus,fable}-latest, ~openai/gpt-{astra,sol,terra,mini}-latest) next to Gemini; video lists Seedance 2.5 / 2.0 / 2.0-fast, Wan 2.6 / 3.0 and HeyGen Video
brand_profile.enabledNOVA_AI_PB_BRAND_PROFILEtrue
brand_profile.max_charsNOVA_AI_PB_BRAND_PROFILE_MAX_CHARSno cap (null/0)
brand_profile.media_max_chars—1500 (images / video / voice are fitted to it)
max_tool_iterations—30
page_defaults—draft (is_public=0, is_active=1), layout_default
locales—null → config('app.allowed_languages')
layouts.exclude—non-content bands excluded from the AI palette
images.max_per_page—12
rate_limitNOVA_AI_PB_RATE_LIMIT30,1

Minimum .env:

OPENROUTER_API_KEY=sk-or-...

Features

  • Clarify-first gate: for complex or ambiguous requests the coordinator asks the questions it needs (max 4, with proposed defaults) and makes no tool call until the user answers; simple, complete questions are answered directly.
  • BI consultant stage (select_products in research mode): bestsellers / worst performers, FĂRĂ MIȘCARE (dead stock with immobilised value), MIȘCARE LENTĂ, SUPRASTOC / STOC CRITIC (days of cover), bought-together pairs, bundle proposals (slow mover + bestseller, both in stock, no invented discounts) and campaign product proposals that flow into propose_structure. The SQL agent receives the real orders interval and the excluded (cancelled/returned) status ids computed from the DB, so an empty calendar window is reported honestly and analysed on the last days of activity; one time window per analysis.
  • Conversion skeleton for proposals: hero + image context band → bestsellers grid → benefits/guide → secondary products on an image layout (catalog_09/catalog_08) → FAQ → final CTA band; generate_image is dropped server-side on layouts that cannot render a generated image.
  • Conversational flow with consent gates: short interview → live layout catalog → proposed band structure (requires explicit user approval) → generation → data entry → links.
  • Per-agent model dropdowns in the chat UI (Coordinator / DB queries / Copy / Images), config defaults + per-conversation override stored on the conversation (models json), validated server-side against per-role allowlists.
  • Product selection agent (select_products): read-only SQL sub-agent (sanitized SELECT/WITH only, LIMIT cap, sensitive-column masking, session execution timeout). Supports curated pinned SKU selections (incl. best/worst-sellers via site_orders_items) and dynamic filter specs (categories/brands/in_promo/last_added) consumed by the existing BlocktextRepository pipeline.
  • Copy generation in all active locales (native per-locale writing, strict JSON, SEO meta limits enforced).
  • Image generation via OpenRouter multimodal chat (modalities: ['image','text']), persisted per-conversation to tmp storage and attached to bands via Spatie MediaLibrary by the applier.
  • Deterministic data entry (LandingPageApplier): validates the full plan before any write (layouts on disk, min/max items, image refs, SKU existence), then creates the draft Page + bands + translations + media in one pass and flushes catalog/media cache tags. Draft pages are previewable on the frontend by logged-in admins (Page::scopePublic).
  • Composition canvas (drag & drop): split view next to the chat — a layout palette (thumbnails + badges), band cards with per-band instructions, image toggle, variant select and product drop-zones; product chips from chat research are draggable onto bands; the agent's numbered structure proposals render as an importable card (layout choice stays with the user); "Generate page from composition" builds exactly the user's canvas (1:1 validation in the applier, pinned-product specs auto-filled from the canvas).
  • Proposal → canvas handoff: propose_structure carries the SKUs already chosen with select_products (validated server-side against active products), the per-band generated-image flag and the page-hero flag; "Import into the composition" pins them on the bands, so generation runs with no second product selection.
  • Server-side plan assembly: create_landing_page is called WITHOUT a plan — PlanAssembler builds it from the canvas composition, the last successful generate_copy (band refs in canvas order) and the generated images (band image ref = band copy ref, hero, <ref>_<n> per item); the coordinator only sends products_by_band for product bands without pinned products. The explicit plan argument remains supported.
  • Live progress: sub-agents report steps over SSE (tool_progress: each SQL query with its purpose and row count, copy attempts, parallel image batch) and running tool cards show an elapsed-time counter.
  • Parallel images: all generate_image calls of one coordinator turn run as one HTTP pool (ImageGenerator::generateMany).
  • Product-aware copy: the copy agent receives the title, price and stock of the products pinned on each band and may name products/prices only from that list.
  • Freeform bands: the freeform layout (free HTML + Tailwind + data-anim, live product data) sits in the palette next to the classic bands and can be mixed with them. The coordinator calls generate_freeform_band(nr, brief, products?, feedback?) per band after generate_copy; the html model (default Claude Opus 5.5, own dropdown) returns HTML + per-locale strings, FreeformHtmlValidator REJECTS dangerous markup, hardcoded visible text, unknown c24-each sources / data-anim names and missing string keys (retry with feedback), PlanAssembler carries html/strings/button/products into the plan and LandingPageApplier writes them through the model (sanitised, CSS job fired). After create_landing_page, FreeformVisualQa screenshots the freeform bands (desktop + mobile, scripts/band-shots.cjs, throwaway admin session for the draft, destroyed right after; since 0.9.0 its cookie reaches the script through the NAPB_QA_COOKIE environment variable, never the command line, which every OS user can read in ps) and runs App\Ai\Agents\BandsVisualCritic; qa.problems drive one revision round. QA compared with the site (0.8.1): the script also screenshots a reference page of the site (freeform.qa.reference_url / NOVA_AI_PB_QA_REFERENCE_URL, default: first category with products) which the critic receives next to each band („banda trebuie să arate ca parte din acest site”), and measures each band — biggest font (> 1.2 × the reference h1), product image width (> 320px), backdrop behind product images (not white-ish), page background (not light unless the brief asked for dark) and, with motion ON and software GL, text covered by the WebGL canvas — turning the numbers into deterministic problems merged with the critic's; a band still covered after the revision round loses its data-gl* hooks (qa.repaired). Edit mode (0.6.0): FreeformBandGenerator::edit(html, strings, instruction, scope?, model?, sources, userId) applies an instruction to an existing band (diff-style prompt: everything not asked for stays identical; with scope only that element changes; new copy goes into strings for ALL locales), validated by the same validator with retry-with-feedback; logged as phase html with no conversation. Used by the host app's Nova preview chat. Needs PHP 8.4 (Dom\HTMLDocument) and puppeteer in the host app. Blog/categories/brands/benefits sources are linked manually in Nova.
  • Background runs: a chat run no longer dies when the tab is closed or the user navigates to another Nova menu — the stream ignores client disconnects, so the page, the visual QA and the final message are saved anyway. While it runs, a cache marker makes GET conversations/{id} return running: true; reopening the conversation shows it live and re-polls every 4s until it ends. Stop posts POST conversations/{id}/cancel, checked before each coordinator turn (the tool in flight still finishes and saves its result). A tool call left without a result by a dead run shows as „întrerupt", not as an endless spinner.
  • Page preview drawer (0.7.0): a "Previzualizare pagină" button (chat toolbar and the page-result card) opens a 98vw admin-themed drawer (Esc/×, focus trap, scroll lock) with an iframe of the real draft page (Desktop/Tabletă/Mobil + Reîncarcă) and the page's bands in order (layout, title, "Editează în Nova", click scrolls the iframe to the band). It reloads after every create/update in the chat. Data: GET conversations/{id}/page-preview. Scrolling needs the host app's data-block-id marker (rendered only for viewers who can open Nova).
  • Pick an element → edit (0.7.0): "Alege element" in the page preview resolves the clicked element to its band (nearest preceding data-block-id marker) and, for freeform bands, to the stored element (data-pv, also admin-only). Freeform band → POST conversations/{id}/band-edit (BandEditService::proposeFreeform → FreeformBandGenerator::edit, nothing saved) shown as a proposal in the drawer; Acceptă → POST conversations/{id}/band-apply (applyFreeform: re-validated, written through the model → sanitised html, CSS job, strings per locale, caches flushed). Classic band → an [EDITARE BANDĂ] block_id=… message in the chat; the coordinator calls generate_copy for that single band and the new tool apply_band_copy(block_id, ref) (BandEditService::applyCopy), which writes only that band's texts (and item texts, same count) without regenerating the page. Both refuse bands that don't belong to the conversation's page.
  • data-anim="carousel" (0.7.0): the freeform vocabulary (validator ANIMATIONS + prompt) gained carousel — a wrapper with a [data-anim-track] of slides (typically one product per slide via c24-each), optional [data-carousel-prev|next], [data-carousel-dots] and data-autoplay; native scroll-snap without JS, the host app's motion layer adds buttons/dots/keyboard/autoplay.
  • Design-system rule for freeform HTML (0.7.0): the HTML agent inherits the site's design system. FreeformTheme reads the token names/values and the site font from the host app's resources/css/theme.css at runtime; the prompt shows a semantic palette (token → role) and the rule "colours only through theme utilities (bg-brand, text-ink, from-surface-dark, /opacity and variants OK), site font only, radius from the scale". FreeformHtmlValidator rejects arbitrary colour values (bg-[#…], rgb(/hsl(/oklch(/color-mix( in any colour utility, incl. variants), Tailwind default-palette families (bg-red-500, text-gray-900), font-[…], inline style colour/font declarations and literal SVG fill/stroke; white/black/transparent/current, currentColor and none stay allowed. The feedback lists the offenders and the allowed tokens for the retry. In edit/apply of an existing band, violations already present in its html are tolerated — only NEW ones are rejected.
  • Art direction for freeform campaigns (0.7.0): coordinator tool design_campaign (ArtDirectionGenerator, html model, called once before any band) returns concept, a 6-colour palette (bg/surface/ink/accent/accent2/muted, valid hex, WCAG ink-on-bg and ink-on-surface >= 4.5), a display font from the host's config('campaign.display_fonts'), 2-3 motifs, a motion signature and a band rhythm plan (closed set of composition types, never 3 identical in a row), validated with retry-with-feedback. PlanAssembler turns it into the page's campaign_skin ({palette, display_font}, written by LandingPageApplier when site_pages.campaign_skin exists) and every generate_freeform_band / edit call receives it as a shared brief with the band's own rhythm slot. The HTML prompt gained editorial composition rules, product imagery treatment (mix-blend-multiply on campaign surfaces), generated full-bleed images, the campaign effects vocabulary (intro, reveal-image, story, curtain, scramble, zoom, data-cursor) and the campaign tokens (campaign-*, font-display); the validator knows the new names and enforces one intro per band and 2+ data-story-step per story. Shipped examples rewritten to this level.
  • Multi-conversation chat UI (SSE streaming, tool-call cards with image thumbnails, final page-result card with frontend + Nova links), per-phase cost logging (coordinator/sql/copy/image).
  • Generated video + voice-over (0.7.0): coordinator tools generate_video (Seedance via OpenRouter /videos, real product pictures as input_references, generate_audio=false; optional ElevenLabs voice via /audio/speech, script written by the copy model in <= 12 words or taken from the canvas) and apply_band_video. ffmpeg composes a voice-over mp4 (-16 LUFS, +faststart), a muted web loop (<= 6 MB) and a poster; files are staged per conversation (VideoStaging) and attached to the band as video_loop / video_full / video_poster (freeform html reads {{video.loop|full|poster}}). New model roles video / voice, a canvas panel (VideoOptionsPanel: toggle, per-band clips, duration, voice + slogan) stored in composition.video, prompt partial 09_video, log phase video (migration 2026_10_10_000002) and a video config block (presets hero 10 s/1080p, accent 6 s/720p, ffmpeg paths). Requires ffmpeg/ffprobe on the host. Real cost: about $3.76 for a 10 s 1080p hero with voice, about $0.91 per 6 s 720p clip.
  • Video models and capability fitting: bytedance/seedance-2.5 (default), bytedance/seedance-2.0, bytedance/seedance-2.0-fast, alibaba/wan-2.6, alibaba/wan-3.0, selectable per conversation. Models differ in resolutions and durations (e.g. Seedance 2.5: 480p/720p, 4-30 s; Wan 2.6: 720p/1080p, 5 or 10 s; Wan 3.0: 480p-1080p, 2-30 s), so Video\VideoModelCapabilities reads GET /videos/models (cached 24 h, key napb:video-models) and adapts each request before submit: highest supported resolution not above the requested one (else the lowest), nearest supported duration (ties go to the shorter). Without catalog data the request is sent unchanged. generate_video reports the effective video_duration / video_resolution. Presets (hero 10 s/1080p, accent 6 s/720p) are therefore only targets.
  • Background music + voice style (0.9.0): generate_video accepts music: {enabled, prompt?} (hero only) and voice.style. Video\MusicClient calls Google Lyria 3 (models.music, default google/lyria-3-clip-preview, also …-pro-preview; own dropdown role) through OpenRouter POST /chat/completions with stream: true, modalities: [text, audio], audio.format: mp3 and joins the base64 delta.audio.data pieces into an mp3 (~30 s stereo 44.1 kHz, ~$0.04 from the last chunk's usage.cost); logged in the usage table with phase music (migration 2026_10_16_000001 adds it to the enum — run php artisan migrate). MusicPromptWriter: the user's description (canvas video.music.prompt or an explicit chat request) is the style VERBATIM, otherwise it is derived from the campaign theme (art direction concept + motifs + the clip scene); it always appends instrumental / no vocals / commercial bed / clip length. CampaignVideoComposer mixes the bed into the FULL version only (the muted web loop stays silent): trimmed or looped to the clip length, fade in 0.4 s / out 1 s, two-pass loudnorm to -16 LUFS alone, -26 LUFS under a voice-over with sidechain ducking (video.music.* in the config). A band has {{video.full}} + the sound button when it has voice OR music (the HTML agent is told which per band); if the music call fails the clip is kept without it and the result carries music_error. Voice style: video.voice.style (free text: warm, feminine, mysterious, whispered…) is mapped by the copy model to one of the voices in video.speech.voices (the ids OpenRouter accepts for the ElevenLabs models, validated against the list; unknown → default brian) plus up to two inline audio tags ([whispers]) only for models in video.speech.tag_models (v3/v4 — other models would read the tags aloud). Canvas: "Muzică de fundal" switch + melody textarea, voice "how should it sound" textarea.
  • Site design system (0.8.0): the palette comes from the host theme tokens (palette_mode site | free), the heading font from the site, and FreeformTheme is now a contract reader (semantic tokens + @role annotations in the host theme.css); design_campaign derives the art direction from them.
  • Motion intensity (0.8.0): calm | balanced | dynamic — chosen per conversation, steers the art direction, the animation density in freeform bands and the WebGL scene limits per page.
  • WebGL in freeform bands (0.8.0): hook vocabulary data-gl="distort|reveal|scroll-wave|slides" and data-gl-scene="gradient|blob|particles", validated by the freeform validator, taught in the HTML prompt (WebGL + intensity sections), with page scene limits per intensity. The WebGL runtime (motion/gl) lives in the host app, not in the package.
  • Model aliases "latest" (0.8.0): model selectors accept latest aliases resolved at call time; new video models Seedance 2.5 (default) / 2.0 / 2.0-fast and Wan 2.6 / 3.0 with capability fitting (resolution/duration clamped to what the model supports).
  • Design mode Site / Creativ (0.8.1): per conversation, composition.design_mode = site (default) | creative (Conversation::designMode()). Site: the page follows the site's design system — type scale (text-h1…text-body, nothing above text-4xl, validated), products only through <c24-product-card>, light palette (dark bands only as accents), campaign font only on request, WebGL only when the brief asks, no mix-blend-* on images. Creativ: the pagebuilder 0.7 style — the art director asks for the campaign's own free palette (palette_mode: "free", #rrggbb; site tokens are only inspiration) and a campaign display font from the list by default, plus the 0.8.0 editorial behaviour (big editorial type, wide images, dark pages allowed). WebGL only on explicit request in BOTH modes (gl_signature is forced to none/none unless the brief asks; FreeformBandGenerator::webglAllowed, the PlanAssembler backstop and the prompt's WebGL section all follow the signature). The run lock (napb:run:{id}) has a 300s TTL refreshed at every coordinator turn and emitted event, so a killed run frees the conversation in at most 5 minutes. The HTML prompt is the 0.8.0 text plus the common rules, with its own examples (freeform_examples/site|creative). Both modes: no outline-only text (text-transparent + text-stroke), muted text contrast >= 4.5 on bg/surface, no mix-blend-* on images, data-video / {{video.*}} only on a band that has a staged/attached video. The mode is read by the art direction (a direction made in the other mode is ignored and design_campaign re-runs), the band generator (create + edit), the validator (also at apply time), PlanAssembler and the visual QA. QA by mode: the site reference screenshot and the fidelity metrics (font cap, product image size, light page) run in Site only; covered-by-WebGL, product image backdrop and horizontal overflow run in both, and a failed reference capture no longer stops the QA (the band is judged without the comparison).
  • Band variety (0.9.0): ArtDirectionGenerator::BAND_TYPES has 14 compositions (the original 8 + countdown-offer, tabbed-cabinet, card-stack, ledger, legend, poster; each described in one line in the art-director prompt, countdown-offer only when the brief mentions a deadline). Rhythm validation: no two consecutive identical types, a type at most twice per page, >= 4 distinct types from 4 bands. The HTML agent gets, per band, the overview of all types, the recipe (composition + 1-2 own effects) and the single example of ITS rhythm type (freeform_examples/{site,creative}/<type>.html, FreeformPrompt::exampleNames(); hero band of another type also gets full-bleed; types without a file fall back to product-spotlight). New hooks, validated: data-anim="view-reveal" (CSS-only scroll reveal), data-countdown="<ISO 8601 with timezone>" (+ optional [data-countdown-days|hours|minutes|seconds|expired] children, which must sit inside it), data-tabs (every <button type="button" data-tab="k"> pairs with a [data-tab-panel="k"]), and <c24-each offset="N"> (integer >= 0, skips the first N items). Runtime for these lives in the host app. Global rules: split-lines only on the hero + at most one other band; the numbered section kicker only for editorial-split / statement.
  • Effect controls (0.9.0): a collapsible «Efecte» section in the canvas (EffectsPanel) stores in the composition effects (the ALLOWED family keys; a missing key = all, so old conversations keep working), motion_intensity (auto | calm | balanced | dynamic) and video.accentClips (0-4, default 2, shown next to «clips on category bands»; replaces the old fixed "max 2" in prompt 09_video). Services\EffectFamilies is the single map family → data-anim values / data-micro values (lift, tilt only: buttons always keep fill press) / data-* attributes / band types: titles (split-lines, reveal-text, scramble), scroll_reveals (fade-up, stagger, view-reveal, reveal-image, zoom, reveal-mask), parallax_drawings (parallax, draw-svg, morph-svg), moving_bands (marquee, carousel, pin-scrub; band types marquee-strip, carousel), story (story; sticky-story), counters (counter, data-countdown; countdown-offer), tabs (data-tab; tabbed-cabinet), card_micro, cursor (data-cursor), webgl (data-gl; the direction's gl_signature is forced to none/none), smooth_scroll (runtime, no HTML hook; video keeps its own video.enabled toggle). Enforced in four places: FreeformPrompt teaches only the allowed vocabulary and lists the forbidden hooks (recipes of band types that need a disabled family are dropped); FreeformHtmlValidator::validate(..., ?array $effects = null) (null = all, hooks already in the stored band are tolerated) rejects hooks of a disabled family with a Romanian message — threaded from the conversation in FreeformBandGenerator generate/edit, BandEditService and LandingPageApplier; ArtDirectionGenerator removes the dependent band types from the prompt and the validation (the "min 4 distinct types" rule shrinks to the types that remain) and a non-auto motion_intensity replaces the director's (also re-applied when the stored direction is read, PlanAssembler::artDirection); smooth_scroll off writes smooth_scroll: false into the page's campaign_skin (also on pages without art direction, where the skin carries only that flag), which the host's campaign-skin partial turns into <meta name="smooth-scroll" content="off"> so the motion index skips Lenis. Endpoint: PUT /conversations/{id}/composition validates effects.* (in the family keys), motion_intensity, video.accentClips (0-4).
  • Header + footer in the campaign colours (0.9.0): a switch «Header and footer in the campaign colours» in the Style tab, right under the design-mode choice (hint: only on the campaign page; the rest of the site stays unchanged), plus a «Coloured header» chip in the summary strip. Stored in composition.campaign_chrome (nullable boolean, validated by updateComposition): missing/null = follows the design mode (creative on, site off, Conversation::campaignChrome()); an explicit value wins, and the canvas only sends one after the user touched the switch, so switching the mode moves an untouched switch with it. PlanAssembler writes campaign_skin.chrome = true only when enabled (absent otherwise, like smooth_scroll; a skin made of flags alone skips the palette checks in LandingPageApplier). The host app renders it: data-campaign-chrome on the site header and footer, remapping the site tokens to the campaign palette (contract in the app's docs/CLAUDE-FRONTEND.md). The host's new Logo Dark setting (APP_LOGO_FRONT_DARK) is the logo used on a dark campaign header; without it the Light logo sits on a light island.
  • Effects v2 (0.9.3): seven new effect families plus two extensions, vocabulary shared with the host motion runtime (resources/js/motion/fx.js; contract in the host's tasks/effects-v2-contract.md). EffectFamilies gains paths (motion-path, orbit), gestures (drag-deck, flip-card, compare), pointer_fx (spotlight, image-trail, cursor-preview, data-micro="glow"), kinetic_text (typewriter, char-wave, marker, rotate-words), layout_fx (flip-filter, expand-media, stack-scroll), scroll_physics (velocity-skew, scroll-progress) and living_grids (grid-ripple); counters gains odometer and webgl the scenes dither and waves (also valid in the art director's gl_signature; calm forbids dither). A conversation without the new keys in composition.effects is unchanged in meaning (missing = all allowed). FreeformHtmlValidator enforces the structure each hook needs (item counts incl. <c24-each limit>, required children, <button> controls, text-only / length caps of typewriter 140 and char-wave 48 resolved from strings, grid-ripple size, odometer numbers) with Romanian retry messages, and data-gl is refused inside the new transforming effects. FreeformPrompt teaches one line per effect (canonical markup, at most 2 per band); in site mode expand-media, stack-scroll and velocity-skew only at dynamic. The canvas «Effects» list shows the seven new families (resources/js/effects.js, lang keys).
  • WebGL request signal (0.9.3): WebGL stays default-off, but ArtDirectionGenerator::asksForWebgl also counts a brief that explicitly asks for ALL effects / effect families / motion elements (asksForAllEffects: „toate familiile de efecte”, „toate elementele de motion”, „all effects”, „every effect”, „all the motion”; with or without diacritics). The canvas webgl family still wins: disabled there means no WebGL. Deterministic placement: the scene band is the first composition band without a staged video (FreeformBandGenerator::sceneBand; a full video background hides the canvas). The band prompt says mandatory data-gl-scene="{scene}" on that band and „scena e pe banda N” on the others, bands with a large image may put the direction's data-gl image effect on ONE image, and a scene band whose HTML lacks data-gl-scene gets a correction round. A particles scene on a scene band without a gallery source (needs <img data-gl-source>) falls back to gradient. With an all-effects brief the direction carries all_effects and every band is told to use 1–2 effects-v2 hooks (allowed families only), varied band to band.
  • Video sound effects (sfx): generate_video accepts sfx: {enabled, prompt?} on hero and accent clips (a prompt per clip written from its brief; music stays hero-only; composition video.sfx.*, canvas «Efecte sonore» switch + textarea, MCP pagebuilder_apply_structure video.sfx). OpenRouter GET /videos/models exposes generate_audio per model (Seedance 1.5/2.x, Wan 2.6/2.7/3.0, Veo, Kling…; VideoModelCapabilities::supportsAudio); the client sends generate_audio: true only for such models (models listed without audio get no generate_audio field at all: HeyGen Video always renders a track and answers 400 to false; the composer drops a clip's own track unless sfx were asked for) and the scene's diegetic sounds (no speech/music) are appended to the clip prompt. Models without audio skip gracefully (sfx_note). CampaignVideoComposer takes the clip's own track as SFX at -16.5 LUFS (video.sfx.* in the config) with the music bed 9 dB under it (short fades), so the total stays ~-16 LUFS; the web loop stays silent; the band gets {{video.full}}. Cost: Seedance 2.x / Wan price audio the same (no surcharge); video.audio_price_factor covers models that charge more (Seedance 1.5 Pro x2).
  • Audio-copyright retry (0.9.3): when a clip with sfx fails because the provider refuses the generated audio (message about output audio and copyright; SeedanceVideoClient::isAudioCopyrightRefusal), the client retries the same job once with generate_audio off, in the one place every caller goes through. The clip is kept (silent; music/voice mixing is unchanged) and generate_video returns sfx_error (like music_error); both attempts are logged (the refused job as error, cost 0). Any other failure, or a refusal on the retry, fails as before.
  • Per-campaign choreography + anti-repetition:
    • Why: creative pages had converged on one pattern (WebGL waves after the hero, distort on the final image). The planning guide pinned effects to band positions; now it describes content roles only.
    • What the art direction returns: ArtDirectionGenerator returns a choreography, one {lead, support[], moment} per band, aligned with rhythm.
    • Rules validated server-side:
      • effects come only from the campaign's allowed families;
      • no lead is repeated on a page;
      • adjacent bands never share the family of their lead;
      • at most one gl:<scene> band, equal to gl_signature.scene;
      • creative pages get 1 moment (2 at dynamic), a pinned set-piece: video-scrub, rise-in-pin, expand-media, stack-scroll, pin-scrub or story. In Site mode, pinned effects appear only at dynamic;
      • pinned set-pieces are lead-only (never support), so each appears at most once per page;
      • at most 2 leads come from the recently used list;
      • nothing repeats the previous campaign's skeleton by position: band i never reuses that campaign's band-i lead (not even as support), and the moments and the WebGL band move to other bands.
    • Fallback: a missing choreography gets a deterministic fallback seeded by the conversation id.
    • Delivery: the coordinator copies choreography[i] into band i's brief, and FreeformBandGenerator also gives the HTML agent its band's line (coregrafie_banda).
    • Recently used effects (Support\RecentEffects): counts, per PAGE, the choreography LEADS stored by design_campaign over the last art_direction.recent_conversations campaign pages (default 6, 0 = off), plus the data-gl-scene scenes in their HTML (older pages without a choreography count only their scenes). Filler animations (fade-up, stagger…) are not counted, so they can't crowd out the leads. Ties rank the newest campaign first. The director sees «Folosite recent pe site», «Scene WebGL folosite recent» and «Ultima campanie de pe site» (RecentEffects::previous(), the previous campaign's choreography by position). A scene used on 2 or more of those pages is rejected unless the brief names it.
  • Scroll-driven video (video-scrub, rise-in-pin): new effect family scroll_video («Video la scroll», EffectFamilies + the canvas list in effects.js; hooks data-anim="video-scrub"|"rise-in-pin", attributes data-scrub* / data-rise*). video-scrub is the page's one big «discovery» moment: a pinned stage ([data-scrub-stage]) whose video[data-scrub-video] (src="{{video.scrub}}") is played by the scroll, with [data-scrub-text], [data-scrub-window], optional [data-scrub-end] / [data-scrub-caption], data-scrub-distance 150–300 and data-scrub-rise="0"; rise-in-pin keeps a [data-rise-text] headline pinned while a [data-rise-media] image or clip rises and fills the stage. The freeform validator enforces both contracts (and rejects video-scrub on a band without video, or whose clip has no scrub file); the HTML prompt teaches them with the dynamic-only note in site mode; the runtime lives in the host (resources/js/motion/fx.js).
  • Scrub clips: generate_video accepts scrub: true (the coordinator sets it when the choreography puts video-scrub on a band). The clip prompt gets «One single continuous slow camera move … by the end the subject fills the frame» after the no-text guard, and CampaignVideoComposer also writes {name}-scrub.mp4 (no audio, ≤ 1280 px, -g 4 -bf 0, CRF 24→33 until it fits video.scrub_max_bytes, default 10 MB) which apply_band_video / the applier attach as the video_scrub collection ({{video.scrub}}; a clip without scrub clears a stale one). The HTML agent is told {{video.scrub}} exists only for such bands.
  • distort removed from generation: data-gl="distort" is no longer in the WebGL vocabulary and the validator rejects it («efectul distort a fost scos: folosește reveal, scroll-wave, slides sau niciunul»); pages that already contain it keep working (no runtime or data change).
  • Workspace UX, step 1 (0.9.0): a campaign header above the workspace (CampaignHeader: title, status pill Draft / Running / Page created / Page deleted from has_page / page_deleted / running, mode, consumed cost, and the actions Preview, Page on site, Nova from GET page-preview; the old «Previzualizare pagină» button above the chat is gone) and a composition panel with a summary strip (bands, mode, motion intensity, video / music / voice, effects x/11) plus four tabs remembered per conversation in localStorage: Structure (compact BandRows, one BandCard editor open at a time, drag & drop unchanged, «+ Add band» opens the LayoutPalette sheet), Style (StylePanel: design mode, motion intensity chips, EffectsPanel as a grid of switches), Media (MediaPanel: hero image, video, music, voice cards with SwitchToggles; music and voice need the video) and Advanced (AgentModelsPanel, moved from under the chat). The former Products tab is gone, see «Products on the canvas». VideoOptionsPanel is replaced by MediaPanel. The bottom bar shows the estimated cost of generating the page: GET /conversations/{id}/estimate (Services\CostEstimator, read-only, from the stored composition) = bands x median html call + median coordinator+copy per conversation (last estimate.window_days days of the log table, estimate.* config fallbacks below min_samples) + images x estimate.image + video seconds x video.price_per_second[model] (null = unknown, listed instead of guessed) + estimate.music / estimate.voice. Fix: a drop on a band no longer also fires the canvas-level append/move-to-end handler (reorder and palette drops used to apply twice).
  • Workspace UX, step 2 (0.9.0): the tool opens on a campaigns screen (CampaignsScreen / CampaignCard): a card grid with the page thumbnail (hero video poster, else the first image of band 1, else the page banner, else a gradient of the campaign palette with the title over it), status pill (Draft / Running / Page exists / Page deleted / Error — Error = the latest coordinator log row failed; a user stop is a warning, not an error), mode, band count, cost, relative date, author, a «+ New campaign» card, title search, filters (All / Mine / With page / In progress / Creative / Site) and a ⋯ menu per card (Open, Rename inline, Delete with an inline confirmation). The left conversation column and the conversation tabs are gone: a campaign opens full width with a «← Campaigns» header button and ?c=<id> in the URL (?c=new = empty workspace, the conversation is still created on the first message; Back / Forward / reload restore it; Back remounts the tool through Inertia, a run in progress continues on the server and its card shows Running). Ctrl/Cmd+K opens a quick switcher (CampaignSwitcher: combobox + listbox, arrows / Enter / Esc, focus kept inside), Ctrl/Cmd+N a new campaign. GET /conversations keeps its fields and adds, batched in Services\CampaignSummaries (a fixed number of queries whatever the number of conversations, capped at 500 rows): has_page, page_deleted, running, last_run_status, campaign_status (running > error > created > deleted > draft), page_id, design_mode, band_count (page bands, else the composition's), thumbnail_url, palette (bg, surface, ink, accent as hex — site-palette tokens are resolved from theme.css) and meta.user_id (for the «Mine» filter).
  • Workspace UX, step 3 (0.9.0): the tool calls of one assistant turn (consecutive tool-call / tool-result messages) render as ONE «Steps» card (StepsCard, labels in resources/js/steps.js): collapsed it shows the overall status (✓ / spinner with the running step's label and live progress / ⚠), the step count, the total elapsed time, the error count with the failed steps' one-line messages, or «N products selected»; expanded it lists the steps (failed first, each with its duration) and each opens the old ToolCallCard body (embedded), where the select_products list stays draggable onto bands (it opens with the card). Page-created and structure-proposal results stay outside the card; the interrupted-call logic (hasLaterTurn) is unchanged. A long brief is clamped to ~4 lines with a «More / Less» toggle. Responsive: BuilderTab measures the width of the tool itself (data-bp = wide / tablet < 1180px / phone < 760px, no container queries, so fixed overlays are unaffected): tablet and phone show a «Chat | Composition (N)» switch (both halves stay mounted, so the input and the composition state survive), the header folds its actions into a ⋯ menu on phone with an icon-only back button, campaigns are 2 columns on tablet / 1 on phone with one-row scrolling filters, the quick switcher is full width and the preview drawer is full screen on phone.
  • Editing in the page preview (0.9.0): on a freeform band, a click on a text makes it editable in place (Enter saves, Esc cancels) and POST /conversations/{id}/band-text writes it at once ({block_id, locale, key|field, text}). «Edit with AI» on a picked element now asks the HTML model for that element only and splices it back with the host's FreeformPreview::replaceAt (elementAt returns its index n); the validator compares against the problems the band already had (baseline), so an old issue elsewhere no longer blocks a small edit. Measured: shortening a paragraph went from 77 s / $0.09 to 3.3 s / $0.002. The proposal is swapped into the live preview iframe (page CSS kept) with Accept / Discard; the drawer header links to the page on the site next to Nova.
  • Stop stops (0.9.0): every long step reports progress through one callback that checks the cancel flag and throws RunCancelledException (sub-agents, video polling, visual QA), so a run stops within about a second, mid video or mid QA; the tool result carries cancelled: true.
  • Motion rules in the generated HTML (0.9.0): buttons and CTA links never move on hover — the validator rejects data-micro="magnetic", lift / tilt on a button or CTA link, and lift / tilt on a card that contains a button (it would carry the button along); buttons use fill press. The intro and curtain animations are no longer in the vocabulary (no colour mask over the hero or a band). The legend recipe and the prompt forbid invented facts (stock, percentages, bundle prices, reviews not coming from the data). Carousel controls (data-carousel-prev|next|dots) may sit anywhere in the band, e.g. arrows in the band header, when the band has one carousel (the host runtime looks them up band-wide).
  • Products on the canvas (0.9.0): after every generation LandingPageApplier::syncCompositionProducts() mirrors the SKUs the agent put on each page band into composition.bands[i].products as {sku, title, source: "agent"} (titles from the catalog, order kept — product tabs slice by it), so the canvas shows what the page holds and a regeneration keeps the same products. Bands the user pinned are never overwritten; dynamic selections carry no SKUs and are not listed; nothing is copied when page and canvas no longer have the same number of bands (edited in Nova). Pages made before 0.9.0 are backfilled once when the composition is opened (composition.products_synced), so products the user cleared stay cleared. apply_band_products writes the same rows. In the band editor the agent's products show as outlined chips with a note, «Clear» lets the agent choose again, and «+ Add product» opens the catalog search inside the band (click or Enter adds; dragging from the chat still works). PUT …/composition accepts products.*.source (user | agent).
  • Coloured header on an existing page (0.9.0): switching «Header and footer in the campaign colours» on a campaign that already has a page writes campaign_skin.chrome straight onto that page (LandingPageApplier::applyChrome(), no regeneration, no cost); the coordinator has the same as tool set_campaign_chrome(enabled) and is told never to claim it has no access to the header. The canvas re-reads the choice after a run.
  • Safe chat markdown (0.9.0): assistant replies go through resources/js/markdown.js (marked with an HTML renderer that escapes raw HTML and a link/image renderer that drops javascript:, data: and other non-http(s)/mailto schemes) before v-html.
  • Campaign intro and tooltips (0.9.0): a new campaign opens on an animated AI orb (ChatIntro: breathes while idle, lights up on click, pulses while typing) with four starter prompts that fill the input without sending. The ⓘ tooltips are drawn as one fixed bubble on <body> kept inside the viewport (resources/js/tooltips.js), so the scrolling panes no longer clip them. The band instructions field is taller (6 rows).
  • Campaign ideas of the day (0.9.3): the new-campaign intro shows 12 ideas (Services\StarterSuggestions, GET starters) in an auto-advancing slider (6 per slide — 3 columns × 2 rows, fewer by the slider's own width via container queries; pauses on hover/focus, hidden tab, reduced motion). Each idea is a mini-brief — title, theme (what the moment means to the customer), visual mood, products, commercial goal (clearance / revenue / season_now / season_end / season_next / launch) + reason with real figures — and a click puts the whole brief in the chat. Context: date and month, meteorological season with its end and the next one, occasions of the next 120 days computed in PHP (fixed RO dates, Orthodox Easter, Rusalii, Mother's/Father's Day, the configured Black Friday + Cyber Monday; an unannounced Black Friday is passed as undated), and per main category stock, stock value, value of stock with no sale in 90 days and units sold in the last 30 vs previous 30 days (cancelled/returned/refused orders excluded, same rule as the SQL agent via SqlToolRunner::excludedOrderStatuses(); windows end at the latest valid order). Occasions of the next 45 days missing from the answer are asked for in one follow-up turn and replace ideas tied to none. Cached per locale for the day; warmed every morning by nova-ai-pagebuilder:warm-starters; «Other ideas» generates in the background after the response (dispatch()->afterResponse(), lock per locale, 10-minute back-off after a failure) while the intro polls and shows the elapsed time, with a model picker next to it. Own rate limiter napb-starters (an anonymous throttle shares the per-user bucket with every other route). The coordinator treats «Commercial goal: …» in a brief as settled and picks products by that goal's recipe; «Visual mood» seeds the art direction (accent over the site palette in Site mode).
  • Design mode follows the canvas (0.9.3): the campaign header badge shows the canvas' design mode also before the campaign exists, and settings made before the first message (mode, effects, bands) are saved when that message creates the campaign instead of being overwritten by the empty server copy.
  • The shop's AI profile (0.9.3): when the host binds concept24.brand_book (C24CMS AI Marketing), the coordinator, copy, art direction, freeform bands and campaign ideas get the profile's pagebuilder text at the end of their system prompt (copy may then use the profile's verified facts like the brief's), and generated images get its images text before the no-text guard; the campaign video's prompt gets the video text before its no-text guard; the voice-over line is written with the voice text (register, pace, the spoken form of the brand name), and the voice is cast from it when «Voice style» is empty (an explicit style still decides). No binding, no text, an error, or NOVA_AI_PB_BRAND_PROFILE=false: prompts are exactly as before. Texts have no cap by default; a text over a cap, and the images / video / voice text over 1 500 characters, is fitted (whole lines kept from the top, closing tag kept, one info line in the log) instead of dropped. The ideas of the day are cached per profile text. No migration.
  • Band images in the composition (0.9.4): a composition band may carry images (max 24 of {url, title?, link?, alt?}; url http/https, link http(s) or a path starting with /), so a landing built from another site's products needs no post-hoc gallery work. At page creation each image is downloaded and added to the block's gallery collection (custom properties title, url = link, alt = alt or title) BEFORE the generated image: offsets 0..N-1 are the given images, offset N the generated one. FreeformBandGenerator::sources() returns gallery for a band with images or generate_image, the validator gets count(images) + generated as the gallery size and the HTML agent's payload lists one line per gallery item (offset, title, link), so it can write <c24-each source="gallery" limit="1" offset="N"> with {{image_path}}, {{title}}, {{url}}; the visual QA sees the images too. The package never fetches URLs itself (SSRF): it resolves Concept24\NovaAiPagebuilder\Contracts\RemoteImageFetcher (fetch(string $url): array{bytes, name, extension}) from the container; unbound = images skipped (log + notes in the create_landing_page result), a failing image = skipped with a note, the page is still created. Composition key campaign_chrome_tone (light | dark | null) is written to campaign_skin.chrome_tone next to chrome (also on an existing page, without regeneration).
  • Campaign planning guide (0.9.5): Support\CampaignGuide::markdown(?array $effects) (long text in resources/guides/) is the method of a senior planner: input data to extract (products with brand, details, price + unit, old price, discount, stock, image >= 1024 px, link; messages and FAQ; palette; fonts; editorial images), the 8-10 band architecture (hero with video, numbers + brands band with the WebGL scene, top deals, tabbed listings, collection, story, configurator, info + FAQ, final CTA + locations; dark/light rhythm; accent clips with diegetic sounds; music on the hero only), the per-band brief template (2,000-5,000 characters: anchor, purpose, layout, EXACT effects, products with gallery offset and all data, exact texts, CTA + links, clip, card rules, readability), the effects vocabulary GENERATED from EffectFamilies + the HTML agent's own hook lines (only the families the user allowed), the quality checklist (CampaignGuide::qaChecklist(), reusable by the host's visual critic) and the Black Friday Delta Studio plan as a worked example. The coordinator reads it through the new tool get_campaign_guide (prompt partial 10_campaign_planning), and the coordinator default is now ~anthropic/claude-opus-latest. propose_structure points accept instructions (full brief, <= 5000) and images (same shape and validation as the composition's bands.*.images; the canvas import copies both onto the band); a proposed freeform band whose instructions are under 800 characters is returned to the coordinator with a pointer to the guide.
  • Reading a source site (0.9.5): coordinator tool read_source_page(url) fetches through the host-bound Contracts\RemotePageFetcher (fetch(string $url): array{url, html}, SSRF guard in the host, unbound = the tool says it is not available) and Support\SourcePageReader (pure PHP DOM) turns the HTML into compact text: title, description, a «Paletă și fonturi» block (CSS custom properties, theme-color, Google Fonts, most used font-family), then headings, paragraphs, links with absolute hrefs and images with absolute src + alt in document order, repeated lines dropped, scripts/styles/svg removed, capped at 60,000 characters. The coordinator then puts the products' images into the proposal's band images.
  • FreeformVisualQa::captureBands(Page $page, ?array $bands = null, string $viewport = 'desktop'): array (0.9.5): fresh screenshots of the freeform bands (desktop = 1366 px, mobile = 390 px) as [bandNr => absolute PNG path], for a review by someone else (the host's critic, MCP clients). Same throwaway admin session as the QA (the authenticated user), scripts/band-shots.cjs in shots-only mode (NAPB_QA_SHOTS_ONLY, NAPB_QA_VIEWPORT, NAPB_QA_WIDTH, NAPB_QA_BANDS): no reference page, no WebGL pass.
  • Coordinator on Opus for existing conversations (0.9.5): data migration 2026_10_28_000001_move_coordinator_to_opus sets models.coordinator to ~anthropic/claude-opus-latest in every conversation whose stored coordinator is exactly google/gemini-3.8-flash (the UI copied the old default into each conversation); other explicit choices, other roles and conversations without models are untouched, down() is a no-op. Run php artisan migrate.
  • Romanian voice-over voices (0.9.5): video.speech.voices is now id => {name, gender, language, description} and starts with seven Romanian ElevenLabs voices (men: Robert Mihai, Vlad, Jora, Claudiu; women: Corina, Maria, Andreea) before the English ones; the default video.speech.voice is Vlad (8QdBGRwn9G5tpGGTOaOe, env NOVA_AI_PB_SPEECH_VOICE still overrides). Composition key video.voice.id (optional, validated against the list) is used as is — no casting call, no audio tags — and travels to generate_video as voice.id; empty = the style casting (VoiceScriptWriter::resolveVoice()), which now tells the model the copy's language and to prefer a voice in it, and matches the model's answer case-insensitively but returns the configured (case-sensitive) id. Media tab: a «Vocea» select with Bărbați / Femei / Alte voci (engleză) and «Aleasă după stil» (list from GET /config → voices, default_voice).
  • New campaigns start creative, Media tab order, voice model (0.9.5): a campaign created from now on stores composition.design_mode = creative at creation (Conversation::newCampaignComposition(), used by the UI's first message and POST conversations), so the campaign colours are on by default; conversations without design_mode keep the site fallback, an explicit mode or campaign_chrome still wins (the canvas default is creative only while the campaign does not exist yet). Hosts that create conversations themselves (MCP) should start from Conversation::newCampaignComposition(). Media tab order: hero image, hero video, voice, sound effects, music. The voice card has a «Modelul vocii» select that mirrors the voice role of the Advanced tab (models.voice, the model the speech client already used); model_options.voice is now eleven-v3, eleven-v4, eleven-v4-turbo, eleven-multilingual-v2, eleven-flash-v2.5 (ids checked against OpenRouter), audio tags stay on the tag_models prefixes (v3, v4 and v4-turbo).
  • Re-apply keeps edited bands (0.9.5): PlanAssembler::assemble() compares each freeform band's latest generation (generated_at) with the page block it maps to (freeform blocks by order_column); a block edited later (band edit from the UI or the coordinator's edit_freeform_band, MCP update_block_text, Nova — the host touches the block on a translation save) keeps its current HTML, strings and texts, a newer generation still wins. Before, every re-apply rebuilt all bands from the generation results and dropped the edits.
  • Generated images stored for the web (0.9.5): the image model answers with a lossless 2K PNG of 5–7 MB; ImageGenerator now stores a photo (no alpha channel) as {ref}.jpg, JPEG q82, longest edge ≤ 2880 px (a PNG with transparency stays {ref}.png). The preview route and the applier resolve either extension.
  • Visual QA sees loaded images (0.9.5): scripts/band-shots.cjs waits for each band's own images before its shot (lazy ones switched to eager, a failed one retried once, ≤ 10 s per band, ≤ 45 s per run). An image still missing becomes a capture note passed to the critic, which also no longer flags hover-only reveals. While a long page is judged band by band, the conversation's run lock is kept alive (it used to expire mid-QA and the conversation read as idle).
  • Voice-over length follows the clip: the 12-word cap is gone. VoiceScriptWriter::maxWords($seconds) = (clip seconds - video.speech.voice_delay_ms - 1 s tail) x video.speech.words_per_second (2.0; measured 2.33 for Vlad / eleven-v3), at least 4: 6 words at 5 s, 16 at 10 s, 56 at 30 s, so the voice ends before the composer cuts it at the clip's end. The written text uses the clip's real (fitted) length — a short line up to 14 words, one to a few sentences above; an operator text is capped the same way. GET /config sends the table as roles.voice.max_words (seconds => words) and the Media tab's voice text (now a textarea, 600 characters, video.voice.script max 600 in the composition and MCP) counts against the hero length.
  • Rename a campaign from its header: the title in CampaignHeader edits in place (click or ✎; Enter / blur saves through PATCH conversations/{id}, Esc cancels) and updates the campaigns list, the open tab and the quick switcher; the card's ⋯ → Rename no longer saves after Esc.
  • Hero video up to 30 s, HeyGen Video, media prices: the canvas offers only the hero durations the picked video model accepts (GET /config → roles.video.options[].durations from VideoModelCapabilities::durations; presets 5–30 s, Seedance 2.5 / Wan 3.0 up to 30 s, Seedance 2.0 and HeyGen up to 15 s), shows the length the generator will really use (nearest accepted, as fit) and the composition accepts video.duration within video.duration_limits (2–30). New video model heygen/heygen-video-1 (5–15 s, 480p / 768p / 2K — 2K now ranks as 1440p in the resolution fit). Media model dropdowns (Advanced tab, voice model in Media) show the price per billing unit: video $/s (video.price_per_second, now also Wan 3.0 $0.20 and HeyGen $0.10 from the catalog's pricing_skus), voice $/1k characters (live, from /models?output_modalities=all, which the model map now reads so speech and music models get their names), music $/track (video.music.price_per_track, measured: Lyria 3 Clip $0.04, Pro $0.08). The estimate bills the hero length the model accepts and the picked music model's price. The Advanced tab lists the models in one column so names and prices are not cut.
  • Longer chat runs (0.9.5): request_time_limit (env NOVA_AI_PB_REQUEST_TIME_LIMIT) defaults to 5400 s instead of 900 s: a full campaign with the Opus coordinator, three video clips and the visual QA takes 30–45 minutes, and a CMS run was cut halfway through its bands. Same ceiling as the host's MCP agent chain. The HTML sub-agent's call timeout (timeouts.html, env NOVA_AI_PB_TIMEOUT_HTML) goes from 300 s to 900 s: an Opus band with a dozen products on tabs was cut off at 278 KB. The run lock (Conversation::RUN_LOCK_TTL) goes from 300 s to 960 s, above the longest single sub-agent call, so a run no longer reads as idle (and open to a second, overlapping run) while one band streams.
  • Host-columns migration (0.8.0): an idempotent migration adds the freeform/pagebuilder columns to older host projects; auto-loaded by the service provider and publishable. Run php artisan migrate.
  • New pages default to layout_landing (0.8.0).

Architecture

  • PageBuilderOrchestrator — coordinator tool loop (OpenAI-style function calling over OpenRouter, SSE).
  • Tools: get_layout_catalog, select_products (modes: research/pinned/dynamic), generate_copy, generate_image (img2img with real product photos via product_skus), create_landing_page, get_page_links, get_composition, propose_structure.
  • LayoutCatalog — bands discovered from resources/views/layouts_text/ intersected with curated metadata (layout_catalog_meta.php).
  • SqlToolRunner + QuerySanitizer — read-only SQL sub-loop.
  • CopyGenerator, ImageGenerator — single-call sub-services with their own cost logging.
  • LandingPageApplier — validation + transactional Page/BlockText creation.
  • Tables: nova_ai_pagebuilder_conversations (incl. composition json — the canvas state, shared across users), nova_ai_pagebuilder_messages, nova_ai_pagebuilder_logs.

Changelog

VersionDateNotes
0.9.62026-10-30Per-campaign choreography: design_campaign returns one {lead, support[], moment} per band, validated server-side (allowed families, no repeated lead, no adjacent same family, at most one gl:<scene> band, 1 moment or 1–2 at dynamic, pinned set-pieces lead-only, at most 2 recently used leads) with a deterministic fallback seeded by the conversation; the coordinator copies it into each band's brief and the HTML agent gets its band's line (coregrafie_banda); the planning guide now describes content roles, not effects per position. Anti-repetition across campaigns (Support\RecentEffects): counts, per page, the stored leads and the WebGL scenes of the last N campaign pages (art_direction.recent_conversations, env NOVA_AI_PB_RECENT_CONVERSATIONS, default 6, 0 = off), rejects a scene used on 2+ recent pages unless the brief names it, and does not repeat the previous campaign's skeleton by position. Scroll-driven video: new effect family scroll_video with video-scrub (a clip scrubbed by the scroll in a pinned stage; generate_video scrub: true, {{video.scrub}}, video_scrub collection, CampaignVideoComposer variant -g 4 -bf 0 within video.scrub_max_bytes, 10 MB) and rise-in-pin, both markup contracts enforced by FreeformHtmlValidator. Removed: the distort WebGL effect is no longer generated and the validator rejects it (stored directions read it as none). Hero video up to 30 s: the canvas offers only the durations the picked model accepts (VideoModelCapabilities::durations: Seedance 2.5 4–30, Wan 3.0 2–30, HeyGen 5–15); server and MCP accept 2–30 and fit to the model. HeyGen Video (heygen/heygen-video-1) in the video options; models listed without audio get no generate_audio field (HeyGen answers 400 to false); 2K/4K resolutions parsed correctly. Media prices next to the video ($/s, video.price_per_second now with Wan 3.0 and HeyGen), voice ($/1k characters, live catalog) and music ($/track, video.music.price_per_track) models, used by the estimate too. Voice length follows the clip: the 12-word cap is gone, VoiceScriptWriter::maxWords(seconds) = (seconds − voice delay − 1 s) × video.speech.words_per_second (2.0), e.g. 16 words at 10 s and 56 at 30 s; script up to 600 characters. Rename a campaign from its header: the title edits in place (click or the pencil; Enter / blur saves via PATCH conversations/{id}, Esc cancels) and the card rename no longer saves on the blur that follows Esc. New config keys: art_direction.recent_conversations, video.collections.scrub, video.scrub_max_bytes, video.speech.words_per_second, video.music.price_per_track. No migrations
0.9.52026-10-28Campaigns at the level of a hand-built landing: the coordinator defaults to ~anthropic/claude-opus-latest (data migration moves existing conversations off Gemini Flash) with the model's own maximum output (OpenRouter /models) and a nudge when a turn is cut at the limit; campaign planning guide (Support\CampaignGuide, get_campaign_guide: method, effects, QA checklist, worked example — data never to be copied) and read_source_page (host-bound Contracts\RemotePageFetcher); propose_structure carries full band briefs (instructions ≤ 5000) and images. Re-apply keeps edited bands (newest wins). Romanian voices by ElevenLabs id (Vlad default), voice and voice model in the Media tab (Video → Voice → SFX → Music); new campaigns start creative with the coloured header/footer. Long runs: chat time limit 5400 s, HTML call 900 s, run lock 960 s. Visual QA: waits for each band's images (no more false empty slots), capture notes reach the critic, hover-only reveals not flagged, run lock kept alive while judging; FreeformVisualQa::captureBands() for host screenshots. Generated images stored as web JPEG (≤ 2880 px, q82) instead of 5–7 MB PNGs. Migration: 2026_10_28_000001_move_coordinator_to_opus
0.9.42026-10-28Band images in the composition: bands.*.images (url, title, link, alt; max 24) are attached to the band gallery at page creation, before the generated image, through the host-bound Contracts\RemoteImageFetcher (C24CMS binds its SSRF-guarded RemoteImage); the HTML agent gets them as gallery lines and may use source="gallery" without generate_image; the visual QA sees them and re-applying re-attaches them. campaign_chrome_tone (lightdark) → campaign_skin.chrome_tone. WebGL / all effects are read from the user's own messages too (the coordinator's summary dropped them), the director gets cere_webgl / cere_toate_efectele, a requested WebGL never ends as scene none. No migration
0.9.32026-10-28Effects v2: seven new effect families (paths, gestures, pointer_fx, kinetic_text, layout_fx, scroll_physics, living_grids) + odometer and the WebGL scenes dither / waves, validated (FreeformHtmlValidator V-rules) and taught in FreeformPrompt; needs the host motion runtime resources/js/motion/fx.js (C24CMS). Video sound effects: sfx: {enabled, prompt?} on hero and accent clips (native generate_audio on models that support it), mixed under the music by CampaignVideoComposer; a clip whose audio the provider refuses for copyright is regenerated once without audio and reported as sfx_error. WebGL also on an explicit ask for all effects / all motion elements (still default-off, canvas webgl family still wins). The shop's AI profile (host binding concept24.brand_book) in the coordinator, copy, art direction, freeform, image, video and voice prompts (no text cap by default, fitted at whole lines over a cap; only the profile's Promisiuni may be presented as guarantees). Campaign ideas of the day (calendar, stock, sales; GET starters). SQL agent defaults to Claude Sonnet. Canvas design-mode badge follows the canvas on a new campaign. No migration
0.9.12026-10-18Laravel 13: laravel/framework ^12.0\|^13.0 (Laravel 12 stays the minimum). Checked on a fresh Laravel 13.37 app with Nova 5.11: dependencies resolve, the provider and config load, the 22 routes register and refuse a guest (401), the system prompt and validator load. README requirements corrected to PHP >= 8.4 (as in composer.json). No code change, no migration
0.9.02026-10-18Campaign workspace: campaigns screen (cards with thumbnail, status, cost, filters, Ctrl/Cmd+K switcher, ?c=<id> URLs), campaign header with Preview / Page on site / Nova, composition summary + tabs Structure / Style / Media / Advanced, «Steps» card per turn, responsive tablet/phone, animated orb intro, estimated cost (GET …/estimate). Band variety: 14 band types with one example each, rhythm rules, new validated hooks view-reveal, data-countdown, data-tabs, <c24-each offset>. Effect controls: 11 families enforced in prompt, validator and art direction, motion intensity override, accent clips 0-4. Media: Lyria 3 background music (mixed under the voice with ducking) and voice style → ElevenLabs voice. Header + footer in the campaign colours (campaign_skin.chrome, applied at once on an existing page, tool set_campaign_chrome). Editing: inline text edit in the preview, element-scoped AI edit (77 s → 3.3 s), proposal shown in the live page. Products on the canvas: the agent's products mirrored into the composition and kept on regeneration; Products tab replaced by search inside the band. Rules: buttons never move on hover, no curtain/intro masks, no invented facts. Stop interrupts any step. Security: the visual QA passes its throwaway session cookie to the capture script through the environment (NAPB_QA_COOKIE) instead of argv, so it no longer shows in the server's process list; chat markdown (assistant replies, streamed or stored) no longer renders raw HTML — it is printed as text, and links/images keep only http(s), mailto, relative and #anchor URLs (resources/js/markdown.js); before, an <img onerror> echoed by the model or typed into a shared campaign ran in Nova. Migration 2026_10_16_000001 (log phase music) — run php artisan migrate. The host app provides the runtime for countdown / tabs / carousel controls, the campaign chrome and the Logo Dark setting. 362 feature tests
0.8.12026-10-14Design mode Site / Creativ per conversation (composition.design_mode): Site follows the site's design system (type scale capped at text-4xl and validated, products only via <c24-product-card>, light palette, campaign font only on request); Creativ keeps the 0.7 style (free #rrggbb palette + campaign display font). Visible switch in the canvas + badge in the conversation list; mode kept on partial PUT. WebGL only on explicit request in both modes. Visual QA compared with the site: reference page screenshot for the critic (freeform.qa.reference_url / NOVA_AI_PB_QA_REFERENCE_URL, default first category with products) + deterministic metrics (font cap, product image width/backdrop, light page, text covered by WebGL, horizontal overflow) in Site mode; capture no longer waits for networkidle, retries and continues on the other viewport. Freeform fidelity: stricter validator and prompt on the site's proportions. Run lock with 300s TTL refreshed every turn. Fixes: a new-chat tab no longer wipes an existing composition, QA repair works without the canvas. No migration. 274 feature tests
0.8.02026-10-13Site design system: palette from the host theme tokens (palette_mode site/free), heading font, FreeformTheme contract reader. Motion intensity calm/balanced/dynamic. WebGL in freeform: data-gl (distort/reveal/scroll-wave/slides) + data-gl-scene (gradient/blob/particles) validated and taught in the prompt, scene limits per intensity. Models: latest aliases, video Seedance 2.5 (default)/2.0/2.0-fast and Wan 2.6/3.0 with capability fitting. New pages default to layout_landing. Migration: idempotent host-columns migration (auto-loaded, publishable) — run php artisan migrate. The host app must provide the theme contract (semantic tokens + @role) and the WebGL runtime (motion/gl); they live in the host app, not in the package. 191 feature tests
0.7.12026-10-12Background runs: the chat run no longer dies when the tab is closed or the user navigates away — the stream ignores client disconnects, so the page, the visual QA and the final message are saved anyway; GET conversations/{id} returns running (cache marker) and a reopened conversation shows the run live, re-polling every 4s; Stop now posts POST conversations/{id}/cancel, checked before each coordinator turn. A tool call left without a result by a dead run shows as „întrerupt” instead of an endless spinner. Security: the tool page required no login (Nova::router() applied only nova.middleware) — page and API now require Nova Authenticate + viewNova + the tool's canSee (API on nova:api); RouteAccessTest walks every route as guest / Nova user without permission / Super Admin. No migration. 146 feature tests
0.7.02026-10-10Campaign art direction (design_campaign: concept, WCAG-checked 6-colour palette, display font from the host config, motifs, motion signature, band rhythm → campaign_skin on the page + shared brief per band). Design-system rule for freeform HTML (palette read from the host theme.css, validator rejects arbitrary/default-palette colours, font-[…], inline colour styles, literal SVG colours). Editorial prompt + new effects vocabulary (intro, reveal-image, story (sticky), curtain, scramble, zoom, data-cursor, carousel, data-video). Generated video (generate_video / apply_band_video: Seedance 2.0 with product references, optional ElevenLabs voice-over, ffmpeg loop/full/poster; canvas options + video/voice model roles; migration 2026_10_10_000002, needs ffmpeg). Page preview drawer + pick an element → AI edit (freeform: propose/accept; classic: apply_band_copy), apply_band_products (+ copy review), apply_band_image (multi-ref), placeholder-image filter in product selection, per-band visual QA with clean captures, story media validation. UI: preview button in the chat toolbar, one page-state source (has_page / page_deleted), confirmation before „Actualizează pagina” overwrites edited bands. Run php artisan migrate. 142 feature tests
0.6.02026-10-10Freeform edit mode: FreeformBandGenerator::edit(html, strings, instruction, scope?, model?, sources, userId) — diff-style prompt (everything not asked for stays identical; with scope only that element changes), new copy translated into all locales, same validator with retry-with-feedback (max 2), logged as phase html without a conversation. Used by the host app's Nova live preview + AI chat. No migration
0.5.02026-10-10Requires PHP ≥ 8.4 (Dom\HTMLDocument). Freeform bands: coordinator tool generate_freeform_band (HTML + per-locale strings + product source; prompt with DS tokens, {{…}}/<c24-each> syntax, data-anim/data-micro vocabulary, N1 bans, no hardcoded visible text), new html model role (default anthropic/claude-opus-5.5, env NOVA_AI_PB_MODEL_HTML) + dropdown, FreeformHtmlValidator (rejects literal text, banned elements/attributes, unknown sources, missing keys; 2 retries with feedback; re-validated at apply), pages can mix classic and freeform bands, FreeformVisualQa + BandsVisualCritic before the result. Final chat message always carries the preview/Nova links (fallback from the create_landing_page result); band url ignores the copywriter's #… placeholders. Migration adds the html log phase — run php artisan migrate. E2E: one generation with a freeform band, ~$0.19, QA approved. 82 feature tests
0.4.02026-09-26Server-side plan assembly (PlanAssembler; create_landing_page without plan, optional products_by_band), live sub-agent progress (tool_progress SSE + elapsed timer), parallel image generation (generateMany, one HTTP pool per turn), product-aware copy (pinned products' title/price/stock per band), PHP time limit raised only during the chat stream and restored after (request_time_limit, env NOVA_AI_PB_REQUEST_TIME_LIMIT, default 900s — covers Octane's per-worker set_time_limit). Measured on a full Black Friday generation: 143s and $0.54 end-to-end, 0 rejected plans, 3 images in 17s. 61 feature tests
0.3.12026-09-26Defaults from a benchmark on real sales data (16 conversations, verified against the DB): sql → google/gemini-3.8-flash, copy → openai/gpt-6-astra, coordinator stays google/gemini-3.8-flash (Opus 5.5 / Sonnet 5 / Astra Pro remain selectable). Coordinator: clarify-first gate (no tool call before the user answers), BI consultant stage with terminology and delivery format aligned to nova-ai-report, single time window per analysis, conversion skeleton for propose_structure. SQL agent: analysis recipes (velocity, days of cover, dead stock, bought-together pairs, bundles), real orders interval + excluded status ids injected from the DB, rationale surfaced on empty results. propose_structure drops generate_image on layouts without an image slot. Tests: applier test no longer depends on the host app's active locales
0.3.02026-09-25Cost/latency: Anthropic prompt caching (cache_control on system + last message) in OpenRouterClient, layout catalog no longer duplicated in the system prompt (tool-only), SQL agent told to answer in 2-4 queries, 429 admission-control responses retried (max 2, Retry-After capped at 10s). Quality: SQL agent selects only in-stock products, treats expired price_promo as no discount and varies models (max 2 variants per model); copy agent receives each layout's notes (FAQ JSON body, stats numbers) and is forbidden to invent commercial claims (discounts, warranties, delivery times, customer counts) — same rule for the coordinator; image prompts get a server-side "no text/numbers/logos" suffix. Flow: propose_structure accepts product_skus, generate_image, page_hero and the canvas import pins them; general page instructions field removed from the canvas (tone/audience/goal come from the chat); empty SQL research results surface the agent's rationale. 55 feature tests (package tests/ now versioned)
0.2.02026-09-25Model allowlists refreshed from the live OpenRouter catalog: coordinator default google/gemini-3.8-flash (+ GPT-6 Astra / Astra Pro, Claude Opus 5.5, Fable 5.1, Sonnet 5), SQL agent adds Opus 5.5 / Fable 5.1 / GPT-6 Astra, copy default openai/gpt-6-astra-pro, image adds Nano Banana Pro (google/gemini-3-pro-image) and GPT Image 2 (openai/gpt-5.4-image-2). Also: update an existing page from the composition + generate only from the composition, page-level hero, visible ⓘ tooltips, product search with drag & drop into the canvas; SQL fixes (tool-less closing call when iterations run out, explicit category-search guidance, error listing rejected SKUs)
0.1.12026-08-26No functional changes — release de verificare pentru auto-update-ul Packagist (webhook Bitbucket)
0.1.02026-08-26Composition canvas: split view with layout palette, drag&drop band cards (instructions, image kind, variants, product drop-zones), draggable product chips from chat research, importable numbered structure proposals (propose_structure), get_composition tool + MODUL COMPOZIȚIE prompt stage, GET/PUT conversations/{id}/composition, 1:1 canvas↔plan validation with auto-filled pinned-product specs, single-hero guard (page banner dropped when the first band is a hero with image). Fixes: SSE client dropped streaming tokens equal to "0" (PHP empty()), copy generator delta retry for missing band keys, interrupted tool-calls get synthetic results on replay (conversations stay resumable). Coordinator default moved to GPT (gpt-5.4, gpt-5.5 in allowlists); 45 feature tests
0.0.12026-08-25Initial release: conversational landing-page agent with a two-stage flow — research/analysis turns (analytical product questions answered as markdown tables via mode=research, iterative proposals) and build stage (interview → structure consent → generation → data entry → links); per-agent model dropdowns (Coordinator/SQL/Copy/Image) with per-conversation override; read-only SQL product selection (pinned SKU + dynamic filters, best-sellers); multi-locale copy; image generation with img2img composition integrating the selected products' real photos (media read through the media disk, S3/spaces included); deterministic applier with full pre-write validation; draft-only pages with admin frontend preview; conversations shared across all tool users (owner shown in sidebar); per-phase cost logging; 31 feature tests