fix: eliminate checkout/cart layout shift and low-contrast muted text

Live Lighthouse + performance-trace audits against the running dev app
found two real, measurable defects:

- /checkout and /warenkorb rendered the cart-store's default empty
  state before Zustand's persist middleware finished reading
  localStorage, so any customer who already had items in their cart
  saw an "empty cart" flash before the real form appeared (CLS 0.82).
  Fixed by tracking hasHydrated in the store and rendering the
  existing route skeleton until rehydration completes. Verified via
  Chrome DevTools performance trace: CLS 0.82 -> 0.00 on both pages.

- axe-core (via Lighthouse) flagged text-ink/60 on the cream
  background at 4.09:1 contrast, under the 4.5:1 WCAG AA minimum for
  normal-size text. Bumped to text-ink/70 (5.61:1) everywhere the same
  token is used at text-xs/text-sm across 7 files.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-06 03:35:57 +02:00
1 parent 6143e5704d
commit 0402f7510d
8 files changed
+43 -12

No files matched your search

+4 -4
View File
@@ -49,7 +49,7 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
<h1 className="mt-3 text-4xl md:text-5xl text-forest"> <h1 className="mt-3 text-4xl md:text-5xl text-forest">
{batch.harvest.type} {batch.harvest.year} {batch.harvest.type} {batch.harvest.year}
</h1> </h1>
<p className="mt-4 text-sm text-ink/60">{t.chargePage.provenanceNote(batch.code)}</p> <p className="mt-4 text-sm text-ink/70">{t.chargePage.provenanceNote(batch.code)}</p>
<div className="mt-10 grid sm:grid-cols-2 gap-6"> <div className="mt-10 grid sm:grid-cols-2 gap-6">
<ContentImage <ContentImage
@@ -78,7 +78,7 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
<span className="ml-1 h-px flex-1 bg-ink/15" aria-hidden="true" /> <span className="ml-1 h-px flex-1 bg-ink/15" aria-hidden="true" />
) : null} ) : null}
</div> </div>
<p className="mt-3 text-xs tracking-wide-caps text-ink/60">{step.label}</p> <p className="mt-3 text-xs tracking-wide-caps text-ink/70">{step.label}</p>
<p className="mt-1 text-forest">{step.value}</p> <p className="mt-1 text-forest">{step.value}</p>
</div> </div>
))} ))}
@@ -86,7 +86,7 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
</div> </div>
<div className="mt-10 border-t border-ink/10 pt-8"> <div className="mt-10 border-t border-ink/10 pt-8">
<p className="text-sm tracking-wide-caps text-ink/60 mb-2">{t.chargePage.regionLabel}</p> <p className="text-sm tracking-wide-caps text-ink/70 mb-2">{t.chargePage.regionLabel}</p>
<p className="text-ink/80">{batch.harvest.location}</p> <p className="text-ink/80">{batch.harvest.location}</p>
<p className="mt-6 text-ink/70 leading-relaxed">{batch.harvest.description}</p> <p className="mt-6 text-ink/70 leading-relaxed">{batch.harvest.description}</p>
</div> </div>
@@ -95,7 +95,7 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
{batch.products.length > 0 ? ( {batch.products.length > 0 ? (
<div className="mt-14 border-t border-ink/10 pt-10"> <div className="mt-14 border-t border-ink/10 pt-10">
<p className="text-sm tracking-wide-caps text-ink/60 mb-6">{t.chargePage.fromThisBatch}</p> <p className="text-sm tracking-wide-caps text-ink/70 mb-6">{t.chargePage.fromThisBatch}</p>
<div className="grid sm:grid-cols-2 gap-6"> <div className="grid sm:grid-cols-2 gap-6">
{batch.products.map((product) => ( {batch.products.map((product) => (
<ProductTeaserLink <ProductTeaserLink
+12 -1
View File
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { Container, SectionHeading, Button } from "@/components/ui"; import { Container, SectionHeading, Button } from "@/components/ui";
import { TrustBadges } from "@/components/trust-badges"; import { TrustBadges } from "@/components/trust-badges";
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store"; import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
import CheckoutLoading from "./loading";
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc"; import { resolveShippingCostCents, type ShippingTierLike } from "@/lib/shipping-calc";
import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc"; import { resolveBulkDiscount, type BulkDiscountTierLike } from "@/lib/bulk-discount-calc";
@@ -33,6 +34,7 @@ export function CheckoutForm({
}) { }) {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const items = useCartStore((s) => s.items); const items = useCartStore((s) => s.items);
const hasHydrated = useCartStore((s) => s.hasHydrated);
const subtotal = cartTotalCents(items); const subtotal = cartTotalCents(items);
const totalQuantity = cartItemCount(items); const totalQuantity = cartItemCount(items);
const router = useRouter(); const router = useRouter();
@@ -109,6 +111,15 @@ export function CheckoutForm({
} }
} }
// Der Cart-Store haelt seine Positionen nur im Browser (persist/localStorage)
// und liest sie erst nach dem ersten Client-Render nach. Ohne diese Weiche
// wuerde jede:r Kund:in mit gefuelltem Warenkorb kurz die "leer"-Meldung
// sehen, bevor auf das echte Formular umgeschaltet wird — ein grosser,
// messbarer Layout-Sprung (siehe CLS) und verwirrend fuer echte Kaeufer:innen.
if (!hasHydrated) {
return <CheckoutLoading />;
}
if (items.length === 0) { if (items.length === 0) {
return ( return (
<div className="py-20"> <div className="py-20">
@@ -179,7 +190,7 @@ export function CheckoutForm({
))} ))}
</div> </div>
{fulfillment === "VERSAND" ? ( {fulfillment === "VERSAND" ? (
<p className="mt-2 text-xs text-ink/60"> <p className="mt-2 text-xs text-ink/70">
{t.checkoutPage.deliveryTimeNote} {t.checkoutPage.deliveryTimeNote}
</p> </p>
) : null} ) : null}
+11 -3
View File
@@ -7,6 +7,7 @@ import { CartItemThumbnail } from "@/components/cart-item-thumbnail";
import { QuantityStepper } from "@/components/quantity-stepper"; import { QuantityStepper } from "@/components/quantity-stepper";
import { ProductCard } from "@/components/product-card"; import { ProductCard } from "@/components/product-card";
import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store"; import { useCartStore, cartTotalCents, cartItemCount } from "@/store/cart-store";
import WarenkorbLoading from "./loading";
import { useCartStock } from "@/hooks/use-cart-stock"; import { useCartStock } from "@/hooks/use-cart-stock";
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
import { resolveShippingCostCents, getNextShippingTier, type ShippingTierLike } from "@/lib/shipping-calc"; import { resolveShippingCostCents, getNextShippingTier, type ShippingTierLike } from "@/lib/shipping-calc";
@@ -27,6 +28,7 @@ export function WarenkorbContent({
}) { }) {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const items = useCartStore((s) => s.items); const items = useCartStore((s) => s.items);
const hasHydrated = useCartStore((s) => s.hasHydrated);
const setQuantity = useCartStore((s) => s.setQuantity); const setQuantity = useCartStore((s) => s.setQuantity);
const removeItem = useCartStore((s) => s.removeItem); const removeItem = useCartStore((s) => s.removeItem);
// Live-Bestand je Position — derselbe Hinweis wie auf der Produktkarte/ // Live-Bestand je Position — derselbe Hinweis wie auf der Produktkarte/
@@ -53,6 +55,12 @@ export function WarenkorbContent({
.filter((product) => !cartProductIds.has(product.id)) .filter((product) => !cartProductIds.has(product.id))
.slice(0, CROSS_SELL_LIMIT); .slice(0, CROSS_SELL_LIMIT);
// Siehe checkout-form.tsx: ohne diese Weiche flackert kurz die
// "leer"-Meldung auf, bevor der Cart-Store aus localStorage nachliest.
if (!hasHydrated) {
return <WarenkorbLoading />;
}
return ( return (
<div className="py-16"> <div className="py-16">
<Container> <Container>
@@ -122,7 +130,7 @@ export function WarenkorbContent({
<span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span> <span>{shipping === 0 ? t.cartPage.free : formatPrice(shipping, "EUR", locale)}</span>
</div> </div>
{nextTier && nextTier.unitsUntilNext <= 2 ? ( {nextTier && nextTier.unitsUntilNext <= 2 ? (
<p className="text-xs text-ink/60"> <p className="text-xs text-ink/70">
{t.cartPage.nextTierHint(nextTier.unitsUntilNext, formatPrice(nextTier.priceCents, "EUR", locale))} {t.cartPage.nextTierHint(nextTier.unitsUntilNext, formatPrice(nextTier.priceCents, "EUR", locale))}
</p> </p>
) : null} ) : null}
@@ -134,7 +142,7 @@ export function WarenkorbContent({
<span>-{formatPrice(bulkDiscountCents, "EUR", locale)}</span> <span>-{formatPrice(bulkDiscountCents, "EUR", locale)}</span>
</div> </div>
) : nextBulkDiscount && nextBulkDiscount.unitsUntilNext <= 2 ? ( ) : nextBulkDiscount && nextBulkDiscount.unitsUntilNext <= 2 ? (
<p className="text-xs text-ink/60"> <p className="text-xs text-ink/70">
{t.cartPage.bulkDiscountProgress(nextBulkDiscount.unitsUntilNext, nextBulkDiscount.percent)} {t.cartPage.bulkDiscountProgress(nextBulkDiscount.unitsUntilNext, nextBulkDiscount.percent)}
</p> </p>
) : null} ) : null}
@@ -146,7 +154,7 @@ export function WarenkorbContent({
<LinkButton href="/checkout" className="w-full mt-6"> <LinkButton href="/checkout" className="w-full mt-6">
{t.cart.checkout} {t.cart.checkout}
</LinkButton> </LinkButton>
<Link href="/versand-zahlung" className="mt-4 block text-center text-xs text-ink/60 hover:text-forest underline"> <Link href="/versand-zahlung" className="mt-4 block text-center text-xs text-ink/70 hover:text-forest underline">
{t.cartPage.pickupHint} {t.cartPage.pickupHint}
</Link> </Link>
<TrustBadges className="mt-6 pt-6 border-t border-ink/10" /> <TrustBadges className="mt-6 pt-6 border-t border-ink/10" />
+1 -1
View File
@@ -86,7 +86,7 @@ export default async function WissenArticlePage(
{otherArticles.length > 0 ? ( {otherArticles.length > 0 ? (
<div className="mt-16 border-t border-ink/10 pt-10"> <div className="mt-16 border-t border-ink/10 pt-10">
<p className="text-sm tracking-wide-caps text-ink/60 mb-6">{t.wissenArticlePage.otherArticlesHeading}</p> <p className="text-sm tracking-wide-caps text-ink/70 mb-6">{t.wissenArticlePage.otherArticlesHeading}</p>
<div className="space-y-6"> <div className="space-y-6">
{otherArticles.map((otherSlug) => { {otherArticles.map((otherSlug) => {
const other = t.wissenArticles[otherSlug]; const other = t.wissenArticles[otherSlug];
+1 -1
View File
@@ -123,7 +123,7 @@ export function PasswordField({
/> />
))} ))}
</span> </span>
<span className="text-xs text-ink/60 whitespace-nowrap">{tierLabel}</span> <span className="text-xs text-ink/70 whitespace-nowrap">{tierLabel}</span>
</span> </span>
) : null} ) : null}
</label> </label>
+1 -1
View File
@@ -45,7 +45,7 @@ export function ProductTeaserLink({
<ContentImage src={image ?? null} alt={name} tone="honey" className="w-16 h-16 flex-shrink-0" sizes="64px" /> <ContentImage src={image ?? null} alt={name} tone="honey" className="w-16 h-16 flex-shrink-0" sizes="64px" />
<span> <span>
<span className="block text-forest">{name}</span> <span className="block text-forest">{name}</span>
<span className="block mt-1 text-sm text-ink/60 group-hover:text-forest transition-colors"> <span className="block mt-1 text-sm text-ink/70 group-hover:text-forest transition-colors">
{t.product.viewProduct} {t.product.viewProduct}
</span> </span>
</span> </span>
+1 -1
View File
@@ -163,7 +163,7 @@ function SearchButton() {
className="flex items-center justify-between gap-4 px-4 py-2.5 text-sm text-ink hover:bg-cream/60" className="flex items-center justify-between gap-4 px-4 py-2.5 text-sm text-ink hover:bg-cream/60"
> >
<span>{s.name}</span> <span>{s.name}</span>
<span className="text-ink/60 whitespace-nowrap">{s.price}</span> <span className="text-ink/70 whitespace-nowrap">{s.price}</span>
</Link> </Link>
</li> </li>
))} ))}
+12
View File
@@ -16,6 +16,13 @@ export interface CartItem {
interface CartState { interface CartState {
items: CartItem[]; items: CartItem[];
isOpen: boolean; isOpen: boolean;
// Client-only: waehrend des Server-Renders und des allerersten Client-
// Renders (bevor persist localStorage gelesen hat) ist dies immer false,
// damit UI, die auf "items.length === 0" verzweigt (Checkout/Warenkorb),
// nicht kurz einen falschen "leerer Warenkorb"-Zustand zeigt, bevor die
// echten, aus localStorage wiederhergestellten Positionen ankommen.
hasHydrated: boolean;
setHasHydrated: (hasHydrated: boolean) => void;
openCart: () => void; openCart: () => void;
closeCart: () => void; closeCart: () => void;
addItem: (item: Omit<CartItem, "quantity">, quantity?: number) => void; addItem: (item: Omit<CartItem, "quantity">, quantity?: number) => void;
@@ -29,6 +36,8 @@ export const useCartStore = create<CartState>()(
(set) => ({ (set) => ({
items: [], items: [],
isOpen: false, isOpen: false,
hasHydrated: false,
setHasHydrated: (hasHydrated) => set({ hasHydrated }),
openCart: () => set({ isOpen: true }), openCart: () => set({ isOpen: true }),
closeCart: () => set({ isOpen: false }), closeCart: () => set({ isOpen: false }),
addItem: (item, quantity = 1) => addItem: (item, quantity = 1) =>
@@ -64,6 +73,9 @@ export const useCartStore = create<CartState>()(
// UI-Zustand und darf nach einem Seiten-Reload nicht wiederhergestellt // UI-Zustand und darf nach einem Seiten-Reload nicht wiederhergestellt
// werden (sonst poppt das Cart-Drawer nach jedem Reload erneut auf). // werden (sonst poppt das Cart-Drawer nach jedem Reload erneut auf).
partialize: (state) => ({ items: state.items }), partialize: (state) => ({ items: state.items }),
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true);
},
} }
) )
); );