diff --git a/.claude/project/design/theming.md b/.claude/project/design/theming.md index 581fd6e..a564d49 100644 --- a/.claude/project/design/theming.md +++ b/.claude/project/design/theming.md @@ -52,7 +52,7 @@ theme + the mechanism + one commented example hook **only**. Concrete brand themes (real colours, fonts, logos) and their assets live in the **private infra repo** and are mounted/selected per deployment. Never commit brand overlays or real logos here (see `rules.md` → Tabus). The prototype's base64 brand logos -(MTC/MHZ/Lorenz) were deliberately not ported for this reason. +were deliberately not ported for this reason. ## Logo slot diff --git a/.claude/skills/design-adoption/SKILL.md b/.claude/skills/design-adoption/SKILL.md new file mode 100644 index 0000000..c0c7900 --- /dev/null +++ b/.claude/skills/design-adoption/SKILL.md @@ -0,0 +1,114 @@ +--- +name: design-adoption +description: >- + Adopt a shop's visual identity into the Revoco withdrawal form at deploy time. + Use when setting up a Revoco deployment and you want the form to match a + specific shop's website (colours, fonts, logo). Scans the shop URL, extracts + its corporate identity, and generates a ready-to-place `--wf-*` brand theme + overlay plus a placement report. Deploy-time authoring only — not app runtime. +--- + +# Design Adoption — brand a Revoco deployment from a shop URL + +This skill helps an operator (or their agency) make the Revoco withdrawal form +look like a specific shop's website, without hand-writing CSS. You scan the shop, +extract its corporate identity, and hand it to the deterministic generator command +`revoco:make-theme`, which produces a validated theme overlay and tells the +operator exactly where to put it. + +**You do not write the CSS yourself.** Your job is extraction + mapping; the +command renders and validates the overlay against Revoco's `--wf-*` token contract +(see `.claude/project/design/theming.md`). This keeps the output correct and a11y-safe. + +## Guardrails (read first) + +- **Operator-reviewed, never auto-published.** You produce a draft overlay + a + placement report. The operator places the file and reviews the rendered form + before it goes live. Never claim the result is final. +- **Never commit brand output to this public repo.** The overlay and any logo are + operator/brand assets — they belong in the operator's *private* infra repo or + deployment mount, selected per deployment via `APP_THEME`. Committing them here + violates the project's public/private split (`rules.md` → Tabus). +- **Do not fabricate brand values.** If you cannot confidently extract a colour or + font, ask the operator to confirm it rather than guessing. + +## Procedure + +### 1. Ask for the shop URL + +Ask the operator for the website whose look the form should adopt (e.g. the shop's +home page). If they also have a specific brand/style guide page, take that too. + +### 2. Scan and extract the corporate identity + +Load the page and extract the brand signals. Use whatever browser/fetch tooling is +available (the Claude-in-Chrome tools give you *computed* styles, which are more +reliable than raw HTML; a plain fetch of the CSS is a fallback). Extract: + +- **Colours** — the primary accent (buttons/links), its hover state, body text, + heading text, muted text, the card/surface background, and border colour. +- **Fonts** — the body font stack and the heading/display font stack. +- **Logo** — the URL of the shop's logo image. +- **Brand name** — the shop/company name. + +Prefer values taken from computed styles of prominent elements (primary button, +headings, body). When a value is ambiguous, show the operator your candidate and +confirm before using it. + +### 3. Map to `revoco:make-theme` options + +Each extracted value maps to one command option (which maps to one `--wf-*` token): + +| Extracted signal | Option | +|---|---| +| accent / primary | `--accent` (+ `--accent-hover`) | +| body text | `--fg` | +| heading text | `--heading-fg` | +| muted text | `--muted` | +| card / surface bg | `--card-bg` | +| border | `--card-border` | +| button bg / hover / text | `--btn-bg` / `--btn-hover` / `--btn-fg` | +| body font stack | `--font` | +| heading font stack | `--font-display` | +| logo URL | `--logo-url` (guidance only) | +| brand name | `--brand-name` (guidance only) | + +Only pass the options you actually extracted — unset tokens correctly fall through +to the neutral default. Colours may be hex, `rgb()/rgba()`, `hsl()/hsla()` or a CSS +named colour; the command rejects anything malformed. + +### 4. Run the generator + +Choose a short lowercase `--slug` for the deployment (e.g. the brand name) and +write the overlay to a file with `--output`: + +``` +task artisan -- revoco:make-theme \ + --slug= \ + --accent='#c1121f' --accent-hover='#a00e19' \ + --fg='#1a1a1a' --heading-fg='#111111' \ + --card-bg='#ffffff' --card-border='#e5e5e5' \ + --font='"Inter", system-ui, sans-serif' \ + --font-display='"Poppins", sans-serif' \ + --logo-url='https://shop.example/logo.svg' \ + --brand-name='Example Shop' \ + --output=./-theme.css +``` + +(Outside the Docker/`task` setup, call `php artisan revoco:make-theme …` directly.) +If the command reports an invalid value, fix that one input and re-run — it writes +nothing on a validation error. + +### 5. Hand the result to the operator + +Show the operator the generated `-theme.css` and the command's **placement +report** verbatim, then walk them through it: + +1. Move the overlay into their private infra repo / deployment mount (never this repo). +2. Set `APP_THEME=` in the deployment `.env`. +3. Wire the logo via `REVOCO_LOGO_URL` (and `REVOCO_BRAND_NAME`) in `.env`. +4. **Review** the rendered form against the shop, and check focus-ring/text contrast + against the new accent before going live. + +End by reminding them the result is a starting draft they own and must review — the +form's final appearance and any brand-asset licensing are their responsibility. diff --git a/.claude/skills/legal-extraction/SKILL.md b/.claude/skills/legal-extraction/SKILL.md new file mode 100644 index 0000000..eb65828 --- /dev/null +++ b/.claude/skills/legal-extraction/SKILL.md @@ -0,0 +1,109 @@ +--- +name: legal-extraction +description: >- + Import an operator's existing Impressum and privacy-policy pages into Revoco's + legal settings at deploy time. Use when setting up a Revoco deployment and the + operator already has legal pages elsewhere (their shop, a CMS) that should + populate the withdrawal form's Impressum/Datenschutz. Scrapes the given URLs, + extracts the content, and loads it via `revoco:import-legal` for review in + Filament. Deploy-time authoring only — not app runtime. +--- + +# Legal Extraction — populate Revoco's legal pages from existing URLs + +This skill helps an operator fill Revoco's Impressum (§ 5 DDG) and privacy policy +from pages they already publish, without hand-copying field by field. You scrape +the pages, map the content to a JSON payload, and hand it to the deterministic +`revoco:import-legal` command, which validates, sanitises and persists it into the +Filament-managed legal settings. + +**You do not decide what is legally correct.** Your job is faithful extraction + +mapping; the command validates the shape and sanitises the HTML. The operator +reviews and owns the result. + +## Guardrails (read first) + +- **Operator-reviewed, never auto-published.** The command fills the settings; the + operator reviews and edits them in **Filament → Legal** before they go live. The + panel is the review gate — never present the import as final or legally correct. +- **The operator is the data controller.** Revoco makes no legal-correctness + guarantee. Extract only what is actually on the pages; **do not invent, complete + or "improve" legal fields.** Leave anything you cannot find out of the payload. +- **Never clobber reviewed text silently.** If the command refuses because a field + is already populated, stop and ask the operator before re-running with + `--overwrite`. +- **Scope to the stated locale.** Per-locale fields (address, addendum, privacy + content) are written under the `--locale` you were given. Confirm which language + the scraped pages are in. + +## Procedure + +### 1. Ask for the URLs and the locale + +Ask the operator for: +- the **Impressum** URL, +- the **privacy-policy** URL, +- the **locale** those pages are written in (e.g. `de`). + +Either URL may be omitted if that page does not exist yet. + +### 2. Scrape and extract + +Load each page (Claude-in-Chrome tools or a fetch) and extract: + +- **Impressum → § 5 DDG fields:** entity name, legal form, represented-by, full + postal address, e-mail, phone, register court + number, VAT ID, business ID, and + (only if present) supervisory authority, chamber, statutory job title, + professional rules, liquidation note. Any remaining free-form text becomes the + `addendum`. +- **Privacy → the policy body** as HTML (keep the meaningful structure — headings, + paragraphs, lists; drop the site chrome/navigation). + +Take values verbatim from the page. When a field is ambiguous or absent, leave it out. + +### 3. Build the JSON payload + +Write a payload file matching this shape (include only the keys you actually found): + +```json +{ + "imprint": { + "name": "Muster GmbH", + "legal_form": "GmbH", + "represented_by": "Erika Musterfrau", + "address": "Musterstraße 1\n12345 Musterstadt", + "email": "kontakt@example.com", + "phone": "+49 123 456789", + "register_court": "Amtsgericht Musterstadt", + "register_number": "HRB 12345", + "vat_id": "DE123456789", + "addendum": "

