feat: implement automated brand color extraction from web metadata, CSS, and logos with supporting database schema updates

This commit is contained in:
Paulo Castellano 2026-05-02 13:30:59 -03:00
parent f3605717c7
commit 8ba5877bdf
32 changed files with 927 additions and 39 deletions

View file

@ -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.
</laravel-boost-guidelines>
# Project-Specific Rules

View file

@ -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;

View file

@ -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);

View file

@ -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.'),
];
}
}

View file

@ -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

View file

@ -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'],
];

View file

@ -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'],
];
}

View file

@ -27,6 +27,9 @@ class Workspace extends Model
'brand_description',
'brand_tone',
'brand_voice_notes',
'brand_color',
'background_color',
'text_color',
'content_language',
];

View file

@ -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,
];
}

View file

@ -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 <style> blocks.
*
* @return list<string>
*/
public function extractStylesheetUrls(string $html, string $baseUrl): array
{
$crawler = new Crawler($html, $baseUrl);
$urls = [];
$crawler->filter('link[rel="stylesheet"][href]')->each(function (Crawler $node) use ($baseUrl, &$urls): void {
$href = trim((string) $node->attr('href', ''));
if ($href === '' || str_starts_with(strtolower($href), 'data:')) {
return;
}
$urls[] = UriResolver::resolve($href, $baseUrl);
});
return $urls;
}
/**
* Pull colors from deterministic signals: <meta name="theme-color">, CSS
* custom properties (--primary, --brand, --background, --foreground), and
* top-level body { background / color } rules. As a last resort, fall back
* to the most-frequent non-neutral hex in the raw HTML works well for
* sites whose brand color saturates the markup but doesn't expose semantic
* hooks. The LLM cannot help here because we strip <style> blocks before
* sending markdown to it.
*
* @return array{brand_color: ?string, background_color: ?string, text_color: ?string}
*/
private function extractColors(Crawler $crawler, string $html, string $extraCss = ''): array
{
$brand = $this->metaContent($crawler, 'name', 'theme-color');
$css = $this->collectStyleSheets($crawler)."\n".$extraCss;
if ($brand === null || $this->normalizeHex($brand) === null) {
$brand = $this->matchCssVar($css, ['primary', 'brand', 'brand-primary', 'accent', 'color-primary', 'main', 'theme']);
}
$background = $this->matchCssVar($css, ['background', 'bg', 'background-color', 'surface', 'color-bg', 'body-bg']);
$text = $this->matchCssVar($css, ['foreground', 'text', 'color-text', 'on-background', 'body-color']);
if ($background === null) {
$background = $this->matchBlockRule($css, 'body', 'background-color')
?? $this->matchBlockRule($css, 'body', 'background')
?? $this->matchBlockRule($css, 'html', 'background-color')
?? $this->matchBlockRule($css, 'html', 'background');
}
if ($text === null) {
$text = $this->matchBlockRule($css, 'body', 'color')
?? $this->matchBlockRule($css, 'html', 'color');
}
return [
'brand_color' => $this->normalizeHex($brand),
'background_color' => $this->normalizeHex($background),
'text_color' => $this->normalizeHex($text),
];
}
private function collectStyleSheets(Crawler $crawler): string
{
$css = '';
$crawler->filter('style')->each(function (Crawler $node) use (&$css): void {
$css .= "\n".$node->text('');
});
return $css;
}
/**
* @param list<string> $names
*/
private function matchCssVar(string $css, array $names): ?string
{
foreach ($names as $name) {
if (preg_match('/--'.preg_quote($name, '/').'\s*:\s*([^;\n]+)/i', $css, $m) === 1) {
$value = trim($m[1]);
if ($this->normalizeHex($value) !== null) {
return $value;
}
}
}
return null;
}
private function matchBlockRule(string $css, string $selector, string $property): ?string
{
// Crude but effective: capture the contents of the first matching
// selector { ... } block and grep for the property declaration.
$pattern = '/(?:^|[^\.\#\w-])'.preg_quote($selector, '/').'\s*\{([^}]*)\}/i';
if (preg_match($pattern, $css, $block) !== 1) {
return null;
}
if (preg_match('/(?:^|;)\s*'.preg_quote($property, '/').'\s*:\s*([^;]+)/i', $block[1], $m) !== 1) {
return null;
}
return trim($m[1]);
}
private function normalizeHex(?string $value): ?string
{
if ($value === null) {
return null;
}
$trimmed = strtolower(trim($value));
if ($trimmed === '') {
return null;
}
if (preg_match('/#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})\b/i', $trimmed, $m) === 1) {
return strtolower($m[0]);
}
return null;
}
/**
* Body content converted for the LLM. Returns the main element when available,
* falling back to body, with script/style/nav/footer stripped.

View file

@ -14,6 +14,9 @@ public function __construct(
public string $tone = '',
public string $language = '',
public string $voiceNotes = '',
public string $brandColor = '',
public string $backgroundColor = '',
public string $textColor = '',
) {}
public static function fromResponse(ArrayAccess|array $response): self
@ -24,6 +27,26 @@ public static function fromResponse(ArrayAccess|array $response): self
tone: trim((string) data_get($response, 'tone', '')),
language: trim((string) data_get($response, 'language', '')),
voiceNotes: trim((string) data_get($response, 'voice_notes', '')),
brandColor: self::normalizeHex((string) data_get($response, 'brand_color', '')),
backgroundColor: self::normalizeHex((string) data_get($response, 'background_color', '')),
textColor: self::normalizeHex((string) data_get($response, 'text_color', '')),
);
}
private static function normalizeHex(string $value): string
{
$trimmed = strtolower(trim($value));
if ($trimmed === '') {
return '';
}
if (! str_starts_with($trimmed, '#')) {
$trimmed = '#'.$trimmed;
}
return preg_match('/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/', $trimmed) === 1
? $trimmed
: '';
}
}

View file

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Services\Brand;
use Illuminate\Support\Facades\Log;
use League\ColorExtractor\Color;
use League\ColorExtractor\ColorExtractor;
use League\ColorExtractor\Palette;
use Throwable;
/**
* Pulls the dominant non-neutral color out of a downloaded logo image. This is
* the same technique used by Brandfetch and the `color-thief` JavaScript
* library: quantize pixels into buckets, sort by frequency, then drop the
* grays/blacks/whites that show up in nearly every logo.
*/
final class LogoColorExtractor
{
private const int TOP_COLORS = 10;
public function __construct(private readonly SafeHttpFetcher $fetcher) {}
public function extractFromUrl(?string $logoUrl): ?string
{
if ($logoUrl === null || $logoUrl === '') {
return null;
}
$response = $this->fetcher->tryGet($logoUrl);
if ($response === null || ! $response->successful()) {
return null;
}
$body = $response->body();
if ($body === '') {
return null;
}
// league/color-extractor relies on GD, which doesn't speak SVG/ICO.
$contentType = strtolower((string) $response->header('Content-Type'));
if (str_contains($contentType, 'svg') || str_contains($contentType, 'icon')) {
return null;
}
$tempFile = tempnam(sys_get_temp_dir(), 'logo_color_');
file_put_contents($tempFile, $body);
try {
$palette = Palette::fromFilename($tempFile);
$extractor = new ColorExtractor($palette);
$colors = $extractor->extract(self::TOP_COLORS);
foreach ($colors as $colorInt) {
$hex = strtolower(Color::fromIntToHex($colorInt));
if (! $this->isNeutral($hex)) {
return $hex;
}
}
return null;
} catch (Throwable $e) {
Log::warning('LogoColorExtractor failed', [
'url' => $logoUrl,
'error' => $e->getMessage(),
]);
return null;
} finally {
@unlink($tempFile);
}
}
/**
* Reject true black/white and any color whose channels are within 18 points
* of each other (i.e. essentially gray). This filters out the dominant
* background a logo typically sits on.
*/
private function isNeutral(string $hex): bool
{
if (strlen($hex) !== 7 || $hex[0] !== '#') {
return true;
}
$r = (int) hexdec(substr($hex, 1, 2));
$g = (int) hexdec(substr($hex, 3, 2));
$b = (int) hexdec(substr($hex, 5, 2));
$maxDelta = max(abs($r - $g), abs($g - $b), abs($r - $b));
return $maxDelta < 18;
}
}

