Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/workflow-designer-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Vollständige Auflistung sämtlicher Features des NodePilot-Workflow-Designers (
- **Controls-Panel:** Standard React-Flow-Controls (Zoom in/out, Fit, Lock).
- **Hintergrund:** Ein **Punkt-Raster** (Gap 24 px, Dot-Größe 1.6) — identisch in Premium **und** Classic, der frühere zweistufige Crosshatch ist ersatzlos entfallen. Die Deckkraft ist skin-abhängig: helle Skins tragen einen deutlich stärkeren Alpha, weil das Raster auf hellem Grund mehr Kontrast braucht als auf dunklem. Bei aktivem Snap-to-Grid ersetzt ein **Linien**-Raster auf der Snap-Schrittweite das Punkt-Raster.
- **Snap-to-Grid (Expert):** `G` togglet Raster-Snapping (Standard 20 px, konfigurierbar).
- **Node-Skalierung:** 8 Größen-Presets (XS … 4XL, Default SM); `Ctrl+Shift+>` / `Ctrl+Shift+<` (Expert) ändern Node-Größe, `Ctrl+Alt+.` / `Ctrl+Alt+,` die Label-Schriftgröße (nur Classic-Node-Stil).
- **Node-Skalierung:** 8 Größen-Presets (XS … 4XL, Default L); `Ctrl+Shift+>` / `Ctrl+Shift+<` (Expert) ändern Node-Größe, `Ctrl+Alt+.` / `Ctrl+Alt+,` die Label-Schriftgröße (nur Classic-Node-Stil).
- **Fullscreen / Distraction-Free:** `F11` blendet Sidebar, Panels und Banner aus, behält Header + Canvas + Exit-Pill.
- **Viewport-Virtualisierung:** Nur sichtbare Elemente werden gerendert (`onlyRenderVisibleElements`) — spürbar ab ~50 Nodes.

Expand Down Expand Up @@ -337,7 +337,7 @@ Alle Darstellungs-Einstellungen liegen im **`designStore`** (Zustand + persist,
| `designerTheme` | `atelier` | atelier / classic | Designsprache des Designers: **Atelier** (eigener Werkbank-Look — Papier/Graphit-Grund mit dem gewohnten Karo-Raster, schwebendes Karten-Chrome, ein Akzent; `styles/designer-atelier.css` mappt die `--color-*`-Tokens über eine `--wd-*`-Palettenschicht) vs. **Classic** (bisheriger Look, byte-identisch). **Farb-Skins adaptieren beide Looks:** im Atelier re-pointet jeder Skin Akzentfamilie + Grundton (`--wd-accent*`, `--wd-canvas`/`--wd-panel`), die Atelier-Geometrie bleibt universell; Status-Farben bleiben in beiden Looks skin-stabil. Umschalter (`Brush`-Icon, `role="switch"`, `data-testid="toggle-atelier-theme"`) in beiden Kopfleisten-Layouts |
| `toolbarLayout` | `compact` | compact / classic | Kopfleisten-Layout: kompakt (gruppierte Popover-Menüs, grüner „Ausführen") vs. klassische Inline-Reihe (jeder Toggle/Tool als einzelner Button, icon-only Play). Umschalter-Button in beiden Layouts |
| `nodeStyle` | `classic` | classic / card | Node-Darstellung (`Ctrl+Shift+N`) |
| `nodeScaleIndex` | 1 | 0–7 (XS … 4XL) | Node-Größe |
| `nodeScaleIndex` | 3 | 0–7 (XS … 4XL) | Node-Größe |
| `labelFontOffsetIndex` | 2 | 0–6 (−4 … +8 px) | Label-Schriftgröße |
| `edgesAnimated` | true | bool | Fluss-Animation der Kanten (`A`) |
| `edgeWidthIndex` | 2 | 1.5/2/2.5/3.5/5/7 px | Kantenbreite (`Ctrl+]`/`[`) |
Expand Down
6 changes: 5 additions & 1 deletion src/nodepilot-ui/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ Gilt für `src/nodepilot-ui/`. Projektweite Regeln stehen in der Root-`CLAUDE.md
- Client-State via Zustand-Stores (`src/stores/`), Server-State via TanStack React Query (`refetchOnWindowFocus:false`, SignalR invalidiert Caches).
- **Typografie:** `IBM Plex Sans Variable` (`--font-headline`/`--font-body`/`--font-label`, alle drei identisch) + `IBM Plex Mono` (`--font-mono`), deklariert im `@theme` von `index.css`. Beide self-hosted über fontsource — **keine externen Font-Requests**: die Prod-CSP kennt kein `font-src` und fällt auf `default-src 'self'`, ein CDN-Font wäre in Produktion geblockt. Body trägt ein `font-size-adjust` als einzigen Dichte-Ausgleich (Plex hat eine kleinere x-Höhe als das früher genutzte Inter); Monospace ist davon ausgenommen. Monaco kann keine CSS-Variable verwerten und hält den Stack als `MONO_FONT_STACK` in `lib/monacoSetup.ts` — `fontTokens.test.ts` hält beide Seiten deckungsgleich. Die Doku-Website fährt bewusst ein eigenes Type-System (Geist + JetBrains Mono).

- **Formularfelder:** `.input-field` (index.css) statt handgebauter `border …`-Klassenketten. Der Stil ist *versenkt* (dunkler als der Container + Inset-Schatten) und setzt damit eine **angehobene** Fläche voraus — `.np-card` bzw. das `ModalShell`-Panel (`.np-modal-panel`, dark = `surface-container`). Wer einen eigenen Dialog-Container baut, muss diese Anhebung mitnehmen, sonst malt sich das Feld in der Farbe seines Containers und verschwindet.
- **Formularfelder — zwei Ebenen, nicht eine.** Welche gilt, entscheidet die Fläche darunter:
- **`.input-field`** (index.css) für Felder auf einer angehobenen Fläche: Designer-Property-Panels, `.np-card`. Der Stil ist *versenkt* (dunkler als der Container + Inset-Schatten) und **setzt diese Anhebung voraus**. Handgebaute `border …`-Ketten sind dort falsch.
- **Umriss-Kette** `px-3 py-2 border border-outline-variant rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500` (+ `w-full`, bei `<select>` zusätzlich `bg-surface-lowest`) für **Seiten-Dialoge** — `ModalShell` sitzt auf `surface-lowest`, und die Panel-Anhebung `.np-modal-panel` existiert **nur in den Dark-Skins**. Ein `.input-field` malt sich dort in Panel-Farbe: auf `light-grey` ein flacher beiger Kasten auf Weiß, der beim Fokus *heller* wird als das Panel und seine Kontur verliert. So halten es GlobalVariablesPage, MachinesPage, UsersPage, MaintenanceWindowsPage, die Alerting-Editoren und CustomActivitiesPage.
- `focus:ring-blue-500` ist hier **kein** Farbliteral-Verstoß: index.css remappt die Blau-Ring-Utilities über `np-accent-remap` auf `--np-accent-ring`, der Ring folgt also dem Skin-Akzent.
- **Breite nie in eine geteilte Feld-Konstante backen.** Zwei konkurrierende Utilities derselben Tailwind-Layer werden über ihre Reihenfolge im generierten Stylesheet aufgelöst, nicht über die Klassen-Attribut-Reihenfolge — `w-full` in der Konstante und `w-28` am Feld ist ein Münzwurf. Ebenso: `.input-field` ist **unlayered** und schlägt damit *jede* Utility aus `@layer utilities`; ein `w-28 text-xs` daneben ist wirkungslos.

## E2E (Playwright)

Expand Down
15 changes: 11 additions & 4 deletions src/nodepilot-ui/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,18 @@ alongside `dotnet test` and `npm run test:run`.
combination keeps the pre-existing DOM of every spec (buttons visible, nav entry hidden) —
do NOT change it to all-true, or the AI-Chat nav appears suite-wide. Override per test with
`mockCaps(page, capsJson({...}))` (both exported from `fixtures/mockApi.ts`).
- `installDefaultMocks` also **pins the designer to the CLASSIC look** (localStorage seed
`designerTheme: 'classic'`) — the Atelier design (default for fresh real profiles) re-tokenises
colors/geometry the existing visual assertions were written against. The seed is skipped once
the app itself persisted a full designStore state (so mid-test toggles survive `page.reload`).
- `installDefaultMocks` also **pins the designer to the CLASSIC look and the SMALL node scale**
(localStorage seed `designerTheme: 'classic'`, `nodeScaleIndex: 1`, `version: 2`) — both are
geometry knobs the existing visual assertions were written against. The Atelier design (default
for fresh real profiles) re-tokenises colors/geometry; the node scale (default `3` = `lg`) changes
how much room a node takes, and `fitView` turns that into a different pan/zoom for the same seeded
positions — at `lg` a node can slide under the bottom-right minimap and lose its clicks. The
seeded `version` must match the store's persist version, or the store's own migration reads the
pinned `sm` as "still on the old default" and lifts it back to `lg`. The seed is skipped once the
app itself persisted a full designStore state (so mid-test toggles survive `page.reload`).
Atelier-path specs live in `designer-atelier.spec.ts` and seed `'atelier'` explicitly.
**Any seed that replaces the whole `nodepilot-design` key must re-assert both pins** — that is why
`seedExpertMode` and `seedAtelier` repeat them.
- **The preview build renders the UI in English** (i18n falls back to EN). All
activity- and trigger-config panels are translated for both DE and EN. Prefer
language-agnostic selectors anyway — `getByRole` with bilingual regex
Expand Down
17 changes: 15 additions & 2 deletions src/nodepilot-ui/e2e/admin-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,20 +131,33 @@ test.describe('Admin Settings (Teil 38 + 76)', () => {
test('76.1 — all System section tabs render; Integrations shows SMTP + LLM', async ({ page }) => {
await openSystemTab(page);

// The eight section sub-tabs are always rendered in the tab bar.
// The nine section sub-tabs are always rendered in the tab bar, in this order.
// System info is deliberately LAST — it is the only read-only tab, so it sits after
// every tab that edits configuration instead of splitting that run.
const tabs = [
/integrations/i,
/ai knowledge/i,
/retention/i,
/system info|system-info/i,
/authentication/i,
/logging .* telemetry|logging & telemetry/i,
/^security$/i,
/^performance$/i,
/^database$/i,
/system info|system-info/i,
];
for (const name of tabs) {
await expect(page.getByRole('button', { name }).first()).toBeVisible({ timeout: 15_000 });
}
// Pin the order, not just the presence: the read-only tab must stay at the right edge.
// Three `.np-tab-list`s are on screen (Personal/System, these sub-tabs, the LLM profile
// strip inside Integrations) — pick ours by the one tab only it carries.
const tabBar = page.locator('.np-tab-list')
.filter({ has: page.getByRole('button', { name: /^security$/i }) });
const rendered = await tabBar.getByRole('button').allInnerTexts();
expect(rendered.map((s) => s.trim().toLowerCase())).toEqual([
'integrations', 'ai knowledge', 'retention', 'authentication',
'logging & telemetry', 'security', 'performance', 'database', 'system info',
]);

// Default active tab is Integrations → SMTP + LLM cards mount from their mocked snapshots.
await expect(page.getByRole('heading', { name: /^smtp$/i })).toBeVisible();
Expand Down
7 changes: 6 additions & 1 deletion src/nodepilot-ui/e2e/designer-atelier.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ async function seedAtelier(page: Page) {
let appWritten = false;
try { appWritten = !!raw && JSON.parse(raw).state?.nodeStyle !== undefined; } catch { /* reseed */ }
if (!appWritten) {
localStorage.setItem('nodepilot-design', JSON.stringify({ state: { designerTheme: 'atelier' }, version: 1 }));
// Carries the node-scale pin from installDefaultMocks too: this seed replaces the whole
// key, so without it the Atelier specs would be the only ones running at the `lg` default
// and would drift away from the rest of the suite's canvas geometry.
localStorage.setItem('nodepilot-design', JSON.stringify({
state: { designerTheme: 'atelier', nodeScaleIndex: 1 }, version: 2,
}));
}
});
}
Expand Down
37 changes: 26 additions & 11 deletions src/nodepilot-ui/e2e/fixtures/mockApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,23 +52,34 @@ export async function mockCaps(page: Page, caps: KnowledgeCapabilities) {
}

export async function installDefaultMocks(page: Page) {
// Pin the designer to the CLASSIC look for the whole hermetic suite. The Atelier design
// (designStore.designerTheme, default 'atelier') re-tokenises colors/geometry the visual
// assertions in these specs were written against; the classic look must stay byte-identical,
// so the entire existing suite keeps running against it. Atelier gets its own dedicated
// specs (designer-atelier.spec.ts) that seed 'atelier' explicitly.
// Pin the designer to the CLASSIC look AND the small node scale for the whole hermetic suite.
// Both are geometry knobs the canvas assertions in these specs were written against:
// - designerTheme (default 'atelier') re-tokenises colors/geometry; the classic look must stay
// byte-identical, so the entire existing suite keeps running against it. Atelier gets its own
// dedicated specs (designer-atelier.spec.ts) that seed 'atelier' explicitly.
// - nodeScaleIndex (default 3 = `lg` since the v2 store bump) changes how much room a node
// occupies, and `fitView` turns that into a different pan/zoom for the same seeded positions.
// At `lg`, ai-assistant's step-b slid under the bottom-right minimap, which then swallowed the
// click (the hazard e2e/README.md already warns about). Pin `sm` so a future size tweak can
// never reshuffle unrelated specs' canvas coordinates; the scale itself is covered by unit
// tests (designStore.test.ts, CanvasSettings.test.tsx).
// `version: 2` matches the store's current persist version so the seed is taken as-is — at
// version 1 the store's own migration would read this pinned `sm` as "still on the old default"
// and lift it straight back to `lg`.
//
// Init scripts re-run on EVERY navigation (including page.reload) — an unconditional
// setItem would stomp state the app itself persisted mid-test (e.g. after clicking the
// Atelier toggle) and make persistence untestable. An app write always contains the full
// designStore state (nodeStyle & friends); seeds only carry 1-2 keys — use that to only
// designStore state (nodeStyle & friends); seeds only carry a few keys — use that to only
// seed fresh contexts.
await page.addInitScript(() => {
const raw = localStorage.getItem('nodepilot-design');
let appWritten = false;
try { appWritten = !!raw && JSON.parse(raw).state?.nodeStyle !== undefined; } catch { /* reseed */ }
if (!appWritten) {
localStorage.setItem('nodepilot-design', JSON.stringify({ state: { designerTheme: 'classic' }, version: 1 }));
localStorage.setItem('nodepilot-design', JSON.stringify({
state: { designerTheme: 'classic', nodeScaleIndex: 1 }, version: 2,
}));
}
});
// Hermetic catch-all for any REST endpoint a test doesn't explicitly mock: return an
Expand Down Expand Up @@ -180,17 +191,21 @@ function emptyArray(route: Route) {
* Seed the designer into "expert" mode before the SPA boots. The default ("standard") mode hides
* power-user affordances — node-context-menu breakpoints, the Debug-run toolbar button, and most
* view-toggles — behind `designerMode === 'expert'` (designStore, persisted under the key
* 'nodepilot-design', schema version 1). Specs that exercise those features must run in expert
* 'nodepilot-design', schema version 2). Specs that exercise those features must run in expert
* mode. Call this BEFORE `page.goto(...)` so the init script wins over the store's default.
*/
export async function seedExpertMode(page: Page) {
// Init scripts run in addition order and the LAST setItem wins — this seed replaces the
// whole 'nodepilot-design' key, so it must re-assert the classic pin from
// installDefaultMocks or expert-mode specs would silently flip to the Atelier design.
// whole 'nodepilot-design' key, so it must re-assert BOTH pins from installDefaultMocks
// (classic look + small node scale) or expert-mode specs would silently flip to the Atelier
// design and the large node geometry.
await page.addInitScript(() =>
localStorage.setItem(
'nodepilot-design',
JSON.stringify({ state: { designerMode: 'expert', designerTheme: 'classic' }, version: 1 }),
JSON.stringify({
state: { designerMode: 'expert', designerTheme: 'classic', nodeScaleIndex: 1 },
version: 2,
}),
),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,36 @@ describe('SecuritySection', () => {
expect(screen.getAllByText(/Changes apply immediately/i).length).toBe(5);
});

it('every hardening switch carries an explanatory hint', async () => {
// These are security switches an operator will not touch without knowing what they do.
// Pin one distinctive phrase per hint so a dropped `hint` prop — or a key that silently
// resolves to itself — fails here instead of shipping an unexplained checkbox.
renderAll();
await waitFor(() => expect(screen.getByDisplayValue('*')).toBeInTheDocument(), { timeout: 3000 });
const phrases = [
/baseline SSRF guard/i, // RestApi:BlockPrivateNetworks
/Overridable per node via the proxyMode key/i, // RestApi:Proxy:Enabled
/climb out of an allowed root/i, // FileSystemOperation:RejectTraversal
/symlinks\/junctions are followed/i, // FileSystemOperation:AllowedRoots
/no database secrets in the workflow definition/i, // SqlActivity:RequireConnectionRef
/second injection surface/i, // StartProgram:DisallowShellExecute
/HMAC mode is always fail-closed/i, // Webhook:RequireSecret
/applies at boot only/i, // Security:StrictAllowedHosts
/health probe targets/i, // Security:AllowedHosts
];
for (const phrase of phrases) {
expect(screen.getByText(phrase)).toBeInTheDocument();
}
});

it('the Remote pointer note is translated, not hardcoded German', async () => {
// It used to be inline German JSX and rendered German inside the English UI.
renderAll();
await waitFor(() => expect(screen.getByDisplayValue('*')).toBeInTheDocument(), { timeout: 3000 });
expect(screen.getByText(/every remote switch lives there together/i)).toBeInTheDocument();
expect(screen.queryByText(/werden unter/i)).not.toBeInTheDocument();
});

it('Security card Save sends StrictAllowedHosts + AllowedHosts in PascalCase', async () => {
let putBody: unknown = null;
server.use(http.put('/api/admin/settings/Security', async ({ request }) => {
Expand Down
Loading
Loading