diff --git a/.env.example b/.env.example index 0955855..12542ad 100644 --- a/.env.example +++ b/.env.example @@ -32,7 +32,10 @@ SESSION_DOMAIN=web.codebar.test BROADCAST_CONNECTION=log FILESYSTEM_DISK=local -QUEUE_CONNECTION=sync +# Not "sync": the network profile link is sent from a queued job, and running it inside +# the request makes the deliberately constant response observable by its timing. +# Needs `php artisan queue:work` (see README). +QUEUE_CONNECTION=database MAIL_MAILER=log MAIL_HOST=localhost @@ -62,3 +65,6 @@ LARAVEL_DEFAULT_FATHOM_SITE_ID=WECFOADW LITELLM_URL=https://llm.codebar.net LITELLM_MASTER_KEY= GITHUB_TOKEN= + +# Where a failing health check reports to. Unset means no notification is sent. +HEALTH_NOTIFICATION_EMAIL= diff --git a/.gitignore b/.gitignore index ea4c8f9..d123952 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ auth.json # Lighthouse audit reports (regenerate via tests/lighthouse/run.sh) /tests/lighthouse/reports/ --cache-directory + +# Generated Lighthouse runs — keep run.sh and pages.json, not their output. +tests/lighthouse/reports/ diff --git a/README.md b/README.md index 99b754e..84ac4fd 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The site ships in German (`de_CH`) and English (`en_CH`) under distinct, fully t - **Structured data** — a single `@graph` JSON-LD payload (`App\Seo\SchemaGraph` / `App\Seo\SchemaNodes`) built from `config/company.php`, the one source of truth for the company's name, addresses, phone and `sameAs` profiles. Every page ships `Organization`, `WebSite`, `WebPage` and `BreadcrumbList` nodes; content pages add `Service`, `Person`, `BlogPosting`, etc. - **Sitemap** — `App\Sitemap\SitemapBuilder` + `App\Http\Controllers\Sitemap\SitemapController` build `/sitemap.xml` from the same models the site renders, cached 24h via `Cache::remember`. -- **Response cache** — `spatie/laravel-responsecache` caches full HTTP responses for up to 7 days. `App\Observers\SitemapCacheObserver` drops the sitemap's own data cache on every relevant model save, and `responsecache:clear` runs hourly via the scheduler (`routes/console.php`) as a backstop for the full-page cache layer — these are two independent caches and both need clearing after a bulk content change (`php artisan responsecache:clear` after a fresh `db:seed`, for instance). +- **Response cache** — `spatie/laravel-responsecache` caches full HTTP responses for up to 7 days. Invalidation is event-driven, not scheduled: `App\Observers\SitemapCacheObserver` drops the sitemap's data cache and the content observers drop both the listing caches and the rendered pages on every relevant model save. Clearing goes through `App\Support\ResponseCacheFlusher`, which collapses the flush to once per import — the response cache has no per-URL invalidation, so an import that saves 50 rows would otherwise clear the whole site 50 times. - **Social images** — article heroes that are local SVG placeholders (no real photography yet) are not usable as `og:image` — social crawlers don't render SVG. `App\Support\NewsImage::ogImage()` falls back to a same-named `.png` rendered from the SVG when one exists, or the site's default share image otherwise. - **Tests** — `tests/Feature/Seo/` asserts on the actual rendered JSON-LD and meta tags (not just "does it look right"), and `tests/lighthouse/` audits real Lighthouse scores against the built (`npm run build`) output, not the Vite dev server. @@ -100,6 +100,17 @@ valet open > Set `valetTls: 'your-domain.test'` below `refresh: true` in `vite.config.js` if you use `valet secure` / `herd secure`. +### Background work + +`QUEUE_CONNECTION=database`, so a worker has to be running for anything queued to happen — the network profile link, the LLM usage sync. Deliberately not `sync`: running the mail job inside the request makes the intentionally constant response of `network.request.store` observable by its timing. + +```bash +php artisan queue:work # network profile mails, LLM usage sync +php artisan schedule:work # hourly LLM sync, health checks +``` + +Without the scheduler, `health:check` never runs and `/health` reports stale results. A dispatched job with no worker is a silent black hole — `JobsCheck` exists to catch exactly that, and reports through `/health`. + ## Testing & code quality ```bash @@ -115,8 +126,11 @@ The site runs on [Laravel Cloud](https://cloud.laravel.com), behind Cloudflare. - `CSP_ENABLED=true` — enables Content-Security-Policy enforcement via Spatie CSP middleware - `FPH_ENABLED=true` — enables Permissions-Policy headers +- `HEALTH_NOTIFICATION_EMAIL` — where a failing health check reports to; unset means no notification is sent + +HSTS, COOP, CORP, `X-Content-Type-Options`, `Referrer-Policy` and `X-Frame-Options` are applied automatically by the `SecurityHeaders` middleware on every web response. -HSTS, COOP, `X-Content-Type-Options`, `Referrer-Policy` and `X-Frame-Options` are applied automatically by the `SecurityHeaders` middleware on every web response. +`/health` returns the Spatie Health JSON result set. It is rate limited and never response-cached; it needs `schedule:work` running to hold current results. **Lighthouse note:** deprecated-API warnings for `/cdn-cgi/challenge-platform/scripts/jsd/main.js` come from Cloudflare's bot protection, injected at the edge — not from application code. Run Lighthouse in incognito without extensions for an accurate score, and prefer `tests/lighthouse/` (which audits the built output) over ad-hoc runs against the dev server. @@ -124,7 +138,7 @@ HSTS, COOP, `X-Content-Type-Options`, `Referrer-Policy` and `X-Frame-Options` ar - **Content & storage** — `spatie/laravel-translatable`, `codebar-ag/laravel-flysystem-cloudinary` (editorial images), `league/flysystem-aws-s3-v3` (DigitalOcean Spaces for other assets), `symfony/yaml` - **SEO** — `spatie/laravel-sitemap`, `spatie/laravel-responsecache` -- **Security & health** — `spatie/laravel-csp`, `spatie/laravel-honeypot`, `spatie/laravel-permission`, `spatie/laravel-health`, `spatie/security-advisories-health-check`, `mazedlx/laravel-feature-policy` +- **Security & health** — `spatie/laravel-csp`, `spatie/laravel-honeypot` (network request form), `spatie/laravel-permission`, `spatie/laravel-health`, `spatie/security-advisories-health-check`, `mazedlx/laravel-feature-policy` - **Ops** — `laravel/nightwatch` (observability), `symfony/postmark-mailer` - **Analytics** — [Fathom](https://usefathom.com) (privacy-friendly, no cookie banner) diff --git a/app/Actions/LlmUsageStatsAction.php b/app/Actions/LlmUsageStatsAction.php index 89ce355..9bde376 100644 --- a/app/Actions/LlmUsageStatsAction.php +++ b/app/Actions/LlmUsageStatsAction.php @@ -10,7 +10,6 @@ use Closure; use DateTimeInterface; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; @@ -44,16 +43,18 @@ public function hasOtherModels(): bool } /** - * @return Collection + * The calendar years usage has been recorded in, oldest first. + * + * @return Collection */ public function years(): Collection { return $this->remember('years', function () { return AiModelDailyUsage::query() - ->orderBy('date') - ->get(['date']) - ->map(fn (AiModelDailyUsage $row): string => $row->date->format('Y')) - ->unique() + ->selectRaw('DISTINCT EXTRACT(YEAR FROM date)::int AS year') + ->orderBy('year') + ->pluck('year') + ->map(fn (mixed $year): string => $this->toYear($year)) ->values(); }); } @@ -78,31 +79,33 @@ private function toDate(mixed $value): ?Carbon } /** - * @return Collection, completion_tokens: int<0, max>, total_tokens: int<0, max>, requests: int<0, max>}> + * @return Collection */ public function monthlyBreakdown(?string $year, ?string $month, ?string $model): Collection { $suffix = 'breakdown_'.($year ?? 'all').'_'.($month ?? 'all').'_'.($model ?? 'all'); return $this->remember($suffix, function () use ($year, $month, $model) { - return AiModelDailyUsage::query() - ->when($model === self::OTHER_MODEL, fn (Builder $query) => $query->whereNull('ai_model_id')) - ->when($model && $model !== self::OTHER_MODEL, fn (Builder $query) => $query->where('model', $model)) + // Grouped in the database rather than in PHP: this table grows by one row + // per model per day forever, and the page only ever shows the totals. + return $this->filtered($model) ->when($year, fn (Builder $query) => $query->whereYear('date', $year)) ->when($month, fn (Builder $query) => $query->whereMonth('date', $month)) - ->orderBy('date') + ->selectRaw("to_char(date, 'YYYY-MM') AS label") + ->selectRaw('COALESCE(SUM(prompt_tokens), 0)::bigint AS prompt_tokens') + ->selectRaw('COALESCE(SUM(completion_tokens), 0)::bigint AS completion_tokens') + ->selectRaw('COALESCE(SUM(total_tokens), 0)::bigint AS total_tokens') + ->selectRaw('COALESCE(SUM(requests), 0)::bigint AS requests') + ->groupByRaw("to_char(date, 'YYYY-MM')") + ->orderByRaw("to_char(date, 'YYYY-MM')") ->get() - ->groupBy(fn (AiModelDailyUsage $row) => $row->date->format('Y-m')) - ->map( - /** @param EloquentCollection $rows */ - fn (EloquentCollection $rows, string $label) => [ - 'label' => $label, - 'prompt_tokens' => $rows->sum(fn (AiModelDailyUsage $row): int => $row->prompt_tokens), - 'completion_tokens' => $rows->sum(fn (AiModelDailyUsage $row): int => $row->completion_tokens), - 'total_tokens' => $rows->sum(fn (AiModelDailyUsage $row): int => $row->total_tokens), - 'requests' => $rows->sum(fn (AiModelDailyUsage $row): int => $row->requests), - ] - ) + ->map(fn (AiModelDailyUsage $row): array => [ + 'label' => $this->toString($row->getAttribute('label')), + 'prompt_tokens' => $this->toInt($row->getAttribute('prompt_tokens')), + 'completion_tokens' => $this->toInt($row->getAttribute('completion_tokens')), + 'total_tokens' => $this->toInt($row->getAttribute('total_tokens')), + 'requests' => $this->toInt($row->getAttribute('requests')), + ]) ->values(); }); } @@ -137,21 +140,56 @@ public function totalSummary(?string $model = null): array private function summary(string $suffix, ?CarbonImmutable $from, ?string $model = null): array { return $this->remember($suffix.'_'.($model ?? 'all'), function () use ($from, $model) { - $rows = AiModelDailyUsage::query() + $row = $this->filtered($model) ->when($from, fn (Builder $query) => $query->where('date', '>=', $from)) - ->when($model === self::OTHER_MODEL, fn (Builder $query) => $query->whereNull('ai_model_id')) - ->when($model && $model !== self::OTHER_MODEL, fn (Builder $query) => $query->where('model', $model)) - ->get(); + ->selectRaw('COALESCE(SUM(prompt_tokens), 0)::bigint AS prompt_tokens') + ->selectRaw('COALESCE(SUM(completion_tokens), 0)::bigint AS completion_tokens') + ->selectRaw('COALESCE(SUM(total_tokens), 0)::bigint AS total_tokens') + ->selectRaw('COALESCE(SUM(requests), 0)::bigint AS requests') + ->first(); return [ - 'prompt_tokens' => $rows->sum(fn (AiModelDailyUsage $row): int => $row->prompt_tokens), - 'completion_tokens' => $rows->sum(fn (AiModelDailyUsage $row): int => $row->completion_tokens), - 'total_tokens' => $rows->sum(fn (AiModelDailyUsage $row): int => $row->total_tokens), - 'requests' => $rows->sum(fn (AiModelDailyUsage $row): int => $row->requests), + 'prompt_tokens' => $this->toInt($row?->getAttribute('prompt_tokens')), + 'completion_tokens' => $this->toInt($row?->getAttribute('completion_tokens')), + 'total_tokens' => $this->toInt($row?->getAttribute('total_tokens')), + 'requests' => $this->toInt($row?->getAttribute('requests')), ]; }); } + /** + * Aggregate columns come back untyped — PostgreSQL hands SUM() over a bigint back as + * a string, and a grouped row carries no cast from the model. + */ + private function toInt(mixed $value): int + { + return is_numeric($value) ? (int) $value : 0; + } + + private function toString(mixed $value): string + { + return is_string($value) ? $value : ''; + } + + /** The filter compares against the query string, so a year travels as text. */ + private function toYear(mixed $value): string + { + return (string) $this->toInt($value); + } + + /** + * The model filter every aggregate shares: a named model, everything that is not a + * known model ("other"), or no filter at all. + * + * @return Builder + */ + private function filtered(?string $model): Builder + { + return AiModelDailyUsage::query() + ->when($model === self::OTHER_MODEL, fn (Builder $query) => $query->whereNull('ai_model_id')) + ->when($model !== null && $model !== self::OTHER_MODEL, fn (Builder $query) => $query->where('model', $model)); + } + /** * @template TValue * diff --git a/app/Actions/PageAction.php b/app/Actions/PageAction.php index 74398e6..a40b83c 100644 --- a/app/Actions/PageAction.php +++ b/app/Actions/PageAction.php @@ -68,7 +68,7 @@ public function news(News $news, bool $withReferences = false, ?string $locale = title: $this->translatedString($news->getTranslation('title', $locale)), description: $this->translatedString($news->getTranslation('teaser', $locale)), image: $news->hero_image, - lastModificationDate: Carbon::parse($news->updated_at ?? now()), + lastModificationDate: Carbon::parse($news->revised_at ?? $news->published_at ?? $news->updated_at ?? now()), routeParameters: LocalizedRouteParameters::for(['locale' => $locale, 'news' => $news], $locale), referencePages: $withReferences ? $this->alternateLocalePages($news, $locale, fn (News $n, string $l) => $this->news($n, false, $l)) : null, publishedAt: $news->published_at !== null ? Carbon::parse($news->published_at) : null, diff --git a/app/Actions/ViewDataAction.php b/app/Actions/ViewDataAction.php index 1b01c91..c30b2d6 100644 --- a/app/Actions/ViewDataAction.php +++ b/app/Actions/ViewDataAction.php @@ -10,6 +10,7 @@ use App\Enums\ContactSectionEnum; use App\Models\AiModel; use App\Models\Contact; +use App\Models\Network; use App\Models\News; use App\Models\OpenSource; use App\Models\Product; @@ -125,7 +126,13 @@ public function openSource(string $locale): Collection }); } - public function contacts(string $locale): \stdClass + /** + * The published team, grouped by the section each person appears in. Every section + * is present, empty ones included, so a caller never has to guard the lookup. + * + * @return Collection> + */ + public function contacts(string $locale): Collection { $key = CacheKeyEnum::CONTACTS_PUBLISHED->forLocale($locale); @@ -138,21 +145,39 @@ public function contacts(string $locale): \stdClass ->orderBy('name') ->get(); - return (object) collect([ - ContactSectionEnum::EMPLOYEES, - ContactSectionEnum::COLLABORATIONS, - ContactSectionEnum::BOARD_MEMBERS, - ])->mapWithKeys(function (string $section) use ($publishedContacts, $locale): array { - $contacts = $publishedContacts - ->filter(function (Contact $contact) use ($section): bool { - $sections = $contact->sections ?? []; - - return array_key_exists($section, $sections); - }) - ->map(fn (Contact $contact) => ContactDTO::fromModel($contact, $section, $locale)); - - return [$section => $contacts->values()]; - })->all(); + return collect(ContactSectionEnum::cases()) + ->mapWithKeys(fn (ContactSectionEnum $section): array => [ + $section->value => $publishedContacts + ->filter(fn (Contact $contact): bool => array_key_exists($section->value, $contact->sections ?? [])) + ->map(fn (Contact $contact): ContactDTO => ContactDTO::fromModel($contact, $section, $locale)) + ->values(), + ]); + }); + } + + /** + * @return Collection + */ + public function contactsInSection(string $locale, ContactSectionEnum $section): Collection + { + /** @var Collection $contacts */ + $contacts = $this->contacts($locale)->get($section->value, new Collection); + + return $contacts; + } + + /** + * @return Collection + */ + public function networks(): Collection + { + return Cache::rememberForever(CacheKeyEnum::NETWORKS_PUBLISHED->value, function () { + return Network::query() + ->published() + ->active() + ->with('publishedUsers') + ->orderBy('sort') + ->get(); }); } } diff --git a/app/Checks/FilesystemsDefaultCheck.php b/app/Checks/FilesystemsDefaultCheck.php deleted file mode 100644 index 02c5cb8..0000000 --- a/app/Checks/FilesystemsDefaultCheck.php +++ /dev/null @@ -1,33 +0,0 @@ -shortSummary("invalid filesystems default: {$filesystemsDefault}"); - - if ($defaultDisk === $fallbackDisk) { - return $result->failed(); - } - - return $result->ok(); - } -} diff --git a/app/Console/Commands/ImportCommand.php b/app/Console/Commands/ImportCommand.php index 12df437..e364f94 100644 --- a/app/Console/Commands/ImportCommand.php +++ b/app/Console/Commands/ImportCommand.php @@ -4,9 +4,12 @@ namespace App\Console\Commands; +use App\Support\ResponseCacheFlusher; use Illuminate\Console\Command; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; @@ -24,6 +27,11 @@ abstract class ImportCommand extends Command /** Directory under database/ this importer reads when --path is not given. */ abstract protected function defaultPath(): string; + protected function execute(InputInterface $input, OutputInterface $output): int + { + return ResponseCacheFlusher::batch(fn (): int => parent::execute($input, $output)); + } + protected function basePath(): string { $override = $this->option('path'); diff --git a/app/Console/Commands/ImportNewsCommand.php b/app/Console/Commands/ImportNewsCommand.php index 601971e..070eff7 100644 --- a/app/Console/Commands/ImportNewsCommand.php +++ b/app/Console/Commands/ImportNewsCommand.php @@ -27,6 +27,9 @@ class ImportNewsCommand extends ImportCommand protected $description = 'Import news articles from database/files/news/{locale}/*.md'; + /** @var array|null */ + private ?array $contactIds = null; + public function handle(NewsMarkdown $markdown): int { // PARSE_DATETIME: without it a bare `published_at: 2026-07-28` arrives as a Unix timestamp. @@ -42,13 +45,13 @@ public function handle(NewsMarkdown $markdown): int } $dryRun = $this->isDryRun(); - $only = $this->option('key'); + $only = $this->nullableString($this->option('key')); $imported = 0; $skipped = 0; foreach ($documents as $key => $localeDocuments) { - if (is_string($only) && $only !== '' && $only !== $key) { + if ($only !== null && $only !== $key) { continue; } @@ -81,6 +84,12 @@ public function handle(NewsMarkdown $markdown): int $imported++; } + // Skipped on a single-key run: that import sees one article and must never + // conclude the rest are orphans. + if (! $dryRun && $only === null) { + $this->removeOrphans(News::query(), 'key', array_keys($documents)); + } + if (! $dryRun && $imported > 0) { $this->linkRelatedArticles($documents); NewsObserver::flush(); @@ -106,7 +115,7 @@ protected function defaultPath(): string */ protected function checkFileName(string $path, string $key, array $front): void { - $publishedAt = $this->publishedAt($front['published_at'] ?? null); + $publishedAt = $this->date($front['published_at'] ?? null); if ($publishedAt === null) { return; @@ -150,7 +159,9 @@ private function store(string $key, array $localeDocuments, NewsMarkdown $markdo $news->fill([ ...$translated, 'hero_image' => $this->nullableString($primary['hero'] ?? null), - 'published_at' => $this->publishedAt($primary['published_at'] ?? null), + 'thumb_image' => $this->nullableString($primary['thumb'] ?? null), + 'published_at' => $this->date($primary['published_at'] ?? null), + 'revised_at' => $this->date($primary['updated_at'] ?? null), // Defaults to true: an article with a date is live unless it says otherwise. 'published' => (bool) ($primary['published'] ?? true), 'author' => $this->nullableString($primary['author_name'] ?? null), @@ -189,7 +200,7 @@ private function existing(string $key, array $slugs): ?News })->first(); } - private function publishedAt(mixed $value): ?Carbon + private function date(mixed $value): ?Carbon { if ($value === null || $value === '') { return null; @@ -225,12 +236,7 @@ private function resolveContactId(mixed $value): ?int $needle = mb_strtolower($this->string($value)); - $match = Contact::all()->first(function (Contact $contact) use ($needle): bool { - $icons = $contact->icons; - $email = is_array($icons) && isset($icons['email']) ? $this->string($icons['email']) : ''; - - return mb_strtolower($email) === $needle || mb_strtolower($contact->name) === $needle; - }); + $match = $this->contactIdsByIdentifier()[$needle] ?? null; if ($match === null) { $this->components->warn(sprintf( @@ -241,7 +247,34 @@ private function resolveContactId(mixed $value): ?int return null; } - return $match->id; + return $match; + } + + /** + * Every contact addressable by name or by the email in its icons, resolved once per + * run rather than once per article. + * + * @return array + */ + private function contactIdsByIdentifier(): array + { + if ($this->contactIds !== null) { + return $this->contactIds; + } + + $identifiers = []; + + foreach (Contact::all() as $contact) { + $email = $this->string(data_get($contact->icons, 'email')); + + if ($email !== '') { + $identifiers[mb_strtolower($email)] = $contact->id; + } + + $identifiers[mb_strtolower($contact->name)] = $contact->id; + } + + return $this->contactIds = $identifiers; } /** diff --git a/app/Console/Commands/SyncRepositoriesCommand.php b/app/Console/Commands/SyncRepositoriesCommand.php index 2c746f4..228564c 100644 --- a/app/Console/Commands/SyncRepositoriesCommand.php +++ b/app/Console/Commands/SyncRepositoriesCommand.php @@ -6,6 +6,7 @@ use App\Models\OpenSource; use Illuminate\Console\Command; +use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; @@ -65,7 +66,8 @@ private function fetchAllRepositories(): ?array } do { - $response = Http::withHeaders($headers) + $response = $this->client() + ->withHeaders($headers) ->accept('application/vnd.github+json') ->get(sprintf('https://api.github.com/orgs/%s/repos', self::ORG), [ 'type' => 'public', @@ -181,9 +183,21 @@ private function syncRepository(array $repo): void $this->line(sprintf(' %s %s downloads', $entry->title, number_format($downloads))); } + /** + * A sync that walks every repository makes one call per repository, so a slow + * endpoint must fail fast rather than hold the command open on the default timeout. + */ + private function client(): PendingRequest + { + return Http::connectTimeout(5) + ->timeout(20) + ->retry(times: 2, sleepMilliseconds: 500, throw: false); + } + private function fetchPackagistDownloads(string $fullName): int { - $response = Http::accept('application/json') + $response = $this->client() + ->accept('application/json') ->get(sprintf('https://packagist.org/packages/%s.json', $fullName)); if ($response->failed()) { diff --git a/app/DTO/ContactDTO.php b/app/DTO/ContactDTO.php index 994fabd..9eb3f94 100644 --- a/app/DTO/ContactDTO.php +++ b/app/DTO/ContactDTO.php @@ -4,6 +4,7 @@ namespace App\DTO; +use App\Enums\ContactSectionEnum; use App\Models\Contact; use Illuminate\Support\Arr; @@ -14,18 +15,20 @@ class ContactDTO */ public function __construct( public readonly string $locale, - public readonly string $section, + public readonly ContactSectionEnum $section, + public readonly string $key, public readonly string $name, public readonly ?string $role, public readonly string $image, public readonly array $icons, ) {} - public static function fromModel(Contact $contact, string $section, string $locale): self + public static function fromModel(Contact $contact, ContactSectionEnum $section, string $locale): self { - $role = Arr::get($contact->sections ?? [], "$section.role.$locale"); + $role = Arr::get($contact->sections ?? [], "{$section->value}.role.{$locale}"); return new self( + key: $contact->key, name: $contact->name, role: is_string($role) ? $role : null, locale: $locale, diff --git a/app/DTO/LlmUsageFilterDTO.php b/app/DTO/LlmUsageFilterDTO.php new file mode 100644 index 0000000..9cee549 --- /dev/null +++ b/app/DTO/LlmUsageFilterDTO.php @@ -0,0 +1,89 @@ + $years + * @param Collection $monthOptions + * @param Collection $models + */ + public static function resolve( + Request $request, + Collection $years, + Collection $monthOptions, + Collection $models, + string $otherLabel, + bool $hasOtherModels, + ): self { + return new self( + year: $years->first(fn (string $option): bool => $option === $request->query('year')), + month: self::month($monthOptions, (string) $request->query('month')), + model: self::model($models, $otherLabel, (string) $request->query('model'), $hasOtherModels), + ); + } + + /** + * Accepts both the URL form ("05") and the localized label ("Mai"), so a month can + * be linked to either way. + * + * @param Collection $monthOptions + */ + private static function month(Collection $monthOptions, string $input): ?string + { + if ($monthOptions->has($input)) { + return $input; + } + + $key = $monthOptions->search(fn (string $label): bool => strcasecmp($label, $input) === 0); + + return $key === false ? null : $key; + } + + /** + * @param Collection $models + */ + private static function model(Collection $models, string $otherLabel, string $input, bool $hasOther): ?string + { + if ($hasOther && (strcasecmp($input, $otherLabel) === 0 || strcasecmp($input, LlmUsageStatsAction::OTHER_MODEL) === 0)) { + return LlmUsageStatsAction::OTHER_MODEL; + } + + return $models->first(fn (string $option): bool => strcasecmp($option, $input) === 0); + } + + /** + * @return array + */ + public function toQuery(): array + { + return array_filter([ + 'year' => $this->year, + 'month' => $this->month, + 'model' => $this->model, + ], fn (?string $value): bool => $value !== null); + } + + public function modelLabel(string $otherLabel): ?string + { + return $this->model === LlmUsageStatsAction::OTHER_MODEL ? $otherLabel : $this->model; + } +} diff --git a/app/Enums/CacheKeyEnum.php b/app/Enums/CacheKeyEnum.php index ac9ce20..9145962 100644 --- a/app/Enums/CacheKeyEnum.php +++ b/app/Enums/CacheKeyEnum.php @@ -19,8 +19,6 @@ */ enum CacheKeyEnum: string { - const string VALID_FILESYSTEMS_DEFAULT = 'valid_filesystems_defaullt'; - case CONTACTS_PUBLISHED = 'contacts_published'; case NEWS_PUBLISHED = 'news_published'; @@ -33,6 +31,8 @@ enum CacheKeyEnum: string case OPEN_SOURCE_PUBLISHED = 'open_source_published'; + case NETWORKS_PUBLISHED = 'networks_published'; + case AI_MODELS_ACTIVE = 'ai_models_active'; case AI_MODELS_ARCHIVED = 'ai_models_archived'; diff --git a/app/Enums/ContactSectionEnum.php b/app/Enums/ContactSectionEnum.php index 116d1fb..c43606e 100644 --- a/app/Enums/ContactSectionEnum.php +++ b/app/Enums/ContactSectionEnum.php @@ -6,9 +6,7 @@ enum ContactSectionEnum: string { - const string EMPLOYEES = 'employees'; - - const string COLLABORATIONS = 'collaborations'; - - const string BOARD_MEMBERS = 'board_members'; + case EMPLOYEES = 'employees'; + case COLLABORATIONS = 'collaborations'; + case BOARD_MEMBERS = 'board_members'; } diff --git a/app/Enums/CookieNameEnum.php b/app/Enums/CookieNameEnum.php new file mode 100644 index 0000000..52a479a --- /dev/null +++ b/app/Enums/CookieNameEnum.php @@ -0,0 +1,10 @@ +format('Ymd_').$name; - } - - public function nameWithDateTime(string $name): string - { - return now()->format('YmdHm_').$name; - } -} diff --git a/app/Helpers/HelperMarkdown.php b/app/Helpers/HelperMarkdown.php deleted file mode 100644 index 3521fef..0000000 --- a/app/Helpers/HelperMarkdown.php +++ /dev/null @@ -1,15 +0,0 @@ -markdown()->toString(); - } -} diff --git a/app/Helpers/HelperMoney.php b/app/Helpers/HelperMoney.php deleted file mode 100755 index fc9af9b..0000000 --- a/app/Helpers/HelperMoney.php +++ /dev/null @@ -1,38 +0,0 @@ - substr($number, 0, 3).' (0) '.substr($number, 3, 2).' '.substr($number, 5, 3).' '.substr($number, 8, 2).' '.substr($number, 10, 2), - default => chunk_split($number, 3, ' '), - }; - } -} diff --git a/app/Http/Controllers/AboutUs/AboutUsIndexController.php b/app/Http/Controllers/AboutUs/AboutUsIndexController.php index 5f442e2..9df5950 100644 --- a/app/Http/Controllers/AboutUs/AboutUsIndexController.php +++ b/app/Http/Controllers/AboutUs/AboutUsIndexController.php @@ -12,7 +12,6 @@ use App\Seo\SchemaNodes; use Illuminate\Support\Collection; use Illuminate\View\View; -use stdClass; class AboutUsIndexController extends Controller { @@ -36,15 +35,13 @@ public function __invoke(ViewDataAction $viewData): View * Flattens the section-keyed contact groups into one list — the schema * cares about the people, not about which block they render in. * + * @param Collection> $contacts * @return Collection */ - private function flatten(stdClass $contacts): Collection + private function flatten(Collection $contacts): Collection { /** @var Collection $flattened */ - $flattened = collect((array) $contacts) - ->flatMap(fn (mixed $group): array => $group instanceof Collection ? $group->all() : []) - ->filter(fn (mixed $contact): bool => $contact instanceof ContactDTO) - ->values(); + $flattened = $contacts->flatMap(fn (Collection $group): array => $group->all())->values(); return $flattened; } diff --git a/app/Http/Controllers/Ai/AiIndexController.php b/app/Http/Controllers/Ai/AiIndexController.php index 94ce290..6e42721 100644 --- a/app/Http/Controllers/Ai/AiIndexController.php +++ b/app/Http/Controllers/Ai/AiIndexController.php @@ -6,16 +6,37 @@ use App\Actions\LlmUsageStatsAction; use App\Actions\PageAction; +use App\Actions\ViewDataAction; use App\Http\Controllers\Controller; +use App\Models\News; +use App\Models\NewsTag; use Illuminate\View\View; class AiIndexController extends Controller { - public function __invoke(LlmUsageStatsAction $stats): View + /** + * The topics this page collects its articles from. «KI» in the German files and + * «AI» in the English ones become two separate tags on import, so both keys count. + * + * @var list + */ + private const array TOPIC_KEYS = ['ki', 'ai']; + + public function __invoke(LlmUsageStatsAction $stats, ViewDataAction $viewData): View { + $locale = app()->getLocale(); + return view('app.ai.index')->with([ 'page' => (new PageAction(locale: null, routeName: 'ai.index'))->default(), 'llmSummary' => $stats->currentMonthSummary(), + 'hasUsage' => $stats->totalSummary()['requests'] > 0, + // Every article on the topic, not a teaser of the newest few: this is the + // page about it, and the news index stays one click away for the rest. + 'news' => $viewData->news($locale) + ->filter(fn (News $entry): bool => $entry->newsTags->contains( + fn (NewsTag $tag): bool => in_array($tag->key, self::TOPIC_KEYS, true), + )) + ->values(), ]); } } diff --git a/app/Http/Controllers/Ai/AiLlmAnalyticsIndexController.php b/app/Http/Controllers/Ai/AiLlmAnalyticsIndexController.php index ab73cf5..96d91ea 100644 --- a/app/Http/Controllers/Ai/AiLlmAnalyticsIndexController.php +++ b/app/Http/Controllers/Ai/AiLlmAnalyticsIndexController.php @@ -6,6 +6,7 @@ use App\Actions\LlmUsageStatsAction; use App\Actions\PageAction; +use App\DTO\LlmUsageFilterDTO; use App\Helpers\Facades\HelperDate; use App\Http\Controllers\Controller; use Illuminate\Http\Request; @@ -22,78 +23,78 @@ public function __invoke(Request $request, LlmUsageStatsAction $stats): View { $models = $stats->models(); $years = $stats->years(); - $monthOptions = collect(range(1, 12))->mapWithKeys(fn (int $month) => [ - str_pad((string) $month, 2, '0', STR_PAD_LEFT) => HelperDate::monthName($month), - ]); + $monthOptions = $this->monthOptions(); + $hasOtherModels = $stats->hasOtherModels(); $otherLabel = __('components.ai_llm_analytics.filter.other_models'); - $modelOptions = $stats->hasOtherModels() ? $models->concat([$otherLabel]) : $models; + $otherLabel = is_string($otherLabel) ? $otherLabel : LlmUsageStatsAction::OTHER_MODEL; - $year = $years->first(fn (string $option) => $option === $request->query('year')); - $month = $this->resolveMonth($monthOptions, (string) $request->query('month')); - $model = $this->resolveModel($models, $otherLabel, (string) $request->query('model'), $stats->hasOtherModels()); + $filter = LlmUsageFilterDTO::resolve($request, $years, $monthOptions, $models, $otherLabel, $hasOtherModels); - $breakdown = $stats->monthlyBreakdown($year, $month, $model)->reverse()->values(); - - $page = Paginator::resolveCurrentPage(); - - $periods = new LengthAwarePaginator( - items: $breakdown->forPage($page, self::PER_PAGE)->values(), - total: $breakdown->count(), - perPage: self::PER_PAGE, - currentPage: $page, - options: [ - 'path' => Paginator::resolveCurrentPath(), - 'query' => array_filter(['year' => $year, 'month' => $month, 'model' => $model]), - ], - ); + $breakdown = $stats->monthlyBreakdown($filter->year, $filter->month, $filter->model)->reverse()->values(); return view('app.ai.llm.analytics')->with([ 'page' => (new PageAction(locale: null, routeName: 'ai.llm.analytics.index'))->default(), - 'monthSummary' => $stats->currentMonthSummary($model), - 'yearSummary' => $stats->currentYearSummary($model), - 'totalSummary' => $stats->totalSummary($model), - 'periods' => $periods, - 'grandTotal' => [ - 'prompt_tokens' => $breakdown->sum(fn (array $row): int => $row['prompt_tokens']), - 'completion_tokens' => $breakdown->sum(fn (array $row): int => $row['completion_tokens']), - 'total_tokens' => $breakdown->sum(fn (array $row): int => $row['total_tokens']), - 'requests' => $breakdown->sum(fn (array $row): int => $row['requests']), - ], - 'modelOptions' => $modelOptions, + 'monthSummary' => $stats->currentMonthSummary($filter->model), + 'yearSummary' => $stats->currentYearSummary($filter->model), + 'totalSummary' => $stats->totalSummary($filter->model), + 'periods' => $this->paginate($breakdown, $filter), + 'grandTotal' => $this->grandTotal($breakdown), + 'modelOptions' => $hasOtherModels ? $models->concat([$otherLabel]) : $models, 'years' => $years, 'monthOptions' => $monthOptions, - 'year' => $year, - 'month' => $month, - 'model' => $model, - 'modelLabel' => $model === LlmUsageStatsAction::OTHER_MODEL ? $otherLabel : $model, + 'year' => $filter->year, + 'month' => $filter->month, + 'model' => $filter->model, + 'modelLabel' => $filter->modelLabel($otherLabel), 'lastSyncedAt' => $stats->lastSyncedAt(), ]); } /** - * @param Collection $models + * @return Collection */ - private function resolveModel(Collection $models, string $otherLabel, string $input, bool $hasOther): ?string + private function monthOptions(): Collection { - if ($hasOther && (strcasecmp($input, $otherLabel) === 0 || strcasecmp($input, LlmUsageStatsAction::OTHER_MODEL) === 0)) { - return LlmUsageStatsAction::OTHER_MODEL; - } - - return $models->first(fn (string $option) => strcasecmp($option, $input) === 0); + return collect(range(1, 12))->mapWithKeys(fn (int $month): array => [ + str_pad((string) $month, 2, '0', STR_PAD_LEFT) => HelperDate::monthName($month), + ]); } /** - * @param Collection $monthOptions + * The breakdown is already one row per month, so it is paginated in memory rather + * than re-queried per page. + * + * @param Collection $breakdown + * @return LengthAwarePaginator */ - private function resolveMonth(Collection $monthOptions, string $input): ?string + private function paginate(Collection $breakdown, LlmUsageFilterDTO $filter): LengthAwarePaginator { - if ($monthOptions->has($input)) { - return $input; - } + $page = Paginator::resolveCurrentPage(); - $key = $monthOptions->search(fn (string $label) => strcasecmp($label, $input) === 0); + return new LengthAwarePaginator( + items: $breakdown->forPage($page, self::PER_PAGE)->values(), + total: $breakdown->count(), + perPage: self::PER_PAGE, + currentPage: $page, + options: [ + 'path' => Paginator::resolveCurrentPath(), + 'query' => $filter->toQuery(), + ], + ); + } - return $key === false ? null : $key; + /** + * @param Collection $breakdown + * @return array + */ + private function grandTotal(Collection $breakdown): array + { + return [ + 'prompt_tokens' => $breakdown->sum(fn (array $row): int => $row['prompt_tokens']), + 'completion_tokens' => $breakdown->sum(fn (array $row): int => $row['completion_tokens']), + 'total_tokens' => $breakdown->sum(fn (array $row): int => $row['total_tokens']), + 'requests' => $breakdown->sum(fn (array $row): int => $row['requests']), + ]; } } diff --git a/app/Http/Controllers/Ai/AiLlmIndexController.php b/app/Http/Controllers/Ai/AiLlmIndexController.php index b6df9bd..6c5ad76 100644 --- a/app/Http/Controllers/Ai/AiLlmIndexController.php +++ b/app/Http/Controllers/Ai/AiLlmIndexController.php @@ -19,6 +19,7 @@ public function __invoke(LlmUsageStatsAction $stats, ViewDataAction $viewData): 'groups' => $viewData->aiModelGroups(), 'archive' => $viewData->aiModelArchive(), 'llmSummary' => $stats->currentMonthSummary(), + 'hasUsage' => $stats->totalSummary()['requests'] > 0, ]); } } diff --git a/app/Http/Controllers/CoWorking/CoWorkingIndexController.php b/app/Http/Controllers/CoWorking/CoWorkingIndexController.php index 8c7ba5a..af45cc1 100644 --- a/app/Http/Controllers/CoWorking/CoWorkingIndexController.php +++ b/app/Http/Controllers/CoWorking/CoWorkingIndexController.php @@ -4,37 +4,14 @@ namespace App\Http\Controllers\CoWorking; -use App\Actions\PageAction; use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Str; -use Illuminate\View\View; class CoWorkingIndexController extends Controller { - public function __invoke(): View|RedirectResponse + public function __invoke(): RedirectResponse { return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); - - /* return view('app.co-working.index')->with([ - 'page' => (new PageAction(locale: null, routeName: 'co-working.index'))->default(), - 'services' => $this->services(), - 'pricing' => [ - 'name' => __('Single workstation'), - 'price_chf' => 750, - 'period' => __('month'), - ], - 'optionalServices' => [ - ['name' => __('Monitor & video camera'), 'price' => __('On request')], - ['name' => __('Parking with EV charging'), 'price' => __('from CHF 250.00 / month')], - ['name' => __('Fibre upgrade up to 5 Gbit/s'), 'price' => __('On request')], - ['name' => __('Static public IP'), 'price' => __('On request')], - ], - 'rentalConditions' => [ - 'minimum_months' => 12, - 'notice_months' => 3, - 'deposit_text' => __('3 months\' rent per workstation'), - ], - ]);*/ } } diff --git a/app/Http/Controllers/Contact/ContactIndexController.php b/app/Http/Controllers/Contact/ContactIndexController.php index 5538d0d..aae301d 100644 --- a/app/Http/Controllers/Contact/ContactIndexController.php +++ b/app/Http/Controllers/Contact/ContactIndexController.php @@ -5,19 +5,36 @@ namespace App\Http\Controllers\Contact; use App\Actions\PageAction; +use App\Actions\ViewDataAction; +use App\DTO\ContactDTO; +use App\Enums\ContactSectionEnum; use App\Http\Controllers\Controller; use App\Seo\SchemaNodes; use Illuminate\View\View; class ContactIndexController extends Controller { - public function __invoke(): View + public function __invoke(ViewDataAction $viewData): View { return view('app.contact.index')->with([ 'page' => (new PageAction(locale: null, routeName: 'contact.index'))->default(), 'openingHours' => config('company.opening_hours'), 'locations' => config('company.locations'), + 'contactPerson' => $this->contactPerson($viewData), 'schema' => SchemaNodes::locations(), ]); } + + private function contactPerson(ViewDataAction $viewData): ?ContactDTO + { + $key = config('company.contact_person'); + + if (! is_string($key) || $key === '') { + return null; + } + + return $viewData + ->contactsInSection(app()->getLocale(), ContactSectionEnum::EMPLOYEES) + ->firstWhere('key', $key); + } } diff --git a/app/Http/Controllers/Demo/FlowsLayoutDemoController.php b/app/Http/Controllers/Demo/FlowsLayoutDemoController.php deleted file mode 100644 index 7b83abd..0000000 --- a/app/Http/Controllers/Demo/FlowsLayoutDemoController.php +++ /dev/null @@ -1,221 +0,0 @@ - - */ - public static function variants(): array - { - return [ - 'saas-landing' => [ - 'view' => 'demo.flows.variants.saas-landing', - 'title' => 'Klassische SaaS-Landingpage', - 'description' => 'Grosser Gradient-Hero, Feature-Grid, sticky CTA-Leiste — vertraute Marketing-Rhythmik.', - ], - 'editorial' => [ - 'view' => 'demo.flows.variants.editorial', - 'title' => 'Editorial / Long-Form', - 'description' => 'Magazin-artige einspaltige Leseerfahrung mit Initiale, Zwischentiteln und viel Weissraum.', - ], - 'bento' => [ - 'view' => 'demo.flows.variants.bento', - 'title' => 'Bento-Grid', - 'description' => 'Asymmetrisches Kachelraster im Stil moderner Produktseiten (Apple/Linear).', - ], - 'terminal' => [ - 'view' => 'demo.flows.variants.terminal', - 'title' => 'Terminal / Dev-Konsole', - 'description' => 'Dunkles, monospace-geprägtes Layout mit Terminal-Fenster-Chrome — technische Zielgruppe.', - ], - 'big-statement' => [ - 'view' => 'demo.flows.variants.big-statement', - 'title' => 'Grosse Aussagen / Scroll-Story', - 'description' => 'Überdimensionierte Typografie, eine Kernaussage pro Abschnitt, filmisches Scrollen.', - ], - 'docs-split' => [ - 'view' => 'demo.flows.variants.docs-split', - 'title' => 'Split Sticky Docs', - 'description' => 'Linke fixierte Mini-Navigation, rechts scrollender Inhalt — wie moderne Doku-Seiten.', - ], - 'journey' => [ - 'view' => 'demo.flows.variants.journey', - 'title' => 'Nummerierte Reise', - 'description' => 'Alles als durchnummerierte Schritte mit verbindender Zeitlinie erzählt.', - ], - 'before-after' => [ - 'view' => 'demo.flows.variants.before-after', - 'title' => 'Vorher / Nachher', - 'description' => 'Kontrastreiche Zweispalten-Gegenüberstellung als Erzählprinzip.', - ], - 'swiss-grid' => [ - 'view' => 'demo.flows.variants.swiss-grid', - 'title' => 'Swiss Minimalist Grid', - 'description' => 'Strenges Raster, feine Linien, Kapitälchen-Labels — reduziert und hochwertig.', - ], - 'tabs-dashboard' => [ - 'view' => 'demo.flows.variants.tabs-dashboard', - 'title' => 'Interaktives Tab-Dashboard', - 'description' => 'Abschnitte als Tabs/Accordion erkundbar — produkttour-artiges Gefühl.', - ], - ]; - } - - /** - * @return array - */ - public static function content(): array - { - return [ - 'headline' => 'Dokumentenprozesse, gesteuert von Agenten. Auf Infrastruktur, die du kontrollierst.', - 'subheadline' => 'Flows ist eine Orchestrierungsplattform für dokumentenbasierte Prozesse — verbinde dein DMS und lass KI-Agenten Dokumente automatisch extrahieren, validieren und verarbeiten.', - 'problem' => [ - 'heading' => 'Extraktionstools lesen Dokumente. Sie verstehen dein Geschäft nicht.', - 'intro' => 'Zwei Probleme halten Dokumentenprozesse manuell.', - 'paragraphs' => [ - 'Das erste ist der Aufwand: Dein DMS speichert Dokumente zuverlässig, aber die Arbeit drumherum – Lesen, Indexieren, Validieren, Übertragen von Daten in andere Systeme – hängt weiterhin von Personen ab, die Dateien öffnen und Daten eintippen. Das skaliert nur durch zusätzliches Personal.', - 'Das zweite Problem liegt tiefer: Klassische Dokumentenextraktion liefert Felder, nicht mehr. Sie versteht den Inhalt nicht und kann das Ergebnis nicht gegen deine eigenen Daten prüfen – existiert dieser Lieferant, stimmt der Betrag mit der Bestellung überein, ist das die richtige Kostenstelle? Selbst mit einem Extraktionstool muss also weiterhin eine Person jedes Ergebnis prüfen, bevor es vertrauenswürdig ist.', - ], - ], - 'features' => [ - 'heading' => 'Orchestrierte Agenten-Workflows – extrahieren, verstehen, validieren, zurückschreiben.', - 'intro' => 'Flows verbindet sich mit deinem bestehenden DMS. Keine Migration; dein Archiv bleibt, wo es ist. Darauf konfigurierst du Workflows, die auslösen, sobald ein Dokument eintrifft.', - 'items' => [ - ['title' => 'Agentische Extraktion mit Validierung', 'description' => 'Definiere das Schema, das du brauchst. Agenten extrahieren die Daten, validieren sie gegen deine eigenen Systeme und Daten und schreiben geprüfte Ergebnisse zurück – strukturiert und passend zu deinen Feldnamen.'], - ['title' => 'Multi-Agenten-Workflow-Orchestrierung', 'description' => 'Für komplexe Prozesse verkettest du spezialisierte Agenten – Klassifizierung, Validierung, Anreicherung, Routing. Die Orchestrierung basiert auf dem Microsoft Agent Framework, und MCP-Tool-Integrationen verbinden deine Workflows direkt mit deinen Geschäftssystemen und Daten.'], - ['title' => 'Prompt- und Agenten-Engineering', 'description' => 'Verbessere Prompts anhand echter Produktionsdaten, generiere sie aus dem Wissen in deinem Datenpool oder DMS und verwalte Prompts und Schemas in einer versionierten, wiederverwendbaren Knowledge-Bibliothek.'], - ['title' => 'Modell- und Anbieterfreiheit', 'description' => 'Führe Workflows bei dem Anbieter aus, der zu deinen Anforderungen an Genauigkeit, Kosten und Datenresidenz passt – und vergleiche denselben Workflow über mehrere Modelle hinweg, um zu sehen, welches bei deinen Dokumenten am besten abschneidet. Ein Wechsel bedeutet keinen Neuaufbau. Eigene lokale Modelle (Bring-your-own) folgen als Nächstes.'], - ['title' => 'Vollständige Nachvollziehbarkeit', 'description' => 'Jeder Lauf wird lückenlos protokolliert: Dokument, Modell, Tokens, Dauer, Ergebnis.'], - ], - ], - 'deployment' => [ - 'heading' => 'Drei Wege, Flows zu betreiben. Alle isoliert.', - 'intro' => 'Jede Bereitstellung erhält ihre eigene Datenbank, Schlüsselverwaltung, Speicher und Endpunkte – automatisch bereitgestellt, mit Firewalling und automatischer Rotation der Secrets. Du entscheidest, wo sie läuft, und wählst bei jeder Option die Azure-Region deiner Bereitstellung, sofern diese die benötigten Dienste unterstützt.', - 'options' => [ - ['title' => 'Auf deiner eigenen Azure-Infrastruktur', 'description' => 'Flows wird in deine bestehende Azure-Umgebung integriert. Dein Abonnement, deine Governance, deine Kontrolle.'], - ['title' => 'Dediziertes Azure-Abonnement', 'description' => 'Ein vollständig isoliertes Abonnement, das für dich betrieben wird – maximale Trennung, ohne dass du es selbst betreiben musst.'], - ['title' => 'Gemeinsames Abonnement, dedizierte Ressourcengruppe', 'description' => 'Deine eigene Ressourcengruppe und Bereitstellungen innerhalb eines gemeinsamen Abonnements – isolierte Ressourcen bei geringerem Fussabdruck.'], - ], - ], - 'cta' => [ - 'heading' => 'Interessiert?', - 'body' => 'Wir zeigen dir Flows anhand deiner eigenen Dokumente.', - 'buttonLabel' => 'Kontaktiere uns', - ], - ]; - } - - public function index(): View - { - return view('demo.flows.index', [ - 'variants' => self::variants(), - ]); - } - - public function show(string $variant): View - { - if (! array_key_exists($variant, self::variants())) { - throw new NotFoundHttpException; - } - - return view(self::variants()[$variant]['view'], [ - 'content' => self::content(), - 'variantTitle' => self::variants()[$variant]['title'], - ]); - } - - /** - * @return array - */ - public static function v2Variants(): array - { - return [ - 'flow-diagram' => [ - 'view' => 'demo.flows.v2.variants.flow-diagram', - 'title' => 'Fluss-Diagramm', - 'description' => 'Dokument → Agent → geprüftes Ergebnis als technisches Node-Diagramm im Hero.', - ], - 'line-icons' => [ - 'view' => 'demo.flows.v2.variants.line-icons', - 'title' => 'Line-Icon-Set', - 'description' => 'Ein eigenes, konsistentes Strich-Icon für jedes Feature und jede Deployment-Option.', - ], - 'blueprint' => [ - 'view' => 'demo.flows.v2.variants.blueprint', - 'title' => 'Blueprint', - 'description' => 'Millimeterpapier-Textur, Massketten und Eckmarken — technische Zeichnung.', - ], - 'isometric' => [ - 'view' => 'demo.flows.v2.variants.isometric', - 'title' => 'Isometrisch', - 'description' => 'Isometrische Dokumentenstapel- und Server-Illustrationen für Hero & Deployment.', - ], - 'organic-blobs' => [ - 'view' => 'demo.flows.v2.variants.organic-blobs', - 'title' => 'Organische Blobs', - 'description' => 'Weiche Verlaufsflächen in Markenfarben hinter den nummerierten Sektionen.', - ], - 'dot-halftone' => [ - 'view' => 'demo.flows.v2.variants.dot-halftone', - 'title' => 'Halbton-Raster', - 'description' => 'Punktraster-Illustration im Hero, Plakat-artige Druck-Ästhetik.', - ], - 'network-nodes' => [ - 'view' => 'demo.flows.v2.variants.network-nodes', - 'title' => 'Netzwerk-Knoten', - 'description' => 'Verbundene Knoten als Sinnbild für Multi-Agenten-Orchestrierung.', - ], - 'stamp-seal' => [ - 'view' => 'demo.flows.v2.variants.stamp-seal', - 'title' => 'Stempel/Siegel', - 'description' => 'Kreisrunde Stempelmarken mit perforiertem Rand markieren jede Sektion.', - ], - 'data-bars' => [ - 'view' => 'demo.flows.v2.variants.data-bars', - 'title' => 'Daten-Balken', - 'description' => 'Abstrakte Balken-/Wellenformen als Sinnbild für Dokumentenverarbeitung.', - ], - 'doc-stack' => [ - 'view' => 'demo.flows.v2.variants.doc-stack', - 'title' => 'Dokumentenstapel', - 'description' => 'Stilisierte Dokumente mit Eselsohr als durchgehendes Bildmotiv.', - ], - ]; - } - - public function indexV2(): View - { - return view('demo.flows.v2.index', [ - 'variants' => self::v2Variants(), - ]); - } - - public function showV2(string $variant): View - { - if (! array_key_exists($variant, self::v2Variants())) { - throw new NotFoundHttpException; - } - - $product = Product::where('locale', 'de_CH')->where('slug', 'flows')->first(); - - $page = $product - ? (new PageAction)->product(product: $product) - : (new PageAction(locale: null, routeName: 'products.index'))->default(); - - return view(self::v2Variants()[$variant]['view'], [ - 'content' => self::content(), - 'variantTitle' => self::v2Variants()[$variant]['title'], - 'page' => $page, - ]); - } -} diff --git a/app/Http/Controllers/Entry/EntryIndexController.php b/app/Http/Controllers/Entry/EntryIndexController.php index 2cb757f..f854dc9 100644 --- a/app/Http/Controllers/Entry/EntryIndexController.php +++ b/app/Http/Controllers/Entry/EntryIndexController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Entry; +use App\Enums\CookieNameEnum; use App\Enums\LocaleEnum; use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; @@ -11,6 +12,8 @@ class EntryIndexController extends Controller { + private const int HINT_LIFETIME_IN_MINUTES = 5; + public function __invoke(): RedirectResponse { // Always the default locale, never the session's: a redirect whose @@ -19,6 +22,27 @@ public function __invoke(): RedirectResponse // x-default hreflang, which also points at the German start page. $locale = Str::slug(LocaleEnum::DE->value); - return redirect()->route("{$locale}.start.index", status: 301); + // Marks the arrival as one through the domain root, which is the only + // arrival that was never a language choice. Attached to the response + // rather than queued, so it survives the response cache replaying + // this redirect. + // A 301 is cached by the browser for good, and a redirect replayed from + // that cache never reaches us — so without this the marker below is + // handed out once per browser, ever. The permanence a crawler reads + // lives in the status code, not in this header. + return redirect() + ->route("{$locale}.start.index", status: 301) + ->header('Cache-Control', 'no-store, private') + ->cookie( + CookieNameEnum::ENTRY_REDIRECT->value, + '1', + self::HINT_LIFETIME_IN_MINUTES, + '/', + null, + null, + false, + false, + 'lax', + ); } } diff --git a/app/Http/Controllers/Network/NetworkIndexController.php b/app/Http/Controllers/Network/NetworkIndexController.php index 6e08dd0..7401b5d 100644 --- a/app/Http/Controllers/Network/NetworkIndexController.php +++ b/app/Http/Controllers/Network/NetworkIndexController.php @@ -5,22 +5,17 @@ namespace App\Http\Controllers\Network; use App\Actions\PageAction; +use App\Actions\ViewDataAction; use App\Enums\NetworkCategoryEnum; use App\Http\Controllers\Controller; -use App\Models\Network; use Illuminate\Support\Collection; use Illuminate\View\View; class NetworkIndexController extends Controller { - public function __invoke(): View + public function __invoke(ViewDataAction $viewData): View { - $networks = Network::query() - ->published() - ->active() - ->with('publishedUsers') - ->orderBy('sort') - ->get(); + $networks = $viewData->networks(); $groups = collect(NetworkCategoryEnum::cases()) ->mapWithKeys(fn (NetworkCategoryEnum $category): array => [ diff --git a/app/Http/Controllers/Network/NetworkShowController.php b/app/Http/Controllers/Network/NetworkShowController.php index 58a22c6..064b61d 100644 --- a/app/Http/Controllers/Network/NetworkShowController.php +++ b/app/Http/Controllers/Network/NetworkShowController.php @@ -4,32 +4,14 @@ namespace App\Http\Controllers\Network; -use App\Actions\PageAction; use App\Http\Controllers\Controller; -use App\Models\Network; -use Illuminate\View\View; +use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Str; class NetworkShowController extends Controller { - public function __invoke(string $slug): View + public function __invoke(string $slug): RedirectResponse { - $network = Network::query() - ->published() - ->active() - ->where('page_slug', $slug) - ->first(); - - abort_unless((bool) $network, 404); - abort_unless(view()->exists('app.network.pages.'.$slug), 404); - - return view('app.network.pages.'.$slug)->with([ - // Built from the Network itself, not from the index page: otherwise - // every partner page inherits the index's title and description and - // its hreflang alternates point back at /netzwerk instead of at the - // partner page's own translation. - 'page' => (new PageAction)->network(network: $network), - 'network' => $network, - 'users' => $network->publishedUsers()->get(), - ]); + return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); } } diff --git a/app/Http/Controllers/News/NewsShowController.php b/app/Http/Controllers/News/NewsShowController.php index fbaa5f0..5ee7d22 100644 --- a/app/Http/Controllers/News/NewsShowController.php +++ b/app/Http/Controllers/News/NewsShowController.php @@ -6,8 +6,10 @@ use App\Actions\PageAction; use App\DTO\ContactDTO; +use App\Enums\ContactSectionEnum; use App\Http\Controllers\Controller; use App\Markdown\NewsMarkdown; +use App\Models\Contact; use App\Models\News; use App\Seo\SchemaNodes; use Illuminate\Database\Eloquent\Collection; @@ -15,7 +17,7 @@ class NewsShowController extends Controller { - private const int RELATED_ARTICLES = 3; + private const int RELATED_ARTICLES = 2; private const int RELATED_CANDIDATE_POOL = 30; @@ -31,7 +33,11 @@ public function __invoke(string $locale, News $news, NewsMarkdown $markdown): Vi $page = (new PageAction($locale))->news(news: $news, withReferences: true); $body = is_string($news->content) ? $news->content : ''; - $author = $news->authorContact; + + $authors = collect([$news->authorContact]) + ->filter() + ->map(fn (Contact $contact): ContactDTO => ContactDTO::fromModel($contact, ContactSectionEnum::EMPLOYEES, $locale)) + ->values(); return view('app.news.show')->with([ 'page' => $page, @@ -41,10 +47,7 @@ public function __invoke(string $locale, News $news, NewsMarkdown $markdown): Vi 'tags' => collect($news->tags), 'content' => $markdown->toHtml($body), 'headings' => $markdown->headings($body), - 'authorName' => $news->authorName(), - 'authorRole' => $author !== null ? ContactDTO::fromModel($author, 'employees', $locale)->role : null, - 'authorImage' => $author?->image, - 'authorLinkedin' => $author !== null && is_array($author->icons) ? ($author->icons['linkedin'] ?? null) : null, + 'authors' => $authors, 'series' => $news->series, 'seriesParts' => $news->seriesParts(), 'related' => $this->relatedArticles($news), @@ -60,7 +63,12 @@ public function __invoke(string $locale, News $news, NewsMarkdown $markdown): Vi */ private function relatedArticles(News $news): Collection { - $curated = $news->relatedArticles()->published()->get(); + // The footer rows are the same rows as the index and the start page — series + // chip, author picture and all — so they need the same relations loaded. + // Model::shouldBeStrict() turns a lazy load into an exception locally. + $related = ['series', 'authorContact']; + + $curated = $news->relatedArticles()->with($related)->published()->get(); if ($curated->count() >= self::RELATED_ARTICLES) { return $curated->take(self::RELATED_ARTICLES); @@ -69,10 +77,11 @@ private function relatedArticles(News $news): Collection $tags = $this->tagStrings($news); $byTag = News::query() + ->with($related) ->published() // Only the card fields — `content` holds the full article body in both // languages, and scoring by tag overlap never looks at it. - ->select(['id', 'key', 'slug', 'title', 'teaser', 'hero_image', 'published_at', 'reading_minutes', 'tags', 'series_id', 'contact_id']) + ->select(['id', 'key', 'slug', 'title', 'teaser', 'hero_image', 'thumb_image', 'published_at', 'reading_minutes', 'tags', 'series_id', 'contact_id']) ->whereKeyNot($news->getKey()) ->whereNotIn('id', $curated->modelKeys()) ->when($news->series_id !== null, fn ($query) => $query->where(function ($inner) use ($news) { diff --git a/app/Http/Controllers/OpenSource/OpenSoruceShowController.php b/app/Http/Controllers/OpenSource/OpenSoruceShowController.php deleted file mode 100644 index 43f6900..0000000 --- a/app/Http/Controllers/OpenSource/OpenSoruceShowController.php +++ /dev/null @@ -1,44 +0,0 @@ -route(Str::slug(app()->getLocale()).'.start.index'); - - /* // `sync:repositories` creates an entry per GitHub repository but only - // fills title and teaser — content is written by hand. Without it there - // is no page here worth having, let alone indexing, so serve a 404 - // rather than a near-empty URL. - if (! $openSource->hasWrittenContent()) { - throw new NotFoundHttpException; - } - - $page = (new PageAction(locale: $locale))->openSource(openSource: $openSource, withReferences: true); - - return view('app.open-source.show')->with([ - 'page' => $page, - 'name' => $openSource->title, - 'teaser' => $openSource->teaser, - 'content' => Str::of($openSource->content ?? '')->markdown(), - 'tags' => $openSource->tags, - 'link' => $openSource->link, - 'schema' => SchemaNodes::softwareSourceCode($openSource, $page, $locale), - ]);*/ - } -} diff --git a/app/Http/Controllers/OpenSource/OpenSourceIndexController.php b/app/Http/Controllers/OpenSource/OpenSourceIndexController.php index c74eb24..d201bf6 100644 --- a/app/Http/Controllers/OpenSource/OpenSourceIndexController.php +++ b/app/Http/Controllers/OpenSource/OpenSourceIndexController.php @@ -7,25 +7,16 @@ use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Str; -use Illuminate\View\View; class OpenSourceIndexController extends Controller { /** - * Disabled until the listing actually has entries. `sync:repositories` is - * not scheduled, so this page rendered its intro and nothing else — an - * empty URL that search engines read as thin content. Restore the body - * below once repositories are synced and written up. + * Disabled until the listing actually has entries. `sync:repositories` is not + * scheduled, so this page rendered its intro and nothing else — an empty URL that + * search engines read as thin content. */ - public function __invoke(): View|RedirectResponse + public function __invoke(): RedirectResponse { return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); - - /* $locale = app()->getLocale(); - - return view('app.open-source.index')->with([ - 'page' => (new PageAction(locale: null, routeName: 'open-source.index'))->default(), - 'openSource' => (new ViewDataAction)->openSource($locale), - ]);*/ } } diff --git a/app/Http/Controllers/OpenSource/OpenSourceShowController.php b/app/Http/Controllers/OpenSource/OpenSourceShowController.php new file mode 100644 index 0000000..3aa14dc --- /dev/null +++ b/app/Http/Controllers/OpenSource/OpenSourceShowController.php @@ -0,0 +1,23 @@ +route(Str::slug(app()->getLocale()).'.start.index'); + } +} diff --git a/app/Http/Controllers/Products/ProductsIndexController.php b/app/Http/Controllers/Products/ProductsIndexController.php index 4c43b2f..355374d 100644 --- a/app/Http/Controllers/Products/ProductsIndexController.php +++ b/app/Http/Controllers/Products/ProductsIndexController.php @@ -4,24 +4,14 @@ namespace App\Http\Controllers\Products; -use App\Actions\PageAction; -use App\Actions\ViewDataAction; use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Str; -use Illuminate\View\View; class ProductsIndexController extends Controller { - public function __invoke(): View|RedirectResponse + public function __invoke(): RedirectResponse { return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); - - /* $locale = app()->getLocale(); - - return view('app.products.index')->with([ - 'page' => (new PageAction(locale: null, routeName: 'products.index'))->default(), - 'products' => (new ViewDataAction)->products($locale), - ]);*/ } } diff --git a/app/Http/Controllers/Products/ProductsShowController.php b/app/Http/Controllers/Products/ProductsShowController.php index 4ed7b56..1438e22 100644 --- a/app/Http/Controllers/Products/ProductsShowController.php +++ b/app/Http/Controllers/Products/ProductsShowController.php @@ -4,34 +4,15 @@ namespace App\Http\Controllers\Products; -use App\Actions\PageAction; use App\Http\Controllers\Controller; use App\Models\Product; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Str; -use Illuminate\View\View; class ProductsShowController extends Controller { - public function __invoke(string $locale, Product $product): View|RedirectResponse + public function __invoke(string $locale, Product $product): RedirectResponse { return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); - - /* return view('app.products.show')->with([ - 'page' => (new PageAction(locale: $locale))->product(product: $product), - 'name' => $product->name, - 'headline' => $product->headline, - 'teaser' => $product->teaser, - 'content' => Str::of($product->content ?? '')->markdown(), - 'tags' => $product->tags, - 'featuresHeading' => $product->features_heading, - 'featuresIntro' => $product->features_intro, - 'features' => $product->features, - 'deploymentHeading' => $product->deployment_heading, - 'deploymentIntro' => $product->deployment_intro, - 'deploymentOptions' => $product->deployment_options, - 'ctaHeading' => $product->cta_heading, - 'ctaBody' => $product->cta_body, - ]);*/ } } diff --git a/app/Http/Controllers/Services/ServicesShowController.php b/app/Http/Controllers/Services/ServicesShowController.php index 5223388..d4ee60c 100644 --- a/app/Http/Controllers/Services/ServicesShowController.php +++ b/app/Http/Controllers/Services/ServicesShowController.php @@ -4,25 +4,15 @@ namespace App\Http\Controllers\Services; -use App\Actions\PageAction; use App\Http\Controllers\Controller; use App\Models\Service; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Str; -use Illuminate\View\View; class ServicesShowController extends Controller { - public function __invoke(string $locale, Service $service): View|RedirectResponse + public function __invoke(string $locale, Service $service): RedirectResponse { return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); - - /* return view('app.services.show')->with([ - 'page' => (new PageAction(locale: $locale, routeName: null))->service(service: $service, withReferences: true), - 'name' => $service->name, - 'teaser' => $service->teaser, - 'content' => Str::of($service->content ?? '')->markdown(), - 'tags' => $service->tags, - ]);*/ } } diff --git a/app/Http/Controllers/Sitemap/SitemapController.php b/app/Http/Controllers/Sitemap/SitemapController.php index 275a634..b353314 100644 --- a/app/Http/Controllers/Sitemap/SitemapController.php +++ b/app/Http/Controllers/Sitemap/SitemapController.php @@ -9,7 +9,6 @@ use App\Enums\CacheKeyEnum; use App\Enums\LocaleEnum; use App\Http\Controllers\Controller; -use App\Models\Network; use App\Models\News; use App\Sitemap\SitemapBuilder; use Illuminate\Http\Response; @@ -61,28 +60,9 @@ public function __invoke(): Response private function builder(SitemapBuilder $sitemap): void { $this->addDefaultRoutesToSitemap($sitemap); - $this->addNetworksToSitemap($sitemap); $this->addNewsToSitemap($sitemap); } - private function addNetworksToSitemap(SitemapBuilder $sitemap): void - { - Network::query() - ->published() - // Same filters NetworkShowController enforces: without active() and - // the view check, the sitemap advertises URLs that answer 404. - ->active() - ->whereNotNull('page_slug') - ->get() - ->filter(fn (Network $network): bool => view()->exists('app.network.pages.'.$network->page_slug)) - ->each(function (Network $network) use ($sitemap): void { - $this->addLocalizedSet( - $sitemap, - fn (string $locale): PageDTO => (new PageAction)->network(network: $network, locale: $locale), - ); - }); - } - private function addNewsToSitemap(SitemapBuilder $sitemap): void { News::query() diff --git a/app/Http/Controllers/Technologies/TechnologiesIndexController.php b/app/Http/Controllers/Technologies/TechnologiesIndexController.php index 9efd78a..2c9782b 100644 --- a/app/Http/Controllers/Technologies/TechnologiesIndexController.php +++ b/app/Http/Controllers/Technologies/TechnologiesIndexController.php @@ -4,24 +4,14 @@ namespace App\Http\Controllers\Technologies; -use App\Actions\PageAction; -use App\Actions\ViewDataAction; use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Str; -use Illuminate\View\View; class TechnologiesIndexController extends Controller { - public function __invoke(): View|RedirectResponse + public function __invoke(): RedirectResponse { return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); - - /* $locale = app()->getLocale(); - - return view('app.technologies.index')->with([ - 'page' => (new PageAction(locale: null, routeName: 'technologies.index'))->default(), - 'technologies' => (new ViewDataAction)->technologies($locale), - ]);*/ } } diff --git a/app/Http/Controllers/Technologies/TechnologiesShowController.php b/app/Http/Controllers/Technologies/TechnologiesShowController.php index 32f6c1a..4a0d250 100644 --- a/app/Http/Controllers/Technologies/TechnologiesShowController.php +++ b/app/Http/Controllers/Technologies/TechnologiesShowController.php @@ -4,25 +4,15 @@ namespace App\Http\Controllers\Technologies; -use App\Actions\PageAction; use App\Http\Controllers\Controller; use App\Models\Technology; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Str; -use Illuminate\View\View; class TechnologiesShowController extends Controller { - public function __invoke(string $locale, Technology $technology): View|RedirectResponse + public function __invoke(string $locale, Technology $technology): RedirectResponse { return redirect()->route(Str::slug(app()->getLocale()).'.start.index'); - - /* return view('app.technologies.show')->with([ - 'page' => (new PageAction(locale: $locale))->technology(technology: $technology), - 'name' => $technology->title, - 'teaser' => $technology->teaser, - 'content' => Str::of($technology->content ?? '')->markdown(), - 'tags' => $technology->tags, - ]);*/ } } diff --git a/app/Http/Middleware/SecurityHeaders.php b/app/Http/Middleware/SecurityHeaders.php index dc780b3..3c177a1 100644 --- a/app/Http/Middleware/SecurityHeaders.php +++ b/app/Http/Middleware/SecurityHeaders.php @@ -32,6 +32,7 @@ public function handle(Request $request, Closure $next): Response } $response->headers->set('Cross-Origin-Opener-Policy', 'same-origin'); + $response->headers->set('Cross-Origin-Resource-Policy', 'same-origin'); $response->headers->set('X-Content-Type-Options', 'nosniff'); $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); $response->headers->set('X-Frame-Options', 'DENY'); diff --git a/app/Jobs/Network/SendNetworkInviteJob.php b/app/Jobs/Network/SendNetworkInviteJob.php index d40e9fd..3599cb4 100644 --- a/app/Jobs/Network/SendNetworkInviteJob.php +++ b/app/Jobs/Network/SendNetworkInviteJob.php @@ -8,18 +8,45 @@ use App\Notifications\NetworkInviteNotification; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\URL; use Illuminate\Support\Str; +use Throwable; class SendNetworkInviteJob implements ShouldQueue { use Queueable; + public int $tries = 3; + + public int $timeout = 30; + public function __construct( public string $email, public string $locale, ) {} + /** + * @return array + */ + public function backoff(): array + { + return [60, 300]; + } + + /** + * Invites go out in bulk from an interactive command, so a failure that only shows + * up in the failed_jobs table is a partner nobody notices was never invited. + */ + public function failed(Throwable $exception): void + { + Log::error('Failed to send a network invite.', [ + 'email_sha256' => hash('sha256', $this->email), + 'locale' => $this->locale, + 'exception' => $exception->getMessage(), + ]); + } + public function handle(): void { $networkUser = NetworkUser::query() diff --git a/app/Jobs/Network/SendNetworkManageLinkJob.php b/app/Jobs/Network/SendNetworkManageLinkJob.php index 395d511..29a69bb 100644 --- a/app/Jobs/Network/SendNetworkManageLinkJob.php +++ b/app/Jobs/Network/SendNetworkManageLinkJob.php @@ -8,18 +8,46 @@ use App\Notifications\NetworkManageLinkNotification; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\URL; use Illuminate\Support\Str; +use Throwable; class SendNetworkManageLinkJob implements ShouldQueue { use Queueable; + public int $tries = 3; + + public int $timeout = 30; + public function __construct( public string $email, public string $locale, ) {} + /** + * @return array + */ + public function backoff(): array + { + return [60, 300]; + } + + /** + * The requester is told nothing either way, so a silent failure here looks to them + * like a link that never arrives. Log it with a hashed address — the plain one is + * exactly the kind of thing that should not end up in a log file. + */ + public function failed(Throwable $exception): void + { + Log::error('Failed to send a network manage link.', [ + 'email_sha256' => hash('sha256', $this->email), + 'locale' => $this->locale, + 'exception' => $exception->getMessage(), + ]); + } + public function handle(): void { $networkUser = NetworkUser::query() diff --git a/app/Markdown/CodeTheme.php b/app/Markdown/CodeTheme.php new file mode 100644 index 0000000..5695909 --- /dev/null +++ b/app/Markdown/CodeTheme.php @@ -0,0 +1,66 @@ + on purpose —
 is the scroll container, and