View file

@ -49,6 +49,7 @@
"laravel/socialite": "^5.24",
"laravel/tinker": "^3.0",
"laravel/wayfinder": "^0.1.9",
"league/color-extractor": "^0.4.0",
"league/flysystem-aws-s3-v3": "^3.0",
"league/html-to-markdown": "^5.1",
"posthog/posthog-php": "^4.1",

63
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "e91bdddec5e3a5554e27501569a73cdf",
"content-hash": "1bb5bb8df262a824129c6b0d33a6d2fa",
"packages": [
{
"name": "aws/aws-crt-php",
@ -3129,6 +3129,67 @@
},
"time": "2026-04-07T17:07:48+00:00"
},
{
"name": "league/color-extractor",
"version": "0.4.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/color-extractor.git",
"reference": "21fcac6249c5ef7d00eb83e128743ee6678fe505"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/color-extractor/zipball/21fcac6249c5ef7d00eb83e128743ee6678fe505",
"reference": "21fcac6249c5ef7d00eb83e128743ee6678fe505",
"shasum": ""
},
"require": {
"ext-gd": "*",
"php": "^7.3 || ^8.0"
},
"replace": {
"matthecat/colorextractor": "*"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~2",
"phpunit/phpunit": "^9.5"
},
"suggest": {
"ext-curl": "To download images from remote URLs if allow_url_fopen is disabled for security reasons"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\ColorExtractor\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mathieu Lechat",
"email": "math.lechat@gmail.com",
"homepage": "http://matthecat.com",
"role": "Developer"
}
],
"description": "Extract colors from an image as a human would do.",
"homepage": "https://github.com/thephpleague/color-extractor",
"keywords": [
"color",
"extract",
"human",
"image",
"palette"
],
"support": {
"issues": "https://github.com/thephpleague/color-extractor/issues",
"source": "https://github.com/thephpleague/color-extractor/tree/0.4.0"
},
"time": "2022-09-24T15:57:16+00:00"
},
{
"name": "league/commonmark",
"version": "2.8.2",

View file

@ -22,6 +22,9 @@ public function up(): void
$table->text('brand_description')->nullable();
$table->string('brand_tone')->default('professional');
$table->text('brand_voice_notes')->nullable();
$table->string('brand_color', 9)->nullable();
$table->string('background_color', 9)->nullable();
$table->string('text_color', 9)->nullable();
$table->string('content_language', 10)->default('en');
$table->timestamps();

View file

@ -106,6 +106,9 @@
'tone_educational' => 'Educational',
'voice_notes' => 'Voice notes',
'voice_notes_placeholder' => 'Additional writing guidelines, words to avoid, style preferences...',
'brand_color' => 'Brand color',
'background_color' => 'Background color',
'text_color' => 'Text color',
'content_language' => 'Content language',
'content_language_description' => 'Language used for AI-generated captions, hashtags, and any text inside generated images or videos.',
],

