diff --git a/apps/api/app/Domain/Ai/CodexPage.php b/apps/api/app/Domain/Ai/CodexPage.php
index 3d3c7be..95c2faf 100644
--- a/apps/api/app/Domain/Ai/CodexPage.php
+++ b/apps/api/app/Domain/Ai/CodexPage.php
@@ -89,6 +89,32 @@ public static function adBrief(string $prompt, ?string $product, ?array $site, s
$user."\n\n".$languageRule);
}
+ /**
+ * What the account list calls a prototype drawn as a picture, which has no
of its own:
+ * the website's name, else its address, else the customer's first sentence. "Prototyp" nine
+ * times over told the customer nothing (2026-09-26).
+ */
+ public static function title(string $prompt, ?array $site): string
+ {
+ $name = trim((string) ($site['name'] ?? ''));
+ if ($name !== '') {
+ // "Küstenpatent Kroatien | Boat Skipper B ..." keeps its first part.
+ $name = trim((string) preg_split('~\s+[|\x{2013}\x{2014}-]\s+~u', $name, 2)[0]);
+ }
+ if ($name === '' && isset($site['url'])) {
+ $name = preg_replace('~^www\.~', '', (string) parse_url((string) $site['url'], PHP_URL_HOST)) ?? '';
+ }
+ if ($name === '') {
+ $name = trim((string) preg_split('~(?<=[.!?])\s|\n~u', trim($prompt), 2)[0]);
+ $name = rtrim($name, '.!? ');
+ }
+ if ($name === '') {
+ return 'Prototyp';
+ }
+
+ return mb_strlen($name) > 70 ? rtrim(mb_substr($name, 0, 67)).'…' : $name;
+ }
+
/** One answer from Claude on the tool-less chat agent. */
private static function ask(string $system, string $user): string
{
diff --git a/apps/api/app/Domain/Ai/ProductPage.php b/apps/api/app/Domain/Ai/ProductPage.php
index c24bad8..4d416c9 100644
--- a/apps/api/app/Domain/Ai/ProductPage.php
+++ b/apps/api/app/Domain/Ai/ProductPage.php
@@ -89,7 +89,7 @@ public function read(string $domain): ?array
return $page ?: null;
}
- /** @return array{url:string,text:string,lang?:?string}|false false so a failed read is cached too, briefly enough */
+ /** @return array{url:string,text:string,lang?:?string,name?:?string}|false false so a failed read is cached too, briefly enough */
private function fetch(string $domain): array|false
{
$url = "https://{$domain}/";
@@ -119,7 +119,10 @@ private function fetch(string $domain): array|false
// The language the business speaks to its customers in, from .
$lang = preg_match('~]*\slang="([a-zA-Z]{2,3})~i', $html, $l) === 1 ? strtolower($l[1]) : null;
- return ['url' => $url, 'text' => $text, 'images' => $this->bigEnough($images, $domain), 'logo' => $logo, 'lang' => $lang];
+ // The site's own name, for the title of a prototype drawn as a picture (CodexPage).
+ $name = preg_match('~]*>(.*?)~is', $html, $t) === 1 ? trim(html_entity_decode(strip_tags($t[1]), ENT_QUOTES | ENT_HTML5)) : null;
+
+ return ['url' => $url, 'text' => $text, 'images' => $this->bigEnough($images, $domain), 'logo' => $logo, 'lang' => $lang, 'name' => $name ?: null];
}
} catch (\Throwable $e) {
Log::info('product page: not read', ['domain' => $domain, 'error' => mb_substr($e->getMessage(), 0, 160)]);
diff --git a/apps/api/app/Domain/Ai/PrototypeWriter.php b/apps/api/app/Domain/Ai/PrototypeWriter.php
index 8671443..b9cf562 100644
--- a/apps/api/app/Domain/Ai/PrototypeWriter.php
+++ b/apps/api/app/Domain/Ai/PrototypeWriter.php
@@ -183,7 +183,7 @@ public function build(string $prompt, string $kind = 'site', ?DesignRefs $refs =
$lap('brief+render');
$timing['total'] = round(array_sum($timing), 1);
- return ['title' => 'Prototyp', 'html' => $out['html'], 'qa' => [
+ return ['title' => CodexPage::title($prompt, $site), 'html' => $out['html'], 'qa' => [
'ok' => null, 'findings' => [], 'writer' => 'codex', 'mockup' => true, 'brief' => $out['brief'],
'refs' => count($looks), 'timing' => $timing,
'product' => isset($site['url']) ? ['url' => $site['url'], 'brief' => $product] : null,
diff --git a/apps/api/database/migrations/2026_09_26_120000_name_mockup_prototypes.php b/apps/api/database/migrations/2026_09_26_120000_name_mockup_prototypes.php
new file mode 100644
index 0000000..f50ffa8
--- /dev/null
+++ b/apps/api/database/migrations/2026_09_26_120000_name_mockup_prototypes.php
@@ -0,0 +1,25 @@
+each(function (Prototype $p): void {
+ $url = $p->qa['product']['url'] ?? null;
+ $title = CodexPage::title((string) $p->prompt, is_string($url) ? ['url' => $url] : null);
+ if ($title !== 'Prototyp') {
+ $p->forceFill(['title' => $title])->saveQuietly();
+ }
+ });
+ }
+
+ public function down(): void {}
+};
diff --git a/apps/api/tests/Feature/MockupTitleTest.php b/apps/api/tests/Feature/MockupTitleTest.php
new file mode 100644
index 0000000..cdb84c8
--- /dev/null
+++ b/apps/api/tests/Feature/MockupTitleTest.php
@@ -0,0 +1,29 @@
+assertSame('Küstenpatent Kroatien', CodexPage::title('x', ['url' => 'https://www.kuestenpatent-kroatien.at/', 'name' => 'Küstenpatent Kroatien | Boat Skipper B in einem Tag']));
+ }
+
+ public function test_then_the_address_then_the_first_sentence(): void
+ {
+ $this->assertSame('kuestenpatent-kroatien.at', CodexPage::title('x', ['url' => 'https://www.kuestenpatent-kroatien.at/']));
+ $this->assertSame('Eine Website für ein Friseurstudio in Wien', CodexPage::title("Eine Website für ein Friseurstudio in Wien. Mit Preisliste.", null));
+ $this->assertSame('Prototyp', CodexPage::title(' ', null));
+ }
+
+ public function test_a_long_sentence_is_cut(): void
+ {
+ $title = CodexPage::title(str_repeat('Bäckerei Lang ', 12), null);
+ $this->assertLessThanOrEqual(70, mb_strlen($title));
+ $this->assertStringEndsWith('…', $title);
+ }
+}