Skip to content
Open
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
104 changes: 55 additions & 49 deletions apps/kdebek/TODO.md
Original file line number Diff line number Diff line change
@@ -1,49 +1,55 @@
# TODO

Follow-ups from the PageSpeed Insights performance audit (mobile, 2026-07-25).
Image sizing, font preload mismatch, and the oversized footer SVG are already fixed.
Remaining items below.

## Performance

- [ ] **Stop hydrating the whole page as one `client:load` island.**
`pages/index.astro` mounts `Main` (`modules/home/presentation/main.tsx`) — which
renders Navbar + Hero + Testimonials + About + Offer + OnlineBookingCta + Footer —
as a single eager React tree. This is the main driver of LCP being 4.2s (poor):
it forces ~83KB of React/ReactDOM/jsx-runtime/Astro-renderer JS onto the critical
path competing with the hero image for bandwidth, and produces the "Reduce unused
JavaScript" finding (42% of the 56KB Astro client runtime chunk unused).

Audited which components actually need client JS (grepped for hooks/handlers):
- Genuinely interactive: `Navbar` (mobile menu `useState` + scroll handlers, above
the fold), `AboutGallery` (carousel `useState`/`useEffect`, below fold),
`Counter` (×7 in About stats, `IntersectionObserver` count-up, below fold),
`OfferWidget` (private/company tab `useState`, below fold).
- Everything else has zero hooks/handlers — purely static markup: Hero shell,
`ServicesGrid`, `Testimonials`, `BookingWidget` (just a styled link),
`OfferPanel`, `OpeningHours`, `OnlineBookingCta` (decorative mockups only),
`Footer`, `LogoMark`, `GreenOnLogo`, `social-icons`.

Two options, pick one:
- **Full fix**: rewrite the static components as plain `.astro` markup (no JS
shipped at all), keep only the 4 interactive pieces above as small islands
(`Navbar` → `client:load`, the other 3 → `client:visible`). Biggest win,
touches ~10 files, needs a visual QA pass + the existing Playwright/e2e specs.
- **Smaller fix**: keep everything as React, but stop mounting it all as one
`client:load` blob — split `Main` into per-section mounts in `index.astro` and
hydrate each with the directive matching its need (interactive ones as above,
static ones as `client:visible` just to defer their bytes past LCP). Lower
effort/risk, smaller win.

## Cleanup (not a performance issue — verified negligible impact, see below)

- [ ] **Decouple `cookies` module from `privacy-policy` module.**
`shared/policy/cookies/presentation/main.tsx:6-8` statically imports
`PrivacyPolicyContent`/`PrivacyPolicyIntro` at module scope, so that code ships
in the Cookies island's bundle even though it only renders when
`consent.view === 'policy'`. Checked the actual impact: the resulting chunk is
4.18 KiB, ~4% of total page JS, loaded in parallel with everything else — it
does not meaningfully affect LCP or clear Lighthouse's "unused JS" threshold.
Fix for module-boundary hygiene, not speed: replace the static import with a
dynamic `import()` gated on opening the policy view (e.g. `React.lazy` +
`Suspense`, or an `import()` inside `openPolicyFromBanner`/`openSettings`).
# TODO

Follow-ups from the PageSpeed Insights performance audit (mobile, 2026-07-25).
Image sizing, font preload mismatch, and the oversized footer SVG are already fixed.
Remaining items below.

## Performance

- [x] **Stop hydrating the whole page as one `client:load` island.**
Fixed: static sections (`Hero`, `Testimonials`, `ServicesGrid`, `BookingWidget`,
`OpeningHours`, `OnlineBookingCta`, `Footer`) now render as plain SSR markup with
zero client JS. `About`/`Offer`/`OfferPanel` were rewritten as `.astro` components
so only the genuinely interactive pieces ship JS: `Navbar` (`client:load`,
above the fold) and `AboutGallery`/`Counter`/`OfferWidget` (`client:visible`,
below the fold). See `modules/home/presentation/home.astro`.
`pages/index.astro` mounts `Main` (`modules/home/presentation/main.tsx`) — which
renders Navbar + Hero + Testimonials + About + Offer + OnlineBookingCta + Footer —
as a single eager React tree. This is the main driver of LCP being 4.2s (poor):
it forces ~83KB of React/ReactDOM/jsx-runtime/Astro-renderer JS onto the critical
path competing with the hero image for bandwidth, and produces the "Reduce unused
JavaScript" finding (42% of the 56KB Astro client runtime chunk unused).

Audited which components actually need client JS (grepped for hooks/handlers):
- Genuinely interactive: `Navbar` (mobile menu `useState` + scroll handlers, above
the fold), `AboutGallery` (carousel `useState`/`useEffect`, below fold),
`Counter` (×7 in About stats, `IntersectionObserver` count-up, below fold),
`OfferWidget` (private/company tab `useState`, below fold).
- Everything else has zero hooks/handlers — purely static markup: Hero shell,
`ServicesGrid`, `Testimonials`, `BookingWidget` (just a styled link),
`OfferPanel`, `OpeningHours`, `OnlineBookingCta` (decorative mockups only),
`Footer`, `LogoMark`, `GreenOnLogo`, `social-icons`.

Two options, pick one:
- **Full fix**: rewrite the static components as plain `.astro` markup (no JS
shipped at all), keep only the 4 interactive pieces above as small islands
(`Navbar` → `client:load`, the other 3 → `client:visible`). Biggest win,
touches ~10 files, needs a visual QA pass + the existing Playwright/e2e specs.
- **Smaller fix**: keep everything as React, but stop mounting it all as one
`client:load` blob — split `Main` into per-section mounts in `index.astro` and
hydrate each with the directive matching its need (interactive ones as above,
static ones as `client:visible` just to defer their bytes past LCP). Lower
effort/risk, smaller win.

