Merge pull request #128 from dantaspaulo/feat/ai-brand-colors-toggle

feat(ai-create): let users choose brand colors or free AI colors for images
This commit is contained in:
Paulo Castellano 2026-07-18 15:17:44 -03:00 committed by GitHub
commit 7b37190358
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 112 additions and 1 deletions

View file

@ -27,6 +27,13 @@ public function previewAsset(): string;
/** Whether a social account must be selected for this template. */
public function needsAccount(): bool;
/**
* Whether this template's generated image honors the workspace brand
* palette (so the "Brand colors" choice is meaningful). Tweet-card styles
* are brand-colored by design and ignore the flag.
*/
public function appliesBrandVisuals(): bool;
/**
* Content types (by value) this template can produce. Empty = all AI formats.
*

View file

@ -44,6 +44,11 @@ public function needsAccount(): bool
return $this->style()->needsAccount();
}
public function appliesBrandVisuals(): bool
{
return true;
}
public function supportedFormats(): array
{
return [];

View file

@ -41,6 +41,11 @@ public function needsAccount(): bool
return $this->style()->needsAccount();
}
public function appliesBrandVisuals(): bool
{
return false;
}
/** @return array<int, string> */
public function supportedFormats(): array
{

View file

@ -41,6 +41,11 @@ public function needsAccount(): bool
return $this->style()->needsAccount();
}
public function appliesBrandVisuals(): bool
{
return false;
}
/** @return array<int, string> */
public function supportedFormats(): array
{

View file

@ -52,6 +52,7 @@ public function start(StartPostCreationRequest $request): JsonResponse
prompt: $request->string('prompt')->toString(),
date: $request->input('date'),
template: $request->input('template', 'image_card'),
applyBrandVisuals: $request->boolean('apply_brand_visuals', true),
);
return response()->json([

View file

@ -153,6 +153,7 @@ public function create(Request $request): Response
'preview' => $t->previewAsset(),
'needs_account' => $t->needsAccount(),
'supported_formats' => $t->supportedFormats(),
'applies_brand_visuals' => $t->appliesBrandVisuals(),
], $registry->all());
return Inertia::render('posts/Create', [

View file

@ -37,6 +37,7 @@ public function rules(): array
'prompt' => AiPromptRules::wizardPromptRule(),
'date' => ['nullable', 'date_format:Y-m-d'],
'template' => ['sometimes', 'string', Rule::enum(ContentStyle::class)],
'apply_brand_visuals' => ['sometimes', 'boolean'],
];
}

View file

