Make the primary nav customer-facing and keep the services submenu readable.

Stack and the CV leave the chrome so Contact stays the main action. The desktop submenu now uses an opaque surface token and a stacking context above page content.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 12:52:17 +02:00
parent 86d6dd085d
commit 646b39b200
9 changed files with 52 additions and 45 deletions

View File

@@ -1,13 +1,12 @@
<a class="skip-link" href="#main-content">{{ shell().skipLink }}</a> <a class="skip-link" href="#main-content">{{ shell().skipLink }}</a>
<app-dot-background aria-hidden="true"></app-dot-background> <app-dot-background aria-hidden="true"></app-dot-background>
<div class="site" [class.nav-collapsed]="!navOpen()" [attr.inert]="palette.open() ? '' : null"> <div class="site" [class.nav-collapsed]="!navOpen()">
<header class="site-header"> <header class="site-header">
<div class="content-container site-header-inner glass-surface"> <div class="content-container site-header-inner glass-surface">
<a class="site-identity" [routerLink]="navigation.link('home')"> <a class="site-identity" [routerLink]="navigation.link('home')">
{{ siteConfig.personName }} {{ siteConfig.personName }}
</a> </a>
<div class="site-toolbar cluster"> <div class="site-toolbar cluster">
<app-command-palette-trigger></app-command-palette-trigger>
<button <button
type="button" type="button"
class="nav-toggle" class="nav-toggle"
@@ -58,13 +57,6 @@
> >
{{ shell().otherLocaleName }} {{ shell().otherLocaleName }}
</a> </a>
<a
[href]="siteConfig.cvAssetPath"
[attr.download]="siteConfig.cvDownloadFileName"
type="application/pdf"
>
{{ shell().cvLabel }}
</a>
<a class="contact-cta" [routerLink]="navigation.contactLink()">{{ shell().contactCta }}</a> <a class="contact-cta" [routerLink]="navigation.contactLink()">{{ shell().contactCta }}</a>
</div> </div>
</div> </div>
@@ -94,4 +86,4 @@
</div> </div>
</footer> </footer>
</div> </div>
<app-command-palette></app-command-palette> <app-terminal-dock></app-terminal-dock>

View File

@@ -16,7 +16,7 @@
.site-header { .site-header {
position: relative; position: relative;
top: 0; top: 0;
z-index: 10; z-index: 30;
padding-block: var(--space-3); padding-block: var(--space-3);
} }
@@ -199,6 +199,7 @@
.primary-nav > li { .primary-nav > li {
position: relative; position: relative;
z-index: 31;
} }
.primary-nav > li > a { .primary-nav > li > a {
@@ -211,20 +212,24 @@
top: 100%; top: 100%;
left: 0; left: 0;
min-width: max-content; min-width: max-content;
z-index: 20; z-index: 32;
padding: var(--space-2) var(--space-3); padding: var(--space-3) var(--space-4);
gap: var(--space-1); gap: var(--space-1);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--surface-glass); background: var(--color-surface-menu);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--surface-glass-border); border: 1px solid var(--surface-glass-border);
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-raised);
} }
.primary-nav > li:hover > .submenu,
.primary-nav > li:focus-within > .submenu { .primary-nav > li:focus-within > .submenu {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@media (hover: hover) and (pointer: fine) {
.primary-nav > li:hover > .submenu {
display: flex;
flex-direction: column;
}
}
} }

View File