## Cleanup (not a performance issue — verified negligible impact, see below)

- [ ] **Decouple `cookies` module from `privacy-policy` module.**
`shared/policy/cookies/presentation/main.tsx:6-8` statically imports
`PrivacyPolicyContent`/`PrivacyPolicyIntro` at module scope, so that code ships
in the Cookies island's bundle even though it only renders when
`consent.view === 'policy'`. Checked the actual impact: the resulting chunk is
4.18 KiB, ~4% of total page JS, loaded in parallel with everything else — it
does not meaningfully affect LCP or clear Lighthouse's "unused JS" threshold.
Fix for module-boundary hygiene, not speed: replace the static import with a
dynamic `import()` gated on opening the policy view (e.g. `React.lazy` +
`Suspense`, or an `import()` inside `openPolicyFromBanner`/`openSettings`).
7 changes: 3 additions & 4 deletions apps/kdebek/src/modules/home/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
export { Main } from './presentation/main';
export { copy } from './presentation/copy';
export { resolveHomeImages } from './integration/resolve-home-images';
export type { HomeImages, ResolvedImage } from './domain/models';
export { copy } from './presentation/copy';
export { resolveHomeImages } from './integration/resolve-home-images';
export type { HomeImages, ResolvedImage } from './domain/models';
147 changes: 147 additions & 0 deletions apps/kdebek/src/modules/home/presentation/about.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
---
import {
Award,
BookOpen,
FileText,
Heart,
Quote,
Star,
Target,
Users,
type LucideIcon,
} from 'lucide-react';

import type { ResolvedImage } from '../domain/models';

import { AboutGallery } from './about-gallery';
import { Counter } from './counter';
import { copy } from './copy';

type Props = {
galleryImages: ResolvedImage[];
};

const { galleryImages }: Props = Astro.props;

const credentialIcons: Record<string, LucideIcon> = {
experience: FileText,
specializations: Target,
approach: Heart,
};

const statIcons: Record<string, LucideIcon> = {
clients: Users,
visits: FileText,
'google-rating': Star,
'booksy-rating': Star,
years: Award,
courses: BookOpen,
};
---

<section id="o-mnie" class="scroll-mt-28 bg-white py-20">
<div class="mx-auto max-w-7xl px-6">
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
<div>
<p class="text-xs font-bold tracking-[0.2em] text-primary">
{copy.about.eyebrow}
</p>
<h2 class="mt-3 font-display text-3xl font-bold leading-tight text-secondary sm:text-4xl">
{copy.about.headingLine1}
<br />
{copy.about.headingLine2}
</h2>
<div class="mt-4 max-w-md space-y-3">
{
copy.about.introParagraphs.map((paragraph) => (
<p class="text-sm leading-relaxed text-ink-light">
{paragraph}
</p>
))
}
</div>
<span class="mt-5 block h-0.5 w-10 bg-primary" />

<ul class="mt-6 divide-y divide-line list-none p-0">
{
copy.about.credentials.map(({ id, title, description }) => {
const Icon = credentialIcons[id];

return (
<li class="flex gap-4 py-4 first:pt-0">
<span
class="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary"
aria-hidden="true"
>
<Icon className="h-5 w-5 shrink-0" />
</span>
<div>
<h3 class="font-semibold text-secondary">{title}</h3>
<p class="mt-0.5 text-sm leading-relaxed text-ink-light">
{description}
</p>
</div>
</li>
);
})
}
</ul>

<blockquote class="mt-6 flex items-start gap-3">
<Quote className="h-6 w-6 shrink-0 text-primary" aria-hidden="true" />
<p class="font-display text-base italic leading-snug text-secondary">
{copy.about.quote}
</p>
</blockquote>
</div>

<AboutGallery client:visible images={galleryImages} />
</div>
</div>

<div class="mx-auto mt-16 max-w-7xl px-6">
<dl class="grid grid-cols-2 gap-8 rounded-3xl bg-surface px-8 py-10 ring-1 ring-line sm:grid-cols-3 lg:grid-cols-7 lg:gap-4">
<div class="col-span-2 sm:col-span-3 lg:col-span-1">
<dt class="flex items-center gap-2">
<Users className="h-6 w-6 shrink-0 text-ink-light" aria-hidden="true" />
<span class="text-sm font-semibold text-secondary">
{copy.about.statsTitle}
</span>
</dt>
<dd class="mt-0.5 text-xs leading-relaxed text-ink-light">
{copy.about.statsDescription}
</dd>
</div>
{
copy.about.stats.map((stat) => {
const { id, value, suffix, label } = stat;
const decimals = 'decimals' in stat ? stat.decimals : undefined;
const Icon = statIcons[id];

return (
<div>
<dt class="flex items-center gap-2">
<Icon className="h-6 w-6 shrink-0 text-primary" aria-hidden="true" />
<span class="sr-only">{label}</span>
</dt>
<dd class="mt-2 font-display text-2xl font-bold text-secondary">
<Counter
client:visible
value={value}
decimals={decimals}
suffix={suffix}
/>
</dd>
<dd
aria-hidden="true"
class="mt-0.5 whitespace-nowrap text-xs leading-relaxed text-ink-light"
>
{label}
</dd>
</div>
);
})
}
</dl>
</div>
</section>
Loading
Loading