View file

@ -45,6 +45,9 @@
'content_language_description' => 'AI-generated captions will be written in this language.',
'voice_notes' => 'Voice notes (optional)',
'voice_notes_placeholder' => 'e.g. short, punchy sentences. avoid jargon.',
'brand_color' => 'Brand color',
'background_color' => 'Background color',
'text_color' => 'Text color',
'submit' => 'Create workspace',
'first_workspace_success' => 'Workspace created. Connect a social account to start posting.',
'success' => 'Workspace created.',

View file

@ -106,6 +106,9 @@
'tone_educational' => 'Educativo',
'voice_notes' => 'Notas de voz',
'voice_notes_placeholder' => 'Directrices adicionales de escritura, palabras a evitar, preferencias de estilo...',
'brand_color' => 'Color de marca',
'background_color' => 'Color de fondo',
'text_color' => 'Color de texto',
'content_language' => 'Idioma del contenido',
'content_language_description' => 'Idioma usado en los subtítulos, hashtags y cualquier texto dentro de imágenes o videos generados por IA.',
],

View file

@ -45,6 +45,9 @@
'content_language_description' => 'Las descripciones generadas por IA se escribirán en este idioma.',
'voice_notes' => 'Notas de voz (opcional)',
'voice_notes_placeholder' => 'ej. frases cortas y directas. evita jerga.',
'brand_color' => 'Color de marca',
'background_color' => 'Color de fondo',
'text_color' => 'Color de texto',
'submit' => 'Crear workspace',
'first_workspace_success' => 'Workspace creado. Conecta una cuenta social para empezar a publicar.',
'success' => 'Workspace creado.',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -106,6 +106,9 @@
'tone_educational' => 'Educacional',
'voice_notes' => 'Notas de voz',
'voice_notes_placeholder' => 'Diretrizes adicionais de escrita, palavras a evitar, preferências de estilo...',
'brand_color' => 'Cor da marca',
'background_color' => 'Cor de fundo',
'text_color' => 'Cor do texto',
'content_language' => 'Idioma do conteúdo',
'content_language_description' => 'Idioma usado nas legendas, hashtags e em qualquer texto dentro de imagens ou vídeos gerados por AI.',
],

