Skip to content

Commit 8ef7c48

Browse files
CopilotjaypatrickCopilot
authored
feat: Implement Bloqr landing page — 10 section components, dark design system, SSR-safe persona tabs, a11y hardening (#1582)
* Initial plan * Initial plan * feat: redesign frontend and API with Bloqr brand design system Agent-Logs-Url: https://github.com/jaypatrick/adblock-compiler/sessions/6e35bc36-648d-459b-acb4-6e6ac48103d6 Co-authored-by: jaypatrick <[email protected]> * fix: improve font preload comment and CDN exception documentation Agent-Logs-Url: https://github.com/jaypatrick/adblock-compiler/sessions/6e35bc36-648d-459b-acb4-6e6ac48103d6 Co-authored-by: jaypatrick <[email protected]> * feat: implement Bloqr landing page with 10 section components - Add @fontsource/space-grotesk and @fontsource/inter to package.json - Replace styles.css with Bloqr dark design token system (orange #FF5500, navy #070B14, cyan #00D4FF, Space Grotesk/Inter fonts) - Update index.html with Bloqr title and meta description - Create 10 standalone section components: NavBar, Hero, Problem, Features, HowItWorks, Byo, Audiences, Pricing, CtaBanner, Footer - Rewrite home.component.ts to compose all landing page sections - Update app.component.ts to hide app shell header/footer on landing page - Update tests: app.component.spec.ts (navigate to non-root for hamburger test), home.component.spec.ts (updated for new HomeComponent)" Agent-Logs-Url: https://github.com/jaypatrick/adblock-compiler/sessions/d5bcbec2-627d-45e5-b68c-60d28159d9b1 Co-authored-by: jaypatrick <[email protected]> * fix: address code review - accurate isLandingPage initial value, SSR-safe persona panels, font preload hint Agent-Logs-Url: https://github.com/jaypatrick/adblock-compiler/sessions/d5bcbec2-627d-45e5-b68c-60d28159d9b1 Co-authored-by: jaypatrick <[email protected]> * Update frontend/src/app/app.component.ts Co-authored-by: Copilot <[email protected]> * fix: address PR review feedback — bundle logo, fix a11y, replace hardcoded colors with CSS vars Agent-Logs-Url: https://github.com/jaypatrick/adblock-compiler/sessions/bc4e0af2-97f5-40ba-88d6-3d2533c917a2 Co-authored-by: jaypatrick <[email protected]> * fix: use boolean true for inert binding and CSS var for orange-glow-intense Agent-Logs-Url: https://github.com/jaypatrick/adblock-compiler/sessions/bc4e0af2-97f5-40ba-88d6-3d2533c917a2 Co-authored-by: jaypatrick <[email protected]> * fix: apply second review feedback — label semantics, test stub, jose pin Agent-Logs-Url: https://github.com/jaypatrick/adblock-compiler/sessions/14da966d-12f7-4f2d-b7b2-13e3227e4039 Co-authored-by: jaypatrick <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: jaypatrick <[email protected]> Co-authored-by: Jayson Knight <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 39fe91c commit 8ef7c48

18 files changed

Lines changed: 1505 additions & 671 deletions

frontend/src/app/app.component.spec.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ComponentFixture, TestBed } from '@angular/core/testing';
2-
import { ErrorHandler, provideZonelessChangeDetection } from '@angular/core';
3-
import { provideRouter } from '@angular/router';
2+
import { Component, ErrorHandler, provideZonelessChangeDetection } from '@angular/core';
3+
import { provideRouter, Router } from '@angular/router';
44
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
55
import { provideHttpClient } from '@angular/common/http';
66
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
@@ -10,6 +10,10 @@ import { AppComponent } from './app.component';
1010
import { GlobalErrorHandler } from './error/global-error-handler';
1111
import { API_BASE_URL } from './tokens';
1212

