Skip to content
2 changes: 1 addition & 1 deletion docs/CLAUDE_DESIGN_BRIEF.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ Do not: add a container, tilt, outline, gradient, color-fill with anything outsi

- **First person singular, never plural.** "We" implies a team; this is one person. "I" on authored content; brand name everywhere else.
- **Lars is not a teacher.** Copy should position him as a developer who builds for teachers, based on listening to them. Do not fabricate classroom experience. The honest framing is more distinctive than the faked one.
- **No product names** until products launch. Reference them by area of focus, not by name.
- **No product names** in the production website's editorial copy until products formally launch. (Dev may carry drafted/scheduled named entries for in-progress verification.) Reference products by area of focus in editorial text.
- **German copy:** clear Hochdeutsch, no buzzwords, no Anglizismen unless the English term is genuinely the term of art (e.g., "Release Notes" is acceptable; "Solution Provider" is not).
- **English copy:** plain English. Short sentences. British or American are both fine; be consistent per page.
- **Never call teachers "users."** Call them "teachers" or "Lehrkräfte" or address them directly.
Expand Down
17 changes: 14 additions & 3 deletions docs/TECH_STACK.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Blackbrowed Labs builds tools for classroom management. Two areas are in active
- An **iPadOS app** for classroom management (grades, student work, observations, without paperwork). Will launch as the first product on its own domain with an independent website and design system.
- A **Claude Cowork plugin** that helps teachers plan units and lessons.

**Product names are deliberately not referenced on this website** until each product formally launches. The website blackbrowedlabs.com acts as the **studio's home page**, not as marketing surface for individual products. When products launch, they get a **minimal presence** here: name, short description, current version, recent release notes (auto-pulled from GitHub), and a prominent link to the product's own site. The marketing depth lives on each product's own website.
**Product names are deliberately not surfaced on the production website** until each product formally launches. Dev may carry named product entries marked `draft: true` (or with a future `releaseDate`) for in-progress visual verification; these are filtered out of the production build. The website blackbrowedlabs.com acts as the **studio's home page**, not as marketing surface for individual products. When products launch, they get a **minimal presence** here: name, short description, current version, recent release notes (auto-pulled from GitHub), and a prominent link to the product's own site. The marketing depth lives on each product's own website.

### 1.3 Editorial profile

Expand Down Expand Up @@ -323,7 +323,18 @@ Frontmatter:

The body of the Markdown file is the long-form description rendered under the product header.

**Auto-generation:** the route file `src/pages/produkte/[slug].astro` (and its English mirror `src/pages/en/products/[slug].astro`) uses `getStaticPaths()` to emit one page per `products` entry matching the file's language. Adding a Markdown file is the only action needed to publish a new product page. No component changes. No config changes.
**Visibility gates** (added in Phase C, G C.2.a). Two complementary fields modulate when a product surfaces:

- `draft: boolean` (optional) — when `true`, the product is hidden on production. Use for in-progress entries the operator wants to test on dev before public release.
- `releaseDate: Date` (optional, ISO-8601 string at file authoring time, coerced to `Date` by Zod) — when set to a future date, the product is hidden on production until the next build runs after that date. Once Phase D.3 ships the nightly cron, scheduled publishing fires automatically within ~24h of the release date.

Both gates are enforced by the env-aware filter in `src/lib/products.ts` (`getVisibleProducts(lang)`), which is called by the index pages and the detail-template `getStaticPaths()`. On dev / staging the filter returns all products regardless of `draft` or `releaseDate` — the operator sees in-progress and scheduled entries on `dev.blackbrowedlabs.com` for visual verification.

**Schema refine.** `externalUrl` and `repo` are both optional on the schema, but a `.refine()` on the `products` collection schema requires at least one to be set. The detail template renders the primary CTA against `externalUrl` if present.

**GitHub fallback CTA — opt-in via `showGithubLink: boolean`** (added in Phase C, G C.2.b fix-up). When `externalUrl` is not set, the detail template renders a ghost-button GitHub CTA only if `showGithubLink: true` is also set on the product entry AND `repo` is populated. Default behaviour (no `showGithubLink` field) is to render no fallback CTA. The opt-in default is intentionally conservative: it prevents broken CTAs that point at private or otherwise unreachable repositories. Set `showGithubLink: true` only when the repo is publicly accessible and you want it to be the product's surfaced public link until a dedicated marketing site lands. Phase D's release-loader work is expected to supersede this manual flag with auto-detection from the GitHub API (`GET /repos/{owner}/{repo}` returns `private: boolean`); the flag becomes either redundant or a force-show/force-hide override at that point.

**Auto-generation:** the route file `src/pages/produkte/[slug].astro` (and its English mirror `src/pages/en/products/[slug].astro`) uses `getStaticPaths()` to emit one page per visible product entry matching the file's language. Adding a Markdown file is the only action needed to publish a new product page. No component changes. No config changes.

