From 8ba5877bdf886df7f415c744bb55c54578a5f07d Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 2 May 2026 13:30:59 -0300 Subject: [PATCH] feat: implement automated brand color extraction from web metadata, CSS, and logos with supporting database schema updates --- CLAUDE.md | 24 +- app/Actions/Ai/AutofillBrand.php | 26 +- app/Actions/Workspace/CreateWorkspace.php | 3 + app/Ai/Agents/BrandAnalyzer.php | 6 + .../Controllers/App/WorkspaceController.php | 2 +- .../App/Workspace/StoreWorkspaceRequest.php | 5 + .../App/Workspace/UpdateWorkspaceRequest.php | 5 + app/Models/Workspace.php | 3 + app/Services/Brand/BrandMetadata.php | 32 +- app/Services/Brand/HomepageMetaExtractor.php | 137 ++++++++- app/Services/Brand/LlmBrandAnalysis.php | 23 ++ app/Services/Brand/LogoColorExtractor.php | 94 ++++++ composer.json | 1 + composer.lock | 63 +++- ...6_01_14_232315_create_workspaces_table.php | 3 + lang/en/settings.php | 3 + lang/en/workspaces.php | 3 + lang/es/settings.php | 3 + lang/es/workspaces.php | 3 + lang/php_en.json | 2 +- lang/php_es.json | 2 +- lang/php_pt-BR.json | 2 +- lang/pt-BR/settings.php | 3 + lang/pt-BR/workspaces.php | 3 + resources/js/components/HexColorInput.vue | 285 ++++++++++++++++++ resources/js/components/settings/BrandTab.vue | 25 ++ resources/js/pages/workspaces/Create.vue | 28 ++ .../views/prompts/brand_analyzer.blade.php | 8 +- routes/app.php | 17 +- tests/Feature/Ai/AutofillBrandTest.php | 140 +++++++++ tests/Feature/WorkspaceControllerTest.php | 12 +- tests/fixtures/blue-logo.png | Bin 0 -> 122 bytes 32 files changed, 927 insertions(+), 39 deletions(-) create mode 100644 app/Services/Brand/LogoColorExtractor.php create mode 100644 resources/js/components/HexColorInput.vue create mode 100644 tests/fixtures/blue-logo.png diff --git a/CLAUDE.md b/CLAUDE.md index b6c2b82f..90621a52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ ## Skills Activation - `cashier-stripe-development` — Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues. - `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns. - `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching. -- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-\* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP. +- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP. - `configure-nightwatch` — Configures Laravel Nightwatch data collection, sampling rates, filtering rules, and redaction policies. Use when setting up Nightwatch, managing data volume, protecting sensitive data (PII), or optimizing event collection for production workloads. - `pennant-development` — Use when working with Laravel Pennant the official Laravel feature flag package. Trigger whenever the query mentions Pennant by name or involves feature flags or feature toggles in a Laravel project. Tasks include defining feature flags checking whether features are active creating class based features in `app/Features` using Blade `@feature` directives scoping flags to users or teams building custom Pennant storage drivers protecting routes with feature flags testing feature flags with Pest or PHPUnit and implementing A B testing or gradual rollouts with feature flags. Do not trigger for generic Laravel configuration authorization policies authentication or non Pennant feature management systems. - `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication. @@ -119,7 +119,7 @@ ## Tinker - Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. - Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` - - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` === php rules === @@ -128,7 +128,7 @@ # PHP - Always use curly braces for control structures, even for single-line bodies. - Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. - Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` -- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Follow existing application Enum naming conventions. - Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. - Use array shape type definitions in PHPDoc blocks. @@ -199,6 +199,10 @@ ## Vite Error - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. +## Deployment + +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. + === wayfinder/core rules === # Laravel Wayfinder @@ -218,7 +222,6 @@ ## Pest - This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`. - Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. -- When running locally, always pass `--parallel` to speed up the suite (e.g. `php artisan test --parallel --compact`). Combine with `--filter` or specific paths when iterating on a small set of tests. - Do NOT delete tests without approval. === inertia-vue/core rules === @@ -226,21 +229,8 @@ ## Pest # Inertia + Vue Vue components must have a single root element. - - IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns. -=== laravel/ai rules === - -## Laravel AI SDK - -- This application uses the Laravel AI SDK (`laravel/ai`) for all AI functionality. -- Activate the `developing-with-ai-sdk` skill when building, editing, updating, debugging, or testing AI agents, text generation, chat, streaming, structured output, tools, image generation, audio, transcription, embeddings, reranking, vector stores, files, conversation memory, or any AI provider integration (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). - -=== spatie/laravel-medialibrary rules === - -- `spatie/laravel-medialibrary` associates files with Eloquent models, with support for collections, conversions, and responsive images. -- Always activate the `medialibrary-development` skill when working with media uploads, conversions, collections, responsive images, or any code that uses the `HasMedia` interface or `InteractsWithMedia` trait. - # Project-Specific Rules diff --git a/app/Actions/Ai/AutofillBrand.php b/app/Actions/Ai/AutofillBrand.php index c8be82c5..8654f346 100644 --- a/app/Actions/Ai/AutofillBrand.php +++ b/app/Actions/Ai/AutofillBrand.php @@ -7,6 +7,7 @@ use App\Services\Brand\BrandAnalyzerRunner; use App\Services\Brand\BrandMetadata; use App\Services\Brand\HomepageMetaExtractor; +use App\Services\Brand\LogoColorExtractor; use App\Services\Brand\SafeHttpFetcher; final class AutofillBrand @@ -15,6 +16,7 @@ public function __construct( private readonly SafeHttpFetcher $fetcher, private readonly HomepageMetaExtractor $extractor, private readonly BrandAnalyzerRunner $analyzer, + private readonly LogoColorExtractor $logoColors, ) {} public function __invoke(string $url): BrandMetadata @@ -23,7 +25,29 @@ public function __invoke(string $url): BrandMetadata $html = $this->fetcher->get($url)->body(); - $metadata = $this->extractor->extract($html, $url); + // Pull up to 3 external stylesheets so the deterministic color + // extractor can find brand/background/text colors that aren't inlined. + $extraCss = ''; + $stylesheetUrls = array_slice($this->extractor->extractStylesheetUrls($html, $url), 0, 3); + foreach ($stylesheetUrls as $cssUrl) { + $response = $this->fetcher->tryGet($cssUrl); + if ($response !== null && $response->successful()) { + $extraCss .= "\n".$response->body(); + } + } + + $metadata = $this->extractor->extract($html, $url, $extraCss); + + // If the deterministic CSS scan didn't yield a brand color, pull the + // dominant non-neutral color out of the logo image itself. This works + // even when the site uses utility CSS (Tailwind, etc.) where no + // semantic --primary var or body { background } rule exists. + if ($metadata->brandColor === null && $metadata->logoUrl !== null) { + $logoColor = $this->logoColors->extractFromUrl($metadata->logoUrl); + if ($logoColor !== null) { + $metadata = $metadata->withBrandColor($logoColor); + } + } if (! $this->analyzer->isAvailable()) { return $metadata; diff --git a/app/Actions/Workspace/CreateWorkspace.php b/app/Actions/Workspace/CreateWorkspace.php index ded837ce..f23e6da7 100644 --- a/app/Actions/Workspace/CreateWorkspace.php +++ b/app/Actions/Workspace/CreateWorkspace.php @@ -21,6 +21,9 @@ public static function execute(User $user, array $data): Workspace 'brand_description' => data_get($data, 'brand_description'), 'brand_tone' => data_get($data, 'brand_tone'), 'brand_voice_notes' => data_get($data, 'brand_voice_notes'), + 'brand_color' => data_get($data, 'brand_color'), + 'background_color' => data_get($data, 'background_color'), + 'text_color' => data_get($data, 'text_color'), 'content_language' => data_get($data, 'content_language'), ], static fn ($value): bool => $value !== null); diff --git a/app/Ai/Agents/BrandAnalyzer.php b/app/Ai/Agents/BrandAnalyzer.php index 337624b9..caa1b640 100644 --- a/app/Ai/Agents/BrandAnalyzer.php +++ b/app/Ai/Agents/BrandAnalyzer.php @@ -47,6 +47,12 @@ public function schema(JsonSchema $schema): array 'voice_notes' => $schema->string() ->description('2-3 sentences of concrete writing guidelines inferred from the site style (e.g. "Use technical but approachable language", "Avoid marketing buzzwords"). Written in the detected content language.') ->required(), + 'brand_color' => $schema->string() + ->description('The primary brand color as a hex string starting with # (e.g. "#0ea5e9"). Pick the most prominent accent color used in CTAs, links, or logos. Return empty string if not confidently identifiable.'), + 'background_color' => $schema->string() + ->description('The dominant page background color as a hex string starting with # (e.g. "#ffffff" or "#0b0f19"). Return empty string if not confidently identifiable.'), + 'text_color' => $schema->string() + ->description('The dominant body text color as a hex string starting with # (e.g. "#0f172a"). Return empty string if not confidently identifiable.'), ]; } } diff --git a/app/Http/Controllers/App/WorkspaceController.php b/app/Http/Controllers/App/WorkspaceController.php index c82fdbd7..25cb24fb 100644 --- a/app/Http/Controllers/App/WorkspaceController.php +++ b/app/Http/Controllers/App/WorkspaceController.php @@ -212,7 +212,7 @@ public function updateSettings(UpdateWorkspaceRequest $request): RedirectRespons session()->flash('flash.banner', __('settings.flash.workspace_updated')); session()->flash('flash.bannerStyle', 'success'); - return redirect()->route('app.workspace.settings'); + return back(); } public function destroy(Request $request, Workspace $workspace): RedirectResponse diff --git a/app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php b/app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php index 8c2e6dac..24b33764 100644 --- a/app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php +++ b/app/Http/Requests/App/Workspace/StoreWorkspaceRequest.php @@ -15,12 +15,17 @@ public function authorize(): bool public function rules(): array { + $hex = ['nullable', 'string', 'regex:/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/']; + return [ 'name' => ['required', 'string', 'max:255'], 'brand_website' => ['nullable', 'url', 'max:255'], 'brand_description' => ['nullable', 'string', 'max:2000'], 'brand_tone' => ['nullable', 'string', 'in:professional,casual,friendly,bold,inspirational,humorous,educational'], 'brand_voice_notes' => ['nullable', 'string', 'max:2000'], + 'brand_color' => $hex, + 'background_color' => $hex, + 'text_color' => $hex, 'content_language' => ['nullable', 'string', 'in:en,pt-BR,es'], 'logo_url' => ['nullable', 'url', 'max:1024'], ]; diff --git a/app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php b/app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php index 99533576..a1ea1786 100644 --- a/app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php +++ b/app/Http/Requests/App/Workspace/UpdateWorkspaceRequest.php @@ -15,12 +15,17 @@ public function authorize(): bool public function rules(): array { + $hex = ['nullable', 'string', 'regex:/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/']; + return [ 'name' => ['required', 'string', 'max:255'], 'brand_website' => ['nullable', 'url', 'max:255'], 'brand_description' => ['nullable', 'string', 'max:2000'], 'brand_tone' => ['nullable', 'string', 'in:professional,casual,friendly,bold,inspirational,humorous,educational'], 'brand_voice_notes' => ['nullable', 'string', 'max:2000'], + 'brand_color' => $hex, + 'background_color' => $hex, + 'text_color' => $hex, 'content_language' => ['sometimes', 'string', 'in:en,pt-BR,es'], ]; } diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index 93cd009b..1e6ea5ab 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -27,6 +27,9 @@ class Workspace extends Model 'brand_description', 'brand_tone', 'brand_voice_notes', + 'brand_color', + 'background_color', + 'text_color', 'content_language', ]; diff --git a/app/Services/Brand/BrandMetadata.php b/app/Services/Brand/BrandMetadata.php index f5697706..3874d44f 100644 --- a/app/Services/Brand/BrandMetadata.php +++ b/app/Services/Brand/BrandMetadata.php @@ -13,6 +13,9 @@ public function __construct( public ?string $tone = null, public ?string $voiceNotes = null, public ?string $logoUrl = null, + public ?string $brandColor = null, + public ?string $backgroundColor = null, + public ?string $textColor = null, ) {} public function mergeLlm(LlmBrandAnalysis $llm): self @@ -24,6 +27,12 @@ public function mergeLlm(LlmBrandAnalysis $llm): self tone: $llm->tone ?: null, voiceNotes: $llm->voiceNotes ?: null, logoUrl: $this->logoUrl, + // Prefer deterministically-extracted colors (theme-color meta, CSS + // custom properties, body rules) — the LLM only sees stripped + // markdown so its color answers are unreliable. + brandColor: $this->brandColor ?: ($llm->brandColor ?: null), + backgroundColor: $this->backgroundColor ?: ($llm->backgroundColor ?: null), + textColor: $this->textColor ?: ($llm->textColor ?: null), ); } @@ -36,11 +45,29 @@ public function withLogoUrl(?string $logoUrl): self tone: $this->tone, voiceNotes: $this->voiceNotes, logoUrl: $logoUrl, + brandColor: $this->brandColor, + backgroundColor: $this->backgroundColor, + textColor: $this->textColor, + ); + } + + public function withBrandColor(?string $brandColor): self + { + return new self( + name: $this->name, + description: $this->description, + language: $this->language, + tone: $this->tone, + voiceNotes: $this->voiceNotes, + logoUrl: $this->logoUrl, + brandColor: $brandColor, + backgroundColor: $this->backgroundColor, + textColor: $this->textColor, ); } /** - * @return array{name: ?string, brand_description: ?string, content_language: ?string, brand_tone: ?string, brand_voice_notes: ?string, logo_url: ?string} + * @return array{name: ?string, brand_description: ?string, content_language: ?string, brand_tone: ?string, brand_voice_notes: ?string, brand_color: ?string, background_color: ?string, text_color: ?string, logo_url: ?string} */ public function toArray(): array { @@ -50,6 +77,9 @@ public function toArray(): array 'content_language' => $this->language, 'brand_tone' => $this->tone, 'brand_voice_notes' => $this->voiceNotes, + 'brand_color' => $this->brandColor, + 'background_color' => $this->backgroundColor, + 'text_color' => $this->textColor, 'logo_url' => $this->logoUrl, ]; } diff --git a/app/Services/Brand/HomepageMetaExtractor.php b/app/Services/Brand/HomepageMetaExtractor.php index 04eeb0e0..c0c09273 100644 --- a/app/Services/Brand/HomepageMetaExtractor.php +++ b/app/Services/Brand/HomepageMetaExtractor.php @@ -15,18 +15,153 @@ final class HomepageMetaExtractor { private const array TITLE_SEPARATORS = [' | ', ' - ', ' — ', ' – ']; - public function extract(string $html, string $baseUrl): BrandMetadata + public function extract(string $html, string $baseUrl, string $extraCss = ''): BrandMetadata { $crawler = new Crawler($html, $baseUrl); + $colors = $this->extractColors($crawler, $html, $extraCss); + return new BrandMetadata( name: $this->extractName($crawler, $baseUrl), description: $this->extractDescription($crawler), language: $this->extractLanguage($crawler), logoUrl: $this->extractLogoUrl($crawler, $baseUrl), + brandColor: data_get($colors, 'brand_color'), + backgroundColor: data_get($colors, 'background_color'), + textColor: data_get($colors, 'text_color'), ); } + /** + * Absolute URLs of external stylesheets referenced from the homepage. + * Used to fetch CSS that the deterministic color extractor needs but isn't + * inlined in + + + + HTML, 200), + ]); + + $result = ($this->autofill)('https://example.com'); + + expect($result->brandColor)->toBe('#ff5722'); + expect($result->backgroundColor)->toBe('#0b0f19'); + expect($result->textColor)->toBe('#e2e8f0'); +}); + +test('falls back to body { background } and body { color } rules', function () { + Http::fake([ + 'example.com' => Http::response(<<<'HTML' + + + + Acme + + + + + HTML, 200), + ]); + + $result = ($this->autofill)('https://example.com'); + + expect($result->backgroundColor)->toBe('#ffffff'); + expect($result->textColor)->toBe('#1f2937'); +}); + +test('extracts colors from external stylesheets', function () { + Http::fake([ + 'example.com' => Http::response(<<<'HTML' + + + + Acme + + + + + HTML, 200), + 'example.com/app.css' => Http::response(':root { --primary: #1d4ed8; --background: #f8fafc; }', 200), + ]); + + $result = ($this->autofill)('https://example.com'); + + expect($result->brandColor)->toBe('#1d4ed8'); + expect($result->backgroundColor)->toBe('#f8fafc'); +}); + +test('falls back to dominant logo color when CSS has no signal', function () { + // Logo fixture is a solid blue PNG. CSS in the page has no semantic hooks, + // so AutofillBrand should reach into the logo and grab #1e6fff via + // LogoColorExtractor. + Http::fake([ + 'example.com' => Http::response(<<<'HTML' + + + + Acme + + + Hello + + HTML, 200), + 'example.com/logo.png' => Http::response( + file_get_contents(__DIR__.'/../../fixtures/blue-logo.png'), + 200, + ['Content-Type' => 'image/png'], + ), + ]); + + $result = ($this->autofill)('https://example.com'); + + expect($result->brandColor)->toBe('#1e6fff'); +}); + +test('rejects malformed color values', function () { + Http::fake([ + 'example.com' => Http::response(<<<'HTML' + + + + Acme + + + + + + HTML, 200), + ]); + + $result = ($this->autofill)('https://example.com'); + + expect($result->brandColor)->toBeNull(); + expect($result->backgroundColor)->toBeNull(); +}); + test('throws when upstream site returns an error', function () { Http::fake([ 'example.com' => Http::response('', 500), diff --git a/tests/Feature/WorkspaceControllerTest.php b/tests/Feature/WorkspaceControllerTest.php index e96612c7..1adeb4e8 100644 --- a/tests/Feature/WorkspaceControllerTest.php +++ b/tests/Feature/WorkspaceControllerTest.php @@ -205,12 +205,14 @@ $response->assertRedirect(route('login')); }); -test('update workspace settings updates workspace', function () { - $response = $this->actingAs($this->user)->put(route('app.workspace.settings.update'), [ - 'name' => 'Updated Name', - ]); +test('update workspace settings updates workspace and redirects back', function () { + $response = $this->actingAs($this->user) + ->from(route('app.workspace.brand')) + ->put(route('app.workspace.settings.update'), [ + 'name' => 'Updated Name', + ]); - $response->assertRedirect(route('app.workspace.settings')); + $response->assertRedirect(route('app.workspace.brand')); $this->workspace->refresh(); expect($this->workspace->name)->toBe('Updated Name'); diff --git a/tests/fixtures/blue-logo.png b/tests/fixtures/blue-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0c060e913f5a388a37ad2a04df079d36b2b87bb1 GIT binary patch literal 122 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1SJ1Ryj={WI14-?iy0XBj({-ZRBb+KprDSY zi(`mK=i9S}j0^@G2R0ZgMjZLis`kK})nww;_nYdg*nx^5pqtn4GUFMRFX