13+
/** Lightweight stub used as a route target to avoid recursively rendering AppComponent. */
14+
@Component({ selector: 'app-stub', template: '', standalone: true })
15+
class StubComponent {}
16+
1317
describe('AppComponent', () => {
1418
let fixture: ComponentFixture<AppComponent>;
1519
let component: AppComponent;
@@ -129,7 +133,8 @@ describe('AppComponent (mobile viewport)', () => {
129133
imports: [AppComponent, NoopAnimationsModule],
130134
providers: [
131135
provideZonelessChangeDetection(),
132-
provideRouter([]),
136+
// Provide a non-root route so we can navigate away from `/` to reveal the app shell.
137+
provideRouter([{ path: 'compiler', component: StubComponent }]),
133138
provideHttpClient(),
134139
provideHttpClientTesting(),
135140
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
@@ -160,8 +165,13 @@ describe('AppComponent (mobile viewport)', () => {
160165
localStorage.clear();
161166
});
162167

163-
it('should render menu button with aria-label', async () => {
168+
it('should render menu button with aria-label on non-landing routes', async () => {
169+
// The app shell hamburger is only shown on non-landing-page routes.
170+
// Navigate away from `/` to make isLandingPage() = false and reveal the shell header.
171+
const router = TestBed.inject(Router);
172+
await router.navigate(['/compiler']);
164173
await fixture.whenStable();
174+
fixture.detectChanges();
165175
const menuBtn = fixture.nativeElement.querySelector('button[aria-label="Toggle navigation"]');
166176
expect(menuBtn).toBeTruthy();
167177
});

frontend/src/app/app.component.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@
1616
*/
1717

1818
import { Component, inject, signal, viewChild, effect } from '@angular/core';
19-
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
19+
import { RouterLink, RouterLinkActive, RouterOutlet, Router, NavigationEnd } from '@angular/router';
2020
import { MatSidenav, MatSidenavModule } from '@angular/material/sidenav';
2121
import { MatListModule } from '@angular/material/list';
2222
import { MatIconModule } from '@angular/material/icon';
2323
import { MatButtonModule } from '@angular/material/button';
2424
import { MatTooltipModule } from '@angular/material/tooltip';
2525
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
2626
import { toSignal } from '@angular/core/rxjs-interop';
27-
import { map } from 'rxjs/operators';
27+
import { filter, map } from 'rxjs/operators';
2828
import { ThemeService } from './services/theme.service';
2929
import { ErrorBoundaryComponent } from './error/error-boundary.component';
3030
import { NotificationContainerComponent } from './notification/notification-container.component';
@@ -105,7 +105,8 @@ interface NavItem {
105105
<mat-sidenav-content>
106106
<div class="page-wrapper">
107107
108-
<!-- Header — Bloqr nav bar: sticky, glass morphism, orange accent -->
108+
<!-- Header — Bloqr nav bar: sticky, glass morphism, orange accent (hidden on landing page) -->
109+
@if (!isLandingPage()) {
109110
<header class="app-header-shell">
110111
<div class="app-title-row">
111112
@if (isMobile()) {
@@ -157,6 +158,7 @@ interface NavItem {
157158
}
158159
</nav>
159160
</header>
161+
} <!-- end @if (!isLandingPage()) header -->
160162
161163
<!-- Main content area -->
162164
<main id="main-content" class="app-main-content" role="main" aria-label="Main content" tabindex="-1">
@@ -166,10 +168,12 @@ interface NavItem {
166168
<router-outlet />
167169
</main>
168170
169-
<!-- Footer matching original -->
171+
<!-- Footer (hidden on landing page which has its own footer) -->
172+
@if (!isLandingPage()) {
170173
<footer class="app-footer-shell">
171174
<p>Powered by <a href="https://github.com/jaypatrick/adblock-compiler" target="_blank" rel="noopener noreferrer">@jk-com/adblock-compiler<span class="visually-hidden"> (opens in new tab)</span></a></p>
172175
</footer>
176+
} <!-- end @if (!isLandingPage()) footer -->
173177
174178
</div>
175179
</mat-sidenav-content>
@@ -237,6 +241,29 @@ export class AppComponent {
237241
*/
238242
readonly themeService = inject(ThemeService);
239243

244+
/**
245+
* Router — used to detect the landing page URL.
246+
* isLandingPage drives @if blocks that hide the shell header/footer on `/`.
247+
* initialValue reads router.url synchronously so direct navigation to non-root
248+
* routes (e.g. /compiler) starts with the correct false value, preventing a
249+
* brief flash of hidden/shown shell on deep-link access.
250+
*/
251+
private readonly router = inject(Router);
252+
253+
private isLandingPageUrl(url: string): boolean {
254+
const normalizedUrl = url.split(/[?#]/u, 1)[0];
255+
256+
return normalizedUrl === '' || normalizedUrl === '/';
257+
}
258+
259+
readonly isLandingPage = toSignal(
260+
this.router.events.pipe(
261+
filter(e => e instanceof NavigationEnd),
262+
map(e => this.isLandingPageUrl((e as NavigationEnd).urlAfterRedirects)),
263+
),
264+
{ initialValue: this.isLandingPageUrl(this.router.url) },
265+
);
266+
240267
constructor() {
241268
// Close the mobile drawer whenever desktop layout is active (horizontal nav takes over).
242269
// Runs whenever isMobile() is false to ensure the drawer stays closed on desktop.
Lines changed: 19 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,141 +1,51 @@
11
import { ComponentFixture, TestBed } from '@angular/core/testing';
22
import { provideZonelessChangeDetection } from '@angular/core';
3-
import { provideRouter, Router } from '@angular/router';
4-
import { provideHttpClient } from '@angular/common/http';
5-
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
6-
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
3+
import { provideRouter } from '@angular/router';
74
import { HomeComponent } from './home.component';
8-
import { API_BASE_URL } from '../tokens';
95

106
describe('HomeComponent', () => {
117
let fixture: ComponentFixture<HomeComponent>;
128
let component: HomeComponent;
13-
let httpTesting: HttpTestingController;
14-
let router: Router;
159

1610
beforeEach(async () => {
1711
await TestBed.configureTestingModule({
18-
imports: [HomeComponent, NoopAnimationsModule],
12+
imports: [HomeComponent],
1913
providers: [
2014
provideZonelessChangeDetection(),
21-
provideHttpClient(),
22-
provideHttpClientTesting(),
2315
provideRouter([]),
24-
{ provide: API_BASE_URL, useValue: '/api' },
2516
],
2617
}).compileComponents();
2718

2819
fixture = TestBed.createComponent(HomeComponent);
2920
component = fixture.componentInstance;
30-
httpTesting = TestBed.inject(HttpTestingController);
31-
router = TestBed.inject(Router);
32-
33-
// Flush the initial rxResource requests triggered by component creation
34-
flushPendingRequests();
35-
});
36-
37-
function flushPendingRequests(): void {
38-
httpTesting.match('/api/metrics').forEach(req => req.flush({
39-
totalRequests: 0, averageDuration: 0, cacheHitRate: 0, successRate: 0,
40-
}));
41-
httpTesting.match('/api/health').forEach(req => req.flush({
42-
status: 'healthy', version: '0.0.0',
43-
}));
44-
}
45-
46-
afterEach(() => {
47-
httpTesting.match(() => true).forEach(req => req.flush({}));
48-
httpTesting.verify();
49-
vi.restoreAllMocks();
21+
await fixture.whenStable();
5022
});
5123

5224
it('should create', () => {
5325
expect(component).toBeTruthy();
5426
});
5527

56-
it('should have 6 navigation cards', () => {
57-
expect(component.navCards.length).toBe(6);
58-
});
59-
60-
it('should include Compiler card', () => {
61-
const compiler = component.navCards.find(c => c.path === '/compiler');
62-
expect(compiler).toBeTruthy();
63-
expect(compiler!.title).toBe('Filter List Compiler');
64-
});
65-
66-
it('should include Admin card with warn tag', () => {
67-
const admin = component.navCards.find(c => c.path === '/admin');
68-
expect(admin).toBeTruthy();
69-
expect(admin!.tagColor).toBe('warn');
70-
});
71-
72-
it('should derive live stats from metrics', () => {
73-
// After flushing with zeroed metrics, stats show formatted values
74-
const stats = component.liveStats();
75-
expect(stats.length).toBe(5);
76-
expect(stats[0].label).toBe('Total Requests');
77-
expect(stats[0].value).toBe('0');
78-
});
79-
80-
it('should navigate when navigateTo is called', () => {
81-
const navigateSpy = vi.spyOn(router, 'navigate');
82-
component.navigateTo('/compiler');
83-
expect(navigateSpy).toHaveBeenCalledWith(['/compiler']);
84-
});
85-
86-
it('should open absolute external URLs in a new tab via window.open', () => {
87-
const openSpy = vi.spyOn(window, 'open');
88-
component.navigateTo('https://example.com');
89-
expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer');
90-
});
91-
92-
it('should open worker-handled relative paths in a new tab when external flag is true', () => {
93-
const openSpy = vi.spyOn(window, 'open');
94-
const navigateSpy = vi.spyOn(router, 'navigate');
95-
component.navigateTo('/docs', true);
96-
expect(openSpy).toHaveBeenCalledWith('/docs', '_blank', 'noopener,noreferrer');
97-
expect(navigateSpy).not.toHaveBeenCalled();
98-
});
99-
100-
it('should NOT call window.open for internal paths without external flag', () => {
101-
const openSpy = vi.spyOn(window, 'open');
102-
const navigateSpy = vi.spyOn(router, 'navigate');
103-
component.navigateTo('/compiler', false);
104-
expect(openSpy).not.toHaveBeenCalled();
105-
expect(navigateSpy).toHaveBeenCalledWith(['/compiler']);
106-
});
107-
108-
it('should navigate to performance on stat card click for Total Requests', () => {
109-
const navigateSpy = vi.spyOn(router, 'navigate');
110-
component.onStatCardClicked('Total Requests');
111-
expect(navigateSpy).toHaveBeenCalledWith(['/performance']);
112-
});
113-
114-
it('should navigate to performance on stat card click for Avg Response Time', () => {
115-
const navigateSpy = vi.spyOn(router, 'navigate');
116-
component.onStatCardClicked('Avg Response Time');
117-
expect(navigateSpy).toHaveBeenCalledWith(['/performance']);
118-
});
119-
120-
it('should not navigate for non-metric stat card clicks', () => {
121-
const navigateSpy = vi.spyOn(router, 'navigate');
122-
component.onStatCardClicked('Cache Hit Rate');
123-
expect(navigateSpy).not.toHaveBeenCalled();
28+
it('should render nav bar', async () => {
29+
await fixture.whenStable();
30+
const nav = fixture.nativeElement.querySelector('app-nav-bar');
31+
expect(nav).toBeTruthy();
12432
});
12533

126-
it('should show default health icon when no data', () => {
127-
// After flushing with healthy status, icon reflects healthy state
128-
expect(component.healthIcon()).toBe('check_circle');
34+
it('should render hero section', async () => {
35+
await fixture.whenStable();
36+
const hero = fixture.nativeElement.querySelector('app-hero-section');
37+
expect(hero).toBeTruthy();
12938
});
13039

131-
it('should show default health color when no data', () => {
132-
// After flushing with healthy status, color reflects healthy state
133-
expect(component.healthColor()).toBe('var(--app-success, #4caf50)');
40+
it('should render landing content wrapper', async () => {
41+
await fixture.whenStable();
42+
const wrapper = fixture.nativeElement.querySelector('.landing-content');
43+
expect(wrapper).toBeTruthy();
13444
});
13545

136-
it('should render the page heading', () => {
137-
fixture.detectChanges();
138-
const el: HTMLElement = fixture.nativeElement;
139-
expect(el.querySelector('h1')?.textContent).toContain('Adblock Compiler Dashboard');
46+
it('should render footer section', async () => {
47+
await fixture.whenStable();
48+
const footer = fixture.nativeElement.querySelector('app-footer-section');
49+
expect(footer).toBeTruthy();
14050
});
14151
});

0 commit comments

Comments
 (0)