**Collection is empty in v1.** When the collection is empty, the Products index renders a "coming soon" empty state (copy in `BASELINE_COPY.md` §5).

Expand Down Expand Up @@ -699,7 +710,7 @@ All fonts, images, and scripts served from the Worker (same origin). No `fonts.g
11. `wrangler.jsonc` with named environments and Custom Domain bindings.
3. **Use the `frontend-design` skill** whenever writing UI components.
4. **Do not invent** company copy, product names, Impressum details, or Datenschutz text. Use exactly what's in `BASELINE_COPY.md`.
5. **Do not reference specific product names** (such as product working titles you may learn from other sources) in any generated copy. The website does not name products until they launch.
5. **Do not reference specific product names** in editorial copy or page templates. Per-product Markdown files under `src/content/products/` carry the product's name and surface on production only when `draft: false` and any `releaseDate` is in the past (see §1.2 + §5.2).
6. **Do not install a CMS.** v1 is Git-only editing.
7. **Do not swap the palette or typography.** If the design bundle from Claude Design contradicts the brief, stop and surface the conflict.
8. **Do not imply Lars is a teacher** in any generated copy. He's a developer who builds for teachers.
Expand Down
2 changes: 2 additions & 0 deletions src/components/SiteFooter.astro
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const path = Astro.url.pathname;
const locale = getLocaleFromPath(path);
const t = getUiStrings(locale).footer;

const productsHref = locale === 'de' ? '/produkte' : '/en/products';
const aboutHref = locale === 'de' ? '/ueber' : '/en/about';
const contactHref = locale === 'de' ? '/kontakt' : '/en/contact';
const legalHref = locale === 'de' ? '/impressum' : '/en/legal';
Expand All @@ -28,6 +29,7 @@ const privacyHref = locale === 'de' ? '/datenschutz' : '/en/privacy';

<nav class="site-footer__nav" aria-label={t.navAriaLabel}>
<ul>
<li><a href={productsHref}>{t.products}</a></li>
<li><a href={aboutHref}>{t.about}</a></li>
<li><a href={contactHref}>{t.contact}</a></li>
<li><a href={legalHref}>{t.legal}</a></li>
Expand Down
9 changes: 9 additions & 0 deletions src/components/SiteHeader.astro
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const locale = getLocaleFromPath(path);
const t = getUiStrings(locale);

const homeHref = locale === 'de' ? '/' : '/en';
const productsHref = locale === 'de' ? '/produkte' : '/en/products';
const productsActive = path === productsHref || path.startsWith(productsHref + '/');
const aboutHref = locale === 'de' ? '/ueber' : '/en/about';
const aboutActive = path === aboutHref || path === aboutHref + '/';
const contactHref = locale === 'de' ? '/kontakt' : '/en/contact';
Expand Down Expand Up @@ -46,6 +48,13 @@ const contactActive = path === contactHref || path === contactHref + '/';

<nav class="site-header__nav" id="primary-nav" aria-label={t.nav.ariaLabel}>
<ul class="site-header__list">
<li>
<a
class="site-header__link"
href={productsHref}
aria-current={productsActive ? 'page' : undefined}
>{t.nav.products}</a>
</li>
<li>
<a
class="site-header__link"
Expand Down
14 changes: 11 additions & 3 deletions src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,20 @@ const products = defineCollection({
lang: z.enum(['de', 'en']),
tagline: z.string(),
description: z.string(),
externalUrl: z.url(),
repo: z.string(),
externalUrl: z.url().optional(),
repo: z.string().optional(),
logo: z.string().optional(),
order: z.number().optional(),
draft: z.boolean().optional(),
}),
releaseDate: z.coerce.date().optional(),
showGithubLink: z.boolean().optional(),
}).refine(
(data) => Boolean(data.externalUrl || data.repo),
{
message: 'Either externalUrl or repo must be set on a product',
path: ['externalUrl'],
},
),
});

// Stub loader — returns no entries. Phase D replaces with a real GitHub-API
Expand Down
11 changes: 11 additions & 0 deletions src/content/products/thalura-plugin.de.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
name: "Thalura für Claude Cowork"
slug: "thalura-plugin"
lang: "de"
tagline: "Unterrichtsplanung im Dialog — passend zum Lehrplan."
description: "Ein Plugin für Claude Cowork. Es hilft Lehrkräften, Unterrichtseinheiten und einzelne Stunden zu planen sowie Materialien zu erstellen, die zum Lehrplan und zum jeweiligen Schulkontext passen — etwa für das Gymnasium in Hamburg in den Fächern Englisch und Philosophie."
repo: "blackbrowed-labs/thalura-plugin"
draft: true
---

<!-- Body iteration deferred — Lars edits on dev once draft entry is live. -->
11 changes: 11 additions & 0 deletions src/content/products/thalura-plugin.en.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
name: "Thalura for Claude Cowork"
slug: "thalura-plugin"
lang: "en"
tagline: "Curriculum-aligned planning, in conversation."
description: "A plugin for Claude Cowork. It helps teachers plan teaching units and individual lessons, and produce student materials that match the curriculum and school context — for example, Gymnasium in Hamburg in English and Philosophy."
repo: "blackbrowed-labs/thalura-plugin"
draft: true
---

<!-- Body iteration deferred — Lars edits on dev once draft entry is live. -->
11 changes: 11 additions & 0 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export const de = {
ariaLabel: 'Blackbrowed Labs — zur Startseite',
},

breadcrumb: {
ariaLabel: 'Brotkrümelnavigation',
},

nav: {
ariaLabel: 'Hauptnavigation',
about: 'Über',
Expand All @@ -37,6 +41,13 @@ export const de = {
system: 'System',
},

productsIndex: {
breadcrumb: 'Produkte',
h1: 'Produkte',
populatedArrowLabel: 'Mehr erfahren →',
arcAriaLabel: 'Der Blackbrowed-Labs-Bogen',
},

footer: {
copyright: '© 2026 Blackbrowed Labs. Lars Weiser, Reinbek.',
about: 'Über',
Expand Down
11 changes: 11 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export const en: UiStrings = {
ariaLabel: 'Blackbrowed Labs — back to home',
},

breadcrumb: {
ariaLabel: 'Breadcrumb',
},

nav: {
ariaLabel: 'Main navigation',
about: 'About',
Expand All @@ -36,6 +40,13 @@ export const en: UiStrings = {
system: 'System',
},

productsIndex: {
breadcrumb: 'Products',
h1: 'Products',
populatedArrowLabel: 'Learn more →',
arcAriaLabel: 'The Blackbrowed Labs arc',
},

footer: {
copyright: '© 2026 Blackbrowed Labs. Lars Weiser, Reinbek.',
about: 'About',
Expand Down
23 changes: 21 additions & 2 deletions src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,26 @@ export function getLocaleFromPath(path: string): Locale {
}

export function getCounterpartUrl(path: string): string | null {
return counterparts[canonicalise(path)] ?? null;
const canonical = canonicalise(path);

// Static map for named pages (homepage, about, legal, products index, etc.).
const mapped = counterparts[canonical];
if (mapped !== undefined) return mapped;

// Dynamic: product detail pages share a slug across locales (per
// TECH_STACK §5.2 — `slug` is the same in DE and EN). Pattern-match
// /produkte/<slug> ↔ /en/products/<slug> so the language switcher and
// hreflang alternates resolve without per-product map entries.
if (canonical.startsWith('/produkte/')) {
const slug = canonical.slice('/produkte/'.length);
return slug ? `/en/products/${slug}` : null;
}
if (canonical.startsWith('/en/products/')) {
const slug = canonical.slice('/en/products/'.length);
return slug ? `/produkte/${slug}` : null;
}

return null;
}

/**
Expand All @@ -59,7 +78,7 @@ export function getHreflangAlternates(
): Array<{ hreflang: 'de' | 'en' | 'x-default'; href: string }> {
const currentLocale = getLocaleFromPath(path);
const canonical = canonicalise(path);
const counterpart = counterparts[canonical];
const counterpart = getCounterpartUrl(canonical);

const dePath = currentLocale === 'de' ? canonical : counterpart;
const enPath = currentLocale === 'en' ? canonical : counterpart;
Expand Down
30 changes: 30 additions & 0 deletions src/lib/products.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Product visibility filter.
*
* Wraps `getCollection('products')` with the env-aware draft + releaseDate
* gates:
* - On dev / staging (anything that is not `isProduction()`), every product
* is visible — drafts and scheduled entries included. Lets Lars test
* in-progress products on `dev.blackbrowedlabs.com` before flipping
* `draft` or before the `releaseDate` is reached.
* - On production, products are hidden if `draft: true` OR if `releaseDate`
* is in the future (compared to build time).
*
* Three call sites use this helper: `src/pages/produkte/index.astro`,
* `src/pages/en/products/index.astro`, and the [slug].astro
* `getStaticPaths()` in both locales.
*/

import { getCollection } from 'astro:content';
import { isProduction } from './env';

export async function getVisibleProducts(lang: 'de' | 'en') {
const all = await getCollection('products', (e) => e.data.lang === lang);
if (!isProduction()) return all;
const now = new Date();
return all.filter((e) => {
if (e.data.draft === true) return false;
if (e.data.releaseDate && e.data.releaseDate > now) return false;
return true;
});
}
5 changes: 4 additions & 1 deletion src/pages/datenschutz.astro
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
*/

import BaseLayout from '../layouts/BaseLayout.astro';
import { getUiStrings } from '../i18n';
import { cloudflareFacts, formatVerifiedDate } from '../lib/cloudflare-facts';

const t = getUiStrings('de');

const title = 'Datenschutzerklärung';
const description =
'Datenschutzerklärung von Blackbrowed Labs. Hosting Cloudflare, E-Mail IONOS, cookielose Cloudflare Web Analytics, Cloudflare Turnstile am Kontaktformular.';
Expand All @@ -35,7 +38,7 @@ const tocItems = [
---

<BaseLayout title={`${title} — Blackbrowed Labs`} description={description}>
<nav class="breadcrumb" aria-label="Breadcrumb">
<nav class="breadcrumb" aria-label={t.breadcrumb.ariaLabel}>
<div class="breadcrumb__inner">
<a href="/">Blackbrowed Labs</a>
<span class="breadcrumb__sep" aria-hidden="true">/</span>
Expand Down
5 changes: 4 additions & 1 deletion src/pages/en/about.astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { getEntry, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import BrandLogo from '../../components/BrandLogo.astro';
import { getUiStrings } from '../../i18n';

const entry = await getEntry('editorial', 'about.en');
if (!entry) throw new Error('Missing editorial entry: about.en');
Expand All @@ -21,10 +22,12 @@ const { title, description, intro, closing } = entry.data;
if (!title || !description) {
throw new Error('about.en editorial entry missing required fields');
}

const t = getUiStrings('en');
---

<BaseLayout title={`${title} — Blackbrowed Labs`} description={description}>
<nav class="breadcrumb" aria-label="Breadcrumb">
<nav class="breadcrumb" aria-label={t.breadcrumb.ariaLabel}>
<div class="breadcrumb__inner">
<a href="/en">Blackbrowed Labs</a>
<span class="breadcrumb__sep" aria-hidden="true">/</span>
Expand Down
5 changes: 4 additions & 1 deletion src/pages/en/contact.astro
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { getEntry, render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import ContactForm from '../../components/forms/ContactForm.astro';
import { getUiStrings } from '../../i18n';

const entry = await getEntry('editorial', 'contact.en');
if (!entry) throw new Error('Missing editorial entry: contact.en');
Expand All @@ -15,10 +16,12 @@ const { title, description } = entry.data;
if (!title || !description) {
throw new Error('contact.en editorial entry missing required fields');
}

const t = getUiStrings('en');
---

<BaseLayout title={`${title} — Blackbrowed Labs`} description={description}>
<nav class="breadcrumb" aria-label="Breadcrumb">
<nav class="breadcrumb" aria-label={t.breadcrumb.ariaLabel}>
<div class="breadcrumb__inner">
<a href="/en">Blackbrowed Labs</a>
<span class="breadcrumb__sep" aria-hidden="true">/</span>
Expand Down
5 changes: 4 additions & 1 deletion src/pages/en/legal.astro
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@
*/

import BaseLayout from '../../layouts/BaseLayout.astro';
import { getUiStrings } from '../../i18n';

const t = getUiStrings('en');

const title = 'Legal Notice';
const description =
'Legal notice for Blackbrowed Labs pursuant to § 5 DDG. Lars Weiser, Reinbek. VAT ID DE461658750.';
---

<BaseLayout title={`${title} — Blackbrowed Labs`} description={description}>
<nav class="breadcrumb" aria-label="Breadcrumb">
<nav class="breadcrumb" aria-label={t.breadcrumb.ariaLabel}>
<div class="breadcrumb__inner">
<a href="/en">Blackbrowed Labs</a>
<span class="breadcrumb__sep" aria-hidden="true">/</span>
Expand Down
5 changes: 4 additions & 1 deletion src/pages/en/privacy.astro
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@
*/

import BaseLayout from '../../layouts/BaseLayout.astro';
import { getUiStrings } from '../../i18n';
import { cloudflareFacts, formatVerifiedDate } from '../../lib/cloudflare-facts';

const t = getUiStrings('en');

const title = 'Privacy Policy';
const description =
'Privacy policy for Blackbrowed Labs. Hosting Cloudflare, email IONOS, cookieless Cloudflare Web Analytics, Cloudflare Turnstile on the contact form.';
Expand All @@ -38,7 +41,7 @@ const tocItems = [
---

<BaseLayout title={`${title} — Blackbrowed Labs`} description={description}>
<nav class="breadcrumb" aria-label="Breadcrumb">
<nav class="breadcrumb" aria-label={t.breadcrumb.ariaLabel}>
<div class="breadcrumb__inner">
<a href="/en">Blackbrowed Labs</a>
<span class="breadcrumb__sep" aria-hidden="true">/</span>
Expand Down
Loading
Loading