@ -43,6 +43,7 @@ public function __construct(
public string $prompt,
public ?string $date = null,
public string $template = 'image_card',
public bool $applyBrandVisuals = true,
) {
$this->onQueue('ai');
}
@ -64,6 +65,7 @@ public function handle(): void
format: $this->format,
imageCount: $this->imageCount,
isCarousel: $isCarousel,
applyBrandVisuals: $this->applyBrandVisuals,
);
$agent = new PostContentGenerator(

View file

@ -606,6 +606,8 @@
'media_optional_label' => 'كم عدد الصور؟',
'media_none' => 'بلا',
'media_count_label' => 'عدد الصور',
'brand_colors_label' => 'ألوان العلامة التجارية',
'brand_colors_description' => 'طبّق لوحة ألوان علامتك التجارية على الصور المُنشأة. عطّله لترك الذكاء الاصطناعي يختار الألوان التي تناسب المحتوى.',
'prompt_title' => 'صِف منشورك',
'prompt_label' => 'ما موضوع هذا المنشور؟',
'prompt_placeholder' => 'مثال: الإعلان عن ميزة العرض الدائري الجديدة لدينا على Instagram',

View file

@ -608,6 +608,8 @@
'media_optional_label' => 'Wie viele Bilder?',
'media_none' => 'Keine',
'media_count_label' => 'Anzahl der Bilder',
'brand_colors_label' => 'Markenfarben',
'brand_colors_description' => 'Wende die Markenpalette deines Workspace auf die generierten Bilder an. Deaktiviere die Option, damit die KI passende Farben zum Inhalt wählt.',
'prompt_title' => 'Beschreibe deinen Beitrag',
'prompt_label' => 'Worum geht es in diesem Beitrag?',
'prompt_placeholder' => 'z. B. Unser neues Karussell-Feature für Instagram ankündigen',

View file

@ -606,6 +606,8 @@
'media_optional_label' => 'Πόσες εικόνες;',
'media_none' => 'Καμία',
'media_count_label' => 'Αριθμός εικόνων',
'brand_colors_label' => 'Χρώματα επωνυμίας',
'brand_colors_description' => 'Εφάρμοσε την παλέτα της επωνυμίας σου στις εικόνες που δημιουργούνται. Απενεργοποίησέ το για να επιλέξει το AI χρώματα που ταιριάζουν στο περιεχόμενο.',
'prompt_title' => 'Περιγράψτε τη δημοσίευσή σας',
'prompt_label' => 'Ποιο είναι το θέμα αυτής της δημοσίευσης;',
'prompt_placeholder' => 'π.χ. Ανακοινώστε τη νέα μας λειτουργία carousel για το Instagram',

View file

@ -606,6 +606,8 @@
'media_optional_label' => 'How many images?',
'media_none' => 'None',
'media_count_label' => 'Number of images',
'brand_colors_label' => 'Brand colors',
'brand_colors_description' => 'Apply your workspace brand palette to the generated images. Turn it off to let the AI pick colors that fit the content.',
'prompt_title' => 'Describe your post',
'prompt_label' => 'What is this post about?',
'prompt_placeholder' => 'e.g. Announce our new carousel feature for Instagram',

View file

@ -607,6 +607,8 @@
'media_optional_label' => '¿Cuántas imágenes?',
'media_none' => 'Ninguna',
'media_count_label' => 'Número de imágenes',
'brand_colors_label' => 'Colores de marca',
'brand_colors_description' => 'Aplica la paleta de tu marca a las imágenes generadas. Desactívalo para que la IA elija los colores que mejor encajen con el contenido.',
'prompt_title' => 'Describe tu post',
'prompt_label' => '¿De qué trata este post?',
'prompt_placeholder' => 'Ej. Anuncia nuestra nueva función de carrusel para Instagram',

View file

@ -606,6 +606,8 @@
'media_optional_label' => 'Combien d\'images ?',
'media_none' => 'Aucune',
'media_count_label' => 'Nombre d\'images',
'brand_colors_label' => 'Couleurs de la marque',
'brand_colors_description' => 'Appliquez la palette de votre marque aux images générées. Désactivez pour laisser lIA choisir des couleurs adaptées au contenu.',
'prompt_title' => 'Décrivez votre publication',
'prompt_label' => 'De quoi parle cette publication ?',
'prompt_placeholder' => 'par ex. Annoncer notre nouvelle fonctionnalité de carrousel pour Instagram',

View file

@ -606,6 +606,8 @@
'media_optional_label' => 'Quante immagini?',
'media_none' => 'Nessuna',
'media_count_label' => 'Numero di immagini',
'brand_colors_label' => 'Colori del brand',
'brand_colors_description' => 'Applica la palette del tuo brand alle immagini generate. Disattivalo per lasciare che lIA scelga i colori più adatti al contenuto.',
'prompt_title' => 'Descrivi il tuo post',
'prompt_label' => 'Di cosa parla questo post?',
'prompt_placeholder' => 'es. Annuncia la nostra nuova funzione carosello per Instagram',

View file

@ -606,6 +606,8 @@
'media_optional_label' => '画像は何枚にしますか?',
'media_none' => 'なし',
'media_count_label' => '画像の枚数',
'brand_colors_label' => 'ブランドカラー',
'brand_colors_description' => '生成される画像にワークスペースのブランドカラーを適用します。オフにすると、AI がコンテンツに合った色を選びます。',
'prompt_title' => '投稿を説明',
'prompt_label' => 'この投稿は何についてですか?',
'prompt_placeholder' => '例: Instagram 向けの新しいカルーセル機能を発表',

View file

@ -606,6 +606,8 @@
'media_optional_label' => '이미지는 몇 장인가요?',
'media_none' => '없음',
'media_count_label' => '이미지 수',
'brand_colors_label' => '브랜드 색상',
'brand_colors_description' => '생성되는 이미지에 워크스페이스 브랜드 색상을 적용합니다. 끄면 AI가 콘텐츠에 어울리는 색상을 선택합니다.',
'prompt_title' => '게시물을 설명하세요',
'prompt_label' => '이 게시물은 무엇에 관한 것인가요?',
'prompt_placeholder' => '예: Instagram용 새 캐러셀 기능 발표',

View file

@ -606,6 +606,8 @@
'media_optional_label' => 'Hoeveel afbeeldingen?',
'media_none' => 'Geen',
'media_count_label' => 'Aantal afbeeldingen',
'brand_colors_label' => 'Merkkleuren',
'brand_colors_description' => 'Pas het merkkleurenpalet van je workspace toe op de gegenereerde afbeeldingen. Zet het uit om de AI kleuren te laten kiezen die bij de inhoud passen.',
'prompt_title' => 'Beschrijf je post',
'prompt_label' => 'Waar gaat deze post over?',
'prompt_placeholder' => 'bijv. Kondig onze nieuwe carrouselfunctie voor Instagram aan',

View file

@ -606,6 +606,8 @@
'media_optional_label' => 'Ile obrazów?',
'media_none' => 'Brak',
'media_count_label' => 'Liczba obrazów',
'brand_colors_label' => 'Kolory marki',
'brand_colors_description' => 'Zastosuj paletę marki Twojego obszaru roboczego do generowanych obrazów. Wyłącz, aby AI dobrała kolory pasujące do treści.',
'prompt_title' => 'Opisz swój post',
'prompt_label' => 'O czym jest ten post?',
'prompt_placeholder' => 'np. Ogłoś naszą nową funkcję karuzeli na Instagramie',

View file

@ -606,6 +606,8 @@
'media_optional_label' => 'Quantas imagens?',
'media_none' => 'Nenhuma',
'media_count_label' => 'Número de imagens',
'brand_colors_label' => 'Cores da marca',
'brand_colors_description' => 'Aplica a paleta da sua marca nas imagens geradas. Desative para deixar a IA escolher as cores que combinam com o conteúdo.',
'prompt_title' => 'Descreva seu post',
'prompt_label' => 'Sobre o que é este post?',
'prompt_placeholder' => 'Ex. Anunciar nossa nova função de carrossel para o Instagram',

View file

@ -606,6 +606,8 @@
'media_optional_label' => 'Сколько изображений?',
'media_none' => 'Нет',
'media_count_label' => 'Количество изображений',
'brand_colors_label' => 'Цвета бренда',
'brand_colors_description' => 'Применяйте фирменную палитру вашего рабочего пространства к создаваемым изображениям. Отключите, чтобы ИИ подобрал цвета под содержание.',
'prompt_title' => 'Опишите свой пост',
'prompt_label' => 'О чём этот пост?',
'prompt_placeholder' => 'например, Анонсировать нашу новую функцию карусели для Instagram',

View file

@ -608,6 +608,8 @@
'media_optional_label' => 'Kaç görsel?',
'media_none' => 'Hiçbiri',
'media_count_label' => 'Görsel sayısı',
'brand_colors_label' => 'Marka renkleri',
'brand_colors_description' => 'Oluşturulan görsellere çalışma alanınızın marka paletini uygular. Yapay zekânın içeriğe uygun renkleri seçmesi için kapatın.',
'prompt_title' => 'Gönderinizi açıklayın',
'prompt_label' => 'Bu gönderi ne hakkında?',
'prompt_placeholder' => 'örn. Instagram için yeni karusel özelliğimizi duyur',

View file

@ -606,6 +606,8 @@
'media_optional_label' => '要多少张图片?',
'media_none' => '无',
'media_count_label' => '图片数量',
'brand_colors_label' => '品牌颜色',
'brand_colors_description' => '将工作区的品牌配色应用到生成的图片。关闭后由 AI 选择适合内容的颜色。',
'prompt_title' => '描述你的帖子',
'prompt_label' => '这条帖子讲的是什么?',
'prompt_placeholder' => '例如 为 Instagram 宣布我们全新的轮播功能',

View file

@ -13,6 +13,7 @@ import { start as startRoute } from '@/actions/App/Http/Controllers/App/PostAiCr
import ContentStylePicker from '@/components/ai/ContentStylePicker.vue';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { loading as loadingRoute } from '@/routes/app/posts/ai';
@ -33,6 +34,7 @@ interface AiTemplate {
preview: string;
needs_account: boolean;
supported_formats: string[];
applies_brand_visuals: boolean;
}
interface Props {
@ -63,6 +65,8 @@ const selectedAccountId = ref<string | null>(null);
const includeImages = ref(true);
const imageCount = ref(2);
const promptText = ref('');
// true = images use the workspace brand palette; false = the AI picks colors freely.
const useBrandColors = ref(true);
const PROMPT_MIN = 3;
const PROMPT_MAX = 2000;
@ -75,7 +79,8 @@ const httpStart = useHttp<{
prompt: string;
date: string | null;
template: string;
}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null, template: 'image_card' });
apply_brand_visuals: boolean;
}>({ format: null, social_account_id: null, image_count: 0, prompt: '', date: null, template: 'image_card', apply_brand_visuals: true });
const AI_FORMATS: Array<{ value: AiFormat; platforms: string[] }> = [
{ value: ContentType.InstagramFeed, platforms: ['instagram', 'instagram-facebook'] },
@ -212,6 +217,7 @@ const startGeneration = async () => {
httpStart.prompt = promptText.value.trim();
httpStart.date = props.date;
httpStart.template = resolvedTemplate.value;
httpStart.apply_brand_visuals = useBrandColors.value;
try {
const data = await httpStart.post(startRoute.url()) as { creation_id: string; channel: string };
@ -354,6 +360,19 @@ const startGeneration = async () => {
</div>
</div>
<!-- Brand colors: apply the workspace palette or let the AI decide. Only
for image templates that honor it (tweet cards are always branded). -->
<div
v-if="selectedFormat && submittedImageCount > 0 && resolvedTemplateRecord?.applies_brand_visuals"
class="flex items-center justify-between gap-4 rounded-xl border-2 border-foreground bg-card p-4 shadow-2xs"
>
<div class="space-y-0.5">
<Label for="apply-brand-visuals" class="text-sm font-bold">{{ $t('posts.create.steps.brand_colors_label') }}</Label>
<p class="text-sm text-foreground/70">{{ $t('posts.create.steps.brand_colors_description') }}</p>
</div>
<Switch id="apply-brand-visuals" v-model="useBrandColors" />
</div>
<!-- Prompt -->
<div v-if="selectedFormat" class="space-y-2">
<Label for="ai-prompt" class="text-sm font-bold">{{ $t('posts.create.steps.prompt_label') }}</Label>

View file

@ -200,6 +200,33 @@
Bus::assertDispatched(StreamPostCreation::class, fn ($job) => $job->date === '2026-06-15');
});
test('start carries apply_brand_visuals=false when the user lets the AI decide colors', function () {
Bus::fake();
$this->actingAs($this->user)
->postJson(route('app.posts.ai.create'), [
'prompt' => 'hello',
'format' => 'x_post',
'apply_brand_visuals' => false,
])
->assertAccepted();
Bus::assertDispatched(StreamPostCreation::class, fn ($job) => $job->applyBrandVisuals === false);
});
test('start defaults apply_brand_visuals to true when omitted', function () {
Bus::fake();
$this->actingAs($this->user)
->postJson(route('app.posts.ai.create'), [
'prompt' => 'hello',
'format' => 'x_post',
])
->assertAccepted();
Bus::assertDispatched(StreamPostCreation::class, fn ($job) => $job->applyBrandVisuals === true);
});
test('start rejects invalid date format', function () {
Bus::fake();

View file

@ -4,6 +4,8 @@
use App\Ai\Templates\ImageCardTemplate;
use App\Ai\Templates\TemplateContext;
use App\Ai\Templates\TweetCardImageTemplate;
use App\Ai\Templates\TweetCardTemplate;
use App\Enums\Ai\ContentStyle;
use App\Models\Workspace;
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
@ -16,10 +18,16 @@
expect($t->style())->toBe(ContentStyle::ImageCard)
->and($t->key())->toBe('image_card')
->and($t->needsAccount())->toBeFalse()
->and($t->appliesBrandVisuals())->toBeTrue()
->and($t->promptView($context))->toBe('prompts.post_content.generator')
->and($t->supportedFormats())->toBe([]);
});
test('tweet-card templates do not apply brand visuals', function () {
expect((new TweetCardTemplate)->appliesBrandVisuals())->toBeFalse()
->and((new TweetCardImageTemplate)->appliesBrandVisuals())->toBeFalse();
});
test('image card carousel context returns carousel schema shape', function () {
$t = new ImageCardTemplate;
$workspace = Workspace::factory()->create();