From 75f65f83641e3cc52913252554bf57c0b48a62ef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 22:37:15 +0000 Subject: [PATCH 1/4] perf: trim critical-path CSS and warm map/data connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the vendored mapbox-gl stylesheet (~40KB raw / 5.4KB gzip) out of the render-blocking globals.css and into MapView, which is loaded via a lazy dynamic(ssr:false) import. The stylesheet now ships with the map chunk instead of every first paint, cutting render-blocking critical CSS from 108.5KB to 68.7KB raw (17.7KB -> 12.4KB gzip, -30%) with no visual change — MapView is the only consumer of those styles. Add preconnect/dns-prefetch resource hints for Mapbox (api/events) and the Supabase origin so the TLS handshakes for tiles, styles and photos start during HTML parse rather than after the map/gallery code runs. Add decoding="async" to the Journey hero slideshow images so large photo decodes stay off the main thread. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Bu6yr5RHkXvk7kXYdL24tu --- app/globals.css | 4 +++- app/layout.tsx | 25 +++++++++++++++++++++++++ components/JourneyHeroCard.tsx | 1 + components/MapView.tsx | 3 +++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/app/globals.css b/app/globals.css index 0cc0085..d6f238c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,5 +1,7 @@ @import "tailwindcss"; -@import "mapbox-gl/dist/mapbox-gl.css"; +/* mapbox-gl's stylesheet is imported inside components/MapView.tsx instead of + here, so its ~40KB ships in the lazy map chunk rather than the render-blocking + critical CSS that every visitor downloads before first paint. */ @theme inline { --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; diff --git a/app/layout.tsx b/app/layout.tsx index fe3a369..3236704 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -14,9 +14,34 @@ export const viewport: Viewport = { themeColor: "#e7efe8", }; +// Origin of the Supabase project (storage + realtime). Derived from the public +// env var so a preconnect can warm the TLS handshake before the first photo or +// data request fires. +const supabaseOrigin = (() => { + try { + return process.env.NEXT_PUBLIC_SUPABASE_URL ? new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin : null; + } catch { + return null; + } +})(); + export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { return ( + + {/* Warm the connections the map and gallery need on first paint: Mapbox + style/tiles/telemetry and Supabase storage. preconnect opens the + TCP+TLS early; dns-prefetch is the cheaper fallback for older browsers. */} + + + + {supabaseOrigin ? ( + <> + + + + ) : null} + {children} ); diff --git a/components/JourneyHeroCard.tsx b/components/JourneyHeroCard.tsx index 9fbc200..ca6d19a 100644 --- a/components/JourneyHeroCard.tsx +++ b/components/JourneyHeroCard.tsx @@ -130,6 +130,7 @@ export function JourneyHeroCard({ photos, momentCount, dayCount, onPlay, disable src={previewUrl(photo) ?? undefined} alt="" aria-hidden + decoding="async" className={cn( "pointer-events-none absolute inset-0 h-full w-full object-cover transition-opacity duration-[1200ms] ease-in-out motion-reduce:transition-none", index === active ? "opacity-100" : "opacity-0", diff --git a/components/MapView.tsx b/components/MapView.tsx index ca349ab..08d4535 100644 --- a/components/MapView.tsx +++ b/components/MapView.tsx @@ -1,6 +1,9 @@ "use client"; import mapboxgl from "mapbox-gl"; +// Loaded here rather than in globals.css so the vendored stylesheet ships with +// this lazily-imported (ssr:false) map chunk, keeping it off the critical path. +import "mapbox-gl/dist/mapbox-gl.css"; import { MapPin } from "lucide-react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { LOFOTEN_CENTER } from "@/lib/geo"; From bef8091c85a2dc6058a0ab21f2a5243e90ece696 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 22:44:13 +0000 Subject: [PATCH 2/4] perf: move map style overrides into the lazy map chunk Follow-up to the mapbox-gl.css relocation: extract the Mapbox control/popup and custom photo-marker overrides out of globals.css into components/map-overrides.css, imported by MapView after the vendor sheet. Every selector moved targets DOM that only exists once the map mounts (verified: mapboxgl/lofoten-photo-marker/ lofoten-popup/lofoten-outlier/map-unavailable classes are referenced only by the map components, all children of the lazy MapView). The hero Ken Burns animation and .font-serif stay in globals since they render independently of the map. This trims render-blocking critical CSS further: 68.7KB -> 63.1KB raw (12.4KB -> 11.1KB gzip). Combined with the earlier change, critical CSS is down from 108.5KB to 63.1KB raw (-42%) and 17.7KB to 11.1KB gzip (-37%); the moved styles now ship in the lazy map chunk. Addresses CodeRabbit's review nitpick on PR #10. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Bu6yr5RHkXvk7kXYdL24tu --- app/globals.css | 327 +---------------------------------- components/MapView.tsx | 7 +- components/map-overrides.css | 322 ++++++++++++++++++++++++++++++++++ 3 files changed, 333 insertions(+), 323 deletions(-) create mode 100644 components/map-overrides.css diff --git a/app/globals.css b/app/globals.css index d6f238c..d4fc3b6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,7 +1,8 @@ @import "tailwindcss"; -/* mapbox-gl's stylesheet is imported inside components/MapView.tsx instead of - here, so its ~40KB ships in the lazy map chunk rather than the render-blocking - critical CSS that every visitor downloads before first paint. */ +/* Mapbox's stylesheet and our map-specific overrides (controls, popups, photo + markers) live in components/map-overrides.css, imported by MapView.tsx. They + ship in the lazy map chunk rather than this render-blocking critical CSS, and + only target DOM that exists once the map has mounted. */ @theme inline { --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; @@ -30,135 +31,9 @@ body { letter-spacing: -0.01em; } -.mapboxgl-popup-content, -.lofoten-popup .mapboxgl-popup-content { - border-radius: 14px !important; - background: rgba(255, 253, 246, 0.98) !important; - color: #1c1917 !important; - border: 1px solid rgba(214, 204, 188, 0.9); - box-shadow: 0 24px 60px rgba(46, 61, 54, 0.22) !important; - padding: 0 !important; - overflow: hidden; -} -.mapboxgl-popup-tip, -.lofoten-popup .mapboxgl-popup-tip { border-top-color: rgba(255, 253, 246, 0.98) !important; } -.mapboxgl-popup-close-button, -.lofoten-popup .mapboxgl-popup-close-button { - border-radius: 999px; - color: #57534e !important; - font-size: 20px; - line-height: 1; - margin: 8px; - padding: 5px 8px; -} -.mapboxgl-popup-close-button:hover, -.mapboxgl-popup-close-button:focus, -.lofoten-popup .mapboxgl-popup-close-button:hover, -.lofoten-popup .mapboxgl-popup-close-button:focus { - background: rgba(28, 25, 23, 0.06) !important; - color: #1c1917 !important; - outline: 2px solid rgba(15, 118, 110, 0.35); - outline-offset: 2px; -} -.mapboxgl-ctrl-group { - border-radius: 10px !important; - overflow: hidden; - background: rgba(255, 253, 246, 0.94) !important; - box-shadow: 0 12px 30px rgba(46, 61, 54, 0.18) !important; -} -.mapboxgl-ctrl button { color: #1c1917 !important; } - -.lofoten-photo-marker { - width: 48px; - height: 48px; - cursor: pointer; - overflow: visible; - border: 3px solid #fffdf6; - border-radius: 12px; - background: #e7a13d; - padding: 0; - box-shadow: - 0 2px 5px rgba(28, 25, 23, 0.22), - 0 10px 22px rgba(46, 61, 54, 0.22); - transition: transform 160ms ease, box-shadow 160ms ease; -} - -.lofoten-photo-marker::before, -.lofoten-photo-marker::after { - content: ""; - position: absolute; - z-index: -1; - inset: 2px; - border: 2px solid #fffdf6; - border-radius: 11px; - background: #d6ccb8; - box-shadow: 0 4px 10px rgba(28, 25, 23, 0.16); -} - -.lofoten-photo-marker:not(.lofoten-photo-marker-cluster)::before, -.lofoten-photo-marker:not(.lofoten-photo-marker-cluster)::after { - display: none; -} - -.lofoten-photo-marker-cluster { - width: 56px; - height: 56px; -} - -.lofoten-photo-marker-cluster::before { - transform: translate(5px, -4px) rotate(7deg); -} - -.lofoten-photo-marker-cluster::after { - transform: translate(-5px, 4px) rotate(-5deg); -} - -.lofoten-photo-marker img { - display: block; - width: 100%; - height: 100%; - border-radius: 9px; - object-fit: cover; - background: #dbe7df; -} - -/* Pulsing ring around the photo currently open in the editor. The outer div is - positioned by Mapbox (inline transform), so the animation lives on a child. */ -.lofoten-photo-highlight { - width: 76px; - height: 76px; - cursor: grab; -} - -.lofoten-photo-highlight:active { - cursor: grabbing; -} - -.lofoten-photo-highlight-ring { - position: absolute; - inset: 0; - border: 3px solid #e7a13d; - border-radius: 18px; - box-shadow: - 0 0 0 7px rgba(231, 161, 61, 0.28), - 0 0 34px rgba(231, 161, 61, 0.55); - animation: lofoten-highlight-pulse 1.5s ease-in-out infinite; -} - -@keyframes lofoten-highlight-pulse { - 0%, - 100% { - transform: scale(1); - opacity: 1; - } - 50% { - transform: scale(1.14); - opacity: 0.65; - } -} - /* Slow drift on the active Journey hero slide — ambient motion that hints the - trip can be played. Disabled for reduced-motion users. */ + trip can be played. Disabled for reduced-motion users. Lives here (not in the + map overrides) because the hero card renders independently of the map. */ .journey-hero-kenburns { animation: journey-hero-kenburns 7s ease-out forwards; } @@ -171,193 +46,3 @@ body { @media (prefers-reduced-motion: reduce) { .journey-hero-kenburns { animation: none; } } - -/* Location-check preview: the flagged photo's time-neighbors and the - suggested corrected spot. */ -.lofoten-outlier-neighbor { - width: 14px; - height: 14px; - border-radius: 9999px; - border: 2.5px solid #fffdf6; - background: #0f766e; - box-shadow: 0 2px 8px rgba(28, 25, 23, 0.35); - pointer-events: none; -} - -.lofoten-outlier-center { - width: 36px; - height: 36px; - border-radius: 9999px; - border: 3px dashed #0f766e; - background: rgba(15, 118, 110, 0.14); - pointer-events: none; -} - -.lofoten-photo-marker-fallback { - display: flex; - width: 100%; - height: 100%; - align-items: center; - justify-content: center; - border-radius: 9px; - color: #fffdf6; - font-size: 0.62rem; - font-weight: 800; - text-transform: uppercase; -} - -.lofoten-photo-marker-count { - position: absolute; - top: -10px; - right: -10px; - min-width: 25px; - height: 25px; - padding: 0 7px; - display: flex; - align-items: center; - justify-content: center; - border: 2px solid #fffdf6; - border-radius: 999px; - background: #0f766e; - color: white; - font-size: 0.7rem; - font-weight: 800; - line-height: 1; - box-shadow: 0 4px 10px rgba(28, 25, 23, 0.24); -} - -.lofoten-photo-marker:hover, -.lofoten-photo-marker:focus-visible { - z-index: 2; - box-shadow: - 0 3px 7px rgba(28, 25, 23, 0.24), - 0 14px 28px rgba(46, 61, 54, 0.28); -} - -.lofoten-photo-marker:focus-visible { - outline: 3px solid rgba(15, 118, 110, 0.55); - outline-offset: 4px; -} - -.map-unavailable .mapboxgl-ctrl, -.map-unavailable .mapboxgl-ctrl-bottom-left, -.map-unavailable .mapboxgl-ctrl-bottom-right, -.map-unavailable-active .mapboxgl-control-container, -.map-unavailable-active .mapboxgl-ctrl, -.map-unavailable-active .mapboxgl-ctrl-bottom-left, -.map-unavailable-active .mapboxgl-ctrl-bottom-right { - display: none !important; -} - -@media (max-width: 767px) { - .mapboxgl-ctrl-bottom-left, - .mapboxgl-ctrl-bottom-right { - bottom: calc(11.75rem + env(safe-area-inset-bottom)) !important; - } - - .mapboxgl-ctrl-bottom-left .mapboxgl-ctrl { - margin-left: 0.75rem !important; - } - - .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl { - margin-right: 0.75rem !important; - } -} - -.lofoten-popup-card { - width: 16rem; - background: rgba(255, 253, 246, 0.98); - color: #1c1917; -} - -.lofoten-popup-image { - display: block; - height: 9rem; - width: 100%; - object-fit: cover; -} - -.lofoten-popup-video-fallback { - align-items: center; - background: #f5f5f4; - color: #78716c; - display: flex; - font-size: 0.85rem; - font-weight: 800; - justify-content: center; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.lofoten-popup-body { - padding: 0.85rem; -} - -.lofoten-popup-title { - color: #1c1917; - font-size: 0.92rem; - font-weight: 800; - line-height: 1.35; - padding-right: 1.5rem; -} - -.lofoten-popup-meta { - color: #78716c; - font-size: 0.78rem; - line-height: 1.4; - margin-top: 0.35rem; -} - -.lofoten-popup-by { font-weight: 600; color: #57534e; } - -.lofoten-popup-actions { - display: flex; - gap: 0.4rem; - margin-top: 0.7rem; - padding-top: 0.6rem; - border-top: 1px solid rgba(214, 204, 188, 0.7); -} -.lofoten-popup-action { - flex: 1; - cursor: pointer; - border-radius: 8px; - border: 1px solid rgba(214, 204, 188, 0.9); - background: #fffdf6; - padding: 0.4rem 0.5rem; - font-size: 0.78rem; - font-weight: 800; - color: #1c1917; - transition: background 0.15s ease; -} -.lofoten-popup-action:hover { background: rgba(28, 25, 23, 0.05); } -.lofoten-popup-action:focus-visible { - outline: 2px solid rgba(15, 118, 110, 0.4); - outline-offset: 1px; -} -.lofoten-popup-action-danger { - border-color: #f6c9cf; - background: #fef2f3; - color: #b4232f; -} -.lofoten-popup-action-danger:hover { background: #fde4e6; } -.lofoten-popup-action-journey { - border-color: rgba(231, 161, 61, 0.6); - background: #fdf1dc; - color: #8a5414; -} -.lofoten-popup-action-journey:hover { background: #fbe6c2; } - -.lofoten-popup-tag { - display: inline-block; - margin-bottom: 0.4rem; - border-radius: 999px; - padding: 0.12rem 0.5rem; - font-size: 0.62rem; - font-weight: 800; - letter-spacing: 0.08em; - text-transform: uppercase; -} -.lofoten-popup-tag-photo { background: #fbe7c6; color: #8a5212; } -.lofoten-popup-tag-note { background: #f6ead0; color: #7c4a14; } -.lofoten-popup-tag-place { background: #d7ecdf; color: #0f5f55; } -.lofoten-popup-tag-route { background: #d7ecdf; color: #0f5f55; } diff --git a/components/MapView.tsx b/components/MapView.tsx index 08d4535..2ea6322 100644 --- a/components/MapView.tsx +++ b/components/MapView.tsx @@ -1,9 +1,12 @@ "use client"; import mapboxgl from "mapbox-gl"; -// Loaded here rather than in globals.css so the vendored stylesheet ships with -// this lazily-imported (ssr:false) map chunk, keeping it off the critical path. +// Loaded here rather than in globals.css so the vendored stylesheet and our map +// overrides ship with this lazily-imported (ssr:false) map chunk, keeping them +// off the render-blocking critical path. The overrides import must follow the +// vendor sheet so its cascade wins. import "mapbox-gl/dist/mapbox-gl.css"; +import "./map-overrides.css"; import { MapPin } from "lucide-react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { LOFOTEN_CENTER } from "@/lib/geo"; diff --git a/components/map-overrides.css b/components/map-overrides.css new file mode 100644 index 0000000..f13c966 --- /dev/null +++ b/components/map-overrides.css @@ -0,0 +1,322 @@ +/* Map-specific style overrides for Mapbox controls, popups and the custom photo + markers. Imported by components/MapView.tsx (after mapbox-gl's own stylesheet) + rather than from globals.css, so these rules ship in the lazy map chunk and + stay off the render-blocking critical path. Every selector here targets DOM + that only exists once the map has mounted. */ + +.mapboxgl-popup-content, +.lofoten-popup .mapboxgl-popup-content { + border-radius: 14px !important; + background: rgba(255, 253, 246, 0.98) !important; + color: #1c1917 !important; + border: 1px solid rgba(214, 204, 188, 0.9); + box-shadow: 0 24px 60px rgba(46, 61, 54, 0.22) !important; + padding: 0 !important; + overflow: hidden; +} +.mapboxgl-popup-tip, +.lofoten-popup .mapboxgl-popup-tip { border-top-color: rgba(255, 253, 246, 0.98) !important; } +.mapboxgl-popup-close-button, +.lofoten-popup .mapboxgl-popup-close-button { + border-radius: 999px; + color: #57534e !important; + font-size: 20px; + line-height: 1; + margin: 8px; + padding: 5px 8px; +} +.mapboxgl-popup-close-button:hover, +.mapboxgl-popup-close-button:focus, +.lofoten-popup .mapboxgl-popup-close-button:hover, +.lofoten-popup .mapboxgl-popup-close-button:focus { + background: rgba(28, 25, 23, 0.06) !important; + color: #1c1917 !important; + outline: 2px solid rgba(15, 118, 110, 0.35); + outline-offset: 2px; +} +.mapboxgl-ctrl-group { + border-radius: 10px !important; + overflow: hidden; + background: rgba(255, 253, 246, 0.94) !important; + box-shadow: 0 12px 30px rgba(46, 61, 54, 0.18) !important; +} +.mapboxgl-ctrl button { color: #1c1917 !important; } + +.lofoten-photo-marker { + width: 48px; + height: 48px; + cursor: pointer; + overflow: visible; + border: 3px solid #fffdf6; + border-radius: 12px; + background: #e7a13d; + padding: 0; + box-shadow: + 0 2px 5px rgba(28, 25, 23, 0.22), + 0 10px 22px rgba(46, 61, 54, 0.22); + transition: transform 160ms ease, box-shadow 160ms ease; +} + +.lofoten-photo-marker::before, +.lofoten-photo-marker::after { + content: ""; + position: absolute; + z-index: -1; + inset: 2px; + border: 2px solid #fffdf6; + border-radius: 11px; + background: #d6ccb8; + box-shadow: 0 4px 10px rgba(28, 25, 23, 0.16); +} + +.lofoten-photo-marker:not(.lofoten-photo-marker-cluster)::before, +.lofoten-photo-marker:not(.lofoten-photo-marker-cluster)::after { + display: none; +} + +.lofoten-photo-marker-cluster { + width: 56px; + height: 56px; +} + +.lofoten-photo-marker-cluster::before { + transform: translate(5px, -4px) rotate(7deg); +} + +.lofoten-photo-marker-cluster::after { + transform: translate(-5px, 4px) rotate(-5deg); +} + +.lofoten-photo-marker img { + display: block; + width: 100%; + height: 100%; + border-radius: 9px; + object-fit: cover; + background: #dbe7df; +} + +/* Pulsing ring around the photo currently open in the editor. The outer div is + positioned by Mapbox (inline transform), so the animation lives on a child. */ +.lofoten-photo-highlight { + width: 76px; + height: 76px; + cursor: grab; +} + +.lofoten-photo-highlight:active { + cursor: grabbing; +} + +.lofoten-photo-highlight-ring { + position: absolute; + inset: 0; + border: 3px solid #e7a13d; + border-radius: 18px; + box-shadow: + 0 0 0 7px rgba(231, 161, 61, 0.28), + 0 0 34px rgba(231, 161, 61, 0.55); + animation: lofoten-highlight-pulse 1.5s ease-in-out infinite; +} + +@keyframes lofoten-highlight-pulse { + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.14); + opacity: 0.65; + } +} + +/* Location-check preview: the flagged photo's time-neighbors and the + suggested corrected spot. */ +.lofoten-outlier-neighbor { + width: 14px; + height: 14px; + border-radius: 9999px; + border: 2.5px solid #fffdf6; + background: #0f766e; + box-shadow: 0 2px 8px rgba(28, 25, 23, 0.35); + pointer-events: none; +} + +.lofoten-outlier-center { + width: 36px; + height: 36px; + border-radius: 9999px; + border: 3px dashed #0f766e; + background: rgba(15, 118, 110, 0.14); + pointer-events: none; +} + +.lofoten-photo-marker-fallback { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + border-radius: 9px; + color: #fffdf6; + font-size: 0.62rem; + font-weight: 800; + text-transform: uppercase; +} + +.lofoten-photo-marker-count { + position: absolute; + top: -10px; + right: -10px; + min-width: 25px; + height: 25px; + padding: 0 7px; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid #fffdf6; + border-radius: 999px; + background: #0f766e; + color: white; + font-size: 0.7rem; + font-weight: 800; + line-height: 1; + box-shadow: 0 4px 10px rgba(28, 25, 23, 0.24); +} + +.lofoten-photo-marker:hover, +.lofoten-photo-marker:focus-visible { + z-index: 2; + box-shadow: + 0 3px 7px rgba(28, 25, 23, 0.24), + 0 14px 28px rgba(46, 61, 54, 0.28); +} + +.lofoten-photo-marker:focus-visible { + outline: 3px solid rgba(15, 118, 110, 0.55); + outline-offset: 4px; +} + +.map-unavailable .mapboxgl-ctrl, +.map-unavailable .mapboxgl-ctrl-bottom-left, +.map-unavailable .mapboxgl-ctrl-bottom-right, +.map-unavailable-active .mapboxgl-control-container, +.map-unavailable-active .mapboxgl-ctrl, +.map-unavailable-active .mapboxgl-ctrl-bottom-left, +.map-unavailable-active .mapboxgl-ctrl-bottom-right { + display: none !important; +} + +@media (max-width: 767px) { + .mapboxgl-ctrl-bottom-left, + .mapboxgl-ctrl-bottom-right { + bottom: calc(11.75rem + env(safe-area-inset-bottom)) !important; + } + + .mapboxgl-ctrl-bottom-left .mapboxgl-ctrl { + margin-left: 0.75rem !important; + } + + .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl { + margin-right: 0.75rem !important; + } +} + +.lofoten-popup-card { + width: 16rem; + background: rgba(255, 253, 246, 0.98); + color: #1c1917; +} + +.lofoten-popup-image { + display: block; + height: 9rem; + width: 100%; + object-fit: cover; +} + +.lofoten-popup-video-fallback { + align-items: center; + background: #f5f5f4; + color: #78716c; + display: flex; + font-size: 0.85rem; + font-weight: 800; + justify-content: center; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.lofoten-popup-body { + padding: 0.85rem; +} + +.lofoten-popup-title { + color: #1c1917; + font-size: 0.92rem; + font-weight: 800; + line-height: 1.35; + padding-right: 1.5rem; +} + +.lofoten-popup-meta { + color: #78716c; + font-size: 0.78rem; + line-height: 1.4; + margin-top: 0.35rem; +} + +.lofoten-popup-by { font-weight: 600; color: #57534e; } + +.lofoten-popup-actions { + display: flex; + gap: 0.4rem; + margin-top: 0.7rem; + padding-top: 0.6rem; + border-top: 1px solid rgba(214, 204, 188, 0.7); +} +.lofoten-popup-action { + flex: 1; + cursor: pointer; + border-radius: 8px; + border: 1px solid rgba(214, 204, 188, 0.9); + background: #fffdf6; + padding: 0.4rem 0.5rem; + font-size: 0.78rem; + font-weight: 800; + color: #1c1917; + transition: background 0.15s ease; +} +.lofoten-popup-action:hover { background: rgba(28, 25, 23, 0.05); } +.lofoten-popup-action:focus-visible { + outline: 2px solid rgba(15, 118, 110, 0.4); + outline-offset: 1px; +} +.lofoten-popup-action-danger { + border-color: #f6c9cf; + background: #fef2f3; + color: #b4232f; +} +.lofoten-popup-action-danger:hover { background: #fde4e6; } +.lofoten-popup-action-journey { + border-color: rgba(231, 161, 61, 0.6); + background: #fdf1dc; + color: #8a5414; +} +.lofoten-popup-action-journey:hover { background: #fbe6c2; } + +.lofoten-popup-tag { + display: inline-block; + margin-bottom: 0.4rem; + border-radius: 999px; + padding: 0.12rem 0.5rem; + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.lofoten-popup-tag-photo { background: #fbe7c6; color: #8a5212; } +.lofoten-popup-tag-note { background: #f6ead0; color: #7c4a14; } +.lofoten-popup-tag-place { background: #d7ecdf; color: #0f5f55; } +.lofoten-popup-tag-route { background: #d7ecdf; color: #0f5f55; } From e4f06199aa0d21ba8576e97b8b640a53215a201e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 22:49:12 +0000 Subject: [PATCH 3/4] a11y: honor prefers-reduced-motion for the photo highlight ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pulsing ring that marks the photo open in the editor ran an infinite animation with no reduced-motion fallback. Add a prefers-reduced-motion guard so the ring stays visible but holds still for those users — matching the existing treatment of the hero Ken Burns animation. Addresses CodeRabbit's review comment on PR #10. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Bu6yr5RHkXvk7kXYdL24tu --- components/map-overrides.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/map-overrides.css b/components/map-overrides.css index f13c966..9b6cb61 100644 --- a/components/map-overrides.css +++ b/components/map-overrides.css @@ -131,6 +131,12 @@ } } +/* The ring stays visible (it marks the photo being edited) but holds still for + users who prefer reduced motion. */ +@media (prefers-reduced-motion: reduce) { + .lofoten-photo-highlight-ring { animation: none; } +} + /* Location-check preview: the flagged photo's time-neighbors and the suggested corrected spot. */ .lofoten-outlier-neighbor { From b79c9203a0c230e8eab4eebe84827132a4001766 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 22:59:31 +0000 Subject: [PATCH 4/4] perf: drop Turf from the initial bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspired by an alternate PR that swapped @turf/length for an inline haversine in geo.ts. On its own that saved nothing — Turf still reached the initial page graph through two other paths — so this carries the idea through to actually remove it: - lib/geo.ts: inline haversine (segmentDistanceMeters/geometryDistanceMeters) replaces @turf/length for bounds and route-distance math, plus a distanceKm helper. Uses Turf's mean Earth radius so displayed distances are unchanged. - lib/photo-outliers.ts: pull distanceKm from geo instead of journey-leg. It only needed that one function, but importing it dragged journey-leg's eight @turf/* packages into the initial bundle (via AdminDataPanel in the always-mounted sidebar). JourneyMiniMap still uses journey-leg in its own lazy chunk. - lib/hooks/useTripMutations.ts: load @/lib/gpx (and its @turf/simplify dep) lazily inside the importGpx callback — a rare, already-async admin action. Net: Turf is no longer present in the initial JS at all (verified by scanning the chunks the prerendered document loads). Initial JS drops from ~1032KB to 1014KB raw / ~302KB to 295.6KB gzip. Turf stays installed for the dynamic journey/upload chunks that genuinely need it; behavior is unchanged (192 tests pass). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Bu6yr5RHkXvk7kXYdL24tu --- lib/geo.ts | 43 +++++++++++++++++++++++++++++------ lib/hooks/useTripMutations.ts | 5 +++- lib/photo-outliers.ts | 2 +- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/lib/geo.ts b/lib/geo.ts index 3912424..87d22c0 100644 --- a/lib/geo.ts +++ b/lib/geo.ts @@ -1,11 +1,34 @@ import type { Feature, FeatureCollection, LineString, Point } from "geojson"; -import length from "@turf/length"; import type { LngLat, Note, Photo, Place, RouteSegment } from "@/types/trip"; export const LOFOTEN_CENTER: [number, number] = [13.0897, 67.9325]; export type CoordinateBounds = { sw: [number, number]; ne: [number, number]; center: [number, number]; diagonalMeters: number }; +// Great-circle (haversine) distance, inlined so route/bounds math doesn't pull +// @turf/length (and its @turf/distance/helpers/meta deps) into the page graph — +// geo.ts is the only Turf consumer reachable from the initial bundle, so this +// drops Turf from first load entirely. Uses Turf's mean Earth radius so the +// displayed distances stay identical to the previous implementation. +const EARTH_RADIUS_METERS = 6_371_008.8; + +function segmentDistanceMeters([fromLng, fromLat]: [number, number], [toLng, toLat]: [number, number]): number { + const fromPhi = (fromLat * Math.PI) / 180; + const toPhi = (toLat * Math.PI) / 180; + const deltaPhi = ((toLat - fromLat) * Math.PI) / 180; + const deltaLambda = ((toLng - fromLng) * Math.PI) / 180; + const a = Math.sin(deltaPhi / 2) ** 2 + Math.cos(fromPhi) * Math.cos(toPhi) * Math.sin(deltaLambda / 2) ** 2; + return 2 * EARTH_RADIUS_METERS * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +function geometryDistanceMeters(geometry: LineString): number { + let meters = 0; + for (let i = 1; i < geometry.coordinates.length; i += 1) { + meters += segmentDistanceMeters(geometry.coordinates[i - 1] as [number, number], geometry.coordinates[i] as [number, number]); + } + return meters; +} + /** * Axis-aligned bounds for a set of [lng, lat] coordinates, in the shape * map.fitBounds accepts ([sw, ne]). Replaces mapboxgl.LngLatBounds at call @@ -24,15 +47,21 @@ export function coordinateBounds(coords: [number, number][]): CoordinateBounds | if (lat < minLat) minLat = lat; if (lat > maxLat) maxLat = lat; } - const diagonal: LineString = { type: "LineString", coordinates: [[minLng, minLat], [maxLng, maxLat]] }; return { sw: [minLng, minLat], ne: [maxLng, maxLat], center: [(minLng + maxLng) / 2, (minLat + maxLat) / 2], - diagonalMeters: length({ type: "Feature", geometry: diagonal, properties: {} }, { units: "kilometers" }) * 1000, + diagonalMeters: segmentDistanceMeters([minLng, minLat], [maxLng, maxLat]), }; } +// Great-circle distance between two points in kilometers. Mirrors @turf/distance +// (same Earth radius) so callers like photo-outlier detection can reach it +// without importing the Turf-heavy journey-leg module into the page graph. +export function distanceKm(a: LngLat, b: LngLat): number { + return segmentDistanceMeters([a.lng, a.lat], [b.lng, b.lat]) / 1000; +} + export function routeGeometry(points: LngLat[]): LineString { return { type: "LineString", coordinates: points.map((point) => [point.lng, point.lat]) }; } @@ -44,7 +73,7 @@ export function routeDistanceMeters(points: LngLat[]) { export function lineDistanceMeters(geometry: LineString) { if (geometry.coordinates.length < 2) return 0; - return Math.round(length({ type: "Feature", geometry, properties: {} }, { units: "kilometers" }) * 1000); + return Math.round(geometryDistanceMeters(geometry)); } export type DayItems = { @@ -77,9 +106,9 @@ export function routeFeatureCollection(routes: RouteSegment[]): FeatureCollectio type: "FeatureCollection", features: routes.map((route) => { const geometry = (route.geometry_geojson.type === "Feature" ? route.geometry_geojson.geometry : route.geometry_geojson) as LineString; - const distanceKm = route.distance_meters + const routeDistanceKm = route.distance_meters ? route.distance_meters / 1000 - : length({ type: "Feature", geometry, properties: {} }, { units: "kilometers" }); + : geometryDistanceMeters(geometry) / 1000; const feature: Feature = { type: "Feature", geometry, @@ -88,7 +117,7 @@ export function routeFeatureCollection(routes: RouteSegment[]): FeatureCollectio name: route.name ?? "Route segment", day_id: route.day_id, mode: route.mode, - distance_km: distanceKm, + distance_km: routeDistanceKm, }, }; return feature; diff --git a/lib/hooks/useTripMutations.ts b/lib/hooks/useTripMutations.ts index 992f200..4fb02f9 100644 --- a/lib/hooks/useTripMutations.ts +++ b/lib/hooks/useTripMutations.ts @@ -5,7 +5,8 @@ import type { Dispatch, SetStateAction } from "react"; import type { SupabaseClient, User } from "@supabase/supabase-js"; import type { Day, LngLat, Note, RouteMode, RouteSegment, TripData } from "@/types/trip"; import { PHOTO_BUCKET } from "@/lib/supabase"; -import { firstBucketDate, groupPointsByDay, parseGpx, simplifyToLineString } from "@/lib/gpx"; +// @/lib/gpx is loaded lazily inside importGpx (below) so its @turf/simplify +// dependency stays out of the initial bundle — GPX import is a rare admin action. import { lineDistanceMeters } from "@/lib/geo"; import { addDay, @@ -148,6 +149,8 @@ export function useTripMutations({ supabase, user, isAdmin, data, setData, loadD return; } + const { firstBucketDate, groupPointsByDay, parseGpx, simplifyToLineString } = await import("@/lib/gpx"); + await runAdminOperation(async () => { const parsed = parseGpx(await file.text()); const pointBuckets = groupPointsByDay(parsed.trackPoints).filter((bucket) => bucket.length >= 2); diff --git a/lib/photo-outliers.ts b/lib/photo-outliers.ts index e4a7372..cdf2df2 100644 --- a/lib/photo-outliers.ts +++ b/lib/photo-outliers.ts @@ -1,4 +1,4 @@ -import { distanceKm } from "./journey-leg"; +import { distanceKm } from "./geo"; import type { LngLat, Photo } from "@/types/trip"; // A photo whose position disagrees with where the photos taken around the