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>
|
||||
<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">
|
||||
<div class="content-container site-header-inner glass-surface">
|
||||
<a class="site-identity" [routerLink]="navigation.link('home')">
|
||||
{{ siteConfig.personName }}
|
||||
</a>
|
||||
<div class="site-toolbar cluster">
|
||||
<app-command-palette-trigger></app-command-palette-trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-toggle"
|
||||
@@ -58,13 +57,6 @@
|
||||
>
|
||||
{{ shell().otherLocaleName }}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,4 +86,4 @@
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<app-command-palette></app-command-palette>
|
||||
<app-terminal-dock></app-terminal-dock>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
.site-header {
|
||||
position: relative;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
z-index: 30;
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@
|
||||
|
||||
.primary-nav > li {
|
||||
position: relative;
|
||||
z-index: 31;
|
||||
}
|
||||
|
||||
.primary-nav > li > a {
|
||||
@@ -211,20 +212,24 @@
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: max-content;
|
||||
z-index: 20;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
z-index: 32;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
gap: var(--space-1);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-glass);
|
||||
backdrop-filter: blur(var(--blur-glass));
|
||||
-webkit-backdrop-filter: blur(var(--blur-glass));
|
||||
background: var(--color-surface-menu);
|
||||
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 {
|
||||
display: flex;
|
||||
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();
|
||||
});
|
||||
|
||||
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);
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
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(trigger).toBeTruthy();
|
||||
expect(site?.contains(trigger)).toBe(true);
|
||||
expect(dock).toBeTruthy();
|
||||
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();
|
||||
fixture.detectChanges();
|
||||
@@ -106,10 +109,10 @@ describe('App', () => {
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const dialog = compiled.querySelector('[role="dialog"]');
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(site?.contains(dialog)).toBe(false);
|
||||
expect(site?.hasAttribute('inert')).toBe(true);
|
||||
const panel = compiled.querySelector('.terminal-dock-panel');
|
||||
expect(panel).toBeTruthy();
|
||||
expect(site?.contains(panel)).toBe(false);
|
||||
expect(site?.hasAttribute('inert')).toBe(false);
|
||||
});
|
||||
|
||||
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 { NavigationService } from './core/navigation/navigation.service';
|
||||
import { isBrowserPlatform, viewportMatches } from './core/platform/browser';
|
||||
import { CommandPalette } from './shared/command-palette/command-palette';
|
||||
import { CommandPaletteTrigger } from './shared/command-palette/command-palette-trigger/command-palette-trigger';
|
||||
import { CommandPaletteService } from './shared/command-palette/command-palette.service';
|
||||
import { TerminalDock } from './shared/terminal/terminal-dock';
|
||||
|
||||
/** Matches `md` in `src/_breakpoints.scss` (48rem). */
|
||||
export const WIDE_NAV_QUERY = '(min-width: 48rem)';
|
||||
@@ -28,21 +26,13 @@ export const WIDE_NAV_QUERY = '(min-width: 48rem)';
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
DotBackground,
|
||||
CommandPalette,
|
||||
CommandPaletteTrigger,
|
||||
],
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground, TerminalDock],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
})
|
||||
export class App {
|
||||
protected readonly navigation = inject(NavigationService);
|
||||
protected readonly localeService = inject(LocaleService);
|
||||
protected readonly palette = inject(CommandPaletteService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface ShellCopy {
|
||||
readonly menuClose: string;
|
||||
readonly languageSwitch: string;
|
||||
readonly otherLocaleName: string;
|
||||
readonly cvLabel: string;
|
||||
readonly contactCta: string;
|
||||
}
|
||||
|
||||
@@ -21,7 +20,6 @@ export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
|
||||
menuClose: 'Menü schließen',
|
||||
languageSwitch: 'Zur englischen Version wechseln',
|
||||
otherLocaleName: 'English',
|
||||
cvLabel: 'Lebenslauf als PDF',
|
||||
contactCta: 'Kontakt',
|
||||
},
|
||||
en: {
|
||||
@@ -32,7 +30,6 @@ export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
|
||||
menuClose: 'Close menu',
|
||||
languageSwitch: 'Switch to the German version',
|
||||
otherLocaleName: 'Deutsch',
|
||||
cvLabel: 'Curriculum vitae as PDF',
|
||||
contactCta: 'Contact',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { routes } from '../../app.routes';
|
||||
import { SITE_CONTENT } from '../content/content.token';
|
||||
import { SITE_CONTENT_DATA } from '../content/site-content';
|
||||
import { LocaleService } from '../i18n/locale.service';
|
||||
import { PRIMARY_NAV } from './navigation';
|
||||
import { NavigationService } from './navigation.service';
|
||||
|
||||
describe('NavigationService', () => {
|
||||
@@ -40,4 +41,11 @@ describe('NavigationService', () => {
|
||||
expect(navigation.link('home')).toEqual(['/', 'en']);
|
||||
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',
|
||||
label: { de: 'Projekte', en: 'Projects' },
|
||||
},
|
||||
{
|
||||
routeId: 'stack',
|
||||
label: { de: 'Stack', en: 'Stack' },
|
||||
},
|
||||
{
|
||||
routeId: '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:/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user