@@ -89,16 +89,19 @@ describe('App', () => {
expect(compiled.querySelector('footer')).toBeTruthy(); expect(compiled.querySelector('footer')).toBeTruthy();
}); });
it('keeps the palette trigger in the header and the dialog outside .site', async () => { it('renders the terminal dock outside .site and keeps the header free of a CV link', async () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
await fixture.whenStable(); await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
const site = compiled.querySelector('.site'); const site = compiled.querySelector('.site');
const trigger = compiled.querySelector('header .command-palette-trigger'); const dock = compiled.querySelector('app-terminal-dock');
const trigger = compiled.querySelector('.terminal-dock-trigger');
expect(site).toBeTruthy(); expect(site).toBeTruthy();
expect(trigger).toBeTruthy(); expect(dock).toBeTruthy();
expect(site?.contains(trigger)).toBe(true); expect(site?.contains(dock)).toBe(false);
expect(compiled.querySelector('header a[href="/cv/CV.pdf"]')).toBeNull();
expect(compiled.querySelector('header .command-palette-trigger')).toBeNull();
(trigger as HTMLButtonElement).click(); (trigger as HTMLButtonElement).click();
fixture.detectChanges(); fixture.detectChanges();
@@ -106,10 +109,10 @@ describe('App', () => {
TestBed.inject(ApplicationRef).tick(); TestBed.inject(ApplicationRef).tick();
fixture.detectChanges(); fixture.detectChanges();
const dialog = compiled.querySelector('[role="dialog"]'); const panel = compiled.querySelector('.terminal-dock-panel');
expect(dialog).toBeTruthy(); expect(panel).toBeTruthy();
expect(site?.contains(dialog)).toBe(false); expect(site?.contains(panel)).toBe(false);
expect(site?.hasAttribute('inert')).toBe(true); expect(site?.hasAttribute('inert')).toBe(false);
}); });
it('keeps the server-rendered nav expanded and collapses after hydration on a narrow viewport', async () => { it('keeps the server-rendered nav expanded and collapses after hydration on a narrow viewport', async () => {

View File

@@ -18,9 +18,7 @@ import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale';
import { LocaleService } from './core/i18n/locale.service'; import { LocaleService } from './core/i18n/locale.service';
import { NavigationService } from './core/navigation/navigation.service'; import { NavigationService } from './core/navigation/navigation.service';
import { isBrowserPlatform, viewportMatches } from './core/platform/browser'; import { isBrowserPlatform, viewportMatches } from './core/platform/browser';
import { CommandPalette } from './shared/command-palette/command-palette'; import { TerminalDock } from './shared/terminal/terminal-dock';
import { CommandPaletteTrigger } from './shared/command-palette/command-palette-trigger/command-palette-trigger';
import { CommandPaletteService } from './shared/command-palette/command-palette.service';
/** Matches `md` in `src/_breakpoints.scss` (48rem). */ /** Matches `md` in `src/_breakpoints.scss` (48rem). */
export const WIDE_NAV_QUERY = '(min-width: 48rem)'; export const WIDE_NAV_QUERY = '(min-width: 48rem)';
@@ -28,21 +26,13 @@ export const WIDE_NAV_QUERY = '(min-width: 48rem)';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground, TerminalDock],
RouterOutlet,
RouterLink,
RouterLinkActive,
DotBackground,
CommandPalette,
CommandPaletteTrigger,
],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss', styleUrl: './app.scss',
}) })
export class App { export class App {
protected readonly navigation = inject(NavigationService); protected readonly navigation = inject(NavigationService);
protected readonly localeService = inject(LocaleService); protected readonly localeService = inject(LocaleService);
protected readonly palette = inject(CommandPaletteService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly injector = inject(Injector); private readonly injector = inject(Injector);
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);

View File

@@ -8,7 +8,6 @@ export interface ShellCopy {
readonly menuClose: string; readonly menuClose: string;
readonly languageSwitch: string; readonly languageSwitch: string;
readonly otherLocaleName: string; readonly otherLocaleName: string;
readonly cvLabel: string;
readonly contactCta: string; readonly contactCta: string;
} }
@@ -21,7 +20,6 @@ export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
menuClose: 'Menü schließen', menuClose: 'Menü schließen',
languageSwitch: 'Zur englischen Version wechseln', languageSwitch: 'Zur englischen Version wechseln',
otherLocaleName: 'English', otherLocaleName: 'English',
cvLabel: 'Lebenslauf als PDF',
contactCta: 'Kontakt', contactCta: 'Kontakt',
}, },
en: { en: {
@@ -32,7 +30,6 @@ export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
menuClose: 'Close menu', menuClose: 'Close menu',
languageSwitch: 'Switch to the German version', languageSwitch: 'Switch to the German version',
otherLocaleName: 'Deutsch', otherLocaleName: 'Deutsch',
cvLabel: 'Curriculum vitae as PDF',
contactCta: 'Contact', contactCta: 'Contact',
}, },
}; };

View File

@@ -5,6 +5,7 @@ import { routes } from '../../app.routes';
import { SITE_CONTENT } from '../content/content.token'; import { SITE_CONTENT } from '../content/content.token';
import { SITE_CONTENT_DATA } from '../content/site-content'; import { SITE_CONTENT_DATA } from '../content/site-content';
import { LocaleService } from '../i18n/locale.service'; import { LocaleService } from '../i18n/locale.service';
import { PRIMARY_NAV } from './navigation';
import { NavigationService } from './navigation.service'; import { NavigationService } from './navigation.service';
describe('NavigationService', () => { describe('NavigationService', () => {
@@ -40,4 +41,11 @@ describe('NavigationService', () => {
expect(navigation.link('home')).toEqual(['/', 'en']); expect(navigation.link('home')).toEqual(['/', 'en']);
expect(navigation.link('contact', 'de')).toEqual(['/', 'kontakt']); expect(navigation.link('contact', 'de')).toEqual(['/', 'kontakt']);
}); });
it('keeps primary nav to home, services, projects, about and contact', () => {
const ids = PRIMARY_NAV.map((item) => item.routeId);
expect(ids).toEqual(['home', 'services', 'projects', 'about', 'contact']);
expect(ids).not.toContain('stack');
expect(ids).not.toContain('pitch');
});
}); });

View File

@@ -38,10 +38,6 @@ export const PRIMARY_NAV: readonly NavItem[] = [
routeId: 'projects', routeId: 'projects',
label: { de: 'Projekte', en: 'Projects' }, label: { de: 'Projekte', en: 'Projects' },
}, },
{
routeId: 'stack',
label: { de: 'Stack', en: 'Stack' },
},
{ {
routeId: 'about', routeId: 'about',
label: { de: 'Über mich', en: 'About' }, label: { de: 'Über mich', en: 'About' },

View File

@@ -0,0 +1,15 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const APP_SCSS = readFileSync(join(process.cwd(), 'src/app/app.scss'), 'utf8');
describe('primary nav submenu surface', () => {
it('uses the opaque menu token and not the glass surface', () => {
const submenu = APP_SCSS.slice(APP_SCSS.indexOf('.primary-nav > li > .submenu'));
expect(submenu).toMatch(/background:\s*var\(--color-surface-menu\)/);
expect(submenu).not.toMatch(/var\(--surface-glass\)/);
expect(submenu).toMatch(/box-shadow:/);
expect(submenu).toMatch(/border-radius:/);
});
});

View File

@@ -5,6 +5,7 @@
--color-surface-raised: #12121a; --color-surface-raised: #12121a;
--color-surface-overlay: #1a1a24; --color-surface-overlay: #1a1a24;
--color-surface-muted: #22222e; --color-surface-muted: #22222e;
--color-surface-menu: #16161f;
/* Color — accent ramp */ /* Color — accent ramp */
--color-accent: #6366f1; --color-accent: #6366f1;