View file

@ -45,6 +45,9 @@
'content_language_description' => 'Legendas geradas por IA serão escritas neste idioma.',
'voice_notes' => 'Notas de voz (opcional)',
'voice_notes_placeholder' => 'ex. frases curtas e diretas. sem jargão.',
'brand_color' => 'Cor da marca',
'background_color' => 'Cor de fundo',
'text_color' => 'Cor do texto',
'submit' => 'Criar workspace',
'first_workspace_success' => 'Workspace criado. Conecte uma conta social para começar a postar.',
'success' => 'Workspace criado.',

View file

@ -0,0 +1,285 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
interface Props {
modelValue: string | null | undefined;
name?: string;
placeholder?: string;
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '#000000',
disabled: false,
});
const emit = defineEmits<{
'update:modelValue': [value: string | null];
}>();
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const PRESETS = [
'#000000', '#ffffff', '#ef4444', '#f97316', '#eab308',
'#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899',
];
const text = ref(props.modelValue ?? '');
const open = ref(false);
// HSV state
const hue = ref(0);
const saturation = ref(1);
const value = ref(1);
const isValid = computed(() => text.value === '' || HEX_RE.test(text.value));
const swatchColor = computed(() => (isValid.value && text.value !== '' ? text.value : ''));
// --- color math -------------------------------------------------------------
const hexToRgb = (hex: string): [number, number, number] | null => {
const clean = hex.replace('#', '');
if (clean.length === 3) {
return [
parseInt(clean[0] + clean[0], 16),
parseInt(clean[1] + clean[1], 16),
parseInt(clean[2] + clean[2], 16),
];
}
if (clean.length === 6 || clean.length === 8) {
return [
parseInt(clean.slice(0, 2), 16),
parseInt(clean.slice(2, 4), 16),
parseInt(clean.slice(4, 6), 16),
];
}
return null;
};
const rgbToHex = (r: number, g: number, b: number): string => {
const toHex = (n: number) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
const rgbToHsv = (r: number, g: number, b: number): [number, number, number] => {
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn);
const min = Math.min(rn, gn, bn);
const d = max - min;
let h = 0;
if (d !== 0) {
if (max === rn) h = ((gn - bn) / d) % 6;
else if (max === gn) h = (bn - rn) / d + 2;
else h = (rn - gn) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
const s = max === 0 ? 0 : d / max;
return [h, s, max];
};
const hsvToRgb = (h: number, s: number, v: number): [number, number, number] => {
const c = v * s;
const hh = h / 60;
const x = c * (1 - Math.abs((hh % 2) - 1));
let r = 0, g = 0, b = 0;
if (hh >= 0 && hh < 1) [r, g, b] = [c, x, 0];
else if (hh < 2) [r, g, b] = [x, c, 0];
else if (hh < 3) [r, g, b] = [0, c, x];
else if (hh < 4) [r, g, b] = [0, x, c];
else if (hh < 5) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
const m = v - c;
return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
};
const syncHsvFromHex = (hex: string) => {
const rgb = hexToRgb(hex);
if (! rgb) return;
const [h, s, v] = rgbToHsv(...rgb);
if (s > 0) hue.value = h;
saturation.value = s;
value.value = v;
};
const emitFromHsv = () => {
const [r, g, b] = hsvToRgb(hue.value, saturation.value, value.value);
const hex = rgbToHex(r, g, b);
text.value = hex;
emit('update:modelValue', hex);
};
watch(
() => props.modelValue,
(incoming) => {
text.value = incoming ?? '';
if (incoming && HEX_RE.test(incoming)) syncHsvFromHex(incoming);
},
{ immediate: true },
);
// --- pointer interactions ---------------------------------------------------
const svRef = ref<HTMLElement | null>(null);
const hueRef = ref<HTMLElement | null>(null);
const setSvFromEvent = (event: PointerEvent) => {
const el = svRef.value;
if (! el) return;
const rect = el.getBoundingClientRect();
const x = Math.min(Math.max(event.clientX - rect.left, 0), rect.width);
const y = Math.min(Math.max(event.clientY - rect.top, 0), rect.height);
saturation.value = rect.width === 0 ? 0 : x / rect.width;
value.value = rect.height === 0 ? 0 : 1 - y / rect.height;
emitFromHsv();
};
const setHueFromEvent = (event: PointerEvent) => {
const el = hueRef.value;
if (! el) return;
const rect = el.getBoundingClientRect();
const x = Math.min(Math.max(event.clientX - rect.left, 0), rect.width);
hue.value = rect.width === 0 ? 0 : (x / rect.width) * 360;
emitFromHsv();
};
const onSvPointerDown = (event: PointerEvent) => {
(event.target as HTMLElement).setPointerCapture(event.pointerId);
setSvFromEvent(event);
};
const onSvPointerMove = (event: PointerEvent) => {
if (event.buttons === 0) return;
setSvFromEvent(event);
};
const onHuePointerDown = (event: PointerEvent) => {
(event.target as HTMLElement).setPointerCapture(event.pointerId);
setHueFromEvent(event);
};
const onHuePointerMove = (event: PointerEvent) => {
if (event.buttons === 0) return;
setHueFromEvent(event);
};
// --- text input -------------------------------------------------------------
const onTextInput = (event: Event) => {
const target = event.target as HTMLInputElement;
let next = target.value;
if (next !== '' && ! next.startsWith('#')) next = `#${next}`;
text.value = next.slice(0, 9);
if (text.value === '') {
emit('update:modelValue', null);
} else if (HEX_RE.test(text.value)) {
emit('update:modelValue', text.value.toLowerCase());
syncHsvFromHex(text.value);
}
};
const pickPreset = (hex: string) => {
text.value = hex;
syncHsvFromHex(hex);
emit('update:modelValue', hex);
};
const hueColor = computed(() => {
const [r, g, b] = hsvToRgb(hue.value, 1, 1);
return rgbToHex(r, g, b);
});
const svPointerStyle = computed(() => ({
left: `${saturation.value * 100}%`,
top: `${(1 - value.value) * 100}%`,
}));
const huePointerStyle = computed(() => ({
left: `${(hue.value / 360) * 100}%`,
}));
</script>
<template>
<div class="flex items-center gap-2">
<Popover v-model:open="open">
<PopoverTrigger as-child>
<button
type="button"
:disabled="disabled"
class="size-9 shrink-0 rounded-md border transition-shadow hover:shadow-sm disabled:cursor-not-allowed disabled:opacity-50"
:style="{ backgroundColor: swatchColor || 'transparent' }"
:aria-label="placeholder"
/>
</PopoverTrigger>
<PopoverContent class="w-64 space-y-3 p-3" align="start">
<!-- Saturation / Value -->
<div
ref="svRef"
class="relative h-40 w-full cursor-crosshair touch-none overflow-hidden rounded-md"
:style="{
backgroundColor: hueColor,
backgroundImage:
'linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)',
}"
@pointerdown="onSvPointerDown"
@pointermove="onSvPointerMove"
>
<div
class="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow ring-1 ring-black/40"
:style="svPointerStyle"
/>
</div>
<!-- Hue slider -->
<div
ref="hueRef"
class="relative h-3 w-full cursor-pointer touch-none rounded-full"
style="background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);"
@pointerdown="onHuePointerDown"
@pointermove="onHuePointerMove"
>
<div
class="pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-transparent shadow ring-1 ring-black/40"
:style="huePointerStyle"
/>
</div>
<!-- Hex input -->
<Input
:value="text"
:placeholder="placeholder"
class="font-mono"
:class="!isValid ? 'border-destructive focus-visible:ring-destructive' : ''"
spellcheck="false"
@input="onTextInput"
/>
<!-- Presets -->
<div class="flex flex-wrap gap-1.5">
<button
v-for="hex in PRESETS"
:key="hex"
type="button"
class="size-5 rounded-full border border-border/50 transition-transform hover:scale-110"
:style="{ backgroundColor: hex }"
:aria-label="hex"
@click="pickPreset(hex)"
/>
</div>
</PopoverContent>
</Popover>
<Input
:value="text"
:name="name"
:placeholder="placeholder"
:disabled="disabled"
class="font-mono"
:class="!isValid ? 'border-destructive focus-visible:ring-destructive' : ''"
spellcheck="false"
@input="onTextInput"
/>
</div>
</template>

