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:
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
15
src/app/submenu.styles.spec.ts
Normal file
15
src/app/submenu.styles.spec.ts
Normal 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:/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user