optional free-form additions

" + }, + "privacy": { + "content": "

Datenschutzerklärung

" + } +} +``` + +Structured imprint fields are global; `address`, `addendum` and `privacy.content` +are stored per the given locale. `addendum` and `privacy.content` are HTML (they are +sanitised on import); everything else is plain text. + +### 4. Run the importer + +``` +task artisan -- revoco:import-legal --locale=de --input=./legal-payload.json +``` + +(Outside the Docker/`task` setup, call `php artisan revoco:import-legal …` directly.) +It writes nothing on a validation error (unknown key, malformed e-mail), and it +**refuses to overwrite already-populated fields** — if it does, report which fields +conflict and ask the operator before re-running with `--overwrite`. + +### 5. Hand the result to the operator + +Show the imported-fields summary, then direct the operator to **Filament → Legal** +to review and correct every field, and to check the rendered `/impressum` and +`/datenschutz` pages. Remind them that nothing is auto-published, the content is +theirs to verify, and legal correctness is their responsibility as the data +controller. diff --git a/README.md b/README.md index 92d502a..309293b 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,74 @@ so changing them needs no redeploy. Key environment variables: --- +## Branding the form + +The form is neutral by default. Its whole appearance is driven by one `--wf-*` CSS +token contract, switched via `data-theme` (`APP_THEME`). Concrete brand overlays — +real colours, fonts and logos — never ship in this public repo; they live in your +private infra repo / deployment mount (see +[`.claude/project/design/theming.md`](.claude/project/design/theming.md)). + +### The `design-adoption` skill + +To match a shop's look without hand-writing CSS, this repo ships a **deploy-time +Claude-Code skill**, `design-adoption` (in +[`.claude/skills/design-adoption/`](.claude/skills/design-adoption/SKILL.md)) — an +authoring tool, not an app-runtime feature. Run it inside a Claude Code session: +give it the shop's URL and it scans the site, extracts the corporate identity +(colours, fonts, logo), and produces a ready-to-place brand overlay plus a +placement report. + +Under the hood it calls a deterministic Artisan command you can also run directly: + +```bash +task artisan -- revoco:make-theme \ + --slug=myshop \ + --accent='#4f46e5' --fg='#1f2937' \ + --font='"Inter", system-ui, sans-serif' \ + --logo-url='https://myshop.example/logo.svg' \ + --brand-name='My Shop' \ + --output=./myshop-theme.css +``` + +The command validates every value against the `--wf-*` contract, rejects malformed +input, and never overrides accessibility-critical tokens (the focus ring stays +intact). The result is **operator-reviewed and operator-placed**: move the overlay +into your deployment, set `APP_THEME=`, wire the logo via `REVOCO_LOGO_URL`, +and review the rendered form before going live. Nothing is auto-published, and +brand assets are never committed to this repo. + +--- + +## Legal pages (Impressum & privacy) + +Revoco serves an Impressum (§ 5 DDG) and a privacy policy at `/impressum` and +`/datenschutz`, backed by operator-editable settings in the Filament **Legal** panel +(neutral "not configured yet" placeholders until filled). The operator is the data +controller and authors the substantive text — Revoco ships only the mechanism. + +### The `legal-extraction` skill + +If the operator already publishes an Impressum and privacy policy elsewhere (their shop, +a CMS), this repo ships a **deploy-time Claude-Code skill**, `legal-extraction` (in +[`.claude/skills/legal-extraction/`](.claude/skills/legal-extraction/SKILL.md)), to +import them instead of copying field by field. Give it the two URLs and the locale; it +scrapes the pages, extracts the § 5 DDG fields and the privacy content, and hands a JSON +payload to a deterministic Artisan command: + +```bash +task artisan -- revoco:import-legal --locale=de --input=./legal-payload.json +``` + +The command validates the payload against the settings schema (unknown key or malformed +e-mail → nothing written), sanitises scraped HTML, scopes per-locale fields to `--locale`, +and **refuses to overwrite already-populated fields** unless `--overwrite` is given — your +reviewed legal text is never silently replaced. The import is **operator-reviewed**: open +Filament → Legal, correct every field, and check the rendered pages before relying on them. +Revoco makes no legal-correctness guarantee. + +--- + ## CI / Release GitHub Actions are configured in [`.github/workflows/`](.github/workflows/): diff --git a/app/Console/Commands/ImportLegalCommand.php b/app/Console/Commands/ImportLegalCommand.php new file mode 100644 index 0000000..0339e41 --- /dev/null +++ b/app/Console/Commands/ImportLegalCommand.php @@ -0,0 +1,141 @@ +resolveLocale(); + if ($locale === null) { + return self::FAILURE; + } + + $payload = $this->readPayload(); + if ($payload === null) { + return self::FAILURE; + } + + try { + $result = $importer->import($payload, $locale, (bool) $this->option('overwrite')); + } catch (InvalidArgumentException $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + + if (! $result->applied) { + $this->error('Refusing to overwrite already-populated legal fields: '.implode(', ', $result->conflicts)); + $this->line('Pass --overwrite to replace them, or clear them first in Filament → Legal.'); + + return self::FAILURE; + } + + return $this->report($result->written, $locale); + } + + private function resolveLocale(): ?string + { + $locale = $this->stringOption('locale'); + if ($locale !== null) { + return $locale; + } + + if (! $this->input->isInteractive()) { + $this->error('The --locale option is required in non-interactive mode.'); + + return null; + } + + return text(label: 'Locale the scraped pages are written in (e.g. de)', required: true); + } + + /** + * Resolve and decode the JSON payload file. + * + * @return array|null + */ + private function readPayload(): ?array + { + $path = $this->stringOption('input'); + if ($path === null) { + if (! $this->input->isInteractive()) { + $this->error('The --input option (path to the JSON payload) is required in non-interactive mode.'); + + return null; + } + $path = text(label: 'Path to the JSON payload file', required: true); + } + + if (! is_file($path)) { + $this->error("Input file not found: {$path}"); + + return null; + } + + $decoded = json_decode((string) file_get_contents($path), true); + if (! is_array($decoded)) { + $this->error("Input file is not a valid JSON object: {$path}"); + + return null; + } + + return $decoded; + } + + /** + * @param list $written + */ + private function report(array $written, string $locale): int + { + if ($written === []) { + $this->warn('Nothing to import — the payload carried no legal fields.'); + + return self::SUCCESS; + } + + $this->info(count($written)." legal field(s) imported for locale '{$locale}':"); + foreach ($written as $field) { + $this->line(" - {$field}"); + } + + $this->line(''); + $this->info('Review the imported content in Filament → Legal before relying on it — you are the data controller; Revoco makes no legal-correctness guarantee.'); + + return self::SUCCESS; + } + + private function stringOption(string $name): ?string + { + $value = $this->option($name); + + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/app/Console/Commands/MakeThemeCommand.php b/app/Console/Commands/MakeThemeCommand.php new file mode 100644 index 0000000..43e7063 --- /dev/null +++ b/app/Console/Commands/MakeThemeCommand.php @@ -0,0 +1,156 @@ + the --wf-* token it maps to. Iteration order fixes the + * emitted-token order, so the rendered overlay is deterministic. + */ + private const OPTION_TOKEN_MAP = [ + 'accent' => '--wf-accent', + 'accent-hover' => '--wf-accent-hover', + 'fg' => '--wf-fg', + 'heading-fg' => '--wf-heading-fg', + 'muted' => '--wf-muted', + 'card-bg' => '--wf-card-bg', + 'card-border' => '--wf-card-border', + 'btn-bg' => '--wf-btn-bg', + 'btn-hover' => '--wf-btn-hover', + 'btn-fg' => '--wf-btn-fg', + 'font' => '--wf-font', + 'font-display' => '--wf-font-display', + ]; + + public function handle(ThemeOverlayGenerator $generator): int + { + $slug = $this->resolveSlug(); + if ($slug === null) { + return self::FAILURE; + } + + try { + $overlay = $generator->generate( + $slug, + $this->collectTokens(), + $this->stringOption('logo-url'), + $this->stringOption('brand-name'), + ); + } catch (InvalidArgumentException $e) { + $this->error($e->getMessage()); + + return self::FAILURE; + } + + return $this->emit($overlay); + } + + /** Resolve the required slug from the option or an interactive prompt. */ + private function resolveSlug(): ?string + { + $slug = $this->stringOption('slug'); + if ($slug !== null) { + return $slug; + } + + if (! $this->input->isInteractive()) { + $this->error('The --slug option is required in non-interactive mode.'); + + return null; + } + + return text(label: 'Theme slug (data-theme / APP_THEME value)', required: true); + } + + /** + * @return array + */ + private function collectTokens(): array + { + $tokens = []; + foreach (self::OPTION_TOKEN_MAP as $option => $token) { + $value = $this->stringOption($option); + if ($value !== null) { + $tokens[$token] = $value; + } + } + + return $tokens; + } + + private function emit(ThemeOverlay $overlay): int + { + foreach ($overlay->warnings as $warning) { + $this->warn($warning); + } + + $output = $this->stringOption('output'); + if ($output !== null) { + if (file_put_contents($output, $overlay->css) === false) { + $this->error("Could not write the overlay to {$output}."); + + return self::FAILURE; + } + $this->info("Overlay written to {$output}"); + } else { + $this->line($overlay->css); + } + + $this->line(''); + $this->info('Placement report:'); + foreach ($overlay->report as $line) { + $this->line(" - {$line}"); + } + + return self::SUCCESS; + } + + private function stringOption(string $name): ?string + { + $value = $this->option($name); + + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/app/Services/LegalContentImporter.php b/app/Services/LegalContentImporter.php new file mode 100644 index 0000000..3e7bf58 --- /dev/null +++ b/app/Services/LegalContentImporter.php @@ -0,0 +1,227 @@ + the global (locale-independent) LegalSettings property. */ + private const IMPRINT_GLOBAL_KEYS = [ + 'name' => 'imprint_name', + 'legal_form' => 'imprint_legal_form', + 'represented_by' => 'imprint_represented_by', + 'email' => 'imprint_email', + 'phone' => 'imprint_phone', + 'contact_note' => 'imprint_contact_note', + 'register_court' => 'imprint_register_court', + 'register_number' => 'imprint_register_number', + 'vat_id' => 'imprint_vat_id', + 'business_id' => 'imprint_business_id', + 'supervisory_authority' => 'imprint_supervisory_authority', + 'chamber' => 'imprint_chamber', + 'job_title' => 'imprint_job_title', + 'professional_rules' => 'imprint_professional_rules', + 'liquidation_note' => 'imprint_liquidation_note', + ]; + + public function __construct(private readonly LegalSettings $settings) {} + + /** + * @param array $raw The decoded JSON payload. + * + * @throws InvalidArgumentException on an invalid locale, unknown key or bad e-mail. + */ + public function import(array $raw, string $locale, bool $overwrite): LegalImportResult + { + $this->assertValidLocale($locale); + $parsed = $this->parse($raw); + + $conflicts = $this->conflicts($parsed, $locale); + if ($conflicts !== [] && ! $overwrite) { + return new LegalImportResult(applied: false, written: [], conflicts: $conflicts); + } + + return new LegalImportResult(applied: true, written: $this->apply($parsed, $locale), conflicts: []); + } + + /** + * Validate and normalize the raw payload into the fields we may write. + * + * @param array $raw + * @return array{globals: array, address: ?string, addendum: ?string, privacy: ?string} + */ + private function parse(array $raw): array + { + foreach (array_keys($raw) as $key) { + if (! in_array($key, ['imprint', 'privacy'], true)) { + throw new InvalidArgumentException("Unknown top-level key '{$key}' (allowed: imprint, privacy)."); + } + } + + $imprint = $this->section($raw, 'imprint'); + $privacy = $this->section($raw, 'privacy'); + + $globals = []; + $address = null; + $addendum = null; + foreach ($imprint as $key => $value) { + if (isset(self::IMPRINT_GLOBAL_KEYS[$key])) { + $globals[self::IMPRINT_GLOBAL_KEYS[$key]] = $this->plainText($this->asString($key, $value)); + } elseif ($key === 'address') { + $address = $this->plainText($this->asString('address', $value)); + } elseif ($key === 'addendum') { + $addendum = $this->sanitizeHtml($this->asString('addendum', $value)); + } else { + throw new InvalidArgumentException("Unknown imprint key '{$key}'."); + } + } + + $privacyContent = null; + foreach ($privacy as $key => $value) { + if ($key === 'content') { + $privacyContent = $this->sanitizeHtml($this->asString('content', $value)); + } else { + throw new InvalidArgumentException("Unknown privacy key '{$key}'."); + } + } + + $email = $globals['imprint_email'] ?? ''; + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) { + throw new InvalidArgumentException("imprint.email is not a valid e-mail address: '{$email}'."); + } + + // Skip empty values so a missing scrape field never blanks an existing setting. + $globals = array_filter($globals, static fn (string $v): bool => $v !== ''); + + return [ + 'globals' => $globals, + 'address' => $address === '' ? null : $address, + 'addendum' => ($addendum === null || trim(strip_tags($addendum)) === '') ? null : $addendum, + 'privacy' => ($privacyContent === null || trim(strip_tags($privacyContent)) === '') ? null : $privacyContent, + ]; + } + + /** + * @param array{globals: array, address: ?string, addendum: ?string, privacy: ?string} $parsed + * @return list + */ + private function conflicts(array $parsed, string $locale): array + { + $conflicts = []; + foreach ($parsed['globals'] as $property => $_value) { + if ($this->isPopulated($this->settings->{$property})) { + $conflicts[] = $property; + } + } + if ($parsed['address'] !== null && ($this->settings->imprint_address[$locale] ?? '') !== '') { + $conflicts[] = "imprint_address[{$locale}]"; + } + if ($parsed['addendum'] !== null && ($this->settings->imprint_addendum[$locale] ?? '') !== '') { + $conflicts[] = "imprint_addendum[{$locale}]"; + } + if ($parsed['privacy'] !== null && ($this->settings->privacy_content[$locale] ?? '') !== '') { + $conflicts[] = "privacy_content[{$locale}]"; + } + + return $conflicts; + } + + /** + * @param array{globals: array, address: ?string, addendum: ?string, privacy: ?string} $parsed + * @return list + */ + private function apply(array $parsed, string $locale): array + { + $written = []; + foreach ($parsed['globals'] as $property => $value) { + $this->settings->{$property} = $value; + $written[] = $property; + } + if ($parsed['address'] !== null) { + $this->settings->imprint_address = [$locale => $parsed['address']] + $this->settings->imprint_address; + $written[] = "imprint_address[{$locale}]"; + } + if ($parsed['addendum'] !== null) { + $this->settings->imprint_addendum = [$locale => $parsed['addendum']] + $this->settings->imprint_addendum; + $written[] = "imprint_addendum[{$locale}]"; + } + if ($parsed['privacy'] !== null) { + $this->settings->privacy_content = [$locale => $parsed['privacy']] + $this->settings->privacy_content; + $written[] = "privacy_content[{$locale}]"; + } + + $this->settings->save(); + + return $written; + } + + /** + * @param array $raw + * @return array + */ + private function section(array $raw, string $key): array + { + $value = $raw[$key] ?? []; + if (! is_array($value)) { + throw new InvalidArgumentException("'{$key}' must be a JSON object."); + } + + return $value; + } + + private function asString(string $key, mixed $value): string + { + if (is_string($value)) { + return $value; + } + if (is_int($value) || is_float($value)) { + return (string) $value; + } + + throw new InvalidArgumentException("Value for '{$key}' must be a string."); + } + + private function plainText(string $value): string + { + return trim(strip_tags($value)); + } + + private function sanitizeHtml(string $value): string + { + return Str::sanitizeHtml($value); + } + + private function isPopulated(mixed $value): bool + { + return is_string($value) && trim($value) !== ''; + } + + private function assertValidLocale(string $locale): void + { + if (preg_match('/^[a-z]{2}(-[a-z]{2})?$/', $locale) !== 1) { + throw new InvalidArgumentException("Invalid --locale '{$locale}': expected a code like 'de' or 'de-at'."); + } + } +} diff --git a/app/Services/LegalImportResult.php b/app/Services/LegalImportResult.php new file mode 100644 index 0000000..53b7484 --- /dev/null +++ b/app/Services/LegalImportResult.php @@ -0,0 +1,24 @@ + $written Settings fields written this import. + * @param list $conflicts Populated fields that blocked a non-`--overwrite` import. + */ + public function __construct( + public bool $applied, + public array $written, + public array $conflicts, + ) {} +} diff --git a/app/Services/ThemeOverlay.php b/app/Services/ThemeOverlay.php new file mode 100644 index 0000000..5ff9fcf --- /dev/null +++ b/app/Services/ThemeOverlay.php @@ -0,0 +1,25 @@ + $report Operator-facing placement instructions. + * @param list $emittedTokens The --wf-* token names written into the overlay. + * @param list $warnings Non-fatal notes (e.g. no tokens provided). + */ + public function __construct( + public string $css, + public array $report, + public array $emittedTokens, + public array $warnings, + ) {} +} diff --git a/app/Services/ThemeOverlayGenerator.php b/app/Services/ThemeOverlayGenerator.php new file mode 100644 index 0000000..820e754 --- /dev/null +++ b/app/Services/ThemeOverlayGenerator.php @@ -0,0 +1,218 @@ +"]` block overriding a subset of the `--wf-*` + * token contract (see .claude/project/design/theming.md), plus a placement report. + * + * This is the deterministic, tested core of the design-adoption skill. The + * (non-deterministic) scraping/extraction happens in the skill, which feeds + * validated token values here. All output honours the token contract and never + * overrides the accessibility-invariant tokens (the visible focus ring), so a + * generated overlay can never regress a11y. + */ +final class ThemeOverlayGenerator +{ + /** Tokens carrying a font stack; every other token is validated as a colour. */ + private const FONT_TOKENS = ['--wf-font', '--wf-font-display']; + + /** + * Accessibility-invariant tokens the generator refuses to override, so an + * overlay can never remove the visible focus ring (theming.md a11y invariants). + * + * @var list + */ + private const A11Y_LOCKED_TOKENS = ['--wf-focus-ring']; + + private string $contractCssPath; + + public function __construct(string $contractCssPath = '') + { + $this->contractCssPath = $contractCssPath !== '' + ? $contractCssPath + : resource_path('css/withdrawal.css'); + } + + /** + * @param array $tokens --wf-* token name => value. + * + * @throws InvalidArgumentException on an invalid slug, token, value or logo URL. + */ + public function generate( + string $slug, + array $tokens, + ?string $logoUrl = null, + ?string $brandName = null, + ): ThemeOverlay { + $this->assertValidSlug($slug); + $this->assertValidLogoUrl($logoUrl); + + $contract = $this->loadContract(); + + /** @var array $emitted */ + $emitted = []; + foreach ($tokens as $token => $value) { + if (! in_array($token, $contract, true)) { + throw new InvalidArgumentException( + "Unknown --wf-* token: {$token} (not in the withdrawal.css contract).", + ); + } + if (in_array($token, self::A11Y_LOCKED_TOKENS, true)) { + throw new InvalidArgumentException( + "Refusing to override accessibility-locked token {$token}.", + ); + } + $this->assertValidValue($token, $value); + $emitted[$token] = $value; + } + + return new ThemeOverlay( + css: $this->renderCss($slug, $emitted), + report: $this->buildReport($slug, $logoUrl, $brandName), + emittedTokens: array_keys($emitted), + warnings: $emitted === [] + ? ['No brand tokens were provided — the overlay is empty and every token falls through to the neutral default.'] + : [], + ); + } + + /** + * @param array $tokens + */ + private function renderCss(string $slug, array $tokens): string + { + $lines = [ + '/* Revoco brand theme overlay — generated by `php artisan revoco:make-theme`.', + " Place it per .claude/project/design/theming.md and select it with APP_THEME={$slug}.", + ' Do NOT commit this file to the public Revoco repo (brand assets stay private). */', + ".wf-card[data-theme=\"{$slug}\"] {", + ]; + foreach ($tokens as $token => $value) { + $lines[] = " {$token}: {$value};"; + } + $lines[] = '}'; + + return implode("\n", $lines)."\n"; + } + + /** + * @return list + */ + private function buildReport(string $slug, ?string $logoUrl, ?string $brandName): array + { + $report = [ + 'Placement: put this overlay in your private infra repo / deployment mount (see theming.md — the public/private split). Never commit it to the public Revoco repo.', + "Selection: set APP_THEME={$slug} in your deployment .env.", + ]; + $report[] = $logoUrl !== null + ? "Logo: set REVOCO_LOGO_URL={$logoUrl} in .env — rendered as an in the reserved .wf-logo slot." + : 'Logo: no --logo-url given. Set REVOCO_LOGO_URL in .env, or add --wf-logo-img/-w/-h to the overlay.'; + if ($brandName !== null) { + $report[] = "Brand name: set REVOCO_BRAND_NAME={$brandName} in .env."; + } + $report[] = 'Accessibility: the neutral focus ring and reduced-motion handling are preserved (not overridden). Verify your accent contrasts sufficiently with text and the focus ring.'; + $report[] = 'Review the rendered form before going live — you own the final appearance.'; + + return $report; + } + + /** + * The token contract is the set of `--wf-*` custom properties defined in the + * neutral stylesheet — the single source of truth an overlay may override. + * + * @return list + */ + private function loadContract(): array + { + if (! is_file($this->contractCssPath)) { + throw new InvalidArgumentException( + "Token contract CSS not found at {$this->contractCssPath}.", + ); + } + + $css = (string) file_get_contents($this->contractCssPath); + if (preg_match_all('/--wf-[a-z0-9-]+/', $css, $matches) === false) { + return []; + } + + return array_values(array_unique($matches[0])); + } + + private function assertValidSlug(string $slug): void + { + if (preg_match('/^[a-z][a-z0-9-]{0,40}$/', $slug) !== 1) { + throw new InvalidArgumentException( + 'Invalid --slug: use lowercase letters, digits and hyphens, starting with a letter (max 41 chars).', + ); + } + } + + private function assertValidValue(string $token, string $value): void + { + $isFont = in_array($token, self::FONT_TOKENS, true); + $ok = $isFont ? $this->isValidFont($value) : $this->isValidColour($value); + if (! $ok) { + $kind = $isFont ? 'font value' : 'colour'; + throw new InvalidArgumentException("Invalid {$kind} for {$token}: '{$value}'."); + } + } + + /** + * Accept hex, rgb()/rgba()/hsl()/hsla() and named colours, and reject anything + * that could break out of the CSS declaration (a scraped value is untrusted). + */ + private function isValidColour(string $value): bool + { + $value = trim($value); + if ($value === '' || strlen($value) > 64) { + return false; + } + if (preg_match('/[;{}<>\\\\]|\/\*|\*\//', $value) === 1) { + return false; + } + if (preg_match('/^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $value) === 1) { + return true; + } + if (preg_match('/^(?:rgb|rgba|hsl|hsla)\([0-9.,%\s\/]+\)$/i', $value) === 1) { + return true; + } + + return preg_match('/^[a-zA-Z]+$/', $value) === 1; + } + + /** + * Accept a font-family stack (names, quotes, commas) and reject CSS-breaking + * characters — same injection guard as colours. + */ + private function isValidFont(string $value): bool + { + $value = trim($value); + if ($value === '' || strlen($value) > 200) { + return false; + } + if (preg_match('/[;{}<>\\\\]|\/\*|\*\//', $value) === 1) { + return false; + } + + return preg_match('/^[a-zA-Z0-9 ,\'"\-.]+$/', $value) === 1; + } + + private function assertValidLogoUrl(?string $logoUrl): void + { + if ($logoUrl === null) { + return; + } + $scheme = parse_url($logoUrl, PHP_URL_SCHEME); + if (filter_var($logoUrl, FILTER_VALIDATE_URL) === false || ! in_array($scheme, ['http', 'https'], true)) { + throw new InvalidArgumentException( + "Invalid --logo-url: '{$logoUrl}' is not a valid http(s) URL.", + ); + } + } +} diff --git a/tests/Feature/ImportLegalCommandTest.php b/tests/Feature/ImportLegalCommandTest.php new file mode 100644 index 0000000..f6b9048 --- /dev/null +++ b/tests/Feature/ImportLegalCommandTest.php @@ -0,0 +1,160 @@ + [ + 'name' => 'Muster GmbH', + 'vat_id' => 'DE123456789', + 'email' => 'kontakt@example.com', + 'address' => "Musterstraße 1\n12345 Musterstadt", + ], + 'privacy' => ['content' => '

Datenschutz-Text

'], + ]); + + $this->artisan('revoco:import-legal', ['--locale' => 'de', '--input' => $path]) + ->assertSuccessful(); + + $s = app(LegalSettings::class)->refresh(); + expect($s->imprint_name)->toBe('Muster GmbH'); + expect($s->imprint_vat_id)->toBe('DE123456789'); + expect($s->imprint_email)->toBe('kontakt@example.com'); + expect($s->imprint_address['de'])->toContain('Musterstraße 1'); + expect($s->privacy_content['de'])->toContain('Datenschutz-Text'); + + @unlink($path); +}); + +it('scopes per-locale fields to --locale and leaves other locales untouched', function (): void { + $seed = app(LegalSettings::class); + $seed->imprint_address = ['en' => 'Example Street 1']; + $seed->save(); + + $path = writeLegalPayload(['imprint' => ['address' => 'Musterstraße 1']]); + $this->artisan('revoco:import-legal', ['--locale' => 'de', '--input' => $path])->assertSuccessful(); + + $s = app(LegalSettings::class)->refresh(); + expect($s->imprint_address['de'])->toBe('Musterstraße 1'); + expect($s->imprint_address['en'])->toBe('Example Street 1'); + + @unlink($path); +}); + +it('sanitizes scraped HTML before storing it', function (): void { + $path = writeLegalPayload(['privacy' => ['content' => '

Safe content

']]); + $this->artisan('revoco:import-legal', ['--locale' => 'de', '--input' => $path])->assertSuccessful(); + + $stored = app(LegalSettings::class)->refresh()->privacy_content['de']; + expect($stored)->toContain('Safe content'); + expect($stored)->not->toContain('