+ * a button positioned inside it scrolls away with the first long line. Both labels
+ * are rendered server-side and read back by Alpine, because a translated string
+ * belongs in lang/, not in a bundle.
+ */
+final readonly class CodeTheme implements WebTheme
+{
+    use EscapesWebTheme;
+
+    private CssTheme $tokens;
+
+    public function __construct()
+    {
+        $this->tokens = new CssTheme;
+    }
+
+    public function before(TokenType $tokenType): string
+    {
+        return $this->tokens->before($tokenType);
+    }
+
+    public function after(TokenType $tokenType): string
+    {
+        return $this->tokens->after($tokenType);
+    }
+
+    public function preBefore(Highlighter $highlighter): string
+    {
+        $language = $highlighter->getCurrentLanguage()?->getName() ?? 'txt';
+
+        return '
' + .'' + .'
';
+    }
+
+    public function preAfter(Highlighter $highlighter): string
+    {
+        // The label is in the markup, not only in x-text, so the button reads correctly
+        // before Alpine boots. Without JavaScript it stays a button that does nothing —
+        // hence hidden until the component initialises.
+        return '
' + .'' + .'
'; + } +} diff --git a/app/Markdown/NewsMarkdown.php b/app/Markdown/NewsMarkdown.php index 39f1359..17e06d2 100644 --- a/app/Markdown/NewsMarkdown.php +++ b/app/Markdown/NewsMarkdown.php @@ -7,12 +7,16 @@ use Illuminate\Support\Facades\View; use Illuminate\Support\Str; use League\CommonMark\Environment\Environment; +use League\CommonMark\Event\DocumentParsedEvent; use League\CommonMark\Exception\CommonMarkException; use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\Extension\CommonMark\Node\Inline\Link; use League\CommonMark\Extension\GithubFlavoredMarkdownExtension; use League\CommonMark\MarkdownConverter; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; +use Tempest\Highlight\CommonMark\HighlightExtension; +use Tempest\Highlight\Highlighter; /** * Renders an article body: GitHub-flavoured Markdown plus fenced block directives. @@ -245,6 +249,22 @@ private function parseYamlList(string $body): array return $items; } + private function openLinksInNewTab(DocumentParsedEvent $event): void + { + $walker = $event->getDocument()->walker(); + + while ($step = $walker->next()) { + $node = $step->getNode(); + + if (! $step->isEntering() || ! $node instanceof Link) { + continue; + } + + $node->data->set('attributes/target', '_blank'); + $node->data->set('attributes/rel', 'noopener noreferrer'); + } + } + private function convert(string $markdown): string { $environment = (new Environment([ @@ -254,7 +274,16 @@ private function convert(string $markdown): string 'allow_unsafe_links' => false, ])) ->addExtension(new CommonMarkCoreExtension) - ->addExtension(new GithubFlavoredMarkdownExtension); + ->addExtension(new GithubFlavoredMarkdownExtension) + // Highlighting happens here, at render time, and emits nothing but + // — no client-side highlighter, no inline styles, + // so the code block costs no JavaScript and needs no CSP exception. + // The colours of those classes live in resources/css/app.css. + ->addExtension(new HighlightExtension(new Highlighter(new CodeTheme))); + + // Every link in an article leaves the article: a reader who follows a source + // keeps the text they were reading open behind it. + $environment->addEventListener(DocumentParsedEvent::class, $this->openLinksInNewTab(...)); $converter = new MarkdownConverter($environment); diff --git a/app/Models/AiModel.php b/app/Models/AiModel.php index 424ac19..f53a811 100644 --- a/app/Models/AiModel.php +++ b/app/Models/AiModel.php @@ -53,7 +53,9 @@ public function dailyUsages(): HasMany public function localizedRole(): ?string { - return $this->role[substr(app()->getLocale(), 0, 2)] ?? null; + $role = data_get($this->role, app()->getLocale()); + + return is_string($role) ? $role : null; } public function licenseLabel(): ?string diff --git a/app/Models/News.php b/app/Models/News.php index 3068330..e481d7e 100644 --- a/app/Models/News.php +++ b/app/Models/News.php @@ -34,9 +34,11 @@ class News extends Model implements HasTranslatedRouteKey 'teaser', 'content', 'hero_image', + 'thumb_image', 'hero_caption', 'hero_alt', 'published_at', + 'revised_at', 'published', 'author', 'contact_id', @@ -50,6 +52,7 @@ class News extends Model implements HasTranslatedRouteKey protected $casts = [ 'tags' => 'json', 'published_at' => 'datetime', + 'revised_at' => 'datetime', 'featured' => 'boolean', 'published' => 'boolean', ]; @@ -93,6 +96,23 @@ public function authorName(): ?string return is_string($this->author) && $this->author !== '' ? $this->author : null; } + /** + * The chips a card shows: the series first, then every tag. One list rather than a + * single topic, because an article can sit in a series and still carry two subjects — + * and because the start page and the news index have to agree on what they label. + * + * @return array + */ + public function topics(): array + { + $series = $this->series?->title; + + return array_values(array_unique(array_filter(array_merge( + is_string($series) ? [$series] : [], + is_array($this->tags) ? array_filter($this->tags, is_string(...)) : [], + )))); + } + /** * @return BelongsTo */ diff --git a/app/Observers/ContentCacheObserver.php b/app/Observers/ContentCacheObserver.php index b773a9d..db5032b 100644 --- a/app/Observers/ContentCacheObserver.php +++ b/app/Observers/ContentCacheObserver.php @@ -10,9 +10,9 @@ use App\Models\Product; use App\Models\Service; use App\Models\Technology; +use App\Support\ResponseCacheFlusher; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Cache; -use Spatie\ResponseCache\Facades\ResponseCache; /** * Drops the cached listing of a content type whenever one of its rows changes. @@ -43,7 +43,7 @@ public static function flush(Model $model): void Cache::forget($key); } - ResponseCache::clear(); + ResponseCacheFlusher::flush(); } /** diff --git a/app/Observers/NetworkObserver.php b/app/Observers/NetworkObserver.php index edad25a..49c21a3 100644 --- a/app/Observers/NetworkObserver.php +++ b/app/Observers/NetworkObserver.php @@ -4,18 +4,27 @@ namespace App\Observers; +use App\Enums\CacheKeyEnum; use App\Models\Network; -use Spatie\ResponseCache\Facades\ResponseCache; +use App\Support\ResponseCacheFlusher; +use Illuminate\Support\Facades\Cache; class NetworkObserver { public function saved(Network $network): void { - ResponseCache::clear(); + self::flush(); } public function deleted(Network $network): void { - ResponseCache::clear(); + self::flush(); + } + + public static function flush(): void + { + Cache::forget(CacheKeyEnum::NETWORKS_PUBLISHED->value); + + ResponseCacheFlusher::flush(); } } diff --git a/app/Observers/NetworkUserObserver.php b/app/Observers/NetworkUserObserver.php index a48e32f..cdbe99b 100644 --- a/app/Observers/NetworkUserObserver.php +++ b/app/Observers/NetworkUserObserver.php @@ -5,17 +5,17 @@ namespace App\Observers; use App\Models\NetworkUser; -use Spatie\ResponseCache\Facades\ResponseCache; class NetworkUserObserver { + /** The cached network listing eager loads publishedUsers, so a person's edit stales it. */ public function saved(NetworkUser $networkUser): void { - ResponseCache::clear(); + NetworkObserver::flush(); } public function deleted(NetworkUser $networkUser): void { - ResponseCache::clear(); + NetworkObserver::flush(); } } diff --git a/app/Observers/NewsObserver.php b/app/Observers/NewsObserver.php index 0bf1bea..2acecb8 100644 --- a/app/Observers/NewsObserver.php +++ b/app/Observers/NewsObserver.php @@ -6,8 +6,8 @@ use App\Enums\CacheKeyEnum; use App\Models\News; +use App\Support\ResponseCacheFlusher; use Illuminate\Support\Facades\Cache; -use Spatie\ResponseCache\Facades\ResponseCache; /** * The published-news list is cached forever per locale and the rendered HTML is cached @@ -32,6 +32,6 @@ public static function flush(): void Cache::forget($key); } - ResponseCache::clear(); + ResponseCacheFlusher::flush(); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a44b0cb..915558e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,7 +5,6 @@ namespace App\Providers; use App\Checks\FailedJobsCheck; -use App\Checks\FilesystemsDefaultCheck; use App\Checks\JobsCheck; use App\Models\AiModel; use App\Models\Network; @@ -22,27 +21,29 @@ use App\Observers\NewsObserver; use App\Observers\SitemapCacheObserver; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; -use Spatie\Health\Checks\Check; use Spatie\Health\Checks\Checks\CacheCheck; use Spatie\Health\Checks\Checks\DebugModeCheck; use Spatie\Health\Checks\Checks\EnvironmentCheck; use Spatie\Health\Checks\Checks\OptimizedAppCheck; +use Spatie\Health\Checks\Checks\ScheduleCheck; use Spatie\Health\Facades\Health; use Spatie\SecurityAdvisoriesHealthCheck\SecurityAdvisoriesCheck; use Spatie\Translatable\Translatable; class AppServiceProvider extends ServiceProvider { - public function register(): void - { - // - } - public function boot(): void { $this->multilanguage(); + URL::forceRootUrl(config()->string('app.url')); + + if (str_starts_with(config()->string('app.url'), 'https://')) { + URL::forceScheme('https'); + } + app(Translatable::class)->allowNullForTranslation(); Network::observe(NetworkObserver::class); @@ -67,15 +68,24 @@ public function boot(): void $environmentCheck = EnvironmentCheck::new(); $environmentCheck->if(app()->isProduction()); + $jobsCheck = JobsCheck::new(); + $jobsCheck->everyFiveMinutes(); + + $scheduleCheck = ScheduleCheck::new(); + $scheduleCheck->everyFiveMinutes(); + + $advisoriesCheck = SecurityAdvisoriesCheck::new(); + $advisoriesCheck->lastDayOfMonth(); + Health::checks([ DebugModeCheck::new(), CacheCheck::new(), OptimizedAppCheck::new(), $environmentCheck, - self::asCheck(FilesystemsDefaultCheck::new()->everyFiveMinutes()), - self::asCheck(JobsCheck::new()->everyFiveMinutes()), + $jobsCheck, + $scheduleCheck, FailedJobsCheck::new(), - self::asCheck(SecurityAdvisoriesCheck::new()->lastDayOfMonth()), + $advisoriesCheck, ]); } @@ -87,9 +97,4 @@ private function multilanguage(): void Technology::registerLocalizedBinding('technology'); OpenSource::registerLocalizedBinding('openSource'); } - - private static function asCheck(Check $check): Check - { - return $check; - } } diff --git a/app/Security/Presets/MyCspPreset.php b/app/Security/Presets/MyCspPreset.php index 369a2bd..e3c48f6 100644 --- a/app/Security/Presets/MyCspPreset.php +++ b/app/Security/Presets/MyCspPreset.php @@ -37,14 +37,19 @@ public function configure(Policy $policy): void $styleSources = array_filter([ Keyword::SELF, - Keyword::UNSAFE_INLINE, $cdnHost ?: null, ]); - $policy->add(Directive::STYLE, $styleSources); + // Stylesheets are Vite-built files, never inline +``` + +The source is `resources/fonts/poppins/poppins-600-normal-latin.woff2` — 8 KB, so the payload is +about 11 KB of base64 and no subsetting is needed. One weight, 600, one class. (`scripts/make-news-hero.py` +still points at `public/fonts/poppins/`, which no longer exists. That script is v1 and is stale; +take the path from here.) + +**German only.** The headline is language-bound, so strictly this file is per locale — but we only +ever post in German, so only the German one gets drawn, and there is no locale suffix on the name. +Fall back to the English title only where an article has no German one. If that ever changes, the +second file is `-en.svg` and nothing else about this document moves. + +The palette holds otherwise: ink for the headline, the accent gradient on the rule and inside the +drawing, `#500472` nowhere — with the tag pills gone, this family has no use for it. + +## 4. The motif is re-composed, not cropped + +Take the article's approved hero and rebuild it into the band. **It is the same sentence with the +same objects and the same act** — a LinkedIn asset is never a place to invent a second idea for one +article — but the hero has 486 units of height for its drawing and this band has 265. + +The story still runs **left → right**, exactly as the hero does: + +``` +x 70–500 before the unresolved thing +x 524–576 arrow one arrowhead +x 596–714 act the chip, on the glow +x 730–782 arrow the second arrowhead +x 790–1124 after the working system +``` + +Two arrowheads, still — §8 of `illustration-services.md` applies unchanged, and so does the ban on +crossing connectors. All three stages share one optical axis; in the reference that is y 168. + +**Drop one object rather than shrink three.** The gateway hero carries a queue panel, a model chip +*and* a database cylinder; this one keeps the first two and lets the panel say what the cylinder +said. Three surfaces in the output half is the ceiling. If everything feels essential, the hero is +doing too much and the LinkedIn asset is the wrong place to discover that. + +Strokes, against the hero: + +| | hero | here | +|---|---|---| +| Outer surfaces | 3 | 3.2 | +| Inner detail, small glyphs | 2.5 | 2.6 | +| Connectors (`opacity 0.55`) | 3.5 | 3.2 | +| Arrow shaft | 4 | 4.2 | +| Squiggle, heading / body / secondary | 5.5 / 4.5 / 3.5 | 4.5 / 4 / 2.4 | + +Scaled groups need the division done by hand: `` with +`stroke-width="2.9"` renders at 2.5, not 2.9. + +## 5. The title block + +**Bottom-anchored, left-aligned.** The last baseline is fixed and the block grows *upward* — a +two-line headline and a three-line headline end on the same line, and the motif band gives up the +difference. Anchoring the top instead makes every article's picture end somewhere else, which is +exactly what a feed makes visible. + +| Element | Position | Notes | +|---|---|---| +| Accent rule | x 70, y 340, 100×7, `rx 3.5` | `url(#accent)`, the block's hook | +| Headline | x 70, baselines 566 / 492 / 418 | 60 px, leading 74, column **850** wide | +| Logo | x 940, y 525, 190×40.35 | `codebar-logo-colored.svg`, inlined as a nested `` | + +The logo sits bottom-right, its centre on the last line's x-height. The headline column stops at +x 920 so the two never meet — that is what makes 850 the column and not the full 1060. + +Two rungs, and the first one that fits wins: + +| Lines | Size / leading | Baselines | Band ends | Rule at | +|---|---|---|---|---| +| up to 3 | 60 / 74 | 566, 492, 418 | 305 | 340 | +| 4 | 52 / 64 | 566, 502, 438, 374 | 270 | 300 | + +**Five lines is not a rung.** A headline that needs one is too long for a feed — shorten it for the +post, or drop the subtitle half of a colon headline. + +SVG does not wrap text, so the lines are wrapped by hand into ``s and every one carries its +own `x="70"`. Measure rather than guess: + +```python +from fontTools.ttLib import TTFont +f = TTFont('resources/fonts/poppins/poppins-600-normal-latin.woff2') +cmap, hmtx, upem = f.getBestCmap(), f['hmtx'], f['head'].unitsPerEm +width = lambda s, px, ls=0.0: sum(hmtx[cmap[ord(c)]][0] / upem * px + for c in s if ord(c) in cmap) + ls * max(len(s) - 1, 0) +``` + +Break where the sentence breaks — after a colon, before a verb — not wherever 850 units run out. +«Lokale LLMs betreiben: / begrenzte Ressourcen / orchestrieren» reads; the same three lines broken +mechanically do not. + +## 6. Where files go + +``` +public/images/social/linkedin/.svg 1200×627 authored +public/images/social/linkedin/.png 1200×627 rendered, never hand-edited +``` + +`` is the article's `key:` — the same string in every locale, and the name the hero and the card +already use. + +**Not in `public/images/news/`.** `scripts/render-news-og.sh` with no arguments globs that directory +and re-renders everything in it at 1200×630. Three units of stretch would not be visible, but the +resulting PNG would sit next to the hero's own and there would be two candidates for one `og:image`. +The separate directory is the guard. + +Nothing in the app reads these files. There is no front matter to wire and no import to run: the +asset is uploaded to LinkedIn by hand, and the repository is where it is kept so the next post can +start from it. + +## 7. Rendering + +```bash +rsvg-convert -w 1200 -h 627 \ + public/images/social/linkedin/.svg \ + -o public/images/social/linkedin/.png +``` + +Not `render-news-og.sh` — that script is hard-wired to 1200×**630** because `config/seo.php` declares +those numbers for `og:image`, and it is right to be. Upload the **PNG**; LinkedIn does not accept SVG. + +Then look at it at feed size before you believe it: + +```bash +rsvg-convert -w 552 -h 288 .svg -o /tmp/check.png +``` + +## 8. The gate does not cover this family + +`scripts/check-illustrations.py` would fail every file here on Gate 4 — `` exists, the canvas +is not 1600×840 — and it does not look in this directory. That is deliberate, and it moves the +burden onto the hero: **only draw a LinkedIn asset for an article whose hero already passes.** The +provenance, the act and the set were checked there; this file inherits them and must not add an +object the hero does not have. + +## 9. Before you post + +- [ ] Derived from a hero that passes `scripts/check-illustrations.py news`, same objects, same act. +- [ ] The story runs left → right on one axis, exactly two arrowheads, no crossing connectors. +- [ ] At most three surfaces in the output half; something from the hero was dropped, not shrunk. +- [ ] Headline bottom-anchored: last baseline on 566, lines stacked upward, wrapped at the sense. +- [ ] Headline column stops at x 920; the logo is bottom-right and nothing reaches into it. +- [ ] Nothing in the picture the post text should be carrying — no teaser, no URL, no tags, no date. +- [ ] Poppins 600 embedded as base64 — check the **PNG**, not the SVG: Helvetica in the render means + the `@font-face` did not resolve. +- [ ] Rendered at 552 px: the headline reads, the motif still tells its story. +- [ ] German, unless the article has no German title. +- [ ] The PNG is 1200×627 and sits next to the SVG, and neither is in `public/images/news/`. diff --git a/prompts/illustration-news-card.md b/prompts/illustration-news-card.md new file mode 100644 index 0000000..23a109e --- /dev/null +++ b/prompts/illustration-news-card.md @@ -0,0 +1,156 @@ +# Generating news cards + +Follow this file for the small square drawing the news index puts next to a list row. It is the +companion to `illustration-news.md`, which covers the 1600×840 hero, and both sit downstream of +`illustration-services.md`, which defines the drawing language. Read those two first; this one is +only what changes when the canvas is 344 units wide. + +Not this: an **SEO card** (`illustration-seo-card.md`) is a page's `og:image` and is a wide +1200×630 drawing despite the name. The two are different files for different canvases, and +`public/images/pages/` never contains a `-card.svg`. + +Reference implementation: `public/images/news/docuware-7-13-is-here-card.svg`, next to its hero. + +## 1. The card is not a crop of the hero + +It is a second drawing of the same sentence, with one stage removed and everything roughly twice +as thick in proportion. + +A hero is a three-stage narrative across 1494 usable units. The index row renders the card at +**168 px**. Scaled down, three stages and an act chip land at about 50 px each and stop being +shapes — they become grey texture with a purple smudge in the middle. So the card keeps the two +ends of the sentence and drops the middle: + +> **hero** loose blocks → *composing* → a page assembled from them +> **card** loose blocks → a page assembled from them + +The act survives as a bare symbol on the wash between them where it genuinely helps, and is +otherwise left out entirely — the before/after pair already carries the change. **There is no act +chip on a card**: a 124-unit white square framing a symbol costs a third of the canvas to say +nothing. + +The services family made the same split for the same reason; `illustration-services.md` §3 has +the long version. + +## 2. The canvas + +| | hero | card | +|---|---|---| +| viewBox | `0 0 1600 840` | `-10 -10 344 344` | +| Draw inside | x 53–1547, y 153–687 (the crop) | **0–320 only** | +| Rendered at | 896 px wide, cropped two ways | **168 px**, uncropped | +| Story | left → right, three stages | top → bottom, two stages | +| Arrow | horizontal | **vertical** | +| Arrowheads | exactly two | **exactly one** | +| Glow | exactly one | none | +| Act | a symbol in a chip on the glow | bare on the wash, or absent | +| PNG | yes, 1200×630 | **never** | + +**The 10 units of bleed on every side are not decoration.** Offset shadows sit up to 12 units +below and right of their shape, so a composition filling 0–320 gets its bottom shadows sliced at +the canvas edge. The margin also keeps the drawing off the headline beside it in the row. + +The card is displayed at its natural ratio with `width="344" height="344"` hard-coded in +`resources/views/components/illustration-row.blade.php` — 1:1, identical to this canvas, so the +row reserves exactly the right space and nothing shifts. If the card ever stops being square, +they have to change with it. + +Unlike the hero, the card is **never cropped**. The whole 0–320 box is always visible. + +It is also **not in the row**. From `xl` up it floats in the outer margin the 60rem frame leaves, +alternating side down the list and tilting toward the reader on hover. That arrangement is not +written here: `x-illustration-row` owns it, and `/dienstleistungen` renders the same component, +so the two pages cannot drift apart on size, offset or rhythm. Below `xl` there is no margin to +break into and the row is text alone. The 168 px render width and the `xl:pr-14` / `xl:pl-18` on +the text block are derived from each other: the drawing sits 128 px past the text column — 96 px +past the page frame, a lg gutter further out — so it reaches `168 − 128 = 40` px back in and the +padding clears that. Change the width and both numbers move with it — and the services page moves +too. + +## 3. No PNG, and no alt text + +The card is never an `og:image` — that is the hero's job, and `NewsImage::ogImage()` only ever +looks beside the hero. A `-card.png` in this directory is a mistake, and the way it gets +made is passing the card to `scripts/render-news-og.sh`, which forces 1200×630 and turns a square +into soup. The script refuses any `-card.svg` or `-square.svg` and says so; the gate fails a card +that has a PNG beside it. If you catch yourself routing around either, stop. + +The index renders the card with `alt=""` and `aria-hidden`: it repeats the headline sitting +directly beside it, so to a screen reader it is decoration. There is no `thumb_alt`, and if you +find yourself wanting one, the drawing is carrying information the teaser should be carrying. + +Wiring is one line in the **German** file, per `illustration-news.md` §3: + +```yaml +thumb: images/news/-card.svg +``` + +Without it the row falls back to nothing — `news/card.blade.php` deliberately does not fall back +to the hero, because a 1600×840 band floating beside the frame at a tilt is not a smaller version +of a drawing, it is a different thing. + +## 4. Weights, and the detail budget + +Stroke weights are `illustration-services.md` §7, card column — 2.4 on outer surfaces, 1.6 on +inner detail, 2.2 on body squiggles, 2 on secondary ones and on connectors. Squiggle words are +the `s*` paths from the shared ``, 11 units per wave, 6 units of gap between words. The +`w*` paths have no business here. + +The card is a fifth the width of the hero, so its lines are proportionally three times heavier. +That is what keeps it readable, and it is also what limits how much can be in it: + +- **Three surfaces at most**, and four is already wrong. The hero's four-tile output column is a + smear here; two tiles and an implied third is the same idea and survives. +- **Nothing thinner than 8 units**, no shape smaller than ~14 units across. +- **One flourish.** A check, a wave, a branch — one, not three. +- **Vary the flourish across the set.** Five cards ending in the same filled accent check circle + is five cards that look identical in a list, which is the exact failure the whole file is + avoiding. +- Two or three squiggle words per line, never a full paragraph. At 168 px a `s3` word is 6 px + wide; a line of six of them is a grey bar. +- **The connector floor is 40 units.** Below that a line is object detail and needs + `data-detail="…"`; above it, it is running between two objects and needs `class="connector"`. + `illustration-services.md` §8 has the rules; they apply here unchanged, at card scale. + +Check it at size before believing any of it: + +```bash +rsvg-convert -w 168 -h 168 public/images/news/-card.svg -o /tmp/check.png +``` + +Then look at it next to its neighbours, which is the only test that matters: + +```bash +for f in public/images/news/*-card.svg public/images/services/*-card.svg; do + rsvg-convert -w 168 -h 168 "$f" -o "/tmp/$(basename "$f" .svg).png" +done +``` + +## 5. The set as it stands + +Each card is the two ends of its hero's sentence. Silhouettes differ before contents do — that is +what makes them tell each other apart at thumbnail size. + +| Slug | Top — before | Bottom — after | Flourish | +|---|---|---|---| +| `docuware-7-12-is-here` | an envelope with a machine file coming out of it | a table over a small bar chart | rising bars | +| `docuware-7-13-is-here` | a desktop window with title-bar controls | a browser with a two-step chain | a branch | +| `docuware-7-14-is-here` | an inbox tray with cards piled in it | a phone with grouped tasks | push waves | +| `bausteine-styleguide` | three loose blocks, unaligned | a page with a colour row | the colour row | +| `llm-gateway-open-source` | three callers on lanes into one laptop | a queue feeding one chip | a progress bar | + +## 6. Before you commit + +Run `scripts/check-illustrations.py news`. It covers the viewBox and the 0–320 bounds, the +palette, the absence of a glow and of a PNG, the single arrowhead, the connectors, the file size +and the provenance of every object. What is left for a person: + +- [ ] Two stages, one vertical arrow, no glow, no act chip. +- [ ] Three surfaces at most, one flourish, and the flourish is not shared with another card. +- [ ] Rendered at 168 px: every surface still reads and the flourish is recognisable. +- [ ] Rendered next to the other cards — news *and* services, they share a component and a page + rhythm: distinguishable at a glance, different silhouettes. +- [ ] The top half is black and white. Colour starts below the arrow. +- [ ] `thumb:` set in the **German** file, path relative to `public/`. +- [ ] `php artisan news:import` run, so the column is filled. +- [ ] `git status` shows no orphaned v1 square left behind by the switch. diff --git a/prompts/illustration-news.md b/prompts/illustration-news.md new file mode 100644 index 0000000..993ab2f --- /dev/null +++ b/prompts/illustration-news.md @@ -0,0 +1,254 @@ +# Generating news illustrations + +Follow this file when a news article needs a hero and no real photograph or screenshot exists. + +Reference implementation: `public/images/news/docuware-7-13-is-here.svg` (hero) and +`public/images/news/docuware-7-13-is-here-card.svg` (card). Open both alongside this document. + +## 0. Read `illustration-services.md` first + +The news hero is the **same drawing language** as a service illustration on a different canvas. +All of this is shared and is not repeated here: + +| Shared, defined in `illustration-services.md` | § | +|---|---| +| The idea grammar — input → act → output, and *draw the subject, not a document about it* | 1 | +| No words. None. No `` element, ever | 4 | +| The palette — five values, and colour starts at the glow | 5 | +| The `` block, `w*` / `s*` squiggle words | 6 | +| Stroke weights | 7 | +| Arrows, connectors and the tangle — two arrowheads, orthogonal lanes, nothing crossing | 8 | +| The parts catalogue | 9 | +| The act vocabulary, and how to test that an act is the right one | 10 | +| The quality gates and `scripts/check-illustrations.py` | 11 | + +What this file adds is everything specific to an *article* rather than an offering: what the idea +has to be when the subject is a release (§1), how to read the source (§2), where the files go +(§3), and the one hard constraint the services canvas does not have — **the news hero is +displayed cropped** (§4). + +A fourth family sits beside these: `illustration-seo-card.md` covers the `og:image` of a *page* — +drawn at 1200×630 rather than 1600×840, no crop, no card, and never displayed on the site itself. A page card must not reuse +a hero's composition either; `/aktuelles` prints five of them directly under its own card. + +Two older families still exist and are not this. `images-news.md` and `images-news-square.md` +describe **v1**: the article title baked in as Poppins, `scripts/make-news-hero.py`, five files +per article, one hero per locale. Those placeholders are still on disk under +`public/images/news/placeholders/` and no article front matter points at them any more; do not +mix their motifs into a v2 drawing, and do not port a v2 composition back into the script. + +## 1. The idea, when the subject is an article + +The grammar is `illustration-services.md` §1 — something unresolved on the left, something +working on the right, the act between them. What changes is how you find the sentence. + +**An article already says what it is, in words, twice.** The `

` sits directly above the hero +and the teaser sits directly above that; in the index the headline sits beside the card. A hero +that re-states the headline is decoration. The hero's job is the thing the headline cannot do in +eight words: show **what was true before and what is true after**. + +So the sentence is always a *change*: + +> the workflow designer was an installed application → *it moved into the browser* → a designer that runs in a tab +> agents, processes and people all called one machine until it ran out of memory → *queueing* → a gateway that stores every request and feeds the model one at a time +> content blocks lie around loose → *composing* → one page assembled from them, in order + +**Pick one change, not all four.** A release note has four sections; a hero has one sentence. +Choose the change a reader would notice on the Monday after upgrading — the new app on the phone, +not the third bullet under "Sicherheit und Konfiguration". Everything else in the release is what +the article is for. + +**A drawing is per article, not per topic.** Three DocuWare releases are all tagged `DMS/ECM` and +the index prints them directly under one another; three variations on a document stack make the +list look broken. Read what the release actually changed. The set gate in +`illustration-services.md` §11 enforces the two cheapest halves of this — no shared act, no +shared opening object — but it cannot tell you that two different objects are boring in the same +way. + +And per `illustration-services.md` §1: a news hero must not reuse a service composition either. +`dms-ecm-consulting` already owns "paper → magnifier → workflow chain". A DocuWare release cannot +have it. + +## 2. The source is both locale files + +An article exists twice: + +``` +database/files/news/de_CH/-.md +database/files/news/en_CH/-.md +``` + +Read **both** before drawing. They are not translations of each other line for line — the English +file is written, not converted, and it regularly names the change more plainly than the German +one does, or vice versa. Since the drawing carries no words it has to be true in both languages +at once, and the fastest way to find the sentence is often the phrasing that survived into both. + +This is also the corpus the provenance gate checks against: an object may cite a phrase from +either file, and the check resolves against the union of the two. `illustration-services.md` §11 +has the format. A news entry additionally requires: + +- both locale files to exist, and +- `hero_alt` to differ between them. Identical `hero_alt` in `de_CH` and `en_CH` means the German + was pasted into the English file, and it fails. + +## 3. Where files go + +``` +public/images/news/.svg 1600×840 hero → the article page and og:image +public/images/news/.png 1200×630 rendered from the hero, never hand-edited +public/images/news/-card.svg 344×344 card → the index row → illustration-news-card.md +``` + +Three files per article, **no locale suffix** — that is the whole dividend of +`illustration-services.md` §4. + +`` is the article's `key:`, not its `slug:`. `slug:` is localised (`docuware-7-14-ist-da` +vs `docuware-7-14-is-here`) and would give one article two names; `key:` is the same string in +every locale, which is exactly the property a locale-free file needs. + +**Not in `public/images/news/placeholders/`.** That directory belongs to v1, and +`scripts/render-news-og.sh` with no arguments globs it — a v2 hero dropped in there gets +re-rendered by an unrelated invocation, and a v2 *card* dropped in there gets stretched to +1200×630 and written next to itself as a PNG. Keep the two generations in separate directories. + +Wire it into the front matter: + +```yaml +# database/files/news/de_CH/-.md +hero: images/news/.svg +hero_alt: Der Workflow Designer zieht aus einer installierten Anwendung in den Browser +thumb: images/news/-card.svg +``` + +**No colon in an unquoted `hero_alt:`.** Symfony's YAML parser rejects +`hero_alt: Illustration: …` with "A colon cannot be used in an unquoted mapping value" and +`news:import` refuses the whole file. Prefixing alt text with "Illustration:" is the obvious way +to walk into this and it is redundant anyway — a screen reader already announces the element as +an image. Write the sentence plainly, or quote the string if a colon is genuinely needed. + +`ImportNewsCommand::store()` reads `hero:` and `thumb:` from **`$primary`, which is the de_CH +document only** — the English file's values are parsed and thrown away. Set them in German. +Mirroring them into `en_CH` is harmless and the existing articles do it, but nothing reads them. + +`hero_alt:` **is** per locale and you still have to write it, in that language. The drawing +carries no words, but on the article page it is content sitting under a caption slot, not +decoration — unlike the card, which the index renders `alt=""` on purpose. Describe the change +the drawing shows, not the shapes: "Gleichzeitige Anfragen laufen neu über eine Warteschlange", +not "Zylinder und Pfeile". + +`App\Support\NewsImage::ogImage()` swaps the `.svg` for the same-named `.png` when emitting +`og:image`. **That is why the PNG must exist and sit next to the SVG**; without it `og:image` +falls back to `images/seo/og-codebar.png`. + +## 4. The crop — the one thing services does not have to think about + +A service banner is only ever the whole 1600×840. A news hero is not. Two components render it, +and both crop: + +```blade +{{-- app/news/show.blade.php — the article hero --}} +class="w-full aspect-[3/1] object-cover" + +{{-- components/news/lead.blade.php — the index lead --}} +class="mb-6 hidden aspect-[16/9] w-full object-cover sm:block lg:aspect-[3/1]" +``` + +Against a 1600×840 source: + +| Where | Ratio | Keeps | Throws away | +|---|---|---|---| +| `og:image` (1200×630) | 1.905 | **everything** — 1600×840 is exactly 1200:630 | nothing | +| Article hero, every width | 3 : 1 | y 153 → 687 | 153 units off the top **and** the bottom | +| Index lead, `sm`–`lg` | 16 : 9 | x 53 → 1547 | 53 off each side | +| Index lead, below `sm` | — | nothing, the image is `hidden` | — | + +Intersect them: + +``` + 0 53 1547 1600 + 0 ┌─────┬────────────────────────────────────────────────┬─────┐ + │ │ ▲ 153 cut on every article │ │ + 153 ├ ─ ─ ┼────────────────────────────────────────────────┼ ─ ─ ┤ + │dots │ │dots │ + │shdw │ the story lives here │shdw │ + │wash │ 1494 × 534 │wash │ + 687 ├ ─ ─ ┼────────────────────────────────────────────────┼ ─ ─ ┤ + │ │ ▼ 153 cut on every article │ │ + 840 └─────┴────────────────────────────────────────────────┴─────┘ + ▲ cut on a tablet lead cut on a tablet lead ▲ +``` + +**Everything that carries meaning lives inside x 53–1547, y 153–687.** The gate allows +x 58–1542, y 158–682 — a few units in, for the round join on a 3-wide stroke. + +Older drafts of this file documented a `4/3` phone crop and a safe area of x 240–1360. There is +no `4/3` any more: below `sm` the lead image is hidden outright rather than squeezed, so the +horizontal budget is nearly three times what it was. The vertical budget is unchanged and is the +one that actually bites. + +That leaves a 2.8:1 band. Two consequences: + +- **The composition is wider than it is tall, always.** A service banner can run an output column + from y 120 to y 660; a hero cannot. Objects here are ~500 tall at most, and the three stages sit + side by side rather than stacking. +- **The glow may spill.** It is a 42-unit blur behind the arrow; it is meant to bleed past the + safe area and does not need to be inside it. The measurement below thresholds it out. + +Check it with a number, not an eyeball — `scripts/check-illustrations.py` does it for you on +every run. It renders at 2× and finds the bounding box of everything darker than 43 % grey, which +walks straight past the 240-grey dot field and the glow and finds only real ink: + +``` +FAIL public/images/news/.svg + crop: ink spans [246, 140, 1352, 682], safe area is [58, 158, 1542, 682] — a crop would cut it +``` + +`rsvg-convert` scales rather than crops, so rendering the file and looking at it cannot show you +what the browser does. Trust the number. + +## 5. The set as it stands + +| Slug | Left — before | Act | Right — after | +|---|---|---|---| +| `docuware-7-12-is-here` | an e-invoice arriving as a machine file — angle brackets and token bars, nothing a person reads | `funnel` | a table with the positions in rows, and the same numbers again as a chart | +| `docuware-7-13-is-here` | the workflow designer as an installed desktop window, next to an install glyph | `window` | a browser running the designer: steps wired by connectors, one exception branch | +| `docuware-7-14-is-here` | an inbox tray with approvals piled in it, untouched, a clock beside it | `bell` | a phone with tasks grouped by process, one approved, push waves off the corner | +| `bausteine-styleguide` | content blocks lying around loose, each a different shape, none aligned | `grid` | one page assembled from them in order, with a colour row | +| `llm-gateway-open-source` | four callers on orthogonal lanes into one laptop whose memory is already full | `queue` | a gateway that persists the queue and feeds the model one at a time, forkable | + +Check a sixth against these before drawing it. Different silhouettes: only 7.12 uses a chart, +only 7.13 uses a browser, only 7.14 uses a phone, only the styleguide uses a page, only the +gateway uses a laptop and a cylinder. Note also that no two *inputs* repeat — a file, a window, a +tray, loose blocks, a set of lanes — which matters more than the outputs, because the input is +the left half and the left half is what a reader sees first. If a new one is "a rounded rectangle +with squiggles in it", it is not finished. + +## 6. Rendering and import + +```bash +scripts/render-news-og.sh public/images/news/.svg +php artisan news:import +``` + +Bare, `render-news-og.sh` re-renders every hero of both generations — +`public/images/news/*.svg` and `public/images/news/placeholders/*.svg` — and skips anything +ending in `-card.svg` or `-square.svg` with a message. That guard exists because a square forced +into 1200×630 comes out as stretched soup, and the resulting `-card.png` sitting next to +`-card.svg` is exactly the file `NewsImage::ogImage()` would then hand a social crawler. Do +not work around it. + +## 7. Before you commit + +Run `scripts/check-illustrations.py news`. It covers the canvas, the palette, the glow, the +shadows, the arrowheads, the connectors, the crop measurement, the file size, the PNG, the act +vocabulary, the set, and every object's provenance against both locale files. What is left for a +person is `illustration-services.md` §13, plus: + +- [ ] The drawing shows the *change*, not the headline restated. +- [ ] The change is the one a reader notices on the Monday after upgrading. +- [ ] Held against the other articles in the index: different silhouettes, different objects. +- [ ] Held against `public/images/services/`: it is not a service composition with new labels. +- [ ] `hero:` and `thumb:` set in the **German** file; `hero_alt:` written in **both**, each in + its own language, describing the change. +- [ ] `php artisan news:import` run. +- [ ] `git status` shows no orphaned v1 placeholder left behind by the switch. diff --git a/prompts/illustration-seo-card.md b/prompts/illustration-seo-card.md new file mode 100644 index 0000000..4a35a92 --- /dev/null +++ b/prompts/illustration-seo-card.md @@ -0,0 +1,428 @@ +# Generating SEO cards + +Follow this file when a **page** needs the image that travels with its link — the picture a +crawler, a chat client or a social feed shows next to the title when somebody shares +`/dienstleistungen` or `/ki/llm`. Services and articles already have one, because their banner +doubles as `og:image`. Pages have nothing: every one of them currently falls back to +`images/seo/og-codebar.png` — no page YAML carries an `image:` at all, so nineteen pages in two +languages share one picture. + +Reference implementation: `public/images/pages/start.index.svg` — the whole set is drawn and +wired; §6 lists it. + +## 0. Read `illustration-services.md` first + +An SEO card is the **same drawing language on a smaller canvas**. All of this is shared and is +not repeated here: + +| Shared, defined in `illustration-services.md` | § | +|---|---| +| The idea grammar — input → act → output, and *draw the subject, not a document about it* | 1 | +| No words. None. No `` element, ever | 4 | +| The palette — five values, and colour starts at the glow | 5 | +| The `` block, `w*` squiggle words | 6 | +| Stroke weights, banner column | 7 | +| Arrows, connectors and the tangle — two arrowheads, orthogonal lanes, nothing crossing | 8 | +| The parts catalogue | 9 | +| The act vocabulary, and how to test that an act is the right one | 10 | +| The quality gates and `scripts/check-illustrations.py` | 11 | + +What this file adds is everything specific to a *page*: what the sentence is when the subject is +not a change and not an offering (§2), where the files go and what setting `image:` switches on +(§3), how small the drawing is actually seen (§4), which pages get one and which deliberately do +not (§5), the set (§6), the nine acts this family added to the vocabulary (§7), and what the gate +checks that it does not check anywhere else (§8). + +### The name is a trap + +"Card" in this repo already means the 344×344 index thumbnail — `-card.svg`, +`illustration-news-card.md`. An **SEO card is not one of those.** It is a wide 1200×630 drawing +rendered to a PNG of exactly that size, and those two are the only files in this family. + +**There is no `public/images/pages/-card.svg`, ever.** Pages are never printed under one +another in a list, so there is no row to sit beside and nothing for a square to do. If you find +yourself drawing one, you are drawing for a component that does not exist. + +## 1. The card travels alone + +Every other drawing in this family is seen **with its page around it**. A news hero sits under +the `

` and over the teaser; a service banner sits on the page it illustrates. Both can lean on +the words above them. + +An SEO card is seen in a WhatsApp bubble, a Slack unfurl, a LinkedIn post — with the page title +beside it, the domain under it, and nothing else. Two consequences run through the whole file: + +- **It has to be true on its own.** Nothing in the drawing may depend on a heading the viewer has + not read. +- **It is never seen by anyone on the site.** No page renders `$page->image`; only + `_seo.blade.php`, `SitemapBuilder` and `SchemaGraph` read it. So nothing on the website will + ever tell you the drawing is wrong, missing or stretched. §9 is how you actually look at it. + +## 2. The sentence, when the subject is a page + +The grammar is `illustration-services.md` §1 — something unresolved, the act, something working. +What changes is where the left half comes from. + +A service page describes an offering, and an article describes a change that happened. **A section +page describes neither: it is a promise.** So the left half of the sentence is not a worse version +of the product — it is **the reader's situation before they had us**: + +> a laptop on the kitchen table at home → *moving in* → a desk in a room with a team in it +> customer documents leaving the building for a provider's rack → *protecting* → the same +> documents on a machine in our own basement +> a request handed down through three layers before it reaches whoever builds it → *meeting* → +> one table, with the person who writes the code at it + +Write the sentence with the page's own promise in it, then check it against the page's `title:` +and `description:` in `database/files/pages/.yaml`. Those two strings are the page's +elevator pitch, they were written carefully, and they are what the crawler prints **directly next +to the drawing**. If the sentence and the description are saying different things, one of them is +wrong, and it is usually the sentence. + +**The drawing must not restate the description.** They are seen together, in one card. The +description already says "vier Bereiche, ein Weg"; the drawing's job is to show what four areas +and one path *look* like. + +**And it must not repeat a drawing the page links to.** `/dienstleistungen` links to four service +banners, `/ki/llm` sits next to the `llm-gateway-open-source` hero, `/aktuelles` prints five news +heroes underneath itself. A page card that reuses one of those compositions makes the whole +section look like one repeated picture. The set gate (§8) cannot see this across families — +`illustration-services.md` §13 asks a person to, and here it is not optional. + +## 3. Where the files go, and what `image:` switches on + +``` +public/images/pages/.svg 1200×630 the drawing, authored at og:image size +public/images/pages/.png 1200×630 rendered from it 1:1, never hand-edited — this is + the file a crawler actually fetches +``` + +**Two files, one size, and that size is 1200×630.** The other families author at 1600×840 and +downscale by 0.75 into the PNG, because their banner is also displayed on the site at full width. +A page card is never displayed anywhere, so there is nothing to downscale for: it is drawn at the +exact pixel size `config/seo.php` declares, and the render is 1:1. No third file, no second +canvas, no card. + +`` is the page's `key:`, identical to the name of its YAML file — `about-us.index`, +`ai.llm.analytics.index`. Dots and all: one name, one lookup rule, no mapping table anywhere. + +Wiring is one line, in the page's YAML — there is one file per page and it is not localised, so +unlike news and services there is no "German file only" rule to get wrong: + +```yaml +# database/files/pages/.yaml +image: images/pages/.svg +``` + +```bash +php artisan pages:import +php artisan responsecache:clear +``` + +**The cache clear is not optional.** The meta tags live inside the cached HTML, so until the +response cache is cleared every crawler keeps getting the old `og:image` — including none at all. + +`_seo.blade.php` hands the value to `App\Support\NewsImage::crawlable()`, which sees the `.svg` +and swaps it for the same-named `.png`. **That is why the PNG must exist and must sit next to the +SVG**: without it, `crawlable()` returns null and `og:image` silently falls back to +`images/seo/og-codebar.png` — the exact state we are trying to leave, and the page looks +completely fine while it happens. + +Setting `image:` also switches on two things that are not `og:image`, both of which already +behave this way for news heroes and neither of which swaps in the PNG: + +- `SitemapBuilder::addItem()` prints the value verbatim as an `` — so + `images/pages/.svg` appears in the sitemap as written. +- `SchemaGraph` emits it as `primaryImageOfPage` through `NewsImage::src()`, which resolves the + **SVG**. Google does not accept SVG for image metadata, so that node is decorative today. + +Neither is a reason not to draw the card, and neither is fixed by hand-editing the drawing. + +**No alt text exists and none is wanted.** `_seo.blade.php` hard-codes +`og:image:alt` to the app name for every page, and there is no `hero_alt` on a page. The SVG's +`` is the only description this drawing will ever carry — write it in German, describing +the promise, per `illustration-services.md` §4. + +## 4. The canvas is easy; the size is not + +| | news hero | service banner | **SEO card** | +|---|---|---|---| +| viewBox | `0 0 1600 840` | `0 0 1600 840` | **`0 0 1200 630`** | +| PNG | 1200×630, a 0.75 downscale | 1200×630, a 0.75 downscale | **1200×630, 1:1** | +| Cropped when displayed | 3:1 and 16:9 | never | **never** | +| Safe area | x 53–1547, y 153–687 | the canvas | **the canvas, less 18 units** | +| Card sibling | yes | yes | **none** | +| Displayed on the site | yes | yes | **no** | +| Seen at | 896 px | full width | **~500 px** | + +1200×630 is what `summary_large_image`, Open Graph and LinkedIn all crop to, so nothing is cut. +This is the one canvas in the family with no crop rule: `illustration-news.md` §4 does not apply. + +The fixed points, all of them derived from the 1600-unit banner at 0.75 and then rounded to +something a person can type: + +```xml +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630" role="img"> +<title>… + + + + + + + + +``` + +The blur in `#glow` is `stdDeviation="32"`, not 42 — the filter is in user units and the canvas +shrank with it. Shadow offsets are `translate(12 12)`, not 16. + +**Stroke weights stay in the banner column of `illustration-services.md` §7, unscaled.** In a +1200-unit canvas they come out a third heavier than on a banner, and that is deliberate: this +drawing is read at 500 px in a feed and a banner is read at full browser width. The same applies +to the `w*` squiggle words — 22 units per wave, unchanged, which makes them proportionally bigger +and therefore fewer per line. + +What replaces it is the **size**. A link preview is roughly 500 px wide in a feed and smaller in +a chat list. That is 31 % of the scale a banner is drawn for, and it turns into arithmetic: + +> Three stages across 500 px is **~160 px per stage**. A news card — the whole drawing, two +> stages, three surfaces, one flourish — renders at 168 px. + +**So each stage of an SEO card gets about as much screen as an entire news card, and inherits its +discipline** (`illustration-news-card.md` §4): three surfaces at most per stage, nothing thinner +than 8 units at banner scale, one flourish per stage, two or three squiggle words per line and +never a paragraph. Stroke weights stay in the banner column of `illustration-services.md` §7 — +they are already right for 1600 units; it is the object count that has to come down. + +The act chip is 124 units, which lands at ~39 px in a feed. That is exactly the size the act +glyphs are designed to survive at, and it is why the act stays: at that size it is the only +element carrying meaning reliably, so **choose it as carefully as §10 says and draw it clean.** + +One soft rule the gate does not check: a few clients (compact Slack rows, some mail previews) +crop a preview to something near square. Keep the act chip and one recognisable object inside the +centre square, **x 285–915**. Anything outside that is worth having but must not be load-bearing. + +## 5. Every page gets one + +**One file in `database/files/pages/` means one drawing.** All nineteen, including the three +legal pages and the `noindex` profile-request page. + +The first pass drew fourteen and argued the other five out: nobody shares an imprint, a `noindex` +page is not worth the work, and `og-codebar.png` already *is* the right picture for a page about +the logo. Each of those is true on its own and all of them together are still wrong, because the +fallback is not neutral. A link carrying the generic card looks like a link to the front page. +Paste `/rechtliches/datenschutz` and `/medien` into one chat and they are indistinguishable from +each other and from `/de-ch` — and the legal pages are exactly the ones somebody sends when they +want one specific answer. A card that says *which page this is* beats a card that says *which +company this is*, everywhere. + +`images/seo/og-codebar.png` is now what it should have been all along: the fallback for a page +that does not have a drawing yet, not the standing image of five that never would. + +Detail pages are not in this family at all. Services, products, technologies, news, open source +and network entries each carry their own `image` from their own source, and +`illustration-services.md` / `illustration-news.md` own those. + +## 6. The set + +Each row is one page's promise. The `key` is the filename. **Silhouettes differ before contents +do**, and the first object named in the manifest is the opening object — no two pages may share +one (§8, set gate). + +| Page | Before — the reader's situation | Act | After — the promise, drawn | +|---|---|---|---| +| `start.index` | an idea that only exists as a sketch and a spoken sentence — a napkin drawing under a big speech bubble | `listen` | software in daily use: one panel somebody works in, with the sketch's shapes recognisable in it | +| `about-us.index` | a request relayed down a chain of boxes before it reaches whoever builds it | `meet` | one table, two people at it, the code panel open between them — no layer in between | +| `services.index` | four offerings lying apart and unaligned — a sketched screen, a code panel, a paper stack, a task board | `grid` | the same four as tiles threaded on one line from idea to operation | +| `products.index` | one project's panel, built once, for one customer, with its job number on it | `braces` | a released product: the same panel with version steps behind it and a usage row under it | +| `technologies.index` | a long shelf of tools, most of them untouched | `funnel` | three tools kept, each with years stacked behind it | +| `open-source.index` | a package we wrote, sitting inside our own repository and nowhere else | `fork` | the same package on a public repository, an install count under it, somebody else's branch off it | +| `ai.index` | customer documents on a lane out of the building to a provider's rack | `shield` | the same documents on a machine inside our own outline, counters running beside it | +| `ai.llm.index` | three rented model tiles with no machine under them | `host` | the three model categories on one laptop in a basement, a UPS beside it and a tunnel out | +| `ai.llm.analytics.index` | requests running past, nothing counting them | `measure` | a month column of token bars per model, with a total pill | +| `news.index` | a whiteboard at the end of a project day — everything learned, staying in the room | `bell` | a dated list of articles, and somebody being told | +| `jobs.index` | a newcomer at the edge of the project, watching the work happen inside it | `board` | the same person's card inside, crossing three roles from the customer conversation to the code | +| `co-working.index` | a laptop on a kitchen table at home | `transfer` | a desk in a room with a team in it, a 250 Mbit/s line into it | +| `contact.index` | a note with a question on it and no address | `call` | a handset, a named contact person, and two address cards | +| `network.index` | partner names listed apart, nothing between them | `nodes` | partners on a shared spine around one hub, tier badges on two of them | +| `legal.imprint.index` | a site you only know by the name in its address bar | `magnifier` | the register behind it: legal form, UID, and the people who sign | +| `legal.privacy.index` | data arriving on three lanes into a box with a closed lid | `disclose` | every category with its purpose and a retention period, and your rights under it | +| `legal.terms.index` | an offer and a project, both blank between them | `agree` | numbered sections, and a signature line | +| `media.index` | a mark lifted off a screenshot at the wrong size | `download` | the four official variants, light and dark, PNG and SVG | +| `network.request.index` | a partner profile with a lock on it | `cursor` | a personal link, and the profile as a form with a focused field | + +Each object in each row has to cite a phrase from that page's own copy — the YAML, and the +`lang/` strings the page's Blade template renders. That is Gate 1, and §8 is what makes it +runnable. Some are already sitting there and are worth using verbatim: "keine Zwischenebene", +"Vier Bereiche, ein Weg", "im täglichen Einsatz", "bewusst gewählt und über Jahre in der Tiefe +beherrscht", "geben etwas zurück", "Kundendaten verlassen unsere Infrastruktur nicht", +"im hauseigenen Bürokeller", "pro Monat und Modell", "Einblicke aus unserem Alltag", +"vom Kundengespräch bis zum Code", "250 Mbit/s Private Virtual Network", +"Deine Ansprechperson", "Gute Software entsteht nicht im Alleingang". + +**Acts may repeat across families, never within one.** `grid` is `bausteine-styleguide`'s act and +`braces` is `individuelle-softwareentwicklung`'s; a page may use either, because a page card and +an article hero are never seen side by side. Two pages sharing an act is the failure the rule +exists for: paste three codebar links into one chat and three identical purple chips is exactly +the "list of one repeated drawing" that `illustration-services.md` §11 Gate 5 describes. + +## 7. Nine acts the vocabulary did not have + +The thirteen acts in `illustration-services.md` §10 were written for **systems changing**. A page +card says what a company does for a reader, and nine of those verbs did not exist. Each one below +passes the §10 test — it is a verb, the subject genuinely performs it, it reads as a silhouette at +40 px, and none of them is an existing act under another name. + +**Nineteen pages need nineteen acts**, because no two in a family may share one. That is what +made this the family that grew the vocabulary from thirteen to twenty-two, and it is the +arithmetic to do before adding a twentieth page: the verb has to exist, or it has to be earned. + +All six are in `ACTS` in `scripts/check-illustrations.py`. A seventh is added the same way, and +only under `illustration-services.md` §10's rules — it must be a verb, it must be legible at +40 px, and it must not already be in the list under another name. + +| Act | Reads as | Use when the promise is | +|---|---|---| +| `listen` | a large bubble and a small answer | it starts with your problem, not with our product | +| `meet` | two people | the person who does the work is in the room | +| `call` | a handset | you reach a human directly, and no form is involved | +| `fork` | two nodes branching from one | it is given back, and someone else can take it further | +| `host` | a roof over a chip | it runs on our own hardware, in our own building | +| `measure` | a gauge | the change is that it is counted and shown | +| `download` | an arrow into a tray | the official file is handed over instead of copied off a screen | +| `disclose` | a page with its content shown | what was collected quietly is written down and limited | +| `agree` | a signature over a line | what both sides owe each other is signed rather than assumed | + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +All six are drawn in the 60×60 box, use nothing but `url(#accent)`, and were rendered at 40 px +before being written down — each still reads there, which is the only reason any of them is in +the table. Re-check after any change to the geometry: + +```bash +rsvg-convert -w 40 -h 40 /tmp/act.svg -o /tmp/act.png +``` + +Every one of them still goes inside `` on the chip +at `x 546 y 261`, which is 108 units square with `rx 22` — the banner's 124 chip at 0.75, rounded. **That `class="act"` is required** — it is how the arrowhead gate knows a +triangle in a glyph is not a third stage arrow. + +## 8. What the gate checks here + +`scripts/check-illustrations.py pages` runs the whole of `illustration-services.md` §11 over this +directory. Five things in it are specific to the family and worth knowing before you fight one: + +- **The corpus is not one file.** A page's copy lives in its YAML — the title and description a + crawler prints next to the drawing — and in the `lang/` files, reached through `__()` keys this + script does not resolve. So `load_sources()` reads `database/files/pages/.yaml` plus + `lang/de_CH.json`, `lang/en_CH.json` and both `components.php`, and checks quotes against the + union. **Known weakness, stated in the code:** the lang files are site-wide, so the gate proves + a quote is in codebar's own words, not that it is on *that page*. A person checks the page; the + gate checks the wording. Quote what the page actually renders. +- **The canvas is `PAGE_VIEWBOX`** — `0 0 1200 630`. A page drawing on the 1600×840 banner canvas + is a failure, not a variant. +- **The safe area is `PAGE_SAFE`** — `(18, 18, 1182, 612)`, measured the same way as a news crop + even though nothing crops: it catches a shadow or a stack running off the right edge, which is + what `technologies.index` did on its first pass. +- **The set rules run over all nineteen** — no two acts, no two opening objects. The opening + object is the *first* key in the manifest's `objects`, so its order is load-bearing. +- **`pages/*-card.svg` fails outright**, with §0's reason. It is not a missing feature. + +## 9. Rendering, and actually looking at it + +```bash +scripts/render-news-og.sh public/images/pages/.svg +php artisan pages:import +php artisan responsecache:clear +``` + +The script is named for news and takes explicit files; it renders at exactly 1200×630, which is +what `config/seo.php` declares in `og:image:width` / `og:image:height`. Do not add a second script +and do not change the size without changing that config. + +Then look at it the way it is seen — at feed size, and next to the other pages, which is the only +test that matters: + +```bash +for f in public/images/pages/*.svg; do + rsvg-convert -w 500 -h 263 "$f" -o "/tmp/$(basename "$f" .svg)-feed.png" +done +``` + +And check the page actually serves it, because nothing on the site will: + +```bash +curl -s https:///dienstleistungen | grep -E 'og:image|twitter:image' +``` + +`og-codebar.png` in that output means the PNG is missing, the import did not run, or the response +cache is stale. In that order. + +## 10. Before you commit + +Run `scripts/check-illustrations.py pages`. It covers the palette, the canvas, the safe area, +the glow, the shadows, the arrowheads, the connectors, the file size, the PNG, the act +vocabulary, the set, and every object's provenance. What is left for a person is +`illustration-services.md` §13, plus: + +- [ ] The sentence is the page's **promise**, and its left half is the reader's situation — not a + worse version of what we sell. +- [ ] Held against the page's own `title:` and `description:`: the drawing shows what those words + claim, and does not restate them. +- [ ] Held against everything the page links to — service banners, news heroes, the models page — + it is not one of those compositions with new objects. +- [ ] Rendered at 500 px: all three stages still read, and the act is recognisable. +- [ ] The act chip and one anchor object sit inside x 285–915. +- [ ] Rendered next to the other page cards: different silhouettes, different opening objects, + no two acts the same. +- [ ] The input half is black and white. Colour starts at the glow. +- [ ] `image:` set in `database/files/pages/.yaml`, path relative to `public/`. +- [ ] The 1200×630 PNG exists next to the SVG, and no `-card.svg` does. +- [ ] `php artisan pages:import` **and** `php artisan responsecache:clear` run. +- [ ] `curl | grep og:image` on the real page shows `images/pages/.png`. diff --git a/prompts/illustration-services.md b/prompts/illustration-services.md new file mode 100644 index 0000000..d8b6dae --- /dev/null +++ b/prompts/illustration-services.md @@ -0,0 +1,666 @@ +# The illustration language + +Follow this file when a service, product, article or landing section needs a drawing. Send an +idea in one sentence — "loose paper gets scanned and becomes a searchable archive" — and this +document is the rest of the brief. + +Reference implementation: `public/images/services/dms-ecm-consulting.svg` (banner) and +`public/images/services/dms-ecm-consulting-card.svg` (card). Open both alongside this document. + +This file, `illustration-news.md`, `illustration-news-card.md` and `illustration-seo-card.md` +describe **one drawing language across four families**. Everything below — the idea grammar, the +palette, the ``, the parts catalogue, the act vocabulary, the gates — is shared, and the +other three only add what is specific to their subject: an article, an index row, or the +`og:image` of a page. Read this one first. + +| File | Subject | Canvas | +|---|---|---| +| this one | a service | 1600×840 banner + 344 card | +| `illustration-news.md` | an article | 1600×840 hero, displayed cropped | +| `illustration-news-card.md` | an index row | 344×344 | +| `illustration-seo-card.md` | a page — the picture its link travels with | 1200×630, no card | +| `illustration-linkedin.md` | an article, uploaded to a LinkedIn post | 1200×627, **carries type** | + +Two older families still exist and are not this: the generated news placeholders in +`images-news.md` / `images-news-square.md` (v1 — a title in Poppins, `make-news-hero.py`, one +file per locale) and the partner drawings in `network.md`. A drawing in this family must not +reuse a `make-news-hero.py` motif, even for the same topic. + +## 1. The idea comes first + +Every illustration in this family is one sentence with a middle: **something unresolved on the +left, something working on the right, and the act that turns one into the other between them.** +That is the whole grammar. Before drawing anything, write the sentence: + +> loose paper → *indexing* → a process that runs itself +> a sketched screenflow → *clicking* → a prototype on desktop and mobile +> three systems that don't talk → *code* → one bus they all sit on +> a task list and a stopwatch kept by hand → *wiring* → a board with the hours booked on it + +If the idea has no middle, it does not belong in this family — it is an icon, and `network.md` +covers those. + +### Draw the subject, not a document about the subject + +**There is no default container.** Nothing needs to sit inside a sheet of paper, a card or a +browser window, and nothing needs an outer frame holding the composition together. Objects float +on the wash and the composition is held by alignment and the arrow alone. + +This is the rule that is easiest to break and does the most damage when broken: the first pass at +this family wrapped all four services in an A4 page, and four completely different offerings came +out looking like the same drawing. A paper sheet belongs in the DMS illustration because that +service is *about* paper. It has no business in the ERP one. + +Pick objects from the subject's own copy, and let them be their own shape: + +| The copy says | Draw | Not | +|---|---|---| +| Odoo, Projektmanagement, Zeiterfassung | a task list, a stopwatch, a kanban board, hours on a card | a document | +| Portale, Schnittstellen, Automatisierungen | a database cylinder, ports, a bus, a code panel, a recurring-job glyph | a document | +| Mockups, klickbare Prototypen, UX | linked wireframe screens, a browser, a phone, a cursor | a document | +| Vom Papier zum papierlosen Büro, Workflows | a paper stack, a magnifier, a workflow chain with a branch | *here* a document is right | + +**Vary the silhouette between illustrations in a set.** Four rounded rectangles in a row is a +failure even when the contents differ — a cylinder, a circle, a phone, a laptop, a tilted board +are what make two drawings tell each other apart at thumbnail size. This is checked: see §11. + +## 2. Where files go + +``` +public/images/services/.svg 1600×840 banner → og:image and the page hero +public/images/services/.png 1200×630 rendered from the banner, never hand-edited +public/images/services/-card.svg 344×344 card → the index row thumbnail +``` + +News drawings sit in `public/images/news/` under the same three names, and a page's `og:image` +sits in `public/images/pages/` as a banner and a PNG with no card — +`illustration-seo-card.md` §3. + +`` must match the service's `key`/`slug` exactly. The banner is wired in front matter, the +card is found by convention: + +```yaml +# database/files/services/de_CH/.md — German only, the importer reads shared +# metadata from de_CH and ignores it in en_CH +image: images/services/.svg +``` + +`resources/views/app/services/index.blade.php` resolves `images/services/-card.svg` with +`file_exists()` and shows nothing if it is missing. There is no registration step anywhere. + +`resources/views/layouts/_partials/_seo.blade.php` sees the `.svg` in `image:` and swaps it for +the same-named `.png` via `App\Support\NewsImage::ogImage()`. **That is why the PNG must exist +and must sit next to the SVG**: without it, `og:image` falls back to `images/seo/og-codebar.png`. + +Every drawing also needs an entry in `public/images/illustrations.json` — that is §11, and it is +not optional. + +## 3. Two canvases, one language + +| | banner | card | +|---|---|---| +| viewBox | `0 0 1600 840` | `-10 -10 344 344` | +| Draw inside | the whole canvas | **0–320 only** | +| Rendered at | full width, and 1200×630 as a social card | **168 px**, and only from `xl` up | +| Story | left → right, three stages | top → bottom, two stages | +| Arrow | horizontal, one | vertical, one | +| Glow | exactly one | none | +| Act | in a chip on the glow | bare, no chip — a card has no room to frame it | + +**168 px is the one number.** `illustration-row.blade.php` renders the card at `w-42`, and the +`width`/`height` attributes on that `` are `344`/`344` so the row reserves the right space +before the SVG loads. Earlier drafts of this file quoted 112, 128 and 144 in three different +places; all three were wrong, and a stale ratio in that component is a layout shift on every page +load. If the card ever stops being square, those attributes change with it. + +**The card viewBox has 10 units of bleed on every side and you draw inside 0–320 regardless.** +That margin is not decoration: offset shadows sit up to 12 units below and right of their shape, +so a composition that fills 0–320 has its bottom shadows sliced off at the canvas edge. The bleed +also keeps the drawing from touching the text beside it in the row. + +`illustration-row` is shared with the news lists — the same size, offset and rhythm on +`/dienstleistungen` and on `/aktuelles`, because they used to be set in two files and drifted +apart. Two numbers in it are derived from the drawing's rendered width: it is 168 px and sits +128 px past the text column, so it reaches 40 px back in and `xl:pr-14` / `xl:pl-18` clear +exactly that. Resizing the drawing without moving those two leaves the text either colliding with +it or floating a long way off — on both pages at once. + +The card earns its place by using the empty outer margin the 60rem frame leaves on a wide screen. +Below `xl` there is no such margin, and every attempt to fit the drawing inside the text column +cost more in reading width than the drawing returned. Hence: `xl` and up, or not at all. A card +is still worth drawing for a subject that will only ever be read on a phone, because the +**banner** is what `og:image` uses and that is unaffected. + +1600×840 is exactly 1200:630, so the PNG is a clean 0.75 downscale with no distortion. Both +canvases in this family are authored at that ratio. + +The card is **not a crop of the banner**. A three-stage narrative is illegible at 168 px, so the +card tells the same story with one stage removed and everything ~2× thicker in proportion. + +The card sits **after** the text in the row, both on screen and in the DOM: the copy starts at the +page's own left margin so all rows share one text edge, and a decorative image that repeats the +title has no business coming first for a screen reader. + +## 4. No words. None. + +Body copy is a hand-drawn squiggle. Code and JSON are coloured token bars. Labels are shapes +inside a coloured pill. There is **no `` element in this family at all**, and that is +load-bearing twice over: + +- `rsvg-convert` cannot fetch a webfont and Poppins is not a system font, so any real type + silently renders as Helvetica in the PNG. A wordless drawing sidesteps it and keeps each file + under 10 KB. +- **One file serves both locales.** v1 news heroes needed a `-de` and an `-en` because the title + was baked in. Nothing here is language-bound, so there is no locale suffix — ever. + +`` is not `<text>`: it renders no glyphs, it is what a screen reader announces, and every +drawing in this family has one. Write it in German, describing the change. + +The one place this rule is off is `illustration-linkedin.md` — a picture uploaded to a feed has no +headline next to it, so it carries its own, with Poppins embedded as base64 to survive +`rsvg-convert`. It stays a single file only because we post in German and draw nothing else. That +family lives in its own directory and outside the gate. Nothing here changes. + +Squiggle rules: ragged right edge, never justified; word lengths mixed 1–4 waves within a line; +line length varies between lines; the last line of a block is short. A block of identical-length +lines reads as a barcode, not as text. + +## 5. Palette — the whole of it + +``` +ink #09090b every stroke, every squiggle (the codebar logo's ink) +brand #500472 metadata chips, secondary accents +accent #C026D3 → #2563EB the logo gradient, at 135° +paper #ffffff every surface +wash #ffffff → #f4eef8 background +``` + +No fourth hue, ever. Depth comes from `opacity` on ink and brand: `0.10–0.12` (dot field, row +dividers) · `0.18–0.20` (chip fills) · `0.3` (shadows) · `0.45` (connectors, secondary +squiggles) · `0.6` (chip squiggles) · `1.0` (everything structural). + +The reference images this family is modelled on use `#37D7FA → #FF8DF2 → #FF8705`. **Those +colours are not ours** and must not appear. The construction is borrowed; the palette is +codebar's. + +Colour is also how the story is told: the left half of a banner is black and white — +unstructured material has no colour. The accent only appears at the moment of transformation and +in the structured result. Do not colour the input. + +### `url(#accent)` needs the element to have area + +`#accent` is a `linearGradient` with the default `gradientUnits="objectBoundingBox"`, so it is +resolved against the bounding box of **the element being painted** — not its parent `<g>`, and +not the path's stroke. An element whose box is zero in either axis has no gradient to resolve, +and librsvg drops the paint entirely: the shape silently does not render. + +The way this bites is a single straight line: + +```xml +<g stroke="url(#accent)" stroke-width="5"> + <path d="M2 24h56"/> <!-- INVISIBLE — bbox is 56×0 --> + <path d="M2 24h56M2 40h56"/> <!-- fine — bbox is 56×16 --> + <path d="M70 15l40 30"/> <!-- fine — diagonal --> +</g> +``` + +A purely horizontal or purely vertical path in its own element is invisible in the accent. Rects, +circles and diagonals are always safe; so is a path that contains two parallel segments, because +together they have area. When you want one accent rule, merge it into a sibling subpath — an +`M…` command appended to a path that already has area costs nothing and fixes it. The `window` +and `queue` glyphs in §9 are written as single merged paths for exactly this reason; do not +"tidy" them back apart. + +Nothing about this applies to `#09090b` or `#500472`, which are flat colours. It is a gradient +problem only, and it is silent — you will not see a warning, you will see a missing line. + +## 6. The shared `<defs>` + +Paste this whole block. `w*` is banner scale (one wave = 22 units), `s*` is card scale (11 +units); a banner needs only `w*`, a card only `s*`. Add longer words by repeating the alternating +`s7.5 6 11 0` / `s7.5-6 11 0` pair. + +```xml +<defs> + <linearGradient id="accent" x1="0" y1="0" x2="1" y2="1"> + <stop offset="0" stop-color="#C026D3"/> + <stop offset="1" stop-color="#2563EB"/> + </linearGradient> + <linearGradient id="wash" x1="0" y1="0" x2="0" y2="1"> + <stop offset="0" stop-color="#ffffff"/> + <stop offset="1" stop-color="#f4eef8"/> + </linearGradient> + <filter id="glow" x="-100%" y="-100%" width="300%" height="300%"> + <feGaussianBlur stdDeviation="42"/> + </filter> + <pattern id="dots" width="18" height="18" patternUnits="userSpaceOnUse"> + <circle cx="2" cy="2" r="1.8" fill="#09090b"/> + </pattern> + + <path id="w1" d="M0 0c3.5-6 7.5-6 11 0s7.5 6 11 0"/> + <path id="w2" d="M0 0c3.5-6 7.5-6 11 0s7.5 6 11 0s7.5-6 11 0s7.5 6 11 0"/> + <path id="w3" d="M0 0c3.5-6 7.5-6 11 0s7.5 6 11 0s7.5-6 11 0s7.5 6 11 0s7.5-6 11 0s7.5 6 11 0"/> + <path id="w4" d="M0 0c3.5-6 7.5-6 11 0s7.5 6 11 0s7.5-6 11 0s7.5 6 11 0s7.5-6 11 0s7.5 6 11 0s7.5-6 11 0s7.5 6 11 0"/> + + <path id="s1" d="M0 0c1.75-3 3.75-3 5.5 0s3.75 3 5.5 0"/> + <path id="s2" d="M0 0c1.75-3 3.75-3 5.5 0s3.75 3 5.5 0s3.75-3 5.5 0s3.75 3 5.5 0"/> + <path id="s3" d="M0 0c1.75-3 3.75-3 5.5 0s3.75 3 5.5 0s3.75-3 5.5 0s3.75 3 5.5 0s3.75-3 5.5 0s3.75 3 5.5 0"/> +</defs> +``` + +Word widths: `w1` 22 · `w2` 44 · `w3` 66 · `w4` 88 — and half those for `s*`. Leave a 14-unit gap +between words on a banner, 6 on a card, so `<use href="#w3" x="44">` is followed by `x="124"`. + +## 7. Stroke weights + +A stroke is a percentage of the canvas, not a number. The card is a fifth the width of the +banner, so its lines are proportionally three times heavier — that is what keeps it readable at +168 px. + +| | banner | card | +|---|---|---| +| Outer surfaces | 3 | 2.4 | +| Inner detail, small glyphs | 2.5 | 1.6 | +| Row dividers (at `opacity 0.12`) | 2 | — | +| Connectors (at `opacity 0.45`) | 3 | 2 | +| Arrow shaft | 4 | 2.2 | +| Squiggle, heading | 5.5 | — | +| Squiggle, body | 4.5 | 2.2 | +| Squiggle, secondary (at `opacity 0.45`) | 3.5 | 2 | + +Everything is `stroke-linecap="round"` on squiggles and `stroke-linejoin="round"` on surfaces. +`rx` is 18–26 on large surfaces, 6–9 on small glyphs, and half the height on a pill. + +## 8. Arrows, connectors and the tangle + +This section exists because two drawings shipped that a reader could not follow, and both +failures were about lines rather than objects. + +**An arrowhead means one stage became the next. Nothing else in a drawing may wear one.** A +banner has exactly two arrowheads — before → act, act → after. A card has exactly one. That is +checked; see §11. + +The failure it prevents: `dms-ecm-consulting-card.svg` used to draw the stage arrow into a +workflow chain and then draw the chain's own step-to-step links with the *same* arrow. Four +arrowheads down one column, all identical, and the before/after split the whole composition +rests on simply disappeared. A step in a chain is not a transformation. It is a connector. + +**Connectors are orthogonal polylines.** Horizontal and vertical segments only, `stroke-width` +per §7 at `opacity 0.45`, no arrowhead. Use one for a relationship that is not a transformation: +a workflow branch, an exception lane, a screen flow, a request routed somewhere. + +**A stacked chain needs no links at all.** Tiles stacked in a column with an even gap already +read as a sequence — that is what stacking means. Drawing a connector between each of them is +three extra lines saying what the spacing has already said, and at 168 px they merge with the +stage arrow above into one column of marks. `dms-ecm-consulting` went through both wrong answers +before this one: arrowheads between the tiles, then thin connectors between the tiles, then +nothing. Nothing is right. + +Link only what the stacking cannot show — the branch off to the side, the exception lane, the one +step that loops back. A single connector leaving the column is legible precisely because it is +the only one. + +**No two connectors may cross.** `llm-gateway-open-source.svg` used to draw four callers reaching +one point with four curved lines that crossed each other twice on the way. It read as a knot. The +fix is not neater curves, it is a different construction: run each lane straight out to a shared +spine and let the spine carry them in. Lanes meeting a spine is a T-junction, which is a join and +not a crossing. If two connectors genuinely have to reach past each other, the composition is +wrong — move an object instead. + +**A curve is only allowed where the curve is the subject** — push waves leaving a phone, the fill +line on a cylinder. Mark those `data-curve="…"` and the gate will let them through and list them +for review. A curve used as routing is never right. + +**Every line has to declare what it is.** A line running between two objects carries +`class="connector"` or `class="arrow"`; a line inside one object carries `data-detail="table +rules"`, `data-detail="chip pins"`, `data-detail="laptop hinge"`. There is no geometric test that +tells those apart — a cylinder's fill line and a lane joining two tiles are the same arc — so the +drawing says which, and an undeclared line long enough to matter is itself a gate failure. You +cannot get past the check by not labelling. + +**A lane diagram does not tilt.** The input side normally sits at `rotate(-4)` (§9, Tilt), but a +tilted orthogonal lane is a contradiction and looks like a mistake. Where the input is a set of +objects wired to something, leave the whole group upright. + +## 9. Parts catalogue + +Every part below is optional. Take what the idea needs and leave the rest out; a drawing that +uses all of them is a drawing with no subject. The only parts every banner has are the arrow, the +glow and the act. + +### Structure + +**Shadow — never blurred.** The same path again, filled ink at `0.3`, drawn *behind*, offset ~4 % +of the shape's width. Never `feDropShadow` on a flat surface; the blur is reserved for the glow. + +```xml +<path d="…" fill="#09090b" stroke="none" opacity="0.3" transform="translate(16 16)"/> +<path d="…" fill="#ffffff"/> +``` + +**Arrow** — strictly horizontal on a banner, vertical on a card. Ink, shaft `stroke-width 4`, +head a filled triangle `l-20-12v24Z` at the tip (`l-9-14h18Z` on a card). No curves, no dashes. +One per gap between stages, and §8 says how many gaps there are. + +**Connector** — §8. Right-angled ink polyline, `opacity 0.45`, no head. Optionally a filled `r 6` +dot at a free end. + +**Bus** — the accent version of a connector, `stroke-width 4`: parallel lines running into a +shared spine with a solid `r 16` hub node on it. Reads as "these are wired together now". + +**Glow** — the accent gradient, heavily blurred, behind the arrow at the transformation point. +Exactly one per banner and none on a card; it is the only soft element in the family. + +```xml +<ellipse cx="780" cy="428" rx="118" ry="146" fill="url(#accent)" filter="url(#glow)" opacity="0.55"/> +``` + +**Act chip** — on a banner, a 124×124 `rx 26` white square on the glow at `x 706 y 358`, holding +one symbol from §10 in the accent. Stroke the symbol where it is a line drawing and fill it where +the shape is solid — a hollow cursor reads as nothing. One symbol, no more. **On a card the chip +is dropped** and the symbol sits bare on the wash, or is left out entirely. + +**Tilt** — the input side sits at `rotate(-4)` about its own centre. The output never tilts: that +asymmetry is what makes one side read as loose material and the other as a system. Exception in +§8: a lane diagram stays upright. + +**Dot field** — `url(#dots)` at `opacity 0.1` in a 300-wide column at each edge. Never behind the +focal object. + +### Objects — pick by subject, not by habit + +**Sheet of paper** — for when the subject really is paper. A rounded rect with the top-right +corner cut, plus the fold as a small white triangle on the same stroke. 380×520 on a banner: + +```xml +<path d="M18 0h282l80 76v426a18 18 0 0 1-18 18H18A18 18 0 0 1 0 502V18A18 18 0 0 1 18 0Z" fill="#ffffff"/> +<path d="M300 0l80 76h-64a16 16 0 0 1-16-16Z" fill="#ffffff"/> +``` + +**Stack** — two or three plain rounded rects offset ~17 units up and right behind the front +object, all white, all the same stroke. Never more than four; beyond that it reads as noise. + +**Screen** — a rounded rect with a nav bar, a placeholder well crossed by two diagonals at +`opacity 0.3`, and two lines of squiggle. Several joined by connectors is a screen flow. + +**Browser** — a screen plus a chrome bar closed by a full-width rule, three `r 8` ink dots at +`0.25`, and an address pill in ink at `0.08`. Pair it with a **phone** — same construction, +`rx 26`, a notch pill at the top — overlapping its bottom-right corner when the point is "works +on both". + +**Laptop** — a lid and a splayed deck, joined by a hinge rule. The shape that says *one specific +machine*, as opposed to a browser (a surface) or a panel (an application): + +```xml +<path id="lid" d="M12 0h116a12 12 0 0 1 12 12v84H0V12A12 12 0 0 1 12 0Z"/> +<path id="deck" d="M0 0h156l8 18a6 6 0 0 1-6 8H-2a6 6 0 0 1-6-8Z"/> +``` + +**Database cylinder** — the shape that instantly is not a document: + +```xml +<path d="M130 200v110a100 26 0 0 0 200 0V200" fill="#ffffff"/> +<ellipse cx="230" cy="200" rx="100" ry="26" fill="#ffffff"/> +<path d="M130 244a100 26 0 0 0 200 0" data-detail="cylinder fill line" opacity="0.4"/> +``` + +**Port** — a short ink line at `opacity 0.5` ending in a hollow `r 10` circle. On the input side +it dangles unconnected; on the output side it joins the bus. + +**Spreadsheet** — a rounded rect with a header rule, then evenly spaced ink rules at `0.4` both +ways and short ink bars at `0.3` in some cells but not all. Empty cells are what make it read as +manual. + +**Task list** — a panel of `rx 5` checkboxes with a squiggle beside each, one of them ticked. +Cheaper and more specific than a spreadsheet when the subject is work rather than data. + +**Stopwatch / clock** — a circle with a `rx 6` crown, four tick marks at `0.4`, and two hands at +different lengths. The single fastest way to say "Zeiterfassung". + +**Kanban board** — a panel whose content is three `#500472 0.05` columns, each with a squiggle +header and one to three white `rx 9` task cards. Give one card an accent duration bar and a small +accent clock to say that hours are booked against the work, not tracked beside it. + +**Workflow chain** — three or four `rx 16` step tiles stacked in a column with an even gap and +**nothing between them** (§8). One step carries an accent check (approved), one an accent +circular arrow (automatic). A chain without a branch is a list; the one connector you draw goes +sideways, to an exception tile, and it reads because it is the only line in the object. + +**Archive drawer** — a `rx 6` rect split by a rule with a small `rx 3` handle pill in each half. + +**Inbox tray** — the container shape, for "this piled up and nobody dealt with it". It is harder +than it looks: a rounded U, thick posts with a rail, and a back panel with a lip were all tried +on `docuware-7-14-is-here` and all three read as bars stacked on a bench. What works is a +**trapezoid with a separate front wall** — a shape wider at the top than the bottom, filled at a +low ink opacity so it sits behind, with the front wall drawn as its own band *in front of* the +pile. The pile is three or four plain white `rx 12` cards, each offset ~16 units up and sideways +so the stack leans, rising well above the tray's opening. The front wall crossing the cards is +the whole trick — without it the cards read as sitting on the tray rather than in it. + +**Panel** — where a subject genuinely is an application: a large white `rx 18`–`20` rounded rect +with a header strip closed by a full-width rule and a 34×34 gradient square in it. An accent ring +6 units outside, `stroke-width 3`, marks it as the finished thing. **Use it for the output at +most, never as a wrapper around the whole composition.** + +**Code panel** — a panel with a gutter rule and, beside it, short rounded bars in `#C026D3`, +`#2563EB` and ink at `0.55`, indented in blocks. It must read as syntax highlighting without +being text. + +**Metadata chip** — a `rx`-half-height pill, `#500472` at `0.12` or the accent at `0.18`, holding +a `w2` squiggle in brand at `0.6`. Two or three per column, not one per row — a chip on every row +reads as a table, not as metadata. + +**Result badge** — a 300×66 `rx 33` pill filled with the accent, straddling the output's top +edge, with a white `rx 11` icon square at its left and white squiggles beside it. The most +saturated element in the family: at most one per banner, never on a card, and only where the +output genuinely is a *result* rather than a running system. + +## 10. The act vocabulary + +The act is a **verb the subject performs**, and it is the single most load-bearing choice in the +drawing — it sits dead centre, in the only saturated colour, and a reader looks at it first. +`docuware-7-14-is-here` shipped with `transfer`, an arrow leaving a box, for a release whose +change was that a phone now tells you when an approval arrives. It read as "open in new window". +The act was `bell` all along. + +The test, before drawing: **write the act as a sentence with the subject in it.** "DocuWare 7.14 +*notifies* you" is true and quotable. "DocuWare 7.14 *relocates* you" is not. If the sentence is +awkward, the act is wrong — not the wording. + +Each symbol is drawn in a 60×60 box. On a banner the chip sits at `x 706 y 358` and the box is +centred in it with `<g class="act" transform="translate(738 390)">`. **That `class="act"` is +required** — it is how the arrowhead gate knows a triangle inside the `queue` or `funnel` glyph +is part of the symbol rather than a third stage arrow. One symbol per drawing, no more. + +| Act | Reads as | Use when the change is | +|---|---|---| +| `magnifier` | a lens | something unfindable becomes searchable | +| `braces` | `{ }` | it is written as code | +| `nodes` | a joined graph | separate things are wired together | +| `cursor` | a pointer | it becomes something you can operate | +| `schedule` | a circular double arrow | it now runs on its own, repeatedly | +| `funnel` | wide in, narrow out | unstructured material is extracted into fields | +| `window` | a browser chrome | a thing that was installed now runs in a tab | +| `transfer` | an arrow leaving a box | it moved house — another org, tenant or surface | +| `grid` | three blocks placed, one still being set | a whole is composed from parts | +| `queue` | a list, one item leaving | order and waiting are the point | +| `shield` | a check inside a shield | it is now protected | +| `bell` | a notification | someone now gets told | +| `board` | columns with cards | work becomes visible and assignable | + +```xml +<!-- funnel --> +<path d="M4 6h52L34 32v20l-8 6V32Z" fill="url(#accent)"/> + +<!-- window — frame and chrome rule are ONE path on purpose, see §5 --> +<path d="M10 8h40a8 8 0 0 1 8 8v28a8 8 0 0 1-8 8H10a8 8 0 0 1-8-8V16a8 8 0 0 1 8-8ZM2 24h56" + stroke="url(#accent)" stroke-width="5" fill="none" stroke-linejoin="round"/> +<g fill="url(#accent)"><circle cx="12" cy="16" r="3"/><circle cx="23" cy="16" r="3"/><circle cx="34" cy="16" r="3"/></g> + +<!-- transfer --> +<g stroke="url(#accent)" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round"> + <path d="M30 10H12a6 6 0 0 0-6 6v30a6 6 0 0 0 6 6h30a6 6 0 0 0 6-6V28"/> + <path d="M32 26L54 4"/><path d="M38 4h16v16"/> +</g> + +<!-- grid --> +<g fill="url(#accent)"> + <rect x="4" y="4" width="22" height="22" rx="5"/><rect x="34" y="4" width="22" height="22" rx="5"/> + <rect x="4" y="34" width="22" height="22" rx="5"/> +</g> +<rect x="34" y="34" width="22" height="22" rx="5" stroke="url(#accent)" stroke-width="5" fill="none"/> + +<!-- queue — the connector is merged into the bars path on purpose, see §5 --> +<path d="M4 12h28M4 30h28M4 48h28M40 30h10" + stroke="url(#accent)" stroke-width="6" stroke-linecap="round" fill="none"/> +<path d="M58 30l-12-7v14Z" fill="url(#accent)"/> + +<!-- shield --> +<path d="M30 3l24 9v18c0 15-10 24-24 28C16 54 6 45 6 30V12Z" fill="url(#accent)"/> +<path d="M21 30l6 7 13-15" stroke="#ffffff" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round"/> + +<!-- bell --> +<path d="M30 6a16 16 0 0 1 16 16v13l6 8H8l6-8V22A16 16 0 0 1 30 6Z" fill="url(#accent)"/> +<path d="M23 47a7 7 0 0 0 14 0Z" fill="url(#accent)"/> + +<!-- board --> +<g stroke="url(#accent)" stroke-width="5" fill="none" stroke-linejoin="round"> + <path d="M4 6h52v48H4ZM22 6v48M40 6v48"/> +</g> +<g fill="url(#accent)"><rect x="8" y="14" width="10" height="12" rx="3"/><rect x="26" y="14" width="10" height="20" rx="3"/></g> +``` + +Adding a fourteenth: it must be a **verb**, it must be legible as a silhouette at 40 px, and it +must not already be in the list under another name. `bell`, `shield` and `board` are as close to +nouns as this family goes, and each earns it because the change it names genuinely is "you get +told" / "it is protected" / "the work is on a board". Add it to `ACTS` in +`scripts/check-illustrations.py` in the same commit, or the gate rejects it. + +**No two drawings in the same family share an act.** That is checked, and it is the cheapest +possible guard against three DocuWare releases becoming three variations on one picture. Across +families it is allowed and normal — a service and a page are never printed side by side, so both +may be `braces`. `illustration-seo-card.md` §7 adds six acts for the pages family; they are part +of the same vocabulary and live in the same `ACTS` table. + +## 11. Quality gates + +Three things kept going wrong that no checklist caught, because a checklist is read by whoever +already believes the drawing is finished: the objects had nothing to do with the copy, the lines +tangled, and the act named a verb the subject never performs. So they are gates now. + +```bash +scripts/check-illustrations.py # everything +scripts/check-illustrations.py news # one family +scripts/check-illustrations.py public/images/services/open-source-erp.svg +scripts/check-illustrations.py --list-acts +``` + +### Gate 1 — the drawing is about its subject + +Every drawing has an entry in `public/images/illustrations.json`, and every object in it cites a +phrase from the subject's own copy: + +```json +"services/open-source-erp": { + "sentence": "a task list and a stopwatch kept beside each other by hand → wiring them together → one board where the work is planned and the hours are booked against it", + "act": "nodes", + "actSource": "Als Odoo-Partner bieten wir dir Schritt für Schritt an, was sich bei uns bewährt", + "objects": { + "task list": "die Einführung von Projektmanagement und Zeiterfassung", + "stopwatch": "Projektmanagement und Zeiterfassung", + "kanban board": "Odoo setzen wir seit Kurzem selbst als ERP ein", + "booked hours": "ohne Lizenz-Lock-in" + } +} +``` + +Each quote has to appear **verbatim** in the source markdown — for a service that is +`database/files/services/{de_CH,en_CH}/<slug>.md`, for an article both locale files, and for a +page its YAML plus the `lang/` files (`illustration-seo-card.md` §8). Markdown +emphasis, curly quotes, en dashes and line wrapping are folded before comparing; wording is not. +If you cannot find a phrase for an object, the object does not belong in the drawing. That rule +is what removed the spreadsheet from `open-source-erp`: nothing on that page has ever mentioned +one. + +Minimum three objects, and the sentence needs both arrows. + +### Gate 2 — it is concrete + +Object names are checked against a list of words that name geometry instead of a thing — `dot`, +`node`, `hub`, `blob`, `shape`, `circle`, `starburst`, `spark`, `cloud`, `widget`, `symbol`, +`gradient`, `accent`. If the honest name for what you drew is "a dot with a starburst", the +drawing is decoration. Name what a reader would name, then check the drawing actually shows that. + +### Gate 3 — the lines can be followed + +- exactly two arrowheads on a banner, one on a card, outside the `class="act"` glyph +- no connector contains a curve command, unless it carries `data-curve="…"` +- no two connectors cross +- every stroked line over 150 units (40 on a card) carries `class`, or `data-detail` + +### Gate 4 — the invariants + +No `<text>`; only the five palette values; the right viewBox; exactly one glow on a banner and +none on a card; no `feDropShadow` and no filter but `#glow`; at least one offset shadow; at least +one squiggle; `stroke-linejoin="round"` present; under 12 KB; the PNG exists at exactly 1200×630 +beside a banner and does not exist beside a card; and, for a news hero, no ink outside the crop +(measured, see `illustration-news.md` §3). + +### Gate 5 — the set + +Held against each other: no two banners in a family share an act, and no two open with the same +object. The index prints these directly under one another, so the thing a reader actually sees is +the set. The pages family has no index and is held to the same rule anyway — three codebar links +pasted into one chat are a set too (`illustration-seo-card.md` §6). + +### What the gates cannot do + +They check that every **declared** object is in the copy. They cannot see an object you drew and +did not declare. They check that lines do not cross; they cannot tell you a composition is dull. +So one human pass stays, and it is short: + +- Look at the banner at 760 px and the card at 168 px, next to their neighbours. +- Name every shape out loud. Anything you cannot name in one noun comes out. +- Read the sentence in the manifest, then look at the drawing. If you had to explain a shape to + make the sentence true, the shape is wrong. +- **Professional, but still a sketch.** Hand-drawn squiggles, flat offset shadows, round joins, + one soft element in the whole drawing and it is the glow. Nothing beveled, nothing with a + second gradient, no drop shadow filter, no icon lifted from a set. If it looks like stock + vector art, something in this list was skipped. + +## 12. Rendering + +```bash +scripts/render-news-og.sh public/images/services/<slug>.svg +``` + +That script is named for news but takes explicit files and renders each at exactly 1200×630, +which is what `config/seo.php` declares in `og:image:width` / `og:image:height` for every page. +Do not add a second script and do not change the size without changing that config. + +**Never pass a `-card.svg` to it.** Cards are square; forced into 1200×630 they come out stretched +into unrecognisable soup, and the wrong PNG next to the wrong SVG is exactly what +`NewsImage::ogImage()` would then serve to Twitter. The script refuses, and so does the gate. + +After a new or changed banner: + +```bash +php artisan services:import +``` + +## 13. Before you commit + +Run `scripts/check-illustrations.py` — it covers the palette, the canvas, the glow, the shadows, +the arrowheads, the connectors, the file size, the PNG, the crop, the act vocabulary, the set, +and every object's provenance. What is left for a person: + +- [ ] The sentence has a middle, and the drawing shows the middle. +- [ ] **No wrapper.** Nothing exists only to contain the rest of it. +- [ ] Held against the rest of the set: different silhouettes, different objects. If two of them + are "a rounded rectangle with squiggles in it", one of them is wrong. +- [ ] Every shape can be named in one noun, and the manifest names all of them. +- [ ] The act, written as a sentence with the subject in it, is true. +- [ ] The input half is black and white. Colour starts at the glow. +- [ ] Rendered at 168 px, the card still tells its story. +- [ ] Still a sketch, not stock vector art — §11, Gate 5. +- [ ] `image:` set in the **German** file only, pointing at the `.svg`. diff --git a/prompts/images-news-square.md b/prompts/images-news-square.md new file mode 100644 index 0000000..855bfd0 --- /dev/null +++ b/prompts/images-news-square.md @@ -0,0 +1,120 @@ +# Generating news square thumbnails (v1) + +> **Superseded by `illustration-news-card.md`.** This file stays because +> `scripts/make-news-square.py` and the existing squares still work; use it to regenerate one, +> not to make a new article's. + +Follow this file whenever an article needs the small square picture the news index shows next +to a list row. It is the companion to `prompts/images-news.md`, which covers the 16:9 hero. + +The square is a **separate file, not a crop**. The hero carries the article title, so the +square slot cuts it in half — «DocuWare 7.13 ist da» arrives as «cuWare 7.13 ist d». Rather +than shrink the hero's type until it survives a centre crop, the square drops the words +entirely and shows only the motif. That has two consequences worth knowing up front: + +- **One file per article, not one per locale.** No text means nothing to translate. +- **No PNG.** The square is never an `og:image`; that stays the hero's job. SVG only. + +## 1. Making one + +```bash +scripts/make-news-square.py docuware-7-14 --motif mobile-app +``` + +Writes `public/images/news/placeholders/docuware-7-14-square.svg` and prints the front-matter +line to paste. No fonts, no rendering step, no dependencies — the file is a couple of KB. + +- **slug** — the *same stem the hero uses*, without the locale: `docuware-7-14-de.svg` and + `docuware-7-14-en.svg` are joined by `docuware-7-14-square.svg`. An article's three files + then sit together in a directory listing. +- **`--motif`** — see §3. This is the whole decision. + +Then wire it into the front matter of **both** language files, next to `hero:`: + +```yaml +thumb: images/news/placeholders/docuware-7-14-square.svg +``` + +Both files carry it even though only the German one is read, exactly as `hero:` does — a +front matter that differs between locales for no visible reason is a trap for the next person. + +There is no `thumb_alt`. The index renders the square with `alt=""`: it repeats the headline +sitting right beside it, so to a screen reader it is decoration. If you ever find yourself +wanting alt text here, the picture is carrying information the teaser should be carrying. + +Without a `thumb:` the row falls back to the hero and the slot reverts to 4:3, so nothing +breaks — the article just shows a cropped hero, which is the state this whole file exists to +get rid of. + +## 2. The layout + +640×640, with one job: survive being displayed at **176 px**. That is the real size in the +index row, and every decision below follows from it. + +| Element | Position | Notes | +|-------------|---------------------------------|------------------------------------------| +| Motif | 80, 80 → 560, 560 | the only thing that changes per article | +| Rings | centre (60, 626), r 216/160/105/52 | bottom-left, only arcs show | +| Dot field | bottom-left, behind the rings | step 26 — see below | +| Band | x 360, rotated 22° | passes behind the motif | + +**No title, no tag row, no logo.** The codebar logo is on the hero and would be an +unreadable smudge at 176 px; the topic already sits in the row as a chip. + +The background is the hero's, scaled to this canvas: same ring centre relative to the corner, +same radii ratio, same band angle, same wash. **It is identical on every square**, and it is +not a place to tell articles apart — change the motif instead. + +One deliberate deviation: the **dot field is enlarged**, step 26 rather than the hero's 34 +scaled down to 14. At card size the correctly-scaled spacing lands around 3.7 px, where the +field stops reading as dots and turns into grey mush. + +The motif box is 480×480 at the origin — the same box the hero uses, tilted the same +`rotate(-4)`. A hero motif can be dropped into a square unchanged, but usually should not: +see the detail budget below. + +## 3. Motifs + +Pick with `--motif`. Defined at the bottom of `scripts/make-news-square.py`: + +| Name | Reads as | Use for | +|---------------------|--------------------------------------------------------------|----------------------------------| +| `invoice-analytics` | an invoice with a bar chart laid over it — the same numbers, read a second way | e-invoicing, IDP, reporting, analytics | +| `workflow-browser` | a browser window with three steps wired together, the last one approved | workflow, configuration, anything that moved into the browser | +| `mobile-app` | a phone with a task list, one done, push waves off the corner | mobile, notifications, tasks | +| `editorial-blocks` | a page assembled from blocks, with a colour row | Styleguide, Redaktion, the site itself | +| `queue-gateway` | requests queueing into a panel that works through them, one answer coming back out | queues, gateways, batch processing | +| `documents` | a stack of documents, signed off | the neutral fallback | + +**A motif is per article, not per topic.** Five DocuWare releases all tagged DMS/ECM must not +share one picture: the index puts them directly under one another, and five identical squares +make the list look broken. Read what the release actually changed and pick — or write — the +motif that says it. If two articles genuinely tell the same story, they are probably one +article. + +To add one, write a function that draws inside the **480×480 box at the origin** and register +it in `MOTIFS`. The rules from `prompts/images-news.md` §4 all still hold — white surfaces, +brand strokes, suggested content rather than literal, no gradients, no text, no third-party +logos — with a tighter budget on top, because 480 units land on 176 px: + +- **Three surfaces at most.** The hero's `dms-ecm` stacks four documents and still reads at + 1600 wide; here it would be a grey smear. +- **`stroke-width="3"` on the large shapes**, not 2, and nothing thinner than 8 units. +- **`rx` 14–22.** +- **One idea, one flourish.** A check, an arrow, a wave — one of them, not three. +- **Vary the flourish across the set.** Every motif ending in the same filled check circle + defeats the point of drawing five of them. +- Check it at 176 px before you believe it: + `rsvg-convert -w 176 -h 176 <file>.svg -o /tmp/check.png` + +## 4. Before you commit + +- [ ] One square per article, file name `<hero-stem>-square.svg`, **no** `-de` / `-en`. +- [ ] No text anywhere in the file — that is what makes it locale-free. +- [ ] No PNG next to it, and `og:image` still points at the hero's PNG. +- [ ] The motif is not shared with another article in the index. +- [ ] Rendered at 176 px: every surface still reads, the flourish is recognisable. +- [ ] Seen next to its neighbours in the list: distinguishable at a glance. +- [ ] `thumb:` set in **both** locale files, path relative to `public/`. +- [ ] `php artisan news:import` run, so the column is filled. +- [ ] `git status` shows no orphaned square left behind by a rename. diff --git a/prompts/images-news.md b/prompts/images-news.md index 4055517..da13442 100644 --- a/prompts/images-news.md +++ b/prompts/images-news.md @@ -1,4 +1,10 @@ -# Generating news hero placeholders +# Generating news hero placeholders (v1) + +> **Superseded by `illustration-news.md`.** New articles get a wordless, hand-authored hero in +> the illustration family — three files instead of five, no locale suffix, no embedded font. +> This file stays because `scripts/make-news-hero.py` and the placeholders under +> `public/images/news/placeholders/` still exist and still work; use it to regenerate an existing +> v1 hero, not to make a new article's. Follow this file whenever a news article needs a hero image and no real photograph or screenshot exists. The output is a **pair** of files per article — one SVG for the page, @@ -44,6 +50,10 @@ hero_alt: Platzhaltergrafik zum DocuWare-Release 7.14 it for you. `App\Support\NewsImage` resolves the `images/…` prefix as a local path, and `NewsImage::ogImage()` swaps `.svg` for `.png` when emitting `og:image`. +An article needs a second graphic on top of this one: the square the news index puts next to +a list row. It is not a crop of the hero — the crop would cut the title in half — but its own +file, without any type. See `prompts/images-news-square.md`. + ## 2. The layout 1600×900, and it hangs off one fixed anchor on the left at x=80: diff --git a/public/fonts/poppins/poppins-regular.woff2 b/public/fonts/poppins/poppins-regular.woff2 deleted file mode 100644 index b69e009..0000000 Binary files a/public/fonts/poppins/poppins-regular.woff2 and /dev/null differ diff --git a/public/images/illustrations.json b/public/images/illustrations.json new file mode 100644 index 0000000..db7e950 --- /dev/null +++ b/public/images/illustrations.json @@ -0,0 +1,317 @@ +{ + "news/docuware-7-12-is-here": { + "sentence": "an e-invoice arrives as a machine file nobody reads → extracting → the line items sit in a table and the same numbers again as a chart", + "act": "funnel", + "actSource": "Rechnungspositionen aus E-Rechnungen werden automatisch in Tabellenfelder übertragen", + "objects": { + "mail intake envelope": "so werden E-Mail-Anhänge bereits vor der Archivierung klassifiziert", + "machine-readable invoice": "Anhänge (z. B. PDF oder XML) werden direkt im Viewer angezeigt", + "line-item table": "ideal für Prüfung und Weiterverarbeitung", + "analytics chart": "Analysieren Sie Prozesse, erkennen Sie Optimierungspotenziale" + } + }, + "news/docuware-7-13-is-here": { + "sentence": "the workflow designer is an installed desktop application → moving into the browser → a designer that runs in a tab, with an exception lane", + "act": "window", + "actSource": "im Browser, ohne dass auf dem Arbeitsplatz etwas installiert sein muss", + "objects": { + "installed designer": "Der Workflow Designer ist keine eigene Anwendung mehr.", + "browser designer": "ein Workflow Designer ohne Installation", + "workflow steps": "Mehrere Schritte lassen sich gemeinsam markieren, kopieren oder entfernen.", + "exception lane": "Für unerwartete Abbrüche kann eine eigene Reaktion hinterlegt werden." + } + }, + "news/docuware-7-14-is-here": { + "sentence": "approvals pile up in an inbox nobody opens → notifying → tasks grouped by process on a phone that says when one arrives", + "act": "bell", + "actSource": "Push-Benachrichtigungen bei neuen Aufgaben", + "objects": { + "inbox of approvals": "weil niemand ins Postfach geschaut hat", + "waiting clock": "bleiben Freigaben nicht mehr tagelang liegen", + "rebuilt mobile app": "Die Mobile App wurde neu entwickelt.", + "tasks grouped by process": "Aufgaben nach Prozess gruppiert abarbeiten.", + "push waves": "Push-Benachrichtigungen bei neuen Aufgaben" + } + }, + "news/bausteine-styleguide": { + "sentence": "content blocks lie around loose, each a different shape → composing → one page assembled from them in order, at one shared width", + "act": "grid", + "actSource": "zeigt jeden verfügbaren Inhaltsbaustein einmal in echt", + "objects": { + "loose content blocks": "als Referenz beim Schreiben", + "image block": "Bilder beginnen und enden dort, wo der Fliesstext beginnt und endet", + "comparison block": "Zwei Zustände direkt nebeneinander.", + "assembled page": "Alle Elemente teilen sich eine Breite", + "callout row": "Ein neutraler Hinweis für Zusatzinformationen" + } + }, + "news/llm-gateway-open-source": { + "sentence": "agents, processes and people all call one in-house machine at once until its memory is full → queueing → a gateway that stores every request and feeds the model one at a time", + "act": "queue", + "actSource": "Wir behandeln deshalb jede Anfrage als asynchronen Auftrag", + "objects": { + "agent and team requests": "Agenten und Benutzer, die mit den Modellen arbeiten", + "request lanes": "Ohne Orchestrierung schlagen Anfragen fehl", + "single in-house machine": "ein dediziertes MacBook mit M5 Max", + "full memory bar": "128 GB Unified Memory", + "stored queue": "Die Anfragen werden gesammelt und nach den vorhandenen Ressourcen abgearbeitet", + "job database": "legt jeden Auftrag in einer Datenbank-Queue ab", + "model chip": "Ein Modell muss im Arbeitsspeicher geladen sein, bevor es antworten kann", + "fork mark": "Der Code liegt öffentlich auf GitHub" + } + }, + "services/konzeption-prototyping": { + "sentence": "a sketched screen flow on paper → clicking → the same thing running in a browser and on a phone", + "act": "cursor", + "actSource": "Klickbare Prototypen machen Ideen früh greifbar und diskutierbar.", + "objects": { + "wireframe screens": "Mockups und klickbare Prototypen schaffen früh ein gemeinsames Bild", + "screen flow": "Wir erarbeiten Informationsarchitektur, Abläufe und Datenmodell", + "browser preview": "machen es als klickbaren Prototyp erlebbar", + "phone preview": "Feedback von echten Nutzer:innen fliesst direkt in die nächste Iteration ein." + } + }, + "services/individuelle-softwareentwicklung": { + "sentence": "an ERP, a CRM and a DMS each with a port that goes nowhere → writing the code → one documented bus they all sit on, running on a schedule", + "act": "braces", + "actSource": "Wir entwickeln individuelle Lösungen, die sich nach deinen Prozessen richten", + "objects": { + "database cylinder": "Wir verbinden bestehende Systeme wie ERP, CRM oder DMS zu durchgängigen Abläufen.", + "service box": "Wir verbinden bestehende Systeme wie ERP, CRM oder DMS zu durchgängigen Abläufen.", + "records grid": "Wir verbinden bestehende Systeme wie ERP, CRM oder DMS zu durchgängigen Abläufen.", + "dangling ports": "Saubere, dokumentierte Schnittstellen, die Systeme zuverlässig verbinden.", + "code panel": "Automatisierte Tests, Code-Reviews und kontinuierliche Deployments", + "scheduled integration": "Portale, Schnittstellen, Automatisierungen" + } + }, + "services/dms-ecm-consulting": { + "sentence": "a deep stack of paper nobody can search → indexing → a workflow that runs itself, with an approval, an archive and one exception lane", + "act": "magnifier", + "actSource": "Ablagestruktur, Indexierung, Berechtigungen und Aufbewahrung, sauber durchdacht.", + "objects": { + "paper stack": "Vom Papier zum papierlosen Büro", + "workflow chain": "automatisierte Dokumentenworkflows", + "approval step": "bis hin zu KI-gestützter Verarbeitung", + "archive drawer": "Ablagestruktur, Indexierung, Berechtigungen und Aufbewahrung" + } + }, + "services/open-source-erp": { + "sentence": "a task list and a stopwatch kept beside each other by hand → wiring them together → one board where the work is planned and the hours are booked against it", + "act": "nodes", + "actSource": "Als Odoo-Partner bieten wir dir Schritt für Schritt an, was sich bei uns bewährt", + "objects": { + "task list": "die Einführung von Projektmanagement und Zeiterfassung", + "stopwatch": "Projektmanagement und Zeiterfassung", + "kanban board": "Odoo setzen wir seit Kurzem selbst als ERP ein", + "booked hours": "ohne Lizenz-Lock-in" + } + }, + "pages/start.index": { + "sentence": "an idea that exists only as a sketch and a spoken sentence → listening to it first → software somebody works in every day", + "act": "listen", + "actSource": "Am Anfang hören wir dir zu.", + "objects": { + "sketch sheet": "von der ersten Skizze bis zur Software im täglichen Einsatz", + "idea stack": "von der ersten Idee bis zur Software im täglichen Einsatz", + "software panel": "Software im täglichen Einsatz", + "team card": "Ein kleines Team aus der Region Basel" + } + }, + "pages/about-us.index": { + "sentence": "a request relayed down a chain of boxes before it reaches whoever builds it → meeting the builder directly → one table with the person who writes the code at it", + "act": "meet", + "actSource": "Wer dein Projekt baut, sitzt auch im Gespräch.", + "objects": { + "layer tiles": "Ein kleines Team, keine Zwischenebene", + "shared table": "Bei uns arbeitest du direkt mit den Menschen, die deine Lösung verstehen", + "two colleagues": "Das spart einen Übersetzungsschritt und macht Zusagen verbindlich.", + "code panel": "Wer dein Projekt baut, sitzt auch im Gespräch." + } + }, + "pages/services.index": { + "sentence": "four offerings lying apart and unaligned → composing them into one sequence → the same four threaded on a single path from the first idea to day-to-day operations", + "act": "grid", + "actSource": "Vier Bereiche, ein Weg", + "objects": { + "prototype screen": "wir schärfen deine Anforderungen und machen Ideen mit klickbaren Prototypen früh greifbar", + "code tile": "Portale, Schnittstellen und Automatisierungen, entwickelt mit offenen Technologien wie Laravel", + "paper sheet": "begleiten wir dich ins papierlose Büro und automatisieren deine Prozesse", + "task board": "Odoo setzen wir selbst ein und begleiten dich als Odoo-Partner Schritt für Schritt bei der Einführung", + "one path spine": "von der ersten Idee über Konzept und Umsetzung bis zum Betrieb" + } + }, + "pages/products.index": { + "sentence": "one project's panel, built once for one customer → writing it as a product → a released thing with versions behind it that is in use every day", + "act": "braces", + "actSource": "entwickelt mit offenen Technologien und Standards", + "objects": { + "project panel": "Produkte, die aus echter Projektarbeit entstanden sind", + "release stack": "laufend weiterentwickelt", + "version pill": "von uns entwickelt, im täglichen Einsatz und laufend weiterentwickelt", + "daily use row": "im täglichen Einsatz" + } + }, + "pages/technologies.index": { + "sentence": "a long shelf of tools, most of them untouched → narrowing it deliberately → three that are kept, each with years stacked behind it", + "act": "funnel", + "actSource": "bewusst gewählt und über Jahre in der Tiefe beherrscht", + "objects": { + "tool shelf": "Die Werkzeuge und Technologien, mit denen wir täglich arbeiten", + "chosen tools": "bewusst gewählt", + "year stack": "über Jahre in der Tiefe beherrscht", + "open standard badge": "bewährt, transparent und herstellerunabhängig" + } + }, + "pages/open-source.index": { + "sentence": "a package we wrote, sitting inside our own repository and nowhere else → giving it back → a public repository with somebody else's branch off it", + "act": "fork", + "actSource": "Wir bauen auf Open Source — und geben etwas zurück.", + "objects": { + "package tile": "Packages, Tools und Libraries, entwickelt und gepflegt von codebar", + "repository panel": "Unsere Beiträge an die Open-Source-Community", + "community branch": "Das sind die Projekte und Beiträge, die wir mit der Community teilen." + } + }, + "pages/ai.index": { + "sentence": "customer documents leaving the building on a lane to a provider's rack → keeping them in → the same documents on our own machine, with the usage counted openly", + "act": "shield", + "actSource": "Kundendaten verlassen unsere Infrastruktur nicht.", + "objects": { + "customer documents": "Kundendaten verlassen unsere Infrastruktur nicht", + "provider rack": "statt Anfragen an einen Cloud-Anbieter zu schicken", + "our own machine": "warum wir Open-Source-Modelle selbst betreiben", + "usage counters": "Die Nutzungsstatistik zeigt ungefiltert, wie oft das tatsächlich vorkommt." + } + }, + "pages/ai.llm.index": { + "sentence": "three model tiles with no machine under them → running them ourselves → the models on one laptop in our own basement, on our own power and reachable through one tunnel", + "act": "host", + "actSource": "alle laufen auf eigener Infrastruktur im hauseigenen Bürokeller", + "objects": { + "model tiles": "Auf diese lokalen Open-Source-Modelle setzen wir aktuell", + "laptop": "Hier laufen unsere lokalen Modelle.", + "power supply": "USV Ubiquiti UniFi.", + "tunnel": "Cloudflare Tunnel auf das lokale MacBook." + } + }, + "pages/ai.llm.analytics.index": { + "sentence": "requests running past an empty counter → measuring them → token bars per month and model, with the total under them", + "act": "measure", + "actSource": "Token-Verbrauch und Anfragen unserer lokal betriebenen Modelle", + "objects": { + "requests": "Anfragen diesen Monat", + "empty counter": "Noch keine Nutzungsdaten vorhanden.", + "monthly bars": "pro Monat und Modell, laufend aktualisiert", + "total pill": "Tokens total" + } + }, + "pages/news.index": { + "sentence": "a whiteboard at the end of a project day, everything learned staying in the room → telling somebody → dated articles anyone can read", + "act": "bell", + "actSource": "was sonst gerade bei codebar passiert", + "objects": { + "whiteboard": "Einblicke aus unserem Alltag: was wir bauen, was wir dabei lernen", + "article rows": "Aktuelle News, Fachbeiträge und Trends rund um Softwareentwicklung", + "date pills": "Veröffentlicht am" + } + }, + "pages/jobs.index": { + "sentence": "a newcomer kept at the edge of the project, watching → putting the work on a board → one person's card crossing every role, from the customer conversation to the code", + "act": "board", + "actSource": "Jede:r deckt mehrere Rollen ab, vom Kundengespräch bis zum Code.", + "objects": { + "onlooker card": "mitten im Projektalltag statt am Rand", + "project panel": "übernimmt vom ersten Tag Verantwortung in echten Projekten", + "role board": "Jede:r deckt mehrere Rollen ab, vom Kundengespräch bis zum Code", + "first day check": "Wer bei uns anfängt, übernimmt vom ersten Tag Verantwortung" + } + }, + "pages/co-working.index": { + "sentence": "a laptop on the kitchen table at home → moving the workplace → an equipped desk on a fast line, with a team around it", + "act": "transfer", + "actSource": "einen ruhigen, professionellen Arbeitsplatz ohne eigenes Büro suchen", + "objects": { + "home laptop": "Ideal für Freelancer, Start-ups und Remote-Mitarbeitende", + "kitchen table": "ohne eigenes Büro", + "equipped desk": "vollausgestattete Arbeitsplätze in professionellem Co-Working-Ambiente in Oberwil", + "network line": "250 Mbit/s Private Virtual Network", + "team desks": "mitten in einem echten Tech-Team" + } + }, + "pages/contact.index": { + "sentence": "a question written down with no address on it → calling → a named contact person and two places you can walk into", + "act": "call", + "actSource": "So erreichst du uns", + "objects": { + "unaddressed note": "Du hast eine Idee, ein Projekt oder einfach eine Frage?", + "contact person card": "Deine Ansprechperson", + "two address cards": "Telefon, E-Mail und unsere zwei Standorte in der Region Basel." + } + }, + "pages/network.index": { + "sentence": "partners listed apart with nothing between them → wiring them together → partners on one shared spine, with the labels behind them and the community under it", + "act": "nodes", + "actSource": "Gute Software entsteht nicht im Alleingang.", + "objects": { + "partner tiles": "Projekt-, Software- und Infrastrukturpartner", + "shared spine": "Unser Netzwerk lebt vom Open-Source-Gedanken und von echten Partnerschaften.", + "tier badges": "und die Labels, die dahinterstehen", + "community group": "unser Engagement in der Community" + } + }, + "pages/legal.imprint.index": { + "sentence": "a company you only know by the name on its website → looking it up → the register behind it: legal form, UID, and the people who sign", + "act": "magnifier", + "actSource": "Rechtliche Angaben zur codebar Solutions AG", + "objects": { + "site tile": "Alle Inhalte dieser Website wurden mit Sorgfalt erstellt", + "register rows": "Rechtsform, UID-Nummer, Handelsregistereintrag", + "role chips": "Präsident des Verwaltungsrates, Geschäftsführer", + "signature card": "Einzelunterschrift" + } + }, + "pages/legal.privacy.index": { + "sentence": "data that simply accumulates and disappears into a closed box → disclosing it → every category with its purpose, its retention period and your rights", + "act": "disclose", + "actSource": "Beim Besuch dieser Website verarbeiten wir folgende Datenkategorien", + "objects": { + "data cards": "Server-Logdaten (IP-Adresse, Datum und Uhrzeit des Zugriffs, Browsertyp, besuchte Seiten)", + "processing box": "Wir verarbeiten diese Daten, um die Website zu betreiben und abzusichern", + "retention pills": "Server-Logs werden bis zu 90 Tage aufbewahrt.", + "rights card": "hast du das Recht auf Auskunft, Berichtigung unrichtiger Daten" + } + }, + "pages/legal.terms.index": { + "sentence": "an offer and a project with everything between them left unsaid → agreeing it → what is delivered, what it costs and who is liable, signed", + "act": "agree", + "actSource": "wenn der Kunde das Angebot schriftlich bestätigt oder digital signiert", + "objects": { + "offer card": "Offerten sind 30 Tage gültig", + "project card": "Dienstleistungen & Projektabwicklung", + "section blocks": "Nutzungsrechte & geistiges Eigentum", + "signature line": "Die Abnahme erfolgt formal durch Abnahmeprotokoll" + } + }, + "pages/media.index": { + "sentence": "a mark lifted off a screenshot at the wrong size → handing over the files → the official marks in every variant, light and dark", + "act": "download", + "actSource": "Offizielle codebar-Logos zum Download für Presse, Partner und Publikationen", + "objects": { + "screenshot tile": "Nicht in Ordnung sind Verzerrungen, eigene Farbgebungen, Effekte", + "logo files": "als PNG und SVG, hell wie dunkel", + "dark variant": "die farbige Variante auf hellem Grund, die invertierte auf dunklem", + "format chips": "als PNG und SVG" + } + }, + "pages/network.request.index": { + "sentence": "a partner profile only we could change → handing over the controls → a personal link that opens it as a form the partner edits themselves", + "act": "cursor", + "actSource": "Persönlichen Link anfordern, um das eigene Profil im codebar Netzwerk zu aktualisieren.", + "objects": { + "locked profile card": "das eigene Profil im codebar Netzwerk zu aktualisieren", + "link note": "Falls die E-Mail-Adresse registriert ist, haben wir dir einen Link gesendet.", + "editable fields": "Aktualisiere dein codebar Netzwerk-Profil", + "save button": "Netzwerk-Profil aktualisieren" + } + } +} diff --git a/public/images/news/bausteine-styleguide-card.svg b/public/images/news/bausteine-styleguide-card.svg new file mode 100644 index 0000000..a0ed253 --- /dev/null +++ b/public/images/news/bausteine-styleguide-card.svg @@ -0,0 +1,75 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -10 344 344" width="344" height="344" fill="none" role="img" aria-hidden="true"> + <defs> + <linearGradient id="accent" x1="0" y1="0" x2="1" y2="1"> + <stop offset="0" stop-color="#C026D3"/> + <stop offset="1" stop-color="#2563EB"/> + </linearGradient> + + <path id="s1" d="M0 0c1.75-3 3.75-3 5.5 0s3.75 3 5.5 0"/> + <path id="s2" d="M0 0c1.75-3 3.75-3 5.5 0s3.75 3 5.5 0s3.75-3 5.5 0s3.75 3 5.5 0"/> + <path id="s3" d="M0 0c1.75-3 3.75-3 5.5 0s3.75 3 5.5 0s3.75-3 5.5 0s3.75 3 5.5 0s3.75-3 5.5 0s3.75 3 5.5 0"/> + </defs> + + <g transform="translate(14 16) rotate(-7 47 26)"> + <rect x="6" y="6" width="94" height="52" rx="7" fill="#09090b" opacity="0.3"/> + <rect width="94" height="52" rx="7" fill="#ffffff" stroke="#09090b" stroke-width="2.4" stroke-linejoin="round"/> + <rect x="10" y="12" width="5" height="28" rx="2.5" fill="#09090b"/> + <g stroke="#09090b" stroke-width="2.2" stroke-linecap="round" fill="none"> + <use href="#s3" x="24" y="22"/><use href="#s1" x="63" y="22"/> + <use href="#s2" x="24" y="38"/> + </g> + </g> + + <g transform="translate(164 8) rotate(6 60 38)"> + <rect x="6" y="6" width="120" height="76" rx="7" fill="#09090b" opacity="0.3"/> + <rect width="120" height="76" rx="7" fill="#ffffff" stroke="#09090b" stroke-width="2.4" stroke-linejoin="round"/> + <path d="M18 14v50" data-detail="block divider" stroke="#09090b" stroke-width="1.6" opacity="0.4"/> + <g fill="#09090b" opacity="0.55"> + <rect x="28" y="16" width="32" height="6" rx="3"/> + <rect x="36" y="30" width="24" height="6" rx="3"/> + <rect x="82" y="44" width="18" height="6" rx="3"/> + </g> + <g fill="#09090b" opacity="0.3"> + <rect x="66" y="16" width="20" height="6" rx="3"/> + <rect x="66" y="30" width="38" height="6" rx="3"/> + <rect x="36" y="44" width="40" height="6" rx="3"/> + <rect x="28" y="58" width="34" height="6" rx="3"/> + </g> + </g> + + <g transform="translate(88 54) rotate(5 52 33)"> + <rect x="6" y="6" width="104" height="66" rx="7" fill="#09090b" opacity="0.3"/> + <rect width="104" height="66" rx="7" fill="#ffffff" stroke="#09090b" stroke-width="2.4" stroke-linejoin="round"/> + <rect x="10" y="10" width="84" height="46" rx="5" fill="#ffffff" stroke="#09090b" stroke-width="1.6" stroke-linejoin="round"/> + <g stroke="#09090b" stroke-width="1.6" opacity="0.3"> + <path d="M10 10l84 46M94 10L10 56" data-detail="image placeholder cross"/> + </g> + </g> + + <g stroke="#09090b" stroke-width="2.2" fill="#09090b"> + <path d="M160 138v18" stroke-linecap="round"/> + <path d="M160 170l-9-14h18Z" stroke="none"/> + </g> + + <rect x="30" y="186" width="280" height="128" rx="11" fill="#09090b" opacity="0.3"/> + <rect x="20" y="176" width="280" height="128" rx="11" fill="#ffffff" stroke="#09090b" stroke-width="2.4" stroke-linejoin="round"/> + <path d="M20 202h280" data-detail="page header rule" stroke="#09090b" stroke-width="2.4"/> + <rect x="32" y="181" width="16" height="16" rx="5" fill="url(#accent)"/> + <g stroke="#09090b" stroke-width="2.2" stroke-linecap="round" fill="none"> + <use href="#s2" x="58" y="192"/><use href="#s1" x="86" y="192"/> + </g> + + <g stroke="#09090b" stroke-width="2.6" stroke-linecap="round" fill="none"> + <use href="#s3" x="32" y="222"/><use href="#s2" x="71" y="222"/><use href="#s3" x="99" y="222"/> + </g> + <g stroke="#09090b" stroke-width="2.2" stroke-linecap="round" fill="none"> + <use href="#s2" x="32" y="242"/><use href="#s3" x="60" y="242"/><use href="#s2" x="99" y="242"/><use href="#s3" x="127" y="242"/><use href="#s2" x="166" y="242"/> + <use href="#s3" x="32" y="256"/><use href="#s3" x="71" y="256"/><use href="#s2" x="110" y="256"/> + </g> + + <rect x="32" y="270" width="30" height="16" rx="4" fill="url(#accent)"/> + <rect x="70" y="270" width="30" height="16" rx="4" fill="#500472"/> + <rect x="108" y="270" width="30" height="16" rx="4" fill="#500472" opacity="0.45"/> + <rect x="146" y="270" width="30" height="16" rx="4" fill="#09090b" opacity="0.2"/> + <rect x="184" y="270" width="30" height="16" rx="4" fill="#ffffff" stroke="#09090b" stroke-width="1.6" stroke-linejoin="round"/> +</svg> diff --git a/public/images/news/bausteine-styleguide.png b/public/images/news/bausteine-styleguide.png new file mode 100644 index 0000000..a7159c3 Binary files /dev/null and b/public/images/news/bausteine-styleguide.png differ diff --git a/public/images/news/bausteine-styleguide.svg b/public/images/news/bausteine-styleguide.svg new file mode 100644 index 0000000..fe9fef5 --- /dev/null +++ b/public/images/news/bausteine-styleguide.svg @@ -0,0 +1,169 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 840" width="1600" height="840" fill="none" role="img" aria-labelledby="title-bausteine"> + <title id="title-bausteine">Einzeln herumliegende Inhaltsbausteine werden gesetzt und ergeben eine Seite aus genau denselben Bausteinen, in Reihenfolge + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/news/docuware-7-12-is-here-card.svg b/public/images/news/docuware-7-12-is-here-card.svg new file mode 100644 index 0000000..bee4ed4 --- /dev/null +++ b/public/images/news/docuware-7-12-is-here-card.svg @@ -0,0 +1,71 @@ + diff --git a/public/images/news/docuware-7-12-is-here.png b/public/images/news/docuware-7-12-is-here.png new file mode 100644 index 0000000..d41403e Binary files /dev/null and b/public/images/news/docuware-7-12-is-here.png differ diff --git a/public/images/news/docuware-7-12-is-here.svg b/public/images/news/docuware-7-12-is-here.svg new file mode 100644 index 0000000..d5e90b1 --- /dev/null +++ b/public/images/news/docuware-7-12-is-here.svg @@ -0,0 +1,114 @@ + + Eine E-Rechnung kommt als Maschinendatei im Mailanhang an, die niemand liest, wird ausgelesen und steht danach als Positionszeilen in einer Tabelle und als Auswertung + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/news/docuware-7-13-is-here-card.svg b/public/images/news/docuware-7-13-is-here-card.svg new file mode 100644 index 0000000..76d4af9 --- /dev/null +++ b/public/images/news/docuware-7-13-is-here-card.svg @@ -0,0 +1,59 @@ + diff --git a/public/images/news/docuware-7-13-is-here.png b/public/images/news/docuware-7-13-is-here.png new file mode 100644 index 0000000..4bdc7f3 Binary files /dev/null and b/public/images/news/docuware-7-13-is-here.png differ diff --git a/public/images/news/docuware-7-13-is-here.svg b/public/images/news/docuware-7-13-is-here.svg new file mode 100644 index 0000000..ed8860b --- /dev/null +++ b/public/images/news/docuware-7-13-is-here.svg @@ -0,0 +1,123 @@ + + Der Workflow Designer musste als Programm installiert sein und ist in den Browser gezogen: Prozesse entstehen in einem Tab, mit einer Verzweigung für den Ausnahmefall + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/news/docuware-7-14-is-here-card.svg b/public/images/news/docuware-7-14-is-here-card.svg new file mode 100644 index 0000000..a44f532 --- /dev/null +++ b/public/images/news/docuware-7-14-is-here-card.svg @@ -0,0 +1,66 @@ + diff --git a/public/images/news/docuware-7-14-is-here.png b/public/images/news/docuware-7-14-is-here.png new file mode 100644 index 0000000..fd5c769 Binary files /dev/null and b/public/images/news/docuware-7-14-is-here.png differ diff --git a/public/images/news/docuware-7-14-is-here.svg b/public/images/news/docuware-7-14-is-here.svg new file mode 100644 index 0000000..275d65e --- /dev/null +++ b/public/images/news/docuware-7-14-is-here.svg @@ -0,0 +1,125 @@ + + Freigaben lagen tagelang unangetastet im Postfach, neu liegen die Aufgaben nach Prozess gruppiert auf dem Telefon und melden sich von selbst + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/news/llm-gateway-open-source-card.svg b/public/images/news/llm-gateway-open-source-card.svg new file mode 100644 index 0000000..64fc260 --- /dev/null +++ b/public/images/news/llm-gateway-open-source-card.svg @@ -0,0 +1,89 @@ + diff --git a/public/images/news/llm-gateway-open-source.png b/public/images/news/llm-gateway-open-source.png new file mode 100644 index 0000000..d04b440 Binary files /dev/null and b/public/images/news/llm-gateway-open-source.png differ diff --git a/public/images/news/llm-gateway-open-source.svg b/public/images/news/llm-gateway-open-source.svg new file mode 100644 index 0000000..2efa018 --- /dev/null +++ b/public/images/news/llm-gateway-open-source.svg @@ -0,0 +1,155 @@ + + Alle Anfragen zeigen auf dieselbe Maschine, deren Speicher bereits belegt ist; neu nimmt ein Gateway sie an, legt sie in eine Warteschlange und gibt sie der Reihe nach an das Modell weiter + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/news/placeholders/bausteine-styleguide-square.svg b/public/images/news/placeholders/bausteine-styleguide-square.svg new file mode 100644 index 0000000..f012ec2 --- /dev/null +++ b/public/images/news/placeholders/bausteine-styleguide-square.svg @@ -0,0 +1,60 @@ + diff --git a/public/images/news/placeholders/docuware-7-12-square.svg b/public/images/news/placeholders/docuware-7-12-square.svg new file mode 100644 index 0000000..93cfecb --- /dev/null +++ b/public/images/news/placeholders/docuware-7-12-square.svg @@ -0,0 +1,63 @@ + diff --git a/public/images/news/placeholders/docuware-7-13-square.svg b/public/images/news/placeholders/docuware-7-13-square.svg new file mode 100644 index 0000000..fb6a413 --- /dev/null +++ b/public/images/news/placeholders/docuware-7-13-square.svg @@ -0,0 +1,78 @@ + diff --git a/public/images/news/placeholders/docuware-7-14-square.svg b/public/images/news/placeholders/docuware-7-14-square.svg new file mode 100644 index 0000000..a70ef56 --- /dev/null +++ b/public/images/news/placeholders/docuware-7-14-square.svg @@ -0,0 +1,73 @@ + diff --git a/public/images/news/placeholders/llm-gateway-open-source-square.svg b/public/images/news/placeholders/llm-gateway-open-source-square.svg new file mode 100644 index 0000000..1e471c4 --- /dev/null +++ b/public/images/news/placeholders/llm-gateway-open-source-square.svg @@ -0,0 +1,83 @@ + diff --git a/public/images/pages/about-us.index.png b/public/images/pages/about-us.index.png new file mode 100644 index 0000000..2d8def0 Binary files /dev/null and b/public/images/pages/about-us.index.png differ diff --git a/public/images/pages/about-us.index.svg b/public/images/pages/about-us.index.svg new file mode 100644 index 0000000..fd49ae5 --- /dev/null +++ b/public/images/pages/about-us.index.svg @@ -0,0 +1,67 @@ + + Zwischen dir und den Menschen, die deine Lösung bauen, liegt keine Zwischenebene + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/ai.index.png b/public/images/pages/ai.index.png new file mode 100644 index 0000000..8e28e25 Binary files /dev/null and b/public/images/pages/ai.index.png differ diff --git a/public/images/pages/ai.index.svg b/public/images/pages/ai.index.svg new file mode 100644 index 0000000..861317a --- /dev/null +++ b/public/images/pages/ai.index.svg @@ -0,0 +1,72 @@ + + Kundendaten verlassen die eigene Infrastruktur nicht mehr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/ai.llm.analytics.index.png b/public/images/pages/ai.llm.analytics.index.png new file mode 100644 index 0000000..8f7f5d6 Binary files /dev/null and b/public/images/pages/ai.llm.analytics.index.png differ diff --git a/public/images/pages/ai.llm.analytics.index.svg b/public/images/pages/ai.llm.analytics.index.svg new file mode 100644 index 0000000..6fbd06c --- /dev/null +++ b/public/images/pages/ai.llm.analytics.index.svg @@ -0,0 +1,79 @@ + + Anfragen und Tokens werden gezählt und pro Monat und Modell sichtbar + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/ai.llm.index.png b/public/images/pages/ai.llm.index.png new file mode 100644 index 0000000..eb9155c Binary files /dev/null and b/public/images/pages/ai.llm.index.png differ diff --git a/public/images/pages/ai.llm.index.svg b/public/images/pages/ai.llm.index.svg new file mode 100644 index 0000000..78aec05 --- /dev/null +++ b/public/images/pages/ai.llm.index.svg @@ -0,0 +1,76 @@ + + Die Modelle laufen auf eigener Hardware im hauseigenen Bürokeller + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/co-working.index.png b/public/images/pages/co-working.index.png new file mode 100644 index 0000000..4a7ebdc Binary files /dev/null and b/public/images/pages/co-working.index.png differ diff --git a/public/images/pages/co-working.index.svg b/public/images/pages/co-working.index.svg new file mode 100644 index 0000000..040b4a1 --- /dev/null +++ b/public/images/pages/co-working.index.svg @@ -0,0 +1,72 @@ + + Der Arbeitsplatz zieht vom Küchentisch an einen ausgestatteten Platz im Team + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/contact.index.png b/public/images/pages/contact.index.png new file mode 100644 index 0000000..0e85ec5 Binary files /dev/null and b/public/images/pages/contact.index.png differ diff --git a/public/images/pages/contact.index.svg b/public/images/pages/contact.index.svg new file mode 100644 index 0000000..afbf815 --- /dev/null +++ b/public/images/pages/contact.index.svg @@ -0,0 +1,68 @@ + + Aus einer Frage ohne Adresse wird eine Nummer, eine Ansprechperson und zwei Standorte + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/jobs.index.png b/public/images/pages/jobs.index.png new file mode 100644 index 0000000..6a91c9e Binary files /dev/null and b/public/images/pages/jobs.index.png differ diff --git a/public/images/pages/jobs.index.svg b/public/images/pages/jobs.index.svg new file mode 100644 index 0000000..fa41987 --- /dev/null +++ b/public/images/pages/jobs.index.svg @@ -0,0 +1,81 @@ + + Wer anfängt, arbeitet nicht am Rand mit, sondern übernimmt vom ersten Tag eine Rolle im Projekt + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/legal.imprint.index.png b/public/images/pages/legal.imprint.index.png new file mode 100644 index 0000000..7f77389 Binary files /dev/null and b/public/images/pages/legal.imprint.index.png differ diff --git a/public/images/pages/legal.imprint.index.svg b/public/images/pages/legal.imprint.index.svg new file mode 100644 index 0000000..25661cb --- /dev/null +++ b/public/images/pages/legal.imprint.index.svg @@ -0,0 +1,75 @@ + + Hinter dem Namen auf der Website werden Rechtsform, Registereintrag und Zeichnungsberechtigte sichtbar + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/legal.privacy.index.png b/public/images/pages/legal.privacy.index.png new file mode 100644 index 0000000..146d1b2 Binary files /dev/null and b/public/images/pages/legal.privacy.index.png differ diff --git a/public/images/pages/legal.privacy.index.svg b/public/images/pages/legal.privacy.index.svg new file mode 100644 index 0000000..3c72520 --- /dev/null +++ b/public/images/pages/legal.privacy.index.svg @@ -0,0 +1,77 @@ + + Aus Daten, die einfach anfallen, wird eine Liste mit Zweck, Aufbewahrungsdauer und deinen Rechten + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/legal.terms.index.png b/public/images/pages/legal.terms.index.png new file mode 100644 index 0000000..1d3e62a Binary files /dev/null and b/public/images/pages/legal.terms.index.png differ diff --git a/public/images/pages/legal.terms.index.svg b/public/images/pages/legal.terms.index.svg new file mode 100644 index 0000000..d6d9bd0 --- /dev/null +++ b/public/images/pages/legal.terms.index.svg @@ -0,0 +1,73 @@ + + Was zwischen Angebot und Projekt ungesagt bleibt, steht danach fest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/media.index.png b/public/images/pages/media.index.png new file mode 100644 index 0000000..1988d40 Binary files /dev/null and b/public/images/pages/media.index.png differ diff --git a/public/images/pages/media.index.svg b/public/images/pages/media.index.svg new file mode 100644 index 0000000..d4418c1 --- /dev/null +++ b/public/images/pages/media.index.svg @@ -0,0 +1,71 @@ + + Statt eines Logos aus dem Screenshot gibt es die offiziellen Dateien in jeder Variante + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/network.index.png b/public/images/pages/network.index.png new file mode 100644 index 0000000..f61c711 Binary files /dev/null and b/public/images/pages/network.index.png differ diff --git a/public/images/pages/network.index.svg b/public/images/pages/network.index.svg new file mode 100644 index 0000000..bfd7452 --- /dev/null +++ b/public/images/pages/network.index.svg @@ -0,0 +1,77 @@ + + Aus einzeln aufgelisteten Partnern wird ein Netzwerk, das zusammenhängt + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/network.request.index.png b/public/images/pages/network.request.index.png new file mode 100644 index 0000000..e1812c4 Binary files /dev/null and b/public/images/pages/network.request.index.png differ diff --git a/public/images/pages/network.request.index.svg b/public/images/pages/network.request.index.svg new file mode 100644 index 0000000..e32a598 --- /dev/null +++ b/public/images/pages/network.request.index.svg @@ -0,0 +1,66 @@ + + Das eigene Netzwerk-Profil wird über einen persönlichen Link selbst gepflegt + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/news.index.png b/public/images/pages/news.index.png new file mode 100644 index 0000000..51cab4a Binary files /dev/null and b/public/images/pages/news.index.png differ diff --git a/public/images/pages/news.index.svg b/public/images/pages/news.index.svg new file mode 100644 index 0000000..a8404ae --- /dev/null +++ b/public/images/pages/news.index.svg @@ -0,0 +1,66 @@ + + Was im Alltag gelernt wird, steht danach als Beitrag mit Datum zum Nachlesen bereit + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/open-source.index.png b/public/images/pages/open-source.index.png new file mode 100644 index 0000000..a946f7c Binary files /dev/null and b/public/images/pages/open-source.index.png differ diff --git a/public/images/pages/open-source.index.svg b/public/images/pages/open-source.index.svg new file mode 100644 index 0000000..1ce6b32 --- /dev/null +++ b/public/images/pages/open-source.index.svg @@ -0,0 +1,68 @@ + + Ein Package aus dem eigenen Repository wird öffentlich und andere bauen darauf weiter + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/products.index.png b/public/images/pages/products.index.png new file mode 100644 index 0000000..ea449a7 Binary files /dev/null and b/public/images/pages/products.index.png differ diff --git a/public/images/pages/products.index.svg b/public/images/pages/products.index.svg new file mode 100644 index 0000000..8401633 --- /dev/null +++ b/public/images/pages/products.index.svg @@ -0,0 +1,64 @@ + + Aus einer Lösung für ein Projekt wird ein Produkt, das täglich im Einsatz ist + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/services.index.png b/public/images/pages/services.index.png new file mode 100644 index 0000000..6136cc1 Binary files /dev/null and b/public/images/pages/services.index.png differ diff --git a/public/images/pages/services.index.svg b/public/images/pages/services.index.svg new file mode 100644 index 0000000..2080eea --- /dev/null +++ b/public/images/pages/services.index.svg @@ -0,0 +1,81 @@ + + Vier Bereiche, die einzeln herumliegen, werden zu einem Weg von der Idee bis zum Betrieb + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/start.index.png b/public/images/pages/start.index.png new file mode 100644 index 0000000..8ab69cb Binary files /dev/null and b/public/images/pages/start.index.png differ diff --git a/public/images/pages/start.index.svg b/public/images/pages/start.index.svg new file mode 100644 index 0000000..565875c --- /dev/null +++ b/public/images/pages/start.index.svg @@ -0,0 +1,68 @@ + + Aus einer ersten Skizze wird Software, die täglich im Einsatz ist + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/pages/technologies.index.png b/public/images/pages/technologies.index.png new file mode 100644 index 0000000..3d766c3 Binary files /dev/null and b/public/images/pages/technologies.index.png differ diff --git a/public/images/pages/technologies.index.svg b/public/images/pages/technologies.index.svg new file mode 100644 index 0000000..157dc20 --- /dev/null +++ b/public/images/pages/technologies.index.svg @@ -0,0 +1,84 @@ + + Aus einem Regal voller Werkzeuge bleiben die wenigen, die wir wirklich beherrschen + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/services/dms-ecm-consulting-card.svg b/public/images/services/dms-ecm-consulting-card.svg new file mode 100644 index 0000000..a44170b --- /dev/null +++ b/public/images/services/dms-ecm-consulting-card.svg @@ -0,0 +1,69 @@ + diff --git a/public/images/services/dms-ecm-consulting.png b/public/images/services/dms-ecm-consulting.png new file mode 100644 index 0000000..1598232 Binary files /dev/null and b/public/images/services/dms-ecm-consulting.png differ diff --git a/public/images/services/dms-ecm-consulting.svg b/public/images/services/dms-ecm-consulting.svg new file mode 100644 index 0000000..92ce4fe --- /dev/null +++ b/public/images/services/dms-ecm-consulting.svg @@ -0,0 +1,139 @@ + + Papier wird erfasst, indexiert und läuft danach automatisch durch Prüfung, Freigabe und Archiv + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/services/individuelle-softwareentwicklung-card.svg b/public/images/services/individuelle-softwareentwicklung-card.svg new file mode 100644 index 0000000..505cd4d --- /dev/null +++ b/public/images/services/individuelle-softwareentwicklung-card.svg @@ -0,0 +1,73 @@ + diff --git a/public/images/services/individuelle-softwareentwicklung.png b/public/images/services/individuelle-softwareentwicklung.png new file mode 100644 index 0000000..142691d Binary files /dev/null and b/public/images/services/individuelle-softwareentwicklung.png differ diff --git a/public/images/services/individuelle-softwareentwicklung.svg b/public/images/services/individuelle-softwareentwicklung.svg new file mode 100644 index 0000000..960dde2 --- /dev/null +++ b/public/images/services/individuelle-softwareentwicklung.svg @@ -0,0 +1,142 @@ + + Getrennte Systeme werden über Schnittstellen und eigenen Code zu einem Ganzen verbunden + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/services/konzeption-prototyping-card.svg b/public/images/services/konzeption-prototyping-card.svg new file mode 100644 index 0000000..921375e --- /dev/null +++ b/public/images/services/konzeption-prototyping-card.svg @@ -0,0 +1,56 @@ + diff --git a/public/images/services/konzeption-prototyping.png b/public/images/services/konzeption-prototyping.png new file mode 100644 index 0000000..9c6836b Binary files /dev/null and b/public/images/services/konzeption-prototyping.png differ diff --git a/public/images/services/konzeption-prototyping.svg b/public/images/services/konzeption-prototyping.svg new file mode 100644 index 0000000..c70974a --- /dev/null +++ b/public/images/services/konzeption-prototyping.svg @@ -0,0 +1,139 @@ + + Ein skizzierter Screenflow wird zum klickbaren Prototyp auf Desktop und Mobile + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/services/open-source-erp-card.svg b/public/images/services/open-source-erp-card.svg new file mode 100644 index 0000000..e1025ec --- /dev/null +++ b/public/images/services/open-source-erp-card.svg @@ -0,0 +1,79 @@ + diff --git a/public/images/services/open-source-erp.png b/public/images/services/open-source-erp.png new file mode 100644 index 0000000..b2b63cb Binary files /dev/null and b/public/images/services/open-source-erp.png differ diff --git a/public/images/services/open-source-erp.svg b/public/images/services/open-source-erp.svg new file mode 100644 index 0000000..a8fb76f --- /dev/null +++ b/public/images/services/open-source-erp.svg @@ -0,0 +1,128 @@ + + Eine Aufgabenliste und eine handgestoppte Zeit werden zu Projektmanagement und Zeiterfassung in einem ERP + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/social/linkedin/llm-gateway-open-source.png b/public/images/social/linkedin/llm-gateway-open-source.png new file mode 100644 index 0000000..701e137 Binary files /dev/null and b/public/images/social/linkedin/llm-gateway-open-source.png differ diff --git a/public/images/social/linkedin/llm-gateway-open-source.svg b/public/images/social/linkedin/llm-gateway-open-source.svg new file mode 100644 index 0000000..776e382 --- /dev/null +++ b/public/images/social/linkedin/llm-gateway-open-source.svg @@ -0,0 +1,183 @@ + + KI im eigenen Serverraum: wenn zu viele Anfragen auf einmal kommen — Anfragen laufen neu über eine Queue, die ein Gateway der Reihe nach an das Modell weitergibt + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KI im eigenen Serverraum: + wenn zu viele Anfragen + auf einmal kommen + + + + + + + + + + + + + + + + + + diff --git a/reports/frontend.md b/reports/frontend.md new file mode 100644 index 0000000..e3c8037 --- /dev/null +++ b/reports/frontend.md @@ -0,0 +1,404 @@ +# Frontend-Audit — Phase 1 + +**Stand:** 2026-08-01, Branch `feature-updates` (Working Tree, nicht Commit — die Dateien wurden während des Audits aktiv bearbeitet) +**Scope:** `resources/views/`, `resources/css/app.css`, `resources/js/app.js` +**Es wurde kein Code geschrieben.** + +> **Umgesetzt am 2026-08-01** — siehe [Kapitel 10](#10-umsetzung). Alle Befunde sind abgearbeitet +> bis auf 3.8 (person-card / network-user-card), das bewusst offen bleibt. Bundle 119 543 → 81 179 B +> (gzip 19 635 → 13 959 B), Dark-Mode-Regeln 14 → 0, 390 Tests grün. + +--- + +## 0. Vorbemerkung — was dieses Audit *nicht* gefunden hat + +Der Prompt geht von einem Frontend aus, das noch kein Designsystem hat. Das trifft hier nicht zu, und das ändert die Prioritäten deutlich. Verifiziert: + +| Erwartete Baustelle | Realität | +|---|---| +| Kein/mehrere Layout-Shells | **Ein** Shell (`layouts/app.blade.php`), 27 von 27 Seiten nutzen ihn | +| `tailwind.config.js` vs. CSS-first | CSS-first, `@theme` mit 30 Tokens, keine JS-Config | +| Magic Numbers / arbitrary values | **11** im Produktivcode — alle begründet (`aspect-[16/9]`, `grid-cols-[12rem_1fr]`, …) | +| Button/Input/Card ad-hoc | Existieren als Komponenten mit Lookup-Arrays, exakt im geforderten Stil | +| Fehlender Viewport-Tag | Vorhanden, `layouts/app.blade.php:8` | +| Fehlendes `cursor-pointer` (v4-Preflight) | Global restauriert, `app.css:121-131` — inkl. Checkbox/Radio/Select/File | +| Uneinheitliche Control-Höhen | `--spacing-control: 2.75rem` (44 px), von Input, Button, File, Badge-Link geteilt | +| Fehlende Breadcrumbs/`aria-current` | `x-breadcrumbs` mit `

+{{-- mb-4, like h2 and h3: the gap under a heading is one number, and the element below + adds nothing to it. The page header used to leave 12px above its lead while the + article page left 16px, because the article added an mt-4 of its own. --}} +

merge(['class' => 'mb-4 text-display font-bold text-balance text-gray-900']) }}>{{ $title }}

diff --git a/resources/views/components/h2.blade.php b/resources/views/components/h2.blade.php index bad15c5..4b5a597 100644 --- a/resources/views/components/h2.blade.php +++ b/resources/views/components/h2.blade.php @@ -1,3 +1,11 @@ @props(['title']) -

merge(['class' => 'mb-2 text-heading font-semibold text-balance']) }}>{{ $title }}

+{{-- One colour for every heading, a step darker than the body's gray-800. h1, h2, h3 and + the article's own headings in .news-prose all sit on gray-900; they used to sit on + three different values. + + mb-4, and the call site adds nothing. The gap under a heading was mb-2 here plus + whatever the next element brought: nothing on the legal pages (8px), mt-2 on the + about-us grids (8px), mt-4 on media, explore, next-page and the news lists (16px). One + number here means a heading binds to its content the same way on every page. --}} +

merge(['class' => 'mb-4 text-heading font-semibold text-balance text-gray-900']) }}>{{ $title }}

diff --git a/resources/views/components/h3.blade.php b/resources/views/components/h3.blade.php index d8face7..af5a5e0 100644 --- a/resources/views/components/h3.blade.php +++ b/resources/views/components/h3.blade.php @@ -1,3 +1,6 @@ @props(['title']) -

merge(['class' => 'mb-3 text-subheading font-bold tracking-tight text-gray-950']) }}>{{ $title }}

+{{-- semibold, not bold: h2 above it is semibold, and a rung cannot be heavier than the one + it hangs under. No tracking either — --text-subheading deliberately carries none, and + this was the only heading on the site overriding its own token. --}} +

merge(['class' => 'mb-4 text-subheading font-semibold text-balance text-gray-900']) }}>{{ $title }}

diff --git a/resources/views/components/illustration-row.blade.php b/resources/views/components/illustration-row.blade.php new file mode 100644 index 0000000..1963bd5 --- /dev/null +++ b/resources/views/components/illustration-row.blade.php @@ -0,0 +1,60 @@ +@props([ + 'illustration' => null, + 'side' => 'right', +]) + +@php + $left = $side === 'left'; +@endphp + +{{-- The one row with a drawing beside it: /dienstleistungen and every news list render + this, so the size of the drawing, how far it sits outside the frame, the gap it leaves + the text and the rhythm between rows are decided once. They used to be decided twice, + and the two pages drifted 8 px apart on the drawing and a whole breakpoint on the + padding. + + What a caller brings is the drawing and which side it is on. Everything else is here. + + xl and up only. The drawing exists to use the empty outer margin the 60rem frame leaves + on a wide screen; below that width there is no margin to break into, and squeezing it + into the text column leaves the column too narrow to read. So a row is text alone until + there is room to do it properly. --}} +{{-- illustration-row carries the vertical rhythm — and the row keeps the class with or + without a drawing, so a list of rows spaces the same either way. See app.css, which + also explains why the padding is not in this class string. --}} +
merge(['class' => 'illustration-row relative']) }}> + {{-- pr and pl are deliberately different numbers, and both are derived from the + drawing: it is 168 px wide and sits 128 px past the text column, so it reaches 40 px + back in and the padding has to clear that. They differ because what a reader sees is + the gap from the last pixel of text, and a ragged right edge stops well short of its + own column while a left edge starts flush against it — equal padding would not read + as an equal gap. Resize the drawing and both numbers move with it. --}} +
$illustration && ! $left, + 'xl:pl-18' => $illustration && $left, + ])> + {{ $slot }} +
+ + {{-- 32, not 24: the drawing belongs 96 px outside the page frame, and the row it hangs + off ends at the text column — a lg gutter, 32 px, further in. The services list used + to buy that gutter back with a negative-margin wrapper; measuring from the column + instead puts the drawing in exactly the same place without one, and leaves the news + list's divider lines where they belong. The drawing only exists from xl, where the + gutter is always lg, so the one number holds. + + Position sits on the wrapper and the hover tilt on the image: the centring here is a + transform too, and one would cancel the other if they shared an element. --}} + @if($illustration) + ! $left, + 'xl:-left-32' => $left, + ])> + + + @endif +
diff --git a/resources/views/components/intro.blade.php b/resources/views/components/intro.blade.php index 5bdbf3d..9112995 100644 --- a/resources/views/components/intro.blade.php +++ b/resources/views/components/intro.blade.php @@ -1,27 +1,111 @@ @php - $sections = ['who_we_are', 'what_we_do', 'how_we_work']; + $sections = [ + ['key' => 'start', 'command' => null, 'next' => null], + ['key' => 'who_we_are', 'command' => __('components.intro.who_we_are.command'), 'next' => 2], + ['key' => 'what_we_do', 'command' => __('components.intro.what_we_do.command'), 'next' => 3], + ['key' => 'how_we_work', 'command' => __('components.intro.how_we_work.command'), 'next' => null], + ]; + + $cap = 'inline-flex min-w-5 items-center justify-center rounded-[3px] border border-gray-400 bg-gray-50 px-1 py-0.5 font-mono text-[0.6875rem] leading-none text-gray-700'; + $pill = 'inline-flex min-h-control cursor-pointer items-center gap-2 rounded-pill px-4 text-sm font-medium transition select-none'; @endphp - - -@foreach ($sections as $key) - - - {{-- A section's text may be a single paragraph or a list of them; markup like comes from our own lang files. --}} - @foreach (\Illuminate\Support\Arr::wrap(__('components.intro.' . $key . '.text')) as $paragraph) -

! $loop->first])>{!! $paragraph !!}

- @endforeach - - @php $items = __('components.intro.' . $key . '.items'); @endphp - @if (is_array($items)) -
    - @foreach ($items as $item) -
  • - - {!! $item !!} -
  • - @endforeach -
- @endif -
-@endforeach + + + +
+ {{ __('components.intro.legend') }} +

{{ __('components.intro.shortcuts') }}

+ +
+ + + + + {{ __('components.intro.window') }} {{ config('company.legal_name') }} + +
+ + + +
+ @foreach(array_slice($sections, 1) as $section) + + @endforeach + + +
+ +
+ @foreach($sections as $section) +
+
+ @if($section['key'] === 'start') +
    + @foreach(array_slice($sections, 1) as $target) +
  • + +
  • + @endforeach +
+ @else +

{{ __('components.intro.'.$section['key'].'.title') }}

+ + @foreach (\Illuminate\Support\Arr::wrap(__('components.intro.'.$section['key'].'.text')) as $paragraph) +

! $loop->first])>{!! $paragraph !!}

+ @endforeach + + @php($items = __('components.intro.'.$section['key'].'.items')) + @if (is_array($items)) +
    + @foreach ($items as $item) +
  • + + {!! $item !!} +
  • + @endforeach +
+ @endif + @endif + + @unless($section['key'] === 'start') +

+ @if($section['next'] !== null) + + @else + + + {{ __('components.intro.cta') }} + + @endif +

+ @endunless +
+
+ @endforeach +
+
+
diff --git a/resources/views/components/layout/lead.blade.php b/resources/views/components/layout/lead.blade.php new file mode 100644 index 0000000..3c9566e --- /dev/null +++ b/resources/views/components/layout/lead.blade.php @@ -0,0 +1,5 @@ +{{-- One lead treatment for the whole site: the paragraph directly under a page title. + Detail pages used to set this semibold and index pages light, so the same slot read + as two different things depending on where you had come from — and the news article + kept its own copy of the class string, which is how a shared treatment drifts. --}} +

merge(['class' => 'max-w-3xl text-lead font-light text-gray-800']) }}>{{ $slot }}

diff --git a/resources/views/components/layout/page-header.blade.php b/resources/views/components/layout/page-header.blade.php index 876951d..afd30a7 100644 --- a/resources/views/components/layout/page-header.blade.php +++ b/resources/views/components/layout/page-header.blade.php @@ -1,18 +1,26 @@ -@props(['title', 'intro' => null, 'page' => null, 'breadcrumbs' => []]) +@props(['title', 'intro' => null, 'page' => null, 'breadcrumbs' => null]) @php - // Explicit intro wins; otherwise fall back to the page's SEO description - // so every page header carries context without duplicating copy. $context = $intro ?? $page?->description; + $trail = $breadcrumbs === null ? [['label' => $title]] : $breadcrumbs; @endphp - +
+
+ - + @isset($eyebrow) + {{ $eyebrow }} + @endisset -@if(filled($context)) - {{-- One lead treatment for the whole site. Detail pages used to set this - semibold and index pages light, so the same slot read as two different - things depending on where you had come from. --}} -

{{ $context }}

-@endif + + + @if(filled($context)) + {{ $context }} + @endif + + @isset($meta) +
{{ $meta }}
+ @endisset +
+
diff --git a/resources/views/components/layout/page-note.blade.php b/resources/views/components/layout/page-note.blade.php new file mode 100644 index 0000000..161a0ad --- /dev/null +++ b/resources/views/components/layout/page-note.blade.php @@ -0,0 +1 @@ +

merge(['class' => 'mt-4 text-sm text-muted']) }}>{{ $slot }}

diff --git a/resources/views/components/nav/link.blade.php b/resources/views/components/nav/link.blade.php index 9377ea3..83cf58a 100644 --- a/resources/views/components/nav/link.blade.php +++ b/resources/views/components/nav/link.blade.php @@ -3,22 +3,19 @@ @php use Illuminate\Support\Str; - // Route names are locale-prefixed (de-ch.services.show), so drop the prefix and - // compare sections: a detail page keeps its section lit — services.show still - // marks «Services» as the page you are on. $current = Str::after(request()->route()?->getName() ?? '', '.'); $isActive = filled($current) && Str::before($current, '.') === Str::before($route, '.'); $variants = [ 'desktop' => [ - 'base' => 'rounded-pill px-1 text-xl transition md:text-2xl focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand', + 'base' => 'rounded-pill px-1 text-xl transition md:text-2xl focus-ring', 'idle' => 'hover:text-brand', 'active' => 'font-semibold text-brand', ], 'mobile' => [ - 'base' => 'flex min-h-control items-center justify-center px-4 text-xl transition focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand', + 'base' => 'flex min-h-control items-center rounded-pill text-2xl transition focus-ring', 'idle' => 'text-gray-800 hover:text-brand', - 'active' => 'bg-brand/10 font-semibold text-brand', + 'active' => 'font-semibold text-brand', ], ]; diff --git a/resources/views/components/nav/locale-switch.blade.php b/resources/views/components/nav/locale-switch.blade.php index ced0b2c..f6abbb4 100644 --- a/resources/views/components/nav/locale-switch.blade.php +++ b/resources/views/components/nav/locale-switch.blade.php @@ -1,8 +1,5 @@ @props(['locales' => [], 'separator' => true]) -{{-- Real links, not a form: crawlers cannot submit forms, so a form would leave the - two language versions connected only by the hreflang tags in . - SetLanguage reads the locale from the URL and persists it, so no POST is needed. --}} @if(! empty($locales))
merge(['class' => 'flex items-center gap-2']) }}> @foreach($locales as $language) @@ -11,7 +8,7 @@ @if($language->value === app()->getLocale()) aria-current="true" @endif title="{{ __('Update to :lang language', ['lang' => $language->getLabel()]) }}" @class([ - 'grid min-h-control min-w-control place-items-center rounded-pill px-2 text-base transition focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand sm:min-h-0 sm:min-w-0', + 'grid tap-target min-w-control place-items-center rounded-pill px-2 text-base transition focus-ring sm:min-w-0', 'font-semibold text-brand' => $language->value === app()->getLocale(), 'text-gray-800 hover:text-brand' => $language->value !== app()->getLocale(), ])> diff --git a/resources/views/components/news/avatar.blade.php b/resources/views/components/news/avatar.blade.php index 5a440d4..b9805d0 100644 --- a/resources/views/components/news/avatar.blade.php +++ b/resources/views/components/news/avatar.blade.php @@ -1,4 +1,4 @@ -@props(['image', 'px' => 96]) +@props(['image', 'px' => 96, 'radius' => 'rounded-panel']) @php use App\Support\CloudinaryUrl; @@ -6,10 +6,14 @@ {{-- The author portrait, in the three places an article shows one: the card meta line, the byline under the hero and the box at the foot of the article. Only - the display size differs, and that comes in as a class. --}} + the display size differs, and that comes in as a class. + + Square with the panel radius, like the team and network cards: a person had two + different shapes depending on which page you met them on. The tiny meta-line + portrait takes a smaller radius — at 24px the panel radius is a circle again. --}} @if(filled($image)) merge(['class' => 'shrink-0 rounded-full bg-surface object-cover']) }}> + {{ $attributes->merge(['class' => 'shrink-0 '.$radius.' bg-surface object-cover']) }}> @endif diff --git a/resources/views/components/news/byline.blade.php b/resources/views/components/news/byline.blade.php deleted file mode 100644 index 0eafca4..0000000 --- a/resources/views/components/news/byline.blade.php +++ /dev/null @@ -1,20 +0,0 @@ -@props([ - 'authorName' => null, - 'authorRole' => null, - 'authorImage' => null, -]) - -{{-- Author only. Date and reading time live in the kicker line above the title — having - them here as well made this row read as a crowded two-column table. --}} -@if($authorName) -
merge(['class' => 'flex items-center gap-4']) }}> - - -
-

{{ $authorName }}

- @if($authorRole) -

{{ $authorRole }}

- @endif -
-
-@endif diff --git a/resources/views/components/news/card-meta.blade.php b/resources/views/components/news/card-meta.blade.php new file mode 100644 index 0000000..4ffa8bf --- /dev/null +++ b/resources/views/components/news/card-meta.blade.php @@ -0,0 +1,54 @@ +@props([ + 'topics' => [], + 'publishedAt' => null, + 'readingMinutes' => null, + 'authorName' => null, + 'authorImage' => null, +]) + +@php + $topics = collect($topics)->filter()->unique()->values(); +@endphp + +{{-- The tail every news card ends with — topics, byline, affordance. A row and the lead + banner differ above this point and agree below it, so it lives in one file. --}} +
+ {{-- The topics get their own row rather than riding along in the caption: an article + can carry several, and a wrapping chip list pushes the author and date around + inside their own line. They stay below the title, though — above it they read as + a second heading. --}} + @if($topics->isNotEmpty()) +
+ @foreach($topics as $topic) + + @endforeach +
+ @endif + + {{-- Every piece of metadata on one line. Splitting author and date above the title + from the reading time below it read as two unrelated captions. --}} +
+ @if($authorName) + + {{ $authorName }} + @endif + + @if($publishedAt) + @if($authorName)@endif + + @endif + + @if($readingMinutes) + @if($authorName || $publishedAt)@endif + {{ __(':count min read', ['count' => $readingMinutes]) }} + @endif +
+ + {{-- A span, not a link: the whole card already is one, and an anchor inside an anchor + is invalid. It is the same affordance every list row on the site carries — see + x-card.item-card-body, which words and animates it alike. --}} + + {{ __('Learn more') }} + + +
diff --git a/resources/views/components/news/card.blade.php b/resources/views/components/news/card.blade.php index baa3e04..173f218 100644 --- a/resources/views/components/news/card.blade.php +++ b/resources/views/components/news/card.blade.php @@ -2,85 +2,39 @@ 'url', 'title', 'teaser' => null, - 'image' => null, - 'kicker' => null, + 'thumb' => null, + 'topics' => [], 'publishedAt' => null, 'readingMinutes' => null, 'authorName' => null, 'authorImage' => null, 'level' => 2, - 'lead' => false, - 'compact' => false, + 'side' => 'right', ]) @php use App\Support\NewsImage; $heading = 'h'.$level; - $imageWidth = $lead ? 1280 : 640; -@endphp - -
merge(['class' => 'group']) }}> - - - {{-- Compact: the picture leads, as in the «continue reading» row. --}} - @if($compact && $src = NewsImage::src($image, $imageWidth)) - - @endif - - + <{{ $heading }} class="{{ $titleClass }} text-gray-900 transition group-hover:text-brand"> + {{ $title }} + - @if(! $lead && ! $compact && $src = NewsImage::src($image, $imageWidth)) - + @if($teaser) +

{{ $teaser }}

@endif + +
-
+ diff --git a/resources/views/components/news/latest.blade.php b/resources/views/components/news/latest.blade.php index bced39b..3a084df 100644 --- a/resources/views/components/news/latest.blade.php +++ b/resources/views/components/news/latest.blade.php @@ -2,15 +2,11 @@ @php $articles ??= collect(); - $urlFor = fn ($entry) => localized_route('news.show', ['locale' => app()->getLocale(), 'news' => $entry]); - // The same chip the news index puts on a card, so an article carries one topic - // across the site instead of a series title here and a tag list there. - $topicFor = fn ($entry) => array_filter([$entry->series?->title ?? (is_array($entry->tags) ? ($entry->tags[0] ?? null) : null)]); @endphp -{{-- Built from the components the start page already uses (x-layout.list + x-card.item-card), - not from the news index's card. The start page carries no imagery at all — a block with - a large lead picture read as a piece of a different site. --}} +{{-- The news index's own list, not a look-alike built from x-card.item-card: one component + means the two blocks cannot drift apart, down to which side the drawing sits on. + See prompts/illustration-news-card.md. --}} @if($articles->isNotEmpty()) {{-- No «read all» link here: the next-page card at the foot of the start page @@ -18,16 +14,6 @@ different destinations. --}} - - @foreach($articles as $entry) - - @endforeach - + @endif diff --git a/resources/views/components/news/lead.blade.php b/resources/views/components/news/lead.blade.php new file mode 100644 index 0000000..9896db7 --- /dev/null +++ b/resources/views/components/news/lead.blade.php @@ -0,0 +1,42 @@ +@props([ + 'url', + 'title', + 'teaser' => null, + 'image' => null, + 'topics' => [], + 'publishedAt' => null, + 'readingMinutes' => null, + 'authorName' => null, + 'authorImage' => null, + 'level' => 2, +]) + +@php + use App\Support\NewsImage; + + $heading = 'h'.$level; +@endphp + + diff --git a/resources/views/components/news/list.blade.php b/resources/views/components/news/list.blade.php new file mode 100644 index 0000000..8e88282 --- /dev/null +++ b/resources/views/components/news/list.blade.php @@ -0,0 +1,43 @@ +@props([ + 'articles' => null, + 'level' => 3, + 'rule' => false, +]) + +@php + $articles ??= collect(); + $urlFor = fn ($entry) => localized_route('news.show', ['locale' => app()->getLocale(), 'news' => $entry]); +@endphp + +{{-- The one news list on the site: the index below its lead, the start page, and + «Continue reading» at the foot of an article all render this. The alternating side of + the drawing lives here and nowhere else — three call sites juggling their own $loop is + how two of them ended up out of step in the first place. --}} +{{-- Every row keeps symmetric padding — the drawing is centred on the row box, and a row + that gave up its top padding would centre its drawing lower than the rest, which is + the one illustration in the list that stops lining up with its own title. + + So the list pulls itself up by exactly the padding of its first row instead. Under a + heading that leaves the heading's own mb-4 as the whole gap, and the rows go on + spacing themselves. rule is the other opening: the news index, where a hairline + separates the list from the featured article above it — there the first row's padding + is the air the line needs, and nothing is pulled back. --}} +class([ + 'border-t border-border' => $rule, + '-mt-8 sm:-mt-10 lg:-mt-12' => ! $rule, +]) }}> + @foreach($articles as $entry) + + @endforeach + diff --git a/resources/views/components/news/series-nav.blade.php b/resources/views/components/news/series-nav.blade.php index a95cb8d..9cde761 100644 --- a/resources/views/components/news/series-nav.blade.php +++ b/resources/views/components/news/series-nav.blade.php @@ -9,16 +9,19 @@ @endphp
-

+

{{ __('Series') }} @if($position !== null) {{ __('Part :position of :total', ['position' => $position + 1, 'total' => $total]) }} @endif

- + {{-- Neither `mb-0` nor `text-title` ever reached the page: merge() only concatenates, + and the built CSS emits .mb-0 before .mb-2 and .text-title before .text-heading, + so the component's own classes won both times. --}} + @if($series->description) -

{{ $series->description }}

+

{{ $series->description }}

@endif diff --git a/resources/views/components/news/table-of-contents.blade.php b/resources/views/components/news/table-of-contents.blade.php index 423750c..a779193 100644 --- a/resources/views/components/news/table-of-contents.blade.php +++ b/resources/views/components/news/table-of-contents.blade.php @@ -1,17 +1,13 @@ @props(['headings' => []]) @if(count($headings) > 1) - {{-- Aligned with the reading column, not the frame: nothing on this page sticks - out past the text. --}}
diff --git a/resources/views/components/ui/arrow-link.blade.php b/resources/views/components/ui/arrow-link.blade.php index 05a338b..ea16039 100644 --- a/resources/views/components/ui/arrow-link.blade.php +++ b/resources/views/components/ui/arrow-link.blade.php @@ -1,8 +1,6 @@ @props(['href', 'label' => null, 'direction' => 'forward']) -{{-- The «read on» link: a label with a directional arrow that slides on hover. - The arrow is decorative — the label alone has to say where the link goes. --}} -merge(['class' => 'group inline-flex min-h-control items-center gap-1.5 text-base font-medium text-brand sm:min-h-0']) }}> +merge(['class' => 'group inline-flex tap-target items-center gap-1.5 text-base font-medium text-brand']) }}> @if($direction === 'back') @endif diff --git a/resources/views/components/ui/badge.blade.php b/resources/views/components/ui/badge.blade.php index 383f5c2..4692c42 100644 --- a/resources/views/components/ui/badge.blade.php +++ b/resources/views/components/ui/badge.blade.php @@ -8,24 +8,17 @@ ]) @php - // Every chip on the site — meta badges, tag lists, news topic filters, partner - // tiers, model links — is this component. Add a variant here, never a one-off - // pill somewhere in a view. $variants = [ 'default' => 'bg-gray-400/10 text-muted ring-1 ring-gray-400/20 ring-inset', 'outline' => 'text-muted ring-1 ring-border ring-inset', - 'solid' => 'bg-gray-900 text-white', 'brand' => 'bg-brand text-white', - // Live status — currently the opening-hours chip on the contact page. 'success' => 'bg-emerald-500/10 text-emerald-700 ring-1 ring-emerald-600/20 ring-inset', - // The partner tier chip — the one place a gradient earns its keep. 'metal' => 'bg-linear-to-b from-gray-100 via-white to-gray-300 text-gray-700 ring-1 ring-gray-400/40 ring-inset', ]; $hovers = [ 'default' => 'hover:bg-gray-400/20 hover:text-gray-800', 'outline' => 'hover:text-brand hover:ring-brand', - 'solid' => 'hover:bg-gray-800', 'brand' => 'hover:bg-brand-strong', 'success' => '', 'metal' => '', @@ -41,9 +34,7 @@ 'inline-flex items-center justify-center rounded-pill font-medium', $variants[$variant] ?? $variants['default'], $sizes[$size] ?? $sizes['sm'], - // A chip that can be clicked has to be reachable with a thumb, so it grows - // to the shared control height on a phone and stays compact from sm up. - $href ? 'min-h-control sm:min-h-0 transition cursor-pointer focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand' : null, + $href ? 'tap-target transition cursor-pointer focus-ring' : null, $href ? ($hovers[$variant] ?? '') : null, ]))); @endphp diff --git a/resources/views/components/ui/button.blade.php b/resources/views/components/ui/button.blade.php index bd381fb..66bc0a0 100644 --- a/resources/views/components/ui/button.blade.php +++ b/resources/views/components/ui/button.blade.php @@ -10,7 +10,7 @@ @php $base = 'inline-flex items-center justify-center gap-2 rounded-pill font-medium transition cursor-pointer ' - . 'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-brand ' + . 'focus-ring ' . 'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none'; $variants = [ @@ -19,8 +19,6 @@ 'ghost' => 'text-brand hover:bg-surface', ]; - // md and lg sit on the shared control height and clear the 44px touch target. - // sm is for dense desktop UI and must never be the only target on a phone. $sizes = [ 'sm' => 'h-control-sm px-4 text-sm', 'md' => 'h-control px-5 text-sm', @@ -31,8 +29,6 @@ $base, $variants[$variant] ?? $variants['primary'], $sizes[$size] ?? $sizes['md'], - // Width is the caller's decision, not the component's — pass block for the - // full-width treatment a form submit wants on a phone. $block ? 'w-full' : null, ]))); @endphp diff --git a/resources/views/components/ui/language-suggestion.blade.php b/resources/views/components/ui/language-suggestion.blade.php new file mode 100644 index 0000000..0f7ef70 --- /dev/null +++ b/resources/views/components/ui/language-suggestion.blade.php @@ -0,0 +1,43 @@ +@use(App\Enums\CookieNameEnum;use App\Enums\LocaleEnum;use Illuminate\Support\Str) + +@php + $subtag = fn (string $locale) => Str::before($locale, '_'); + + $current = app()->getLocale(); + $alternates = array_values(array_filter( + LocaleEnum::cases(), + fn (LocaleEnum $locale) => $locale->value !== $current, + )); +@endphp + +@if(! empty($alternates)) + {{-- Server-rendered hidden and revealed only by the Alpine component: the page is + response-cached, so which visitor sees this cannot be decided here. --}} + +@endif diff --git a/resources/views/components/ui/link.blade.php b/resources/views/components/ui/link.blade.php index 55da09e..5cd1961 100644 --- a/resources/views/components/ui/link.blade.php +++ b/resources/views/components/ui/link.blade.php @@ -1,11 +1,9 @@ @props(['href', 'label' => null, 'target' => '_self', 'title' => null, 'download' => null]) -{{-- Colour, not weight, marks the hover: bolding the label on hover re-flowed the - line and nudged every link beside it in the navigation. --}} merge(['class' => 'rounded-pill transition hover:text-brand focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand']) }}> + {{ $attributes->merge(['class' => 'rounded-pill transition hover:text-brand focus-ring']) }}> {{ $slot->isEmpty() ? $label : $slot }} diff --git a/resources/views/components/ui/pagination.blade.php b/resources/views/components/ui/pagination.blade.php new file mode 100644 index 0000000..c9743ab --- /dev/null +++ b/resources/views/components/ui/pagination.blade.php @@ -0,0 +1,66 @@ +@props(['paginator']) + +@php + use Illuminate\Pagination\UrlWindow; + + $window = UrlWindow::make($paginator); + + $elements = array_filter([ + $window['first'], + is_array($window['slider']) ? '…' : null, + $window['slider'], + is_array($window['last']) ? '…' : null, + $window['last'], + ]); + + $step = 'inline-flex min-h-control items-center justify-center rounded-pill border px-4 text-sm font-medium'; + $stepIdle = 'border-border bg-white text-gray-800 transition hover:border-brand hover:text-brand focus-ring'; + $stepDisabled = 'border-border-soft bg-white text-hint cursor-not-allowed'; + + $page = 'inline-flex min-h-control min-w-control items-center justify-center rounded-pill px-3 text-sm font-medium'; + $pageIdle = 'text-muted transition hover:bg-surface hover:text-brand focus-ring'; + $pageCurrent = 'bg-brand text-white'; +@endphp + +@if($paginator->hasPages()) + +@endif diff --git a/resources/views/components/ui/row.blade.php b/resources/views/components/ui/row.blade.php index 832a43e..e246953 100644 --- a/resources/views/components/ui/row.blade.php +++ b/resources/views/components/ui/row.blade.php @@ -1,9 +1,3 @@ -@props(['compact' => false]) - -@php - $spacing = $compact ? 'gap-1 sm:gap-4 py-2 text-sm' : 'gap-2 sm:gap-6 py-4 px-2'; -@endphp - -
merge(['class' => 'grid grid-cols-1 border-t border-border-soft ' . $spacing]) }}> +
merge(['class' => 'grid grid-cols-1 gap-2 border-t border-border-soft px-2 py-4 sm:gap-6']) }}> {{ $slot }}
diff --git a/resources/views/components/ui/social-links.blade.php b/resources/views/components/ui/social-links.blade.php index 5a1f7ac..5e7871c 100644 --- a/resources/views/components/ui/social-links.blade.php +++ b/resources/views/components/ui/social-links.blade.php @@ -1,9 +1,6 @@ @props(['links' => [], 'name' => null, 'titles' => []]) @php - // One row of contact icons for every card that has people on it. Order comes - // from this table, not from the caller, so two cards side by side list the same - // channels in the same sequence. $types = [ 'linkedin' => ['icon' => 'icon.linkedin', 'label' => 'LinkedIn', 'external' => true], 'github' => ['icon' => 'icon.github', 'label' => 'GitHub', 'external' => true], @@ -27,12 +24,8 @@ 'mailto:' => 'mailto:'.$value, default => $value, }; - // «LinkedIn» repeated once per person is useless in a screen reader's - // link list; the name makes each one identifiable. $label = filled($name) ? $type['label'].' — '.$name : $type['label']; - // Mail and phone show the address itself; a caller can override any - // entry — the network cards show the bare host rather than «Website». $tooltip = data_get($titles, $key) ?? (isset($type['scheme']) ? $value : $type['label']); @endphp @@ -40,7 +33,7 @@ @if($type['external']) target="_blank" rel="noopener noreferrer" @endif aria-label="{{ $label }}" title="{{ $tooltip }}" - class="grid size-control place-items-center rounded-pill text-muted transition hover:text-gray-800 focus:outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand sm:size-8"> + class="grid size-control place-items-center rounded-pill text-muted transition hover:text-gray-800 focus-ring-inset sm:size-8"> @endforeach diff --git a/resources/views/components/ui/table.blade.php b/resources/views/components/ui/table.blade.php new file mode 100644 index 0000000..417d6a2 --- /dev/null +++ b/resources/views/components/ui/table.blade.php @@ -0,0 +1,11 @@ +@props(['caption' => null]) + +
+ merge(['class' => 'w-full text-sm [&_td:not(:last-child)]:pr-4 [&_th:not(:last-child)]:pr-4']) }}> + @if(filled($caption)) + + @endif + + {{ $slot }} +
{{ $caption }}
+
diff --git a/resources/views/components/ui/table/cell.blade.php b/resources/views/components/ui/table/cell.blade.php new file mode 100644 index 0000000..35f8e44 --- /dev/null +++ b/resources/views/components/ui/table/cell.blade.php @@ -0,0 +1,12 @@ +@props(['as' => 'td', 'scope' => null, 'align' => 'start', 'hide' => false]) + +@php + $classes = trim(implode(' ', array_filter([ + 'py-2', + $align === 'end' ? 'text-right' : 'text-left', + $as === 'th' ? 'font-medium' : null, + $hide ? 'hidden sm:table-cell' : null, + ]))); +@endphp + +<{{ $as }} @if(filled($scope)) scope="{{ $scope }}" @endif {{ $attributes->merge(['class' => $classes]) }}>{{ $slot }} diff --git a/resources/views/components/ui/table/row.blade.php b/resources/views/components/ui/table/row.blade.php new file mode 100644 index 0000000..ba49979 --- /dev/null +++ b/resources/views/components/ui/table/row.blade.php @@ -0,0 +1,11 @@ +@props(['variant' => 'body']) + +@php + $variants = [ + 'head' => 'border-b border-border text-muted', + 'body' => 'border-b border-border-soft text-gray-800', + 'foot' => 'font-semibold text-gray-800', + ]; +@endphp + +merge(['class' => $variants[$variant] ?? $variants['body']]) }}>{{ $slot }} diff --git a/resources/views/demo/_app_layout.blade.php b/resources/views/demo/_app_layout.blade.php deleted file mode 100644 index 9cb78a1..0000000 --- a/resources/views/demo/_app_layout.blade.php +++ /dev/null @@ -1,6 +0,0 @@ - - - - @yield('content') - diff --git a/resources/views/demo/_layout.blade.php b/resources/views/demo/_layout.blade.php deleted file mode 100644 index 600597e..0000000 --- a/resources/views/demo/_layout.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -{{-- The standalone shell for the layout prototypes. Deliberately not the app - layout: each variant is a full-bleed design exploration that brings its own - header, palette and
, so the real site chrome would only get in the way. - It is noindex and unreachable from the navigation. --}} - - - - - - {{ $variantTitle ?? 'Flows layout demo' }} — Flows demo - - - @vite(['resources/js/app.js']) - - - -@unless($hideDemoBar ?? false) - -@endunless - -@yield('content') - - - diff --git a/resources/views/demo/flows/index.blade.php b/resources/views/demo/flows/index.blade.php deleted file mode 100644 index 2a6d669..0000000 --- a/resources/views/demo/flows/index.blade.php +++ /dev/null @@ -1,41 +0,0 @@ -@extends('demo._layout') - -@php($hideDemoBar = true) -@php($variantTitle = '10 Layout-Konzepte') - -@section('content') -
-
-
Flows · Layout-Exploration
-

10 Vorschläge, eine Story.

-

- Zehn strukturell und visuell unterschiedliche Layouts für dieselbe deutsche Flows-Content. - Klick dich durch, notier dir was funktioniert — daraus bauen wir die finalen statischen Seiten. -

- - - -

- Nur lokal sichtbar (nicht in Produktion geroutet) · /demo/flows -

-
-
-@endsection diff --git a/resources/views/demo/flows/v2/index.blade.php b/resources/views/demo/flows/v2/index.blade.php deleted file mode 100644 index df2093d..0000000 --- a/resources/views/demo/flows/v2/index.blade.php +++ /dev/null @@ -1,44 +0,0 @@ -@extends('demo._layout') - -@php($hideDemoBar = true) -@php($variantTitle = 'Swiss-Grid · 10 Illustrations-Varianten') - -@section('content') -
-
-
Flows · Swiss-Grid Familie
-

Eine Struktur, zehn Illustrationssprachen.

-

- Alle zehn Varianten übernehmen das Swiss-Grid-Gerüst (Label-Spalte, dünne Regeln, nummerierte Sektionen) - und laufen im echten Seiten-Layout mit Header & Footer. Der Unterschied liegt in der Illustration. -

- - ← zurück zur ersten Runde (10 Grundlayouts) - - - - -

- Nur lokal sichtbar (nicht in Produktion geroutet) · /demo/flows/v2 -

-
-
-@endsection diff --git a/resources/views/demo/flows/v2/variants/blueprint.blade.php b/resources/views/demo/flows/v2/variants/blueprint.blade.php deleted file mode 100644 index 95918bb..0000000 --- a/resources/views/demo/flows/v2/variants/blueprint.blade.php +++ /dev/null @@ -1,87 +0,0 @@ -@extends('demo._app_layout') - -@section('content') - -
- {{-- corner marks --}} - - - - - -
FIG. 01 — FLOWS / SYSTEMÜBERSICHT
-

{{ $content['headline'] }}

-

{{ $content['subheadline'] }}

- - {{-- dimension line --}} -
- A - - 5 Module · 3 Deployments - - B -
-
- -
-
§01 PROBLEM
-
-

{{ $content['problem']['heading'] }}

-

{{ $content['problem']['intro'] }}

- @foreach($content['problem']['paragraphs'] as $p) -

{{ $p }}

- @endforeach -
-
- -
-
§02 PLATTFORM
-
-

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

- -
- @foreach($content['features']['items'] as $i => $feature) -
- 2.{{ $i + 1 }} -

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
- @endforeach -
-
-
- -
-
§03 DEPLOYMENT
-
-

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

- -
- @foreach($content['deployment']['options'] as $i => $option) -
- 3.{{ $i + 1 }} -

{{ $option['title'] }}

-

{{ $option['description'] }}

-
- @endforeach -
-
-
- -
-
§04 KONTAKT
-
-
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

-
- - {{ $content['cta']['buttonLabel'] }} → - -
-
- -@endsection diff --git a/resources/views/demo/flows/v2/variants/data-bars.blade.php b/resources/views/demo/flows/v2/variants/data-bars.blade.php deleted file mode 100644 index 336d1a4..0000000 --- a/resources/views/demo/flows/v2/variants/data-bars.blade.php +++ /dev/null @@ -1,94 +0,0 @@ -@extends('demo._app_layout') - -@php - $heights = [18, 34, 22, 46, 30, 58, 26, 42, 20, 50, 32, 62, 24, 38, 16, 44, 28, 20]; - $barsSvg = ''; - $barWidth = 10; - $gap = 6; - $maxH = 62; - foreach ($heights as $i => $h) { - $x = $i * ($barWidth + $gap); - $y = $maxH - $h; - $color = $i % 5 === 0 ? '#500472' : '#d4d4d8'; - $barsSvg .= ""; - } - $svgWidth = count($heights) * ($barWidth + $gap) - $gap; -@endphp - -@section('content') - -
-
Flows
-
- - {!! $barsSvg !!} - -

{{ $content['headline'] }}

-

{{ $content['subheadline'] }}

-
-
- -
-
01 — Problem
-
-

{{ $content['problem']['heading'] }}

-

{{ $content['problem']['intro'] }}

- @foreach($content['problem']['paragraphs'] as $p) -

{{ $p }}

- @endforeach -
-
- -
-
02 — Plattform
-
-

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

- -
- @foreach($content['features']['items'] as $i => $feature) -
-
- @foreach(array_slice($heights, $i * 3, 5) as $h) -
- @endforeach -
-

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
- @endforeach -
-
-
- -
-
03 — Deployment
-
-

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

- -
- @foreach($content['deployment']['options'] as $option) -
-

{{ $option['title'] }}

-

{{ $option['description'] }}

-
- @endforeach -
-
-
- -
-
04 — Kontakt
-
-
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

-
- - {{ $content['cta']['buttonLabel'] }} → - -
-
- -@endsection diff --git a/resources/views/demo/flows/v2/variants/doc-stack.blade.php b/resources/views/demo/flows/v2/variants/doc-stack.blade.php deleted file mode 100644 index 227371b..0000000 --- a/resources/views/demo/flows/v2/variants/doc-stack.blade.php +++ /dev/null @@ -1,85 +0,0 @@ -@extends('demo._app_layout') - -@php - $docIcon = ''; -@endphp - -@section('content') - -
-
Flows
-
-
-

{{ $content['headline'] }}

-

{{ $content['subheadline'] }}

-
-
- {!! $docIcon !!} - {!! $docIcon !!} - {!! $docIcon !!} -
-
-
- -
-
01 — Problem
-
-

{{ $content['problem']['heading'] }}

-

{{ $content['problem']['intro'] }}

- @foreach($content['problem']['paragraphs'] as $p) -

{{ $p }}

- @endforeach -
-
- -
-
02 — Plattform
-
-

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

- -
- @foreach($content['features']['items'] as $feature) -
- {!! $docIcon !!} -
-

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
-
- @endforeach -
-
-
- -
-
03 — Deployment
-
-

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

- -
- @foreach($content['deployment']['options'] as $option) -
-

{{ $option['title'] }}

-

{{ $option['description'] }}

-
- @endforeach -
-
-
- -
-
04 — Kontakt
-
-
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

-
- - {{ $content['cta']['buttonLabel'] }} → - -
-
- -@endsection diff --git a/resources/views/demo/flows/v2/variants/dot-halftone.blade.php b/resources/views/demo/flows/v2/variants/dot-halftone.blade.php deleted file mode 100644 index c15e69c..0000000 --- a/resources/views/demo/flows/v2/variants/dot-halftone.blade.php +++ /dev/null @@ -1,105 +0,0 @@ -@extends('demo._app_layout') - -@php - $cols = 18; - $rows = 7; - $spacing = 14; - $cx = ($cols - 1) * $spacing / 2; - $cy = ($rows - 1) * $spacing / 2; - $maxDist = sqrt($cx ** 2 + $cy ** 2); - $dots = ''; - for ($row = 0; $row < $rows; $row++) { - for ($col = 0; $col < $cols; $col++) { - $x = $col * $spacing; - $y = $row * $spacing; - $dist = sqrt(($x - $cx) ** 2 + ($y - $cy) ** 2); - $radius = max(0.6, 5.2 * (1 - $dist / $maxDist)); - $dots .= ""; - } - } - $width = ($cols - 1) * $spacing; - $height = ($rows - 1) * $spacing; -@endphp - -@section('content') - -
-
Flows
-
- - {!! $dots !!} - -

{{ $content['headline'] }}

-

{{ $content['subheadline'] }}

-
-
- -
-
01 — Problem
-
-

{{ $content['problem']['heading'] }}

-

{{ $content['problem']['intro'] }}

- @foreach($content['problem']['paragraphs'] as $p) -

{{ $p }}

- @endforeach -
-
- -
-
02 — Plattform
-
-

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

- -
- @foreach($content['features']['items'] as $i => $feature) -
- - - - - - - - -
-

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
-
- @endforeach -
-
-
- -
-
03 — Deployment
-
-

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

- -
- @foreach($content['deployment']['options'] as $option) -
-

{{ $option['title'] }}

-

{{ $option['description'] }}

-
- @endforeach -
-
-
- -
-
04 — Kontakt
-
-
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

-
- - {{ $content['cta']['buttonLabel'] }} → - -
-
- -@endsection diff --git a/resources/views/demo/flows/v2/variants/flow-diagram.blade.php b/resources/views/demo/flows/v2/variants/flow-diagram.blade.php deleted file mode 100644 index ece88fc..0000000 --- a/resources/views/demo/flows/v2/variants/flow-diagram.blade.php +++ /dev/null @@ -1,97 +0,0 @@ -@extends('demo._app_layout') - -@section('content') - -
-
Flows
-
-

{{ $content['headline'] }}

-

{{ $content['subheadline'] }}

- - {{-- Flow diagram illustration --}} - - - Dokument - - - - - - Agent - orchestriert - - - - - - Geprüftes Ergebnis - - -
-
- -
-
01 — Problem
-
-

{{ $content['problem']['heading'] }}

-

{{ $content['problem']['intro'] }}

- @foreach($content['problem']['paragraphs'] as $p) -

{{ $p }}

- @endforeach -
-
- -
-
02 — Plattform
-
-

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

- -
- @foreach($content['features']['items'] as $feature) -
-
- - - -
-

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
- @endforeach -
-
-
- -
-
03 — Deployment
-
-

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

- -
- @foreach($content['deployment']['options'] as $i => $option) -
-
{{ sprintf('%02d', $i + 1) }}
-

{{ $option['title'] }}

-

{{ $option['description'] }}

-
- @endforeach -
-
-
- -
-
04 — Kontakt
-
-
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

-
- - {{ $content['cta']['buttonLabel'] }} → - -
-
- -@endsection diff --git a/resources/views/demo/flows/v2/variants/isometric.blade.php b/resources/views/demo/flows/v2/variants/isometric.blade.php deleted file mode 100644 index c9999f6..0000000 --- a/resources/views/demo/flows/v2/variants/isometric.blade.php +++ /dev/null @@ -1,112 +0,0 @@ -@extends('demo._app_layout') - -@section('content') - -
-
Flows
-
-
-

{{ $content['headline'] }}

-

{{ $content['subheadline'] }}

-
- {{-- isometric stacked document sheets --}} - - - - - - - -
-
- -
-
01 — Problem
-
-

{{ $content['problem']['heading'] }}

-

{{ $content['problem']['intro'] }}

- @foreach($content['problem']['paragraphs'] as $p) -

{{ $p }}

- @endforeach -
-
- -
-
02 — Plattform
-
-

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

- -
- @foreach($content['features']['items'] as $feature) -
-

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
- @endforeach -
-
-
- -
-
03 — Deployment
-
-

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

- -
- {{-- Own infra: isometric house --}} -
- - - - - - -

{{ $content['deployment']['options'][0]['title'] }}

-

{{ $content['deployment']['options'][0]['description'] }}

-
- - {{-- Dedicated: isolated cube with lock --}} -
- - - - - - - -

{{ $content['deployment']['options'][1]['title'] }}

-

{{ $content['deployment']['options'][1]['description'] }}

-
- - {{-- Shared: two cubes --}} -
- - - - - - - -

{{ $content['deployment']['options'][2]['title'] }}

-

{{ $content['deployment']['options'][2]['description'] }}

-
-
-
-
- -
-
04 — Kontakt
-
-
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

-
- - {{ $content['cta']['buttonLabel'] }} → - -
-
- -@endsection diff --git a/resources/views/demo/flows/v2/variants/line-icons.blade.php b/resources/views/demo/flows/v2/variants/line-icons.blade.php deleted file mode 100644 index f46ed6c..0000000 --- a/resources/views/demo/flows/v2/variants/line-icons.blade.php +++ /dev/null @@ -1,93 +0,0 @@ -@extends('demo._app_layout') - -@php - $featureIcons = [ - '', - '', - '', - '', - '', - ]; - - $deploymentIcons = [ - '', - '', - '', - ]; -@endphp - -@section('content') - -
-
Flows
-
-

{{ $content['headline'] }}

-

{{ $content['subheadline'] }}

-
-
- -
-
01 — Problem
-
-

{{ $content['problem']['heading'] }}

-

{{ $content['problem']['intro'] }}

- @foreach($content['problem']['paragraphs'] as $p) -

{{ $p }}

- @endforeach -
-
- -
-
02 — Plattform
-
-

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

- -
- @foreach($content['features']['items'] as $i => $feature) -
- - {!! $featureIcons[$i] !!} - -

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
- @endforeach -
-
-
- -
-
03 — Deployment
-
-

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

- -
- @foreach($content['deployment']['options'] as $i => $option) -
- - {!! $deploymentIcons[$i] !!} - -

{{ $option['title'] }}

-

{{ $option['description'] }}

-
- @endforeach -
-
-
- -
-
04 — Kontakt
-
-
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

-
- - {{ $content['cta']['buttonLabel'] }} → - -
-
- -@endsection diff --git a/resources/views/demo/flows/v2/variants/network-nodes.blade.php b/resources/views/demo/flows/v2/variants/network-nodes.blade.php deleted file mode 100644 index 4e5063f..0000000 --- a/resources/views/demo/flows/v2/variants/network-nodes.blade.php +++ /dev/null @@ -1,102 +0,0 @@ -@extends('demo._app_layout') - -@section('content') - -
-
Flows
-
-
-

{{ $content['headline'] }}

-

{{ $content['subheadline'] }}

-
- - - - - - - - - - - - - - - - - - -
-
- -
-
01 — Problem
-
-

{{ $content['problem']['heading'] }}

-

{{ $content['problem']['intro'] }}

- @foreach($content['problem']['paragraphs'] as $p) -

{{ $p }}

- @endforeach -
-
- -
-
02 — Plattform
-
-

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

- -
- @foreach($content['features']['items'] as $feature) -
- - - - - - - - - -
-

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
-
- @endforeach -
-
-
- -
-
03 — Deployment
-
-

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

- -
- @foreach($content['deployment']['options'] as $option) -
-

{{ $option['title'] }}

-

{{ $option['description'] }}

-
- @endforeach -
-
-
- -
-
04 — Kontakt
-
-
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

-
- - {{ $content['cta']['buttonLabel'] }} → - -
-
- -@endsection diff --git a/resources/views/demo/flows/v2/variants/organic-blobs.blade.php b/resources/views/demo/flows/v2/variants/organic-blobs.blade.php deleted file mode 100644 index ba07c7d..0000000 --- a/resources/views/demo/flows/v2/variants/organic-blobs.blade.php +++ /dev/null @@ -1,79 +0,0 @@ -@extends('demo._app_layout') - -@section('content') - -
-
-
Flows
-
-

{{ $content['headline'] }}

-

{{ $content['subheadline'] }}

-
-
- -
-
-
01 — Problem
-
-

{{ $content['problem']['heading'] }}

-

{{ $content['problem']['intro'] }}

- @foreach($content['problem']['paragraphs'] as $p) -

{{ $p }}

- @endforeach -
-
- -
-
-
02 — Plattform
-
-

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

- -
- @foreach($content['features']['items'] as $feature) -
-

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
- @endforeach -
-
-
- -
-
03 — Deployment
-
-

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

- -
- @foreach($content['deployment']['options'] as $option) -
-

{{ $option['title'] }}

-

{{ $option['description'] }}

-
- @endforeach -
-
-
- -
-
-
04 — Kontakt
-
-
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

-
- - {{ $content['cta']['buttonLabel'] }} → - -
-
- -@endsection diff --git a/resources/views/demo/flows/v2/variants/stamp-seal.blade.php b/resources/views/demo/flows/v2/variants/stamp-seal.blade.php deleted file mode 100644 index 0e31f08..0000000 --- a/resources/views/demo/flows/v2/variants/stamp-seal.blade.php +++ /dev/null @@ -1,92 +0,0 @@ -@extends('demo._app_layout') - -@php - $stampSvg = function (string $num, string $label): string { - return ' - - - '.$num.' - '.strtoupper($label).' - '; - }; -@endphp - -@section('content') - -
-
- {!! $stampSvg('FL', 'Flows') !!} -
-
-

{{ $content['headline'] }}

-

{{ $content['subheadline'] }}

-
-
- -
-
- {!! $stampSvg('01', 'Problem') !!} -
-
-

{{ $content['problem']['heading'] }}

-

{{ $content['problem']['intro'] }}

- @foreach($content['problem']['paragraphs'] as $p) -

{{ $p }}

- @endforeach -
-
- -
-
- {!! $stampSvg('02', 'Plattform') !!} -
-
-

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

- -
- @foreach($content['features']['items'] as $feature) -
-

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
- @endforeach -
-
-
- -
-
- {!! $stampSvg('03', 'Deployment') !!} -
-
-

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

- -
- @foreach($content['deployment']['options'] as $option) -
-

{{ $option['title'] }}

-

{{ $option['description'] }}

-
- @endforeach -
-
-
- -
-
- {!! $stampSvg('04', 'Kontakt') !!} -
-
-
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

-
- - {{ $content['cta']['buttonLabel'] }} → - -
-
- -@endsection diff --git a/resources/views/demo/flows/variants/before-after.blade.php b/resources/views/demo/flows/variants/before-after.blade.php deleted file mode 100644 index d5b714e..0000000 --- a/resources/views/demo/flows/variants/before-after.blade.php +++ /dev/null @@ -1,83 +0,0 @@ -@extends('demo._layout') - -@section('content') -
- -
-

{{ $content['headline'] }}

-

{{ $content['subheadline'] }}

-
- - {{-- Before / After --}} -
-

{{ $content['problem']['heading'] }}

-

{{ $content['problem']['intro'] }}

- -
-
-
- Ohne Flows -
-

{{ $content['problem']['paragraphs'][0] }}

-
-
-
- Ohne Validierung -
-

{{ $content['problem']['paragraphs'][1] }}

-
-
-
- - {{-- Features as "after" reveal --}} -
-
-
-
- Mit Flows -
-

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

-
-
- @foreach($content['features']['items'] as $feature) -
- - - -
-

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
-
- @endforeach -
-
-
- - {{-- Deployment --}} -
-
-

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

-
-
- @foreach($content['deployment']['options'] as $option) -
-

{{ $option['title'] }}

-

{{ $option['description'] }}

-
- @endforeach -
-
- -
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

- - {{ $content['cta']['buttonLabel'] }} - -
- -
-@endsection diff --git a/resources/views/demo/flows/variants/bento.blade.php b/resources/views/demo/flows/variants/bento.blade.php deleted file mode 100644 index 70dd203..0000000 --- a/resources/views/demo/flows/variants/bento.blade.php +++ /dev/null @@ -1,75 +0,0 @@ -@extends('demo._layout') - -@section('content') -
-
- -
- - {{-- HERO --}} -
-
-
-
Flows
-

{{ $content['headline'] }}

-
-

{{ $content['subheadline'] }}

-
- - {{-- CTA tile --}} -
-

{{ $content['cta']['heading'] }}

-
-

{{ $content['cta']['body'] }}

- - {{ $content['cta']['buttonLabel'] }} - -
-
- - {{-- Problem tile --}} -
-
Das Problem
-

{{ $content['problem']['heading'] }}

-

{{ $content['problem']['intro'] }}

-

{{ $content['problem']['paragraphs'][0] }}

-
- -
-

{{ $content['problem']['paragraphs'][1] }}

-
- - {{-- Features intro tile --}} -
-
Die Plattform
-

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

-
- - {{-- 5 feature tiles, varied sizes --}} - @foreach($content['features']['items'] as $i => $feature) -
-

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
- @endforeach - - {{-- Deployment tile --}} -
-
Deployment
-

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

-
- @foreach($content['deployment']['options'] as $option) -
-

{{ $option['title'] }}

-

{{ $option['description'] }}

-
- @endforeach -
-
- -
-
-
-@endsection diff --git a/resources/views/demo/flows/variants/big-statement.blade.php b/resources/views/demo/flows/variants/big-statement.blade.php deleted file mode 100644 index 3c67514..0000000 --- a/resources/views/demo/flows/variants/big-statement.blade.php +++ /dev/null @@ -1,70 +0,0 @@ -@extends('demo._layout') - -@section('content') -
- -
-
-

- {{ $content['headline'] }} -

-

{{ $content['subheadline'] }}

-
-
- -
-
- 01 / Problem -

- {{ $content['problem']['heading'] }} -

-
-

{{ $content['problem']['intro'] }} {{ $content['problem']['paragraphs'][0] }}

-

{{ $content['problem']['paragraphs'][1] }}

-
-
-
- -
- 02 / Plattform -

- {{ $content['features']['heading'] }} -

-

{{ $content['features']['intro'] }}

-
- @foreach($content['features']['items'] as $i => $feature) -
- {{ sprintf('%02d', $i + 1) }} -

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
- @endforeach -
-
- -
- 03 / Deployment -

- {{ $content['deployment']['heading'] }} -

-

{{ $content['deployment']['intro'] }}

-
- @foreach($content['deployment']['options'] as $option) -
-

{{ $option['title'] }}

-

{{ $option['description'] }}

-
- @endforeach -
-
- -
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

- - {{ $content['cta']['buttonLabel'] }} → - -
- -
-@endsection diff --git a/resources/views/demo/flows/variants/docs-split.blade.php b/resources/views/demo/flows/variants/docs-split.blade.php deleted file mode 100644 index 5019b84..0000000 --- a/resources/views/demo/flows/variants/docs-split.blade.php +++ /dev/null @@ -1,82 +0,0 @@ -@extends('demo._layout') - -@php - $sections = [ - ['id' => 'ueberblick', 'label' => 'Überblick'], - ['id' => 'problem', 'label' => 'Das Problem'], - ['id' => 'plattform', 'label' => 'Die Plattform'], - ['id' => 'deployment', 'label' => 'Deployment'], - ['id' => 'kontakt', 'label' => 'Kontakt'], - ]; -@endphp - -@section('content') -
-
- - - -
- -
-

{{ $content['headline'] }}

-

{{ $content['subheadline'] }}

-
- -
-

{{ $content['problem']['heading'] }}

-

{{ $content['problem']['intro'] }}

- @foreach($content['problem']['paragraphs'] as $p) -

{{ $p }}

- @endforeach -
- -
-

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

-
- @foreach($content['features']['items'] as $feature) -
-

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
- @endforeach -
-
- -
-

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

-
- @foreach($content['deployment']['options'] as $option) -
-

{{ $option['title'] }}

-

{{ $option['description'] }}

-
- @endforeach -
-
- -
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

- - {{ $content['cta']['buttonLabel'] }} - -
- -
-
-
-@endsection diff --git a/resources/views/demo/flows/variants/editorial.blade.php b/resources/views/demo/flows/variants/editorial.blade.php deleted file mode 100644 index 17e05ae..0000000 --- a/resources/views/demo/flows/variants/editorial.blade.php +++ /dev/null @@ -1,64 +0,0 @@ -@extends('demo._layout') - -@section('content') -
-
- -
Flows — Produktnotiz
- -

- {{ $content['headline'] }} -

- -

- {{ $content['subheadline'] }} -

- -
- -

{{ $content['problem']['heading'] }}

- -

- {{ $content['problem']['intro'] }} {{ $content['problem']['paragraphs'][0] }} -

- -
- „Selbst mit einem Extraktionstool muss weiterhin eine Person jedes Ergebnis prüfen, bevor es vertrauenswürdig ist.“ -
- -

{{ $content['problem']['paragraphs'][1] }}

- -

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

- -
    - @foreach($content['features']['items'] as $feature) -
  1. {{ $feature['title'] }}. {{ $feature['description'] }}
  2. - @endforeach -
- -

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

- -
- @foreach($content['deployment']['options'] as $option) -
-
{{ $option['title'] }}
-
{{ $option['description'] }}
-
- @endforeach -
- -
- -
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

- - {{ $content['cta']['buttonLabel'] }} → - -
- -
-
-@endsection diff --git a/resources/views/demo/flows/variants/journey.blade.php b/resources/views/demo/flows/variants/journey.blade.php deleted file mode 100644 index 43a71d4..0000000 --- a/resources/views/demo/flows/variants/journey.blade.php +++ /dev/null @@ -1,85 +0,0 @@ -@extends('demo._layout') - -@section('content') -
-
- -
-

{{ $content['headline'] }}

-

{{ $content['subheadline'] }}

-
- -
- - - {{-- Step 1: Problem --}} -
- -
- Das Problem -

{{ $content['problem']['heading'] }}

-

{{ $content['problem']['intro'] }}

- @foreach($content['problem']['paragraphs'] as $p) -

{{ $p }}

- @endforeach -
-
- - {{-- Step 2: Platform intro --}} -
- -
- Die Plattform -

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

-
-
- - {{-- Step 2.x: sub-steps for each feature --}} - @foreach($content['features']['items'] as $i => $feature) -
- -
-

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
-
- @endforeach - - {{-- Step 3: Deployment --}} -
- -
- Deployment -

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

-
- @foreach($content['deployment']['options'] as $i => $option) -
- 3.{{ $i + 1 }} -
-

{{ $option['title'] }}

-

{{ $option['description'] }}

-
-
- @endforeach -
-
-
- - {{-- Step 4: CTA --}} -
- -
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

- - {{ $content['cta']['buttonLabel'] }} - -
-
- -
-
-
-@endsection diff --git a/resources/views/demo/flows/variants/saas-landing.blade.php b/resources/views/demo/flows/variants/saas-landing.blade.php deleted file mode 100644 index 50bc0ef..0000000 --- a/resources/views/demo/flows/variants/saas-landing.blade.php +++ /dev/null @@ -1,104 +0,0 @@ -@extends('demo._layout') - -@section('content') -
- - {{-- HERO --}} -
-
-
-
- - Neu: Flows -
-

- {{ $content['headline'] }} -

-

- {{ $content['subheadline'] }} -

- -
-
- - {{-- PROBLEM --}} -
-
-
-
- Das Problem -

{{ $content['problem']['heading'] }}

-
-
-

{{ $content['problem']['intro'] }}

- @foreach($content['problem']['paragraphs'] as $p) -

{{ $p }}

- @endforeach -
-
-
-
- - {{-- FEATURES --}} -
-
- Die Plattform -

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

-
-
- @foreach($content['features']['items'] as $i => $feature) -
-
- {{ $i + 1 }} -
-
-

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
-
- @endforeach -
-
- - {{-- DEPLOYMENT --}} -
-
-
- Deployment -

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

-
-
- @foreach($content['deployment']['options'] as $option) -
-

{{ $option['title'] }}

-

{{ $option['description'] }}

-
- @endforeach -
-
-
- - {{-- CTA --}} -
-
-
-
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

- - {{ $content['cta']['buttonLabel'] }} - -
-
- -
-@endsection diff --git a/resources/views/demo/flows/variants/swiss-grid.blade.php b/resources/views/demo/flows/variants/swiss-grid.blade.php deleted file mode 100644 index 10facd9..0000000 --- a/resources/views/demo/flows/variants/swiss-grid.blade.php +++ /dev/null @@ -1,78 +0,0 @@ -@extends('demo._layout') - -@section('content') -
-
- -
-
Flows
-
-

{{ $content['headline'] }}

-

{{ $content['subheadline'] }}

-
-
- -
-
01 — Problem
-
-

{{ $content['problem']['heading'] }}

-

{{ $content['problem']['intro'] }}

- @foreach($content['problem']['paragraphs'] as $p) -

{{ $p }}

- @endforeach -
- -
- -
-
02 — Plattform
-
-

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

- -
- @foreach($content['features']['items'] as $i => $feature) -
-
{{ sprintf('%02d', $i + 1) }}
-

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
- @endforeach -
-
-
- -
-
03 — Deployment
-
-

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

- -
- @foreach($content['deployment']['options'] as $i => $option) -
-
{{ sprintf('%02d', $i + 1) }}
-

{{ $option['title'] }}

-

{{ $option['description'] }}

-
- @endforeach -
-
-
- -
-
04 — Kontakt
-
-
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

-
- - {{ $content['cta']['buttonLabel'] }} → - -
-
- -
-
-@endsection diff --git a/resources/views/demo/flows/variants/tabs-dashboard.blade.php b/resources/views/demo/flows/variants/tabs-dashboard.blade.php deleted file mode 100644 index f5acb28..0000000 --- a/resources/views/demo/flows/variants/tabs-dashboard.blade.php +++ /dev/null @@ -1,82 +0,0 @@ -@extends('demo._layout') - -@section('content') -
- -
-

{{ $content['headline'] }}

-

{{ $content['subheadline'] }}

-
- -
- -
- @php - $tabs = [ - ['label' => 'Das Problem'], - ['label' => 'Die Plattform'], - ['label' => 'Deployment'], - ['label' => 'Kontakt'], - ]; - @endphp - @foreach($tabs as $i => $tab) - - @endforeach -
- -
- -
-

{{ $content['problem']['heading'] }}

-

{{ $content['problem']['intro'] }}

- @foreach($content['problem']['paragraphs'] as $p) -

{{ $p }}

- @endforeach -
- -
-

{{ $content['features']['heading'] }}

-

{{ $content['features']['intro'] }}

-
- @foreach($content['features']['items'] as $feature) -
-

{{ $feature['title'] }}

-

{{ $feature['description'] }}

-
- @endforeach -
-
- -
-

{{ $content['deployment']['heading'] }}

-

{{ $content['deployment']['intro'] }}

-
- @foreach($content['deployment']['options'] as $option) -
-

{{ $option['title'] }}

-

{{ $option['description'] }}

-
- @endforeach -
-
- -
-

{{ $content['cta']['heading'] }}

-

{{ $content['cta']['body'] }}

- - {{ $content['cta']['buttonLabel'] }} - -
- -
-
- -
-@endsection diff --git a/resources/views/demo/flows/variants/terminal.blade.php b/resources/views/demo/flows/variants/terminal.blade.php deleted file mode 100644 index f05e87b..0000000 --- a/resources/views/demo/flows/variants/terminal.blade.php +++ /dev/null @@ -1,81 +0,0 @@ -@extends('demo._layout') - -@section('content') -
-
- - {{-- Terminal window --}} -
-
- - - - flows — zsh -
- -
-
$ flows --about
-

- > {{ $content['headline'] }} -

-

{{ $content['subheadline'] }}

- -
$ flows problem --explain
-
-
# {{ $content['problem']['heading'] }}
-

// {{ $content['problem']['intro'] }}

- @foreach($content['problem']['paragraphs'] as $p) -

{{ $p }}

- @endforeach -
- -
$ flows features --list
-
-
# {{ $content['features']['heading'] }}
-

// {{ $content['features']['intro'] }}

-
    - @foreach($content['features']['items'] as $feature) -
  • - -
    - {{ $feature['title'] }} -

    {{ $feature['description'] }}

    -
    -
  • - @endforeach -
-
- -
$ flows deploy --list-targets
-
-
# {{ $content['deployment']['heading'] }}
-

// {{ $content['deployment']['intro'] }}

-
- @foreach($content['deployment']['options'] as $i => $option) -
-
[{{ $i + 1 }}]
-
{{ $option['title'] }}
-

{{ $option['description'] }}

-
- @endforeach -
-
- -
$ flows contact --start
-
-
-
{{ $content['cta']['heading'] }}
-

{{ $content['cta']['body'] }}

-
- - {{ $content['cta']['buttonLabel'] }} - -
- -
$
-
-
- -
-
-@endsection diff --git a/resources/views/errors/partials/_error-page.blade.php b/resources/views/errors/partials/_error-page.blade.php index b4698c6..9ad6b22 100644 --- a/resources/views/errors/partials/_error-page.blade.php +++ b/resources/views/errors/partials/_error-page.blade.php @@ -1,8 +1,7 @@ -{{-- Shared shell for all HTTP error pages: same app layout as regular pages (header, footer, CTAs). --}} - + -

{{ __('errors.status_label', ['code' => $statusCode]) }}

+ {{ __('errors.status_label', ['code' => $statusCode]) }}
diff --git a/resources/views/layouts/_partials/_footer.blade.php b/resources/views/layouts/_partials/_footer.blade.php index 2419e7c..9fca16d 100644 --- a/resources/views/layouts/_partials/_footer.blade.php +++ b/resources/views/layouts/_partials/_footer.blade.php @@ -1,6 +1,4 @@ @php - // The footer menu as data, so a new entry is one line rather than a new block - // of markup. Labels match the page titles they lead to. $columns = [ __('Legal') => [ ['route' => 'legal.privacy.index', 'label' => __('Privacy')], @@ -19,23 +17,19 @@ ]; @endphp -{{-- min-h-[200px] reserved space against layout shift while the labels row loads; - the row is inline SVG and paints with the document, so the reservation was - holding open empty space for nothing. --}} -