View file

@ -5,6 +5,7 @@ import { computed, ref } from 'vue';
import WorkspaceController from '@/actions/App/Http/Controllers/App/WorkspaceController';
import HeadingSmall from '@/components/HeadingSmall.vue';
import HexColorInput from '@/components/HexColorInput.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -25,6 +26,9 @@ interface Workspace {
brand_description: string | null;
brand_tone: string;
brand_voice_notes: string | null;
brand_color: string | null;
background_color: string | null;
text_color: string | null;
content_language: string;
}
@ -34,6 +38,9 @@ const props = defineProps<{
const brandTone = ref(props.workspace.brand_tone ?? 'professional');
const contentLanguage = ref(props.workspace.content_language ?? 'en');
const brandColor = ref<string | null>(props.workspace.brand_color);
const backgroundColor = ref<string | null>(props.workspace.background_color);
const textColor = ref<string | null>(props.workspace.text_color);
const toneLabel = computed(() =>
brandTone.value ? trans(`settings.brand.tone_${brandTone.value}`) : '',
@ -133,6 +140,24 @@ const languageLabel = computed(() => {
{{ $t('settings.brand.content_language_description') }}
</p>
<div class="grid gap-4 sm:grid-cols-3">
<div class="grid gap-2">
<Label for="brand_color">{{ $t('settings.brand.brand_color') }}</Label>
<HexColorInput v-model="brandColor" name="brand_color" />
<InputError :message="errors.brand_color" />
</div>
<div class="grid gap-2">
<Label for="background_color">{{ $t('settings.brand.background_color') }}</Label>
<HexColorInput v-model="backgroundColor" name="background_color" />
<InputError :message="errors.background_color" />
</div>
<div class="grid gap-2">
<Label for="text_color">{{ $t('settings.brand.text_color') }}</Label>
<HexColorInput v-model="textColor" name="text_color" />
<InputError :message="errors.text_color" />
</div>
</div>
<div class="grid gap-2">
<Label for="brand_voice_notes">{{ $t('settings.brand.voice_notes') }}</Label>
<Textarea

View file

@ -5,6 +5,7 @@ import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import HexColorInput from '@/components/HexColorInput.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -26,6 +27,9 @@ const form = useForm({
brand_description: '',
brand_tone: 'professional',
brand_voice_notes: '',
brand_color: null as string | null,
background_color: null as string | null,
text_color: null as string | null,
content_language: 'en',
logo_url: '' as string | null,
});
@ -39,6 +43,9 @@ interface AutofillResponse {
content_language: string | null;
brand_tone: string | null;
brand_voice_notes: string | null;
brand_color: string | null;
background_color: string | null;
text_color: string | null;
logo_url: string | null;
}
@ -81,6 +88,9 @@ const runAutofill = async () => {
if (data?.content_language) form.content_language = data.content_language;
if (data?.brand_tone) form.brand_tone = data.brand_tone;
if (data?.brand_voice_notes) form.brand_voice_notes = data.brand_voice_notes;
if (data?.brand_color) form.brand_color = data.brand_color;
if (data?.background_color) form.background_color = data.background_color;
if (data?.text_color) form.text_color = data.text_color;
if (data?.logo_url) {
form.logo_url = data.logo_url;
@ -198,6 +208,24 @@ const runAutofill = async () => {
{{ $t('workspaces.create.content_language_description') }}
</p>
<div class="grid gap-4 sm:grid-cols-3">
<div class="grid gap-2">
<Label for="brand_color">{{ $t('workspaces.create.brand_color') }}</Label>
<HexColorInput v-model="form.brand_color" />
<InputError :message="form.errors.brand_color" />
</div>
<div class="grid gap-2">
<Label for="background_color">{{ $t('workspaces.create.background_color') }}</Label>
<HexColorInput v-model="form.background_color" />
<InputError :message="form.errors.background_color" />
</div>
<div class="grid gap-2">
<Label for="text_color">{{ $t('workspaces.create.text_color') }}</Label>
<HexColorInput v-model="form.text_color" />
<InputError :message="form.errors.text_color" />
</div>
</div>
<div class="grid gap-2">
<Label for="brand_voice_notes">{{ $t('workspaces.create.voice_notes') }}</Label>
<Textarea

View file

@ -25,4 +25,10 @@
- "Use technical but approachable language. Reference specific features by name. Avoid generic marketing buzzwords."
- "Keep sentences short and punchy. Use emojis sparingly. Address the reader as 'you'."
Be accurate and specific to what the page actually shows. Do not invent features or claims that aren't on the page.
6. **brand_color** the primary brand color as a hex string starting with `#`, lowercase, 6 digits (e.g. `#0ea5e9`). This is the accent color most prominently used in CTAs, primary buttons, links, or the logo. Return an empty string if you can't confidently identify it.
7. **background_color** the dominant page background color as a hex string starting with `#`, lowercase, 6 digits (e.g. `#ffffff` for light themes, `#0b0f19` for dark themes). Return an empty string if you can't confidently identify it.
8. **text_color** the dominant body text color as a hex string starting with `#`, lowercase, 6 digits (e.g. `#0f172a`). Return an empty string if you can't confidently identify it.
Be accurate and specific to what the page actually shows. Do not invent features or claims that aren't on the page. For colors, prefer values visible in the markup/CSS; never guess.

View file

@ -38,12 +38,13 @@
use App\Http\Middleware\App\EnsureAccountReady;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return redirect()->route('app.calendar');
})->name('app.home')->middleware(['auth', 'verified']);
// Subscription selection (requires auth but not subscription)
Route::middleware(['auth', 'verified'])->group(function () {
Route::middleware(['auth'])->group(function () {
Route::get('/', function () {
return redirect()->route('app.calendar');
})->name('app.home');
Route::get('subscribe', [BillingController::class, 'subscribe'])->name('app.subscribe');
Route::post('billing/checkout/{plan}', [BillingController::class, 'checkout'])->name('app.billing.checkout');
Route::get('billing/processing', [BillingController::class, 'processing'])->name('app.billing.processing');
@ -63,7 +64,7 @@
});
// Social Connect routes
Route::middleware(['auth', 'verified'])->group(function () {
Route::middleware(['auth'])->group(function () {
Route::get('connect/linkedin', [LinkedInController::class, 'connect'])->name('app.social.linkedin.connect');
Route::get('accounts/linkedin/callback', [LinkedInController::class, 'callback'])->name('app.social.linkedin.callback');
@ -113,7 +114,7 @@
});
// Routes that require active subscription and completed onboarding
Route::middleware(['auth', 'verified', EnsureAccountReady::class])->group(function () {
Route::middleware(['auth', EnsureAccountReady::class])->group(function () {
// Workspaces
Route::get('workspaces', [WorkspaceController::class, 'index'])->name('app.workspaces.index');
Route::post('workspaces/{workspace}/switch', [WorkspaceController::class, 'switch'])->name('app.workspaces.switch');
@ -227,7 +228,7 @@
Route::put('settings/language', [ProfileController::class, 'updateLanguage'])->name('app.profile.language');
});
Route::middleware(['auth', 'verified'])->group(function () {
Route::middleware(['auth'])->group(function () {
Route::delete('settings/profile', [ProfileController::class, 'destroy'])->name('app.profile.destroy');
Route::get('settings/password', [PasswordController::class, 'edit'])->name('app.user-password.edit');
Route::put('settings/password', [PasswordController::class, 'update'])

View file

@ -161,6 +161,146 @@
expect($result->name)->toBe('Sendkit');
});
test('extracts brand color from theme-color meta', function () {
Http::fake([
'example.com' => Http::response(<<<'HTML'
<!DOCTYPE html>
<html>
<head>
<title>Acme</title>
<meta name="theme-color" content="#0ea5e9">
</head>
<body></body>
</html>
HTML, 200),
]);
$result = ($this->autofill)('https://example.com');
expect($result->brandColor)->toBe('#0ea5e9');
});
test('extracts colors from CSS custom properties', function () {
Http::fake([
'example.com' => Http::response(<<<'HTML'
<!DOCTYPE html>
<html>
<head>
<title>Acme</title>
<style>
:root {
--primary: #ff5722;
--background: #0b0f19;
--foreground: #e2e8f0;
}
</style>
</head>
<body></body>
</html>
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'
<!DOCTYPE html>
<html>
<head>
<title>Acme</title>
<style>
body {
background-color: #ffffff;
color: #1f2937;
}
</style>
</head>
<body></body>
</html>
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'
<!DOCTYPE html>
<html>
<head>
<title>Acme</title>
<link rel="stylesheet" href="/app.css">
</head>
<body></body>
</html>
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'
<!DOCTYPE html>
<html>
<head>
<title>Acme</title>
<link rel="icon" sizes="32x32" href="/logo.png">
</head>
<body>Hello</body>
</html>
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'
<!DOCTYPE html>
<html>
<head>
<title>Acme</title>
<meta name="theme-color" content="rgb(255, 0, 0)">
<style>:root { --primary: hsl(200 50% 50%); --background: red; }</style>
</head>
<body></body>
</html>
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),

View file

@ -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');

BIN
tests/fixtures/blue-logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 B