Skip to content
Merged
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ Standard-Invocations (`dotnet build|test`, in `src/nodepilot-ui` die `package.js

### Testumfang pro Änderung

**Tests schreiben ≠ alle Tests ausführen.** Die Pflicht oben gilt unverändert für das *Schreiben*; lokal *ausgeführt* wird nur, was die Änderung betrifft. Die Voll-Suite ist gemessen unverhältnismäßig (6.597 Backend-Testfälle, 240 Vitest-Dateien, 77 E2E-Specs — die beiden Frontend-Zahlen hält `DocumentationCountsTests` an der Dateiliste fest, die Backend-Zahl bleibt ein Handmaß) und liefert lokal kein neues Signal: das Netz hängt an `ci.yml`, das auf **jedem PR und jedem Push auf main** läuft (Coverage-Gate + E2E eingeschlossen).
**Tests schreiben ≠ alle Tests ausführen.** Die Pflicht oben gilt unverändert für das *Schreiben*; lokal *ausgeführt* wird nur, was die Änderung betrifft. Die Voll-Suite ist gemessen unverhältnismäßig (6.597 Backend-Testfälle, 241 Vitest-Dateien, 77 E2E-Specs — die beiden Frontend-Zahlen hält `DocumentationCountsTests` an der Dateiliste fest, die Backend-Zahl bleibt ein Handmaß) und liefert lokal kein neues Signal: das Netz hängt an `ci.yml`, das auf **jedem PR und jedem Push auf main** läuft (Coverage-Gate + E2E eingeschlossen).

**Der Nightly ist kein verlässlicher zweiter Boden.** Er läuft als Windows-Task um 22:00 gegen den ausgecheckten Baum und wird verpasst, sobald die Maschine dann aus ist. Wer sich auf ihn beruft, prüft vorher `C:\temp\nodepilot-nightly\latest.md` auf sein Datum.

Expand Down
21 changes: 13 additions & 8 deletions src/nodepilot-docs-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ Der Pages-Workflow baut weder Website noch Demo und benötigt deren Assets nicht
| `media/` | `pages-media/` | https://www.nodepilot.run/media/nodepilot-product-tour.mp4 |
| `og-image.png` | `public/og-image.png` | Vorschaubild der Website |

**Schulungsvideos** (`/tutorials/`, „NodePilot in 2 Minuten"): `npm run site:videos` liest die
**Media-Bereich** (`/tutorials/`, „NodePilot in 2 Minuten"): Die Adresse bleibt für
bestehende Such- und Direktlinks erhalten; `/media/` liefert die Videodateien.
`npm run site:videos` liest die
gerenderten Folgen aus `out/nodepilot-training/` und erledigt drei Dinge:

- Es kopiert die MP4s nach `pages-media/training/`. Der Ordner ist gitignored.
Expand Down Expand Up @@ -164,15 +166,18 @@ Doku und serviert Website (Port 5176) und Doku (Port 5187) getrennt per `vite pr

## Geführter Produkteinstieg

Die Website-Route `/walkthrough/` bietet zwei Aufgaben direkt in der Browserdemo an:
eine Konfigurationsdatei bereitstellen und einen fehlgeschlagenen Kopiervorgang untersuchen.
Die Links `demo/?tour=file&lang=de` und `demo/?tour=diagnose&lang=de` öffnen die Führung;
`lang=en` verwendet Englisch. Die Übersichtsgrafik bleibt auf der Startseite.
Die Website-Route `/walkthrough/` bietet zehn Aufgaben direkt in der Browserdemo an:
Workflow-Bau, Fehleranalyse und Dateibereitstellung stehen am Anfang. Danach folgen
Entscheidung, parallele Arbeit, Dienst-Recovery, Live Ops, Versionsvergleich,
Maschinenprüfung und Wartungsfenster.
Jede Karte öffnet `demo/?tour=<id>&lang=de` oder `lang=en`. Die Übersichtsgrafik bleibt
auf der Startseite.

Die Führung in `../nodepilot-ui/demo/ui/tour.ts` begleitet den echten Startdialog und die
Ausführungshistorie. Der Beispiel-Workflow kommt aus `scripts/example-guided-file-workflow.json`;
Registry, Dienst und Dateisystem sind simuliert. Eingaben werden im Lauf gespeichert und bestimmen
die erzeugten Inhalte und Ausgaben. `Protected` simuliert fehlende Schreibrechte beim Kopieren.
Ausführungshistorie. Die acht weiteren Aufgaben liegen in `../nodepilot-ui/demo/ui/additionalTours.ts`.
Alle Änderungen und Ausführungen bleiben im Browser-Tab; ein Reload stellt die Seed-Daten wieder her.
Der Datei-Workflow kommt aus `scripts/example-guided-file-workflow.json`; Registry, Dienst und
Dateisystem sind simuliert. `Protected` simuliert fehlende Schreibrechte beim Kopieren.

`npm run test:site:e2e` prüft Einstieg, Sprachwechsel und drei Bildschirmgrößen.
Die eigentliche Führung wird mit `npm --prefix ../nodepilot-ui run test:e2e:demo` geprüft.
Expand Down
23 changes: 21 additions & 2 deletions src/nodepilot-docs-ui/e2e-site/experience.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,38 @@
import { expect, test } from '@playwright/test'

test('offers both guided tasks with the current language and no second workflow renderer', async ({ page }) => {
test('offers ten guided tasks with the current language and no second workflow renderer', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', error => errors.push(error.message))
await page.goto('/walkthrough/')
await expect(page.locator('.experience-mission').first()).toHaveAttribute('data-mission', 'build')
await expect(page.locator('.experience-mission').nth(1)).toHaveAttribute('data-mission', 'diagnose')
await expect(page.locator('.experience-mission').nth(2)).toHaveAttribute('data-mission', 'file')
await expect(page.locator('.experience-mission-number').first()).toHaveText('01')
await expect(page.locator('.experience-mission-number').nth(2)).toHaveText('03')
await expect(page.locator('.experience-more')).toHaveCount(0)
await expect(page.locator('[data-tour="file"]')).toHaveAttribute('href', '/demo/?tour=file&lang=de')
await expect(page.locator('[data-tour="diagnose"]')).toHaveAttribute('href', '/demo/?tour=diagnose&lang=de')
for (const id of ['build', 'decision', 'parallel', 'service', 'live', 'versions', 'machine', 'maintenance']) {
await expect(page.locator(`[data-tour="${id}"]`)).toHaveAttribute('href', `/demo/?tour=${id}&lang=de`)
}
await expect(page.locator('#experience-root canvas')).toHaveCount(0)
await page.locator('.header-lang [data-lang="en"]').click()
await expect(page.locator('[data-tour="file"]')).toHaveText('Start guided walkthrough')
await expect(page.locator('[data-tour="file"]')).toHaveAttribute('href', '/demo/?tour=file&lang=en')
await expect(page.locator('[data-tour="diagnose"]')).toHaveAttribute('href', '/demo/?tour=diagnose&lang=en')
for (const id of ['build', 'decision', 'parallel', 'service', 'live', 'versions', 'machine', 'maintenance']) {
await expect(page.locator(`[data-tour="${id}"]`)).toHaveAttribute('href', `/demo/?tour=${id}&lang=en`)
}
await page.locator('[data-nav="home"]').click()
await expect(page.locator('#experience-root')).toHaveCount(0)
await page.locator('[data-nav="experience"]').click()
await expect(page.locator('.experience-mission')).toHaveCount(2)
await expect(page.locator('.experience-mission')).toHaveCount(10)
const backgrounds = await page.locator('.experience-mission').evaluateAll(cards => cards.map(card => ({
color: getComputedStyle(card).backgroundColor,
image: getComputedStyle(card).backgroundImage,
})))
expect(new Set(backgrounds.map(background => background.color)).size).toBe(1)
expect(new Set(backgrounds.map(background => background.image)).size).toBe(10)
expect(errors).toEqual([])
})

Expand All @@ -23,6 +41,7 @@ for (const width of [390, 768, 1440]) {
await page.setViewportSize({ width, height: 1000 })
await page.goto('/walkthrough/')
await expect(page.locator('[data-tour="file"]')).toBeVisible()
await expect(page.locator('[data-tour="maintenance"]')).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.screenshot({ path: test.info().outputPath(`entry-${width}.png`), fullPage: true })
})
Expand Down
20 changes: 20 additions & 0 deletions src/nodepilot-docs-ui/e2e-site/seo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@ const siteRoutes = ['', 'product/', 'walkthrough/', 'tutorials/', 'blog/', ...AR

test.describe('static SEO content', () => {
test.use({ javaScriptEnabled: false })
test('home title and Media page are crawlable in both languages', async ({ page, request, baseURL }) => {
for (const language of ['de', 'en'] as const) {
const prefix = language === 'en' ? '/en' : ''
await page.goto(`${prefix}/`)
await expect(page).toHaveTitle(/^NodePilot – .*Windows.*PowerShell/)
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute('content', await page.title())

await page.goto(`${prefix}/tutorials/`)
await expect(page.locator('[data-nav="videos"]')).toHaveText('Media')
await expect(page.locator('#videos-page h1')).toContainText('Media')
await expect(page).toHaveTitle(/^Media: .*?(?:Videos|videos)/)
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', `${baseURL}${prefix}/tutorials/`)
await expect(page.locator('meta[name="description"]')).toHaveAttribute('content', /(?:Video-Tutorials|video tutorials)/)
await page.setViewportSize({ width: 390, height: 844 })
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
}
const sitemap = await (await request.get('/sitemap.xml')).text()
expect(sitemap).toContain(`<loc>${baseURL}/tutorials/</loc>`)
expect(sitemap).toContain(`<loc>${baseURL}/en/tutorials/</loc>`)
})
for (const language of ['de', 'en']) {
test(`${language}: every page contains its content, self canonical and reciprocal languages without JS`, async ({ page, request, baseURL }) => {
for (const route of siteRoutes) {
Expand Down
26 changes: 18 additions & 8 deletions src/nodepilot-docs-ui/src/site/experience/experience.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,29 @@
.experience-intro{padding:40px 0 34px;max-width:850px}
.experience-intro h1{font-size:clamp(32px,4vw,56px);line-height:1.12;letter-spacing:-.045em;margin:14px 0 20px}
.experience-intro p{color:var(--muted);font-size:17px;line-height:1.8;max-width:750px}
.experience-missions{display:grid;grid-template-columns:1.1fr 1fr;gap:24px}
.experience-mission{position:relative;display:flex;flex-direction:column;align-items:flex-start;min-width:0;border:1px solid var(--line);border-radius:10px;padding:34px;background:var(--surface-lowest);overflow:hidden}
.experience-mission:first-child{background:radial-gradient(ellipse at 100% 0,#18325280,transparent 70%),var(--surface-lowest);border-color:#345578}
.experience-mission-number{font:72px/1 var(--font-mono);color:#6da8ff18;position:absolute;right:24px;top:22px;pointer-events:none}
.experience-mission .section-kicker{font-size:10px;position:relative}
.experience-missions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}
.experience-mission{--mission-accent:#6da8ff;position:relative;display:flex;flex-direction:column;align-items:flex-start;min-width:0;border:1px solid color-mix(in srgb,var(--mission-accent) 30%,var(--line));border-radius:10px;padding:34px;background:radial-gradient(ellipse at 100% 0,color-mix(in srgb,var(--mission-accent) 13%,transparent),transparent 70%),var(--surface-lowest);overflow:hidden}
.experience-mission[data-mission="build"]{--mission-accent:#75adf3}
.experience-mission[data-mission="diagnose"]{--mission-accent:#e5bc70}
.experience-mission[data-mission="file"]{--mission-accent:#81c7b3}
.experience-mission[data-mission="decision"]{--mission-accent:#b49ce9}
.experience-mission[data-mission="parallel"]{--mission-accent:#78c5e0}
.experience-mission[data-mission="service"]{--mission-accent:#e19aa3}
.experience-mission[data-mission="live"]{--mission-accent:#a3ce83}
.experience-mission[data-mission="versions"]{--mission-accent:#d9a987}
.experience-mission[data-mission="machine"]{--mission-accent:#9ba8e8}
.experience-mission[data-mission="maintenance"]{--mission-accent:#d19bc8}
.experience-mission-number{font:72px/1 var(--font-mono);color:color-mix(in srgb,var(--mission-accent) 23%,transparent);position:absolute;right:24px;top:22px;pointer-events:none}
.experience-mission .section-kicker{font-size:10px;position:relative;color:var(--mission-accent);text-transform:uppercase}
.experience-mission h2{font-size:29px;letter-spacing:-.03em;line-height:1.25;margin:22px 0 14px;max-width:340px;position:relative}
.experience-mission p{color:var(--muted);font-size:14px;line-height:1.8;margin:0}
.experience-mission ol{padding-left:20px;color:var(--text);font-size:13px;line-height:2.4;margin:26px 0 30px}
.experience-mission li::marker{color:var(--primary);font-family:var(--font-mono)}
.experience-mission blockquote{margin:26px 0 30px;padding:18px;border-left:2px solid #e5bc70;background:#e5bc7008;width:100%;box-sizing:border-box}
.experience-mission blockquote code{font:12px/1.8 var(--font-mono);color:#e5bc70;overflow-wrap:anywhere}
.experience-mission li::marker{color:var(--mission-accent);font-family:var(--font-mono)}
.experience-mission blockquote{margin:26px 0 30px;padding:18px;border-left:2px solid var(--mission-accent);background:color-mix(in srgb,var(--mission-accent) 5%,transparent);width:100%;box-sizing:border-box}
.experience-mission blockquote code{font:12px/1.8 var(--font-mono);color:var(--mission-accent);overflow-wrap:anywhere}
.experience-mission blockquote span{display:block;font-size:12px;color:var(--muted);margin-top:8px;line-height:1.7}
.experience-mission .button{margin-top:auto;min-height:44px;text-align:center}
.experience-mission .button-secondary{border-color:color-mix(in srgb,var(--mission-accent) 45%,var(--line))}
.experience-notice{font-size:12px;line-height:1.8;color:var(--muted);margin:24px 0}
.experience-links{display:flex;align-items:center;flex-wrap:wrap;gap:24px;margin:26px 0 40px;font-size:13px}
@media(max-width:900px){.experience-missions{grid-template-columns:1fr}.experience-intro{padding:24px 0}.experience-mission{padding:26px}.experience-mission h2{max-width:none;font-size:26px}.experience-mission-number{font-size:60px}}
15 changes: 13 additions & 2 deletions src/nodepilot-docs-ui/src/site/experience/markup.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { messages } from '../i18n'
import type { Lang } from '../../i18n/languages'
import { additionalMissions } from './missions'

export function experienceMarkup(lang: Lang, base: string): string {
const copy = messages[lang].experience
const card = (mission: { id: string; title: string; text: string; steps: readonly string[]; duration: string }, number: number) => `<article class="experience-mission" data-mission="${mission.id}">
<span class="experience-mission-number" aria-hidden="true">${String(number).padStart(2, '0')}</span>
<div class="section-kicker">${mission.duration}</div><h2>${mission.title}</h2><p>${mission.text}</p>
<ol>${mission.steps.map(step => `<li>${step}</li>`).join('')}</ol>
<a class="button ${number === 1 ? 'button-primary' : 'button-secondary'}" data-tour="${mission.id}">${copy.moreAction}</a>
</article>`
const build = additionalMissions[lang].find(mission => mission.id === 'build')!
const extra = additionalMissions[lang].filter(mission => mission.id !== 'build').map((mission, index) => card(mission, index + 4)).join('')
return `<div class="experience-missions">
<article class="experience-mission"><span class="experience-mission-number" aria-hidden="true">01</span><div class="section-kicker" data-experience-text="firstKicker"></div><h2 data-experience-text="firstTitle"></h2><p data-experience-text="firstText"></p><ol><li data-experience-text="firstStep"></li><li data-experience-text="secondStep"></li><li data-experience-text="thirdStep"></li></ol><a class="button button-primary" data-tour="file" data-experience-text="firstAction"></a></article>
<article class="experience-mission"><span class="experience-mission-number" aria-hidden="true">02</span><div class="section-kicker" data-experience-text="secondKicker"></div><h2 data-experience-text="secondTitle"></h2><p data-experience-text="secondText"></p><blockquote><code>File Copy → Access denied</code><span data-experience-text="clue"></span></blockquote><a class="button button-secondary" data-tour="diagnose" data-experience-text="secondAction"></a></article>
${card(build, 1)}
<article class="experience-mission" data-mission="diagnose"><span class="experience-mission-number" aria-hidden="true">02</span><div class="section-kicker" data-experience-text="secondKicker"></div><h2 data-experience-text="secondTitle"></h2><p data-experience-text="secondText"></p><blockquote><code>File Copy → Access denied</code><span data-experience-text="clue"></span></blockquote><a class="button button-secondary" data-tour="diagnose" data-experience-text="secondAction"></a></article>
<article class="experience-mission" data-mission="file"><span class="experience-mission-number" aria-hidden="true">03</span><div class="section-kicker" data-experience-text="firstKicker"></div><h2 data-experience-text="firstTitle"></h2><p data-experience-text="firstText"></p><ol><li data-experience-text="firstStep"></li><li data-experience-text="secondStep"></li><li data-experience-text="thirdStep"></li></ol><a class="button button-secondary" data-tour="file" data-experience-text="firstAction"></a></article>
${extra}
</div><p class="experience-notice" data-experience-text="notice"></p>`.replace(/data-experience-text="([^"]+)"><\//g, (_match, key: keyof typeof copy) => `data-experience-text="${key}">${copy[key]}</`)
.replace(/data-tour="([^"]+)"/g, (_match, tour: string) => `data-tour="${tour}" href="${base}demo/?tour=${tour}&amp;lang=${lang}"`)
}
Loading
Loading