aboutsummaryrefslogtreecommitdiff
path: root/src/frontend/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/frontend/app')
-rw-r--r--src/frontend/app/api/schema.ts6
-rw-r--r--src/frontend/app/components/PlannerOverlay.tsx63
-rw-r--r--src/frontend/app/components/layout/NavBar.tsx9
-rw-r--r--src/frontend/app/data/PlannerApi.ts7
-rw-r--r--src/frontend/app/data/StopDataProvider.ts17
-rw-r--r--src/frontend/app/hooks/usePlanQuery.ts4
-rw-r--r--src/frontend/app/hooks/usePlanner.ts178
-rw-r--r--src/frontend/app/i18n/locales/en-GB.json1
-rw-r--r--src/frontend/app/i18n/locales/es-ES.json1
-rw-r--r--src/frontend/app/i18n/locales/gl-ES.json1
-rw-r--r--src/frontend/app/root.css7
-rw-r--r--src/frontend/app/routes/home.tsx74
-rw-r--r--src/frontend/app/routes/map.tsx7
-rw-r--r--src/frontend/app/routes/planner.tsx256
-rw-r--r--src/frontend/app/tailwind-full.css28
15 files changed, 414 insertions, 245 deletions
diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts
index bb2fbcc..63f4368 100644
--- a/src/frontend/app/api/schema.ts
+++ b/src/frontend/app/api/schema.ts
@@ -163,8 +163,10 @@ export const ItinerarySchema = z.object({
transitTimeSeconds: z.number(),
waitingTimeSeconds: z.number(),
legs: z.array(PlannerLegSchema),
- cashFareEuro: z.number().optional().nullable(),
- cardFareEuro: z.number().optional().nullable(),
+ cashFare: z.number().optional().nullable(),
+ cashFareIsTotal: z.boolean().optional().nullable(),
+ cardFare: z.number().optional().nullable(),
+ cardFareIsTotal: z.boolean().optional().nullable(),
});
export const RoutePlanSchema = z.object({
diff --git a/src/frontend/app/components/PlannerOverlay.tsx b/src/frontend/app/components/PlannerOverlay.tsx
index af71e48..55e52d7 100644
--- a/src/frontend/app/components/PlannerOverlay.tsx
+++ b/src/frontend/app/components/PlannerOverlay.tsx
@@ -29,6 +29,8 @@ interface PlannerOverlayProps {
clearPickerOnOpen?: boolean;
showLastDestinationWhenCollapsed?: boolean;
cardBackground?: string;
+ userLocation?: { latitude: number; longitude: number } | null;
+ autoLoad?: boolean;
}
export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
@@ -39,10 +41,12 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
clearPickerOnOpen = false,
showLastDestinationWhenCollapsed = true,
cardBackground,
+ userLocation,
+ autoLoad = true,
}) => {
const { t } = useTranslation();
const { origin, setOrigin, destination, setDestination, loading, error } =
- usePlanner();
+ usePlanner({ autoLoad });
const [isExpanded, setIsExpanded] = useState(false);
const [originQuery, setOriginQuery] = useState(origin?.name || "");
const [destQuery, setDestQuery] = useState("");
@@ -85,6 +89,21 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
: origin?.name || ""
);
}, [origin, t]);
+
+ useEffect(() => {
+ if (userLocation && !origin) {
+ const initial: PlannerSearchResult = {
+ name: t("planner.current_location"),
+ label: "GPS",
+ lat: userLocation.latitude,
+ lon: userLocation.longitude,
+ layer: "current-location",
+ };
+ setOrigin(initial);
+ setOriginQuery(initial.name || "");
+ }
+ }, [userLocation, origin, t, setOrigin]);
+
useEffect(() => {
setDestQuery(destination?.name || "");
}, [destination]);
@@ -185,14 +204,6 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
clearPickerOnOpen ? "" : field === "origin" ? originQuery : destQuery
);
setPickerOpen(true);
-
- // When opening destination picker, auto-fill origin from current location if not set
- if (field === "destination" && !origin) {
- console.log(
- "[PlannerOverlay] Destination picker opened with no origin, requesting geolocation"
- );
- setOriginFromCurrentLocation(false);
- }
};
const applyPickedResult = (result: PlannerSearchResult) => {
@@ -323,11 +334,11 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
const wrapperClass = inline
? "w-full"
- : "pointer-events-none absolute left-0 right-0 top-0 z-20 flex justify-center";
+ : "pointer-events-none absolute left-0 right-0 top-0 z-20 flex justify-center mb-3";
const cardClass = inline
- ? `pointer-events-auto w-full overflow-hidden rounded-xl px-2 flex flex-col gap-3 ${cardBackground || "bg-white dark:bg-slate-900"}`
- : `pointer-events-auto w-[min(640px,calc(100%-16px))] px-2 py-1 flex flex-col gap-3 m-4 overflow-hidden rounded-xl border border-slate-200/80 dark:border-slate-700/70 shadow-2xl backdrop-blur ${cardBackground || "bg-white/95 dark:bg-slate-900/90"}`;
+ ? `pointer-events-auto w-full overflow-hidden rounded-xl px-2 flex flex-col gap-4 ${cardBackground || "bg-white dark:bg-slate-900"} mb-3`
+ : `pointer-events-auto w-[min(640px,calc(100%-16px))] px-2 py-1 flex flex-col gap-4 m-4 overflow-hidden rounded-xl border border-slate-200/80 dark:border-slate-700/70 shadow-2xl backdrop-blur ${cardBackground || "bg-white/95 dark:bg-slate-900/90"} mb-3`;
return (
<div className={wrapperClass}>
@@ -349,10 +360,10 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
</button>
) : (
<>
- <div className="flex items-center gap-">
+ <div className="flex items-center gap-2">
<button
type="button"
- className="w-full rounded-2xl bg-slate-100 dark:bg-slate-800 px-4 py-2.5 text-left text-sm text-slate-900 dark:text-slate-100 hover:bg-slate-200/80 dark:hover:bg-slate-700 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ className="w-full rounded-lg bg-surface border border-slate-200 dark:border-slate-700 px-4 py-2.5 text-left text-sm text-slate-900 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors duration-150 focus:outline-none focus:border-primary-500 shadow-sm"
onClick={() => openPicker("origin")}
>
<span
@@ -368,7 +379,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
<div>
<button
type="button"
- className="w-full rounded-2xl bg-slate-100 dark:bg-slate-800 px-4 py-2.5 text-left text-sm text-slate-900 dark:text-slate-100 hover:bg-slate-200/80 dark:hover:bg-slate-700 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ className="w-full rounded-lg bg-surface border border-slate-200 dark:border-slate-700 px-4 py-2.5 text-left text-sm text-slate-900 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors duration-150 focus:outline-none focus:border-primary-500 shadow-sm"
onClick={() => openPicker("destination")}
>
<span
@@ -383,13 +394,13 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
<div className="flex flex-wrap items-center gap-2 text-sm text-slate-700 dark:text-slate-200">
<span className="font-semibold">{t("planner.when")}</span>
- <div className="flex gap-1 rounded-2xl bg-slate-100 dark:bg-slate-800 p-1">
+ <div className="flex gap-1 rounded-2xl bg-surface border border-slate-200 dark:border-slate-700 p-1 shadow-sm">
<button
type="button"
className={`px-3 py-1.5 rounded-xl text-xs font-semibold transition-colors duration-150 ${
timeMode === "now"
- ? "bg-white dark:bg-slate-700 text-emerald-700 dark:text-emerald-300 shadow"
- : "text-slate-700 dark:text-slate-300 hover:bg-slate-200/70 dark:hover:bg-slate-700"
+ ? "bg-primary-500 text-white shadow-sm"
+ : "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
}`}
onClick={() => setTimeMode("now")}
>
@@ -399,8 +410,8 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
type="button"
className={`px-3 py-1.5 rounded-xl text-xs font-semibold transition-colors duration-150 ${
timeMode === "depart"
- ? "bg-white dark:bg-slate-700 text-emerald-700 dark:text-emerald-300 shadow"
- : "text-slate-700 dark:text-slate-300 hover:bg-slate-200/70 dark:hover:bg-slate-700"
+ ? "bg-primary-500 text-white shadow-sm"
+ : "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
}`}
onClick={() => setTimeMode("depart")}
>
@@ -410,8 +421,8 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
type="button"
className={`px-3 py-1.5 rounded-xl text-xs font-semibold transition-colors duration-150 ${
timeMode === "arrive"
- ? "bg-white dark:bg-slate-700 text-emerald-700 dark:text-emerald-300 shadow"
- : "text-slate-700 dark:text-slate-300 hover:bg-slate-200/70 dark:hover:bg-slate-700"
+ ? "bg-primary-500 text-white shadow-sm"
+ : "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
}`}
onClick={() => setTimeMode("arrive")}
>
@@ -421,7 +432,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
{timeMode !== "now" && (
<div className="flex gap-2 w-full">
<select
- className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 grow"
+ className="rounded-xl border border-slate-200 dark:border-slate-700 bg-surface px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:border-primary-500 grow shadow-sm"
value={dateOffset}
onChange={(e) => setDateOffset(Number(e.target.value))}
>
@@ -447,7 +458,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
</select>
<input
type="time"
- className="rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 grow"
+ className="rounded-xl border border-slate-200 dark:border-slate-700 bg-surface px-3 py-2 text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:border-primary-500 grow shadow-sm"
value={timeValue}
onChange={(e) => setTimeValue(e.target.value)}
/>
@@ -457,7 +468,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
<div>
<button
- className="w-full rounded-lg bg-emerald-600 hover:bg-emerald-700 dark:bg-emerald-700 dark:hover:bg-emerald-800 px-2 py-2 text-sm font-semibold text-white disabled:bg-slate-300 dark:disabled:bg-slate-600 disabled:cursor-not-allowed transition-colors duration-200 focus:outline-none"
+ className="w-full rounded-xl bg-primary-600 hover:bg-primary-700 dark:bg-primary-700 dark:hover:bg-primary-800 px-2 py-2.5 text-sm font-semibold text-white disabled:bg-slate-300 dark:disabled:bg-slate-600 disabled:cursor-not-allowed transition-colors duration-200 focus:outline-none"
disabled={!canSubmit}
onClick={async () => {
if (origin && destination) {
@@ -543,7 +554,7 @@ export const PlannerOverlay: React.FC<PlannerOverlayProps> = ({
<div className="relative">
<input
ref={pickerInputRef}
- className="w-full pr-12 px-4 py-3 text-base border border-slate-300 dark:border-slate-600 rounded-2xl bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder:text-slate-500 dark:placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
+ className="w-full pr-12 px-4 py-3 text-base border border-slate-200 dark:border-slate-700 rounded-2xl bg-surface text-slate-900 dark:text-slate-100 placeholder:text-slate-500 dark:placeholder:text-slate-400 focus:outline-none focus:border-primary-500 shadow-sm transition-all duration-200"
placeholder={
pickerField === "origin"
? t("planner.search_origin")
diff --git a/src/frontend/app/components/layout/NavBar.tsx b/src/frontend/app/components/layout/NavBar.tsx
index 58228c7..fab47e0 100644
--- a/src/frontend/app/components/layout/NavBar.tsx
+++ b/src/frontend/app/components/layout/NavBar.tsx
@@ -1,4 +1,4 @@
-import { Home, Map, Navigation2, Route } from "lucide-react";
+import { Home, Map, Route } from "lucide-react";
import type { LngLatLike } from "maplibre-gl";
import { useTranslation } from "react-i18next";
import { Link, useLocation, useNavigate } from "react-router";
@@ -30,7 +30,7 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) {
const { mapState, updateMapState, mapPositionMode } = useApp();
const location = useLocation();
const navigate = useNavigate();
- const { deselectItinerary } = usePlanner();
+ const { deselectItinerary } = usePlanner({ autoLoad: false });
const navItems = [
{
@@ -70,11 +70,6 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) {
},
},
{
- name: t("navbar.planner", "Planificador"),
- icon: Navigation2,
- path: "/planner",
- },
- {
name: t("navbar.lines", "Líneas"),
icon: Route,
path: "/lines",
diff --git a/src/frontend/app/data/PlannerApi.ts b/src/frontend/app/data/PlannerApi.ts
index be61d4b..4c78004 100644
--- a/src/frontend/app/data/PlannerApi.ts
+++ b/src/frontend/app/data/PlannerApi.ts
@@ -20,8 +20,10 @@ export interface Itinerary {
transitTimeSeconds: number;
waitingTimeSeconds: number;
legs: Leg[];
- cashFareEuro?: number;
- cardFareEuro?: number;
+ cashFare?: number;
+ cashFareIsTotal?: boolean;
+ cardFare?: number;
+ cardFareIsTotal?: boolean;
}
export interface Leg {
@@ -30,6 +32,7 @@ export interface Leg {
routeShortName?: string;
routeLongName?: string;
routeColor?: string;
+ routeTextColor?: string;
headsign?: string;
agencyName?: string;
from?: PlannerPlace;
diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts
index 7bab10c..697e171 100644
--- a/src/frontend/app/data/StopDataProvider.ts
+++ b/src/frontend/app/data/StopDataProvider.ts
@@ -73,7 +73,7 @@ async function initStops() {
async function getStops(): Promise<Stop[]> {
await initStops();
// update favourites
- const rawFav = localStorage.getItem("favouriteStops_vigo");
+ const rawFav = localStorage.getItem("favouriteStops");
const favouriteStops = rawFav
? (JSON.parse(rawFav) as (number | string)[]).map(normalizeId)
: [];
@@ -136,7 +136,7 @@ function getCustomName(stopId: string | number): string | undefined {
function addFavourite(stopId: string | number) {
const id = normalizeId(stopId);
- const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`);
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops`);
let favouriteStops: string[] = [];
if (rawFavouriteStops) {
favouriteStops = (JSON.parse(rawFavouriteStops) as (number | string)[]).map(
@@ -146,13 +146,13 @@ function addFavourite(stopId: string | number) {
if (!favouriteStops.includes(id)) {
favouriteStops.push(id);
- localStorage.setItem(`favouriteStops_vigo`, JSON.stringify(favouriteStops));
+ localStorage.setItem(`favouriteStops`, JSON.stringify(favouriteStops));
}
}
function removeFavourite(stopId: string | number) {
const id = normalizeId(stopId);
- const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`);
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops`);
let favouriteStops: string[] = [];
if (rawFavouriteStops) {
favouriteStops = (JSON.parse(rawFavouriteStops) as (number | string)[]).map(
@@ -161,15 +161,12 @@ function removeFavourite(stopId: string | number) {
}
const newFavouriteStops = favouriteStops.filter((sid) => sid !== id);
- localStorage.setItem(
- `favouriteStops_vigo`,
- JSON.stringify(newFavouriteStops)
- );
+ localStorage.setItem(`favouriteStops`, JSON.stringify(newFavouriteStops));
}
function isFavourite(stopId: string | number): boolean {
const id = normalizeId(stopId);
- const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`);
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops`);
if (rawFavouriteStops) {
const favouriteStops = (
JSON.parse(rawFavouriteStops) as (number | string)[]
@@ -213,7 +210,7 @@ function getRecent(): string[] {
}
function getFavouriteIds(): string[] {
- const rawFavouriteStops = localStorage.getItem(`favouriteStops_vigo`);
+ const rawFavouriteStops = localStorage.getItem(`favouriteStops`);
if (rawFavouriteStops) {
return (JSON.parse(rawFavouriteStops) as (number | string)[]).map(
normalizeId
diff --git a/src/frontend/app/hooks/usePlanQuery.ts b/src/frontend/app/hooks/usePlanQuery.ts
index 103f5f4..8c81073 100644
--- a/src/frontend/app/hooks/usePlanQuery.ts
+++ b/src/frontend/app/hooks/usePlanQuery.ts
@@ -8,7 +8,8 @@ export const usePlanQuery = (
toLon: number | undefined,
time?: Date,
arriveBy: boolean = false,
- enabled: boolean = true
+ enabled: boolean = true,
+ initialData?: any
) => {
return useQuery({
queryKey: [
@@ -25,5 +26,6 @@ export const usePlanQuery = (
enabled: !!(fromLat && fromLon && toLat && toLon) && enabled,
staleTime: 60000, // 1 minute
retry: false,
+ initialData,
});
};
diff --git a/src/frontend/app/hooks/usePlanner.ts b/src/frontend/app/hooks/usePlanner.ts
index a28167a..445a426 100644
--- a/src/frontend/app/hooks/usePlanner.ts
+++ b/src/frontend/app/hooks/usePlanner.ts
@@ -1,21 +1,23 @@
+import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
import { type PlannerSearchResult, type RoutePlan } from "../data/PlannerApi";
import { usePlanQuery } from "./usePlanQuery";
-const STORAGE_KEY = "planner_last_route";
+const STORAGE_KEY = "planner_route_history";
const EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours
interface StoredRoute {
timestamp: number;
origin: PlannerSearchResult;
destination: PlannerSearchResult;
- plan: RoutePlan;
+ plan?: RoutePlan;
searchTime?: Date;
arriveBy?: boolean;
selectedItineraryIndex?: number;
}
-export function usePlanner() {
+export function usePlanner(options: { autoLoad?: boolean } = {}) {
+ const { autoLoad = true } = options;
const [origin, setOrigin] = useState<PlannerSearchResult | null>(null);
const [destination, setDestination] = useState<PlannerSearchResult | null>(
null
@@ -28,6 +30,8 @@ export function usePlanner() {
const [selectedItineraryIndex, setSelectedItineraryIndex] = useState<
number | null
>(null);
+ const [history, setHistory] = useState<StoredRoute[]>([]);
+ const queryClient = useQueryClient();
const {
data: queryPlan,
@@ -41,13 +45,13 @@ export function usePlanner() {
destination?.lon,
searchTime ?? undefined,
arriveBy,
- !!(origin && destination)
+ !!(origin && destination && searchTime)
);
// Sync query result to local state and storage
useEffect(() => {
if (queryPlan) {
- setPlan(queryPlan as any); // Cast because of slight type differences if any, but they should match now
+ setPlan(queryPlan as any);
if (origin && destination) {
const toStore: StoredRoute = {
@@ -59,7 +63,21 @@ export function usePlanner() {
arriveBy,
selectedItineraryIndex: selectedItineraryIndex ?? undefined,
};
- localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
+
+ setHistory((prev) => {
+ const filtered = prev.filter(
+ (r) =>
+ !(
+ r.origin.lat === origin.lat &&
+ r.origin.lon === origin.lon &&
+ r.destination.lat === destination.lat &&
+ r.destination.lon === destination.lon
+ )
+ );
+ const updated = [toStore, ...filtered].slice(0, 3);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
}
}
}, [
@@ -76,22 +94,40 @@ export function usePlanner() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
- const data: StoredRoute = JSON.parse(stored);
- if (Date.now() - data.timestamp < EXPIRY_MS) {
- setOrigin(data.origin);
- setDestination(data.destination);
- setPlan(data.plan);
- setSearchTime(data.searchTime ? new Date(data.searchTime) : null);
- setArriveBy(data.arriveBy ?? false);
- setSelectedItineraryIndex(data.selectedItineraryIndex ?? null);
- } else {
- localStorage.removeItem(STORAGE_KEY);
+ const data: StoredRoute[] = JSON.parse(stored);
+ const valid = data.filter((r) => Date.now() - r.timestamp < EXPIRY_MS);
+ setHistory(valid);
+
+ if (autoLoad && valid.length > 0) {
+ const last = valid[0];
+ if (last.plan) {
+ queryClient.setQueryData(
+ [
+ "plan",
+ last.origin.lat,
+ last.origin.lon,
+ last.destination.lat,
+ last.destination.lon,
+ last.searchTime
+ ? new Date(last.searchTime).toISOString()
+ : undefined,
+ last.arriveBy ?? false,
+ ],
+ last.plan
+ );
+ setPlan(last.plan);
+ }
+ setOrigin(last.origin);
+ setDestination(last.destination);
+ setSearchTime(last.searchTime ? new Date(last.searchTime) : null);
+ setArriveBy(last.arriveBy ?? false);
+ setSelectedItineraryIndex(last.selectedItineraryIndex ?? null);
}
} catch (e) {
localStorage.removeItem(STORAGE_KEY);
}
}
- }, []);
+ }, [autoLoad]);
const searchRoute = async (
from: PlannerSearchResult,
@@ -101,9 +137,78 @@ export function usePlanner() {
) => {
setOrigin(from);
setDestination(to);
- setSearchTime(time ?? new Date());
+ const finalTime = time ?? new Date();
+ setSearchTime(finalTime);
setArriveBy(arriveByParam);
setSelectedItineraryIndex(null);
+
+ // Save to history immediately so other pages can pick it up
+ const toStore: StoredRoute = {
+ timestamp: Date.now(),
+ origin: from,
+ destination: to,
+ searchTime: finalTime,
+ arriveBy: arriveByParam,
+ };
+
+ setHistory((prev) => {
+ const filtered = prev.filter(
+ (r) =>
+ !(
+ r.origin.lat === from.lat &&
+ r.origin.lon === from.lon &&
+ r.destination.lat === to.lat &&
+ r.destination.lon === to.lon
+ )
+ );
+ const updated = [toStore, ...filtered].slice(0, 3);
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
+ };
+
+ const loadRoute = (route: StoredRoute) => {
+ if (route.plan) {
+ queryClient.setQueryData(
+ [
+ "plan",
+ route.origin.lat,
+ route.origin.lon,
+ route.destination.lat,
+ route.destination.lon,
+ route.searchTime
+ ? new Date(route.searchTime).toISOString()
+ : undefined,
+ route.arriveBy ?? false,
+ ],
+ route.plan
+ );
+ setPlan(route.plan);
+ }
+ setOrigin(route.origin);
+ setDestination(route.destination);
+ setSearchTime(route.searchTime ? new Date(route.searchTime) : null);
+ setArriveBy(route.arriveBy ?? false);
+ setSelectedItineraryIndex(route.selectedItineraryIndex ?? null);
+
+ // Move to top of history
+ setHistory((prev) => {
+ const filtered = prev.filter(
+ (r) =>
+ !(
+ r.origin.lat === route.origin.lat &&
+ r.origin.lon === route.origin.lon &&
+ r.destination.lat === route.destination.lat &&
+ r.destination.lon === route.destination.lon
+ )
+ );
+ const updated = [{ ...route, timestamp: Date.now() }, ...filtered].slice(
+ 0,
+ 3
+ );
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
};
const clearRoute = () => {
@@ -113,6 +218,7 @@ export function usePlanner() {
setSearchTime(null);
setArriveBy(false);
setSelectedItineraryIndex(null);
+ setHistory([]);
localStorage.removeItem(STORAGE_KEY);
};
@@ -120,32 +226,26 @@ export function usePlanner() {
setSelectedItineraryIndex(index);
// Update storage
- const stored = localStorage.getItem(STORAGE_KEY);
- if (stored) {
- try {
- const data: StoredRoute = JSON.parse(stored);
- data.selectedItineraryIndex = index;
- localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
- } catch (e) {
- // Ignore
- }
- }
+ setHistory((prev) => {
+ if (prev.length === 0) return prev;
+ const updated = [...prev];
+ updated[0] = { ...updated[0], selectedItineraryIndex: index };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
}, []);
const deselectItinerary = useCallback(() => {
setSelectedItineraryIndex(null);
// Update storage
- const stored = localStorage.getItem(STORAGE_KEY);
- if (stored) {
- try {
- const data: StoredRoute = JSON.parse(stored);
- data.selectedItineraryIndex = undefined;
- localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
- } catch (e) {
- // Ignore
- }
- }
+ setHistory((prev) => {
+ if (prev.length === 0) return prev;
+ const updated = [...prev];
+ updated[0] = { ...updated[0], selectedItineraryIndex: undefined };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
+ return updated;
+ });
}, []);
return {
@@ -159,7 +259,9 @@ export function usePlanner() {
searchTime,
arriveBy,
selectedItineraryIndex,
+ history,
searchRoute,
+ loadRoute,
clearRoute,
selectItinerary,
deselectItinerary,
diff --git a/src/frontend/app/i18n/locales/en-GB.json b/src/frontend/app/i18n/locales/en-GB.json
index 2c58ebe..91c836a 100644
--- a/src/frontend/app/i18n/locales/en-GB.json
+++ b/src/frontend/app/i18n/locales/en-GB.json
@@ -128,6 +128,7 @@
"close": "Close",
"results_title": "Results",
"clear": "Clear",
+ "recent_routes": "Recent routes",
"no_routes_found": "No routes found",
"no_routes_message": "We couldn't find a route for your trip. Try changing the time or locations.",
"walk": "Walk",
diff --git a/src/frontend/app/i18n/locales/es-ES.json b/src/frontend/app/i18n/locales/es-ES.json
index 298733e..526ab2f 100644
--- a/src/frontend/app/i18n/locales/es-ES.json
+++ b/src/frontend/app/i18n/locales/es-ES.json
@@ -128,6 +128,7 @@
"close": "Cerrar",
"results_title": "Resultados",
"clear": "Borrar",
+ "recent_routes": "Rutas recientes",
"no_routes_found": "No se encontraron rutas",
"no_routes_message": "No pudimos encontrar una ruta para tu viaje. Intenta cambiar la hora o las ubicaciones.",
"walk": "Caminar",
diff --git a/src/frontend/app/i18n/locales/gl-ES.json b/src/frontend/app/i18n/locales/gl-ES.json
index 833279f..eec7ab9 100644
--- a/src/frontend/app/i18n/locales/gl-ES.json
+++ b/src/frontend/app/i18n/locales/gl-ES.json
@@ -128,6 +128,7 @@
"close": "Pechar",
"results_title": "Resultados",
"clear": "Limpar",
+ "recent_routes": "Rutas recentes",
"no_routes_found": "Non se atoparon rutas",
"no_routes_message": "Non puidemos atopar unha ruta para a túa viaxe. Intenta cambiar a hora ou as localizacións.",
"walk": "Camiñar",
diff --git a/src/frontend/app/root.css b/src/frontend/app/root.css
index 9f79b08..3f41591 100644
--- a/src/frontend/app/root.css
+++ b/src/frontend/app/root.css
@@ -216,6 +216,11 @@ textarea {
color: var(--ml-c-link-2);
}
-.maplibregl-ctrl button .maplibregl-ctrl-icon:before {
+.maplibregl-ctrl button .maplibregl-ctrl-icon:before,
+.maplibregl-ctrl-attrib-button::before {
display: none !important;
}
+
+.maplibregl-ctrl-attrib-inner {
+ line-height: 1rem !important;
+}
diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx
index a20ba64..b20a349 100644
--- a/src/frontend/app/routes/home.tsx
+++ b/src/frontend/app/routes/home.tsx
@@ -1,7 +1,11 @@
import Fuse from "fuse.js";
+import { History } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router";
+import { PlannerOverlay } from "~/components/PlannerOverlay";
import { usePageTitle } from "~/contexts/PageTitleContext";
+import { usePlanner } from "~/hooks/usePlanner";
import StopGallery from "../components/StopGallery";
import StopItem from "../components/StopItem";
import StopItemSkeleton from "../components/StopItemSkeleton";
@@ -11,6 +15,8 @@ import "../tailwind-full.css";
export default function StopList() {
const { t } = useTranslation();
usePageTitle(t("navbar.stops", "Paradas"));
+ const navigate = useNavigate();
+ const { history, searchRoute, loadRoute } = usePlanner({ autoLoad: false });
const [data, setData] = useState<Stop[] | null>(null);
const [loading, setLoading] = useState(true);
const [searchResults, setSearchResults] = useState<Stop[] | null>(null);
@@ -239,9 +245,73 @@ export default function StopList() {
return (
<div className="flex flex-col gap-4 py-4 pb-8">
+ {/* Planner Section */}
+ <div className="w-full px-4">
+ <details className="group bg-surface border border-slate-200 dark:border-slate-700 shadow-sm">
+ <summary className="list-none cursor-pointer focus:outline-none">
+ <div className="flex items-center justify-between p-3 rounded-xl group-open:mb-3 transition-all">
+ <div className="flex items-center gap-3">
+ <History className="w-5 h-5 text-primary-600 dark:text-primary-400" />
+ <span className="font-semibold text-text">
+ {t("planner.where_to", "¿A dónde quieres ir?")}
+ </span>
+ </div>
+ <div className="text-muted group-open:rotate-180 transition-transform">
+ ↓
+ </div>
+ </div>
+ </summary>
+
+ <PlannerOverlay
+ inline
+ forceExpanded
+ cardBackground="bg-transparent"
+ userLocation={userLocation}
+ autoLoad={false}
+ onSearch={(origin, destination, time, arriveBy) => {
+ searchRoute(origin, destination, time, arriveBy);
+ }}
+ onNavigateToPlanner={() => navigate("/planner")}
+ />
+ </details>
+
+ {history.length > 0 && (
+ <div className="mt-3 flex flex-col gap-2">
+ <h4 className="text-xs font-bold uppercase tracking-wider text-muted px-1">
+ {t("planner.recent_routes", "Rutas recientes")}
+ </h4>
+ <div className="flex flex-col gap-1">
+ {history.map((route, idx) => (
+ <button
+ key={idx}
+ onClick={() => {
+ loadRoute(route);
+ navigate("/planner");
+ }}
+ className="flex items-center gap-3 p-3 rounded-xl bg-surface border border-border hover:bg-surface/80 transition-colors text-left"
+ >
+ <History className="w-4 h-4 text-muted shrink-0" />
+ <div className="flex flex-col min-w-0">
+ <span className="text-sm font-semibold text-text truncate">
+ {route.destination.name}
+ </span>
+ <span className="text-xs text-muted truncate">
+ {t("planner.from_to", {
+ from: route.origin.name,
+ to: route.destination.name,
+ })}
+ </span>
+ </div>
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+
{/* Search Section */}
<div className="w-full px-4">
- <h3 className="text-lg font-semibold mb-2 text-text">
+ <h3 className="text-xs font-bold uppercase tracking-wider text-muted mb-2 px-1">
{t("stoplist.search_label", "Buscar paradas")}
</h3>
<input
@@ -249,7 +319,7 @@ export default function StopList() {
placeholder={randomPlaceholder}
onChange={handleStopSearch}
className="
- w-full px-4 py-3 text-base
+ w-full px-4 py-2 text-sm
border border-border rounded-xl
bg-surface
text-text
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index cccdaa3..b02c494 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -38,7 +38,7 @@ export default function StopMap() {
const [isSheetOpen, setIsSheetOpen] = useState(false);
const mapRef = useRef<MapRef>(null);
- const { searchRoute } = usePlanner();
+ const { searchRoute } = usePlanner({ autoLoad: false });
// Handle click events on clusters and individual stops
const onMapClick = (e: MapLayerMouseEvent) => {
@@ -58,7 +58,7 @@ export default function StopMap() {
};
const stopLayerFilter = useMemo(() => {
- const filter: FilterSpecification = ["any"];
+ const filter: any[] = ["any"];
if (showCitybusStops) {
filter.push(["==", ["get", "transitKind"], "bus"]);
}
@@ -68,7 +68,7 @@ export default function StopMap() {
if (showTrainStops) {
filter.push(["==", ["get", "transitKind"], "train"]);
}
- return filter;
+ return filter as FilterSpecification;
}, [showCitybusStops, showIntercityBusStops, showTrainStops]);
const getLatitude = (center: any) =>
@@ -119,6 +119,7 @@ export default function StopMap() {
clearPickerOnOpen={true}
showLastDestinationWhenCollapsed={false}
cardBackground="bg-white/95 dark:bg-slate-900/90"
+ autoLoad={false}
/>
<AppMap
diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx
index 5968bc2..b71d211 100644
--- a/src/frontend/app/routes/planner.tsx
+++ b/src/frontend/app/routes/planner.tsx
@@ -6,12 +6,13 @@ import { useTranslation } from "react-i18next";
import { Layer, Source, type MapRef } from "react-map-gl/maplibre";
import { useLocation } from "react-router";
-import { type ConsolidatedCirculation, type Itinerary } from "~/api/schema";
+import { type ConsolidatedCirculation } from "~/api/schema";
import LineIcon from "~/components/LineIcon";
import { PlannerOverlay } from "~/components/PlannerOverlay";
import { AppMap } from "~/components/shared/AppMap";
import { APP_CONSTANTS } from "~/config/constants";
import { usePageTitle } from "~/contexts/PageTitleContext";
+import { type Itinerary } from "~/data/PlannerApi";
import { usePlanner } from "~/hooks/usePlanner";
import "../tailwind-full.css";
@@ -21,6 +22,14 @@ const formatDistance = (meters: number) => {
return `${rounded} m`;
};
+const formatDuration = (minutes: number, t: any) => {
+ if (minutes < 60) return `${minutes} ${t("estimates.minutes")}`;
+ const h = Math.floor(minutes / 60);
+ const m = minutes % 60;
+ if (m === 0) return `${h}h`;
+ return `${h}h ${m}min`;
+};
+
const haversineMeters = (a: [number, number], b: [number, number]) => {
const toRad = (v: number) => (v * Math.PI) / 180;
const R = 6371000;
@@ -84,11 +93,8 @@ const ItinerarySummary = ({
});
const walkTotals = sumWalkMetrics(itinerary.legs);
- const busLegsCount = itinerary.legs.filter(
- (leg) => leg.mode !== "WALK"
- ).length;
- const cashFare = (itinerary.cashFareEuro ?? 0).toFixed(2);
- const cardFare = (itinerary.cardFareEuro ?? 0).toFixed(2);
+ const cashFare = (itinerary.cashFare ?? 0).toFixed(2);
+ const cardFare = (itinerary.cardFare ?? 0).toFixed(2);
return (
<div
@@ -99,7 +105,7 @@ const ItinerarySummary = ({
<div className="font-bold text-lg text-text">
{startTime} - {endTime}
</div>
- <div className="text-muted">{durationMinutes} min</div>
+ <div className="text-muted">{formatDuration(durationMinutes, t)}</div>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-2">
@@ -125,7 +131,7 @@ const ItinerarySummary = ({
<div className="flex items-center gap-2 rounded-full bg-surface px-3 py-1.5 text-sm text-text whitespace-nowrap border border-border">
<Footprints className="w-4 h-4 text-muted" />
<span className="font-semibold">
- {legDurationMinutes} {t("estimates.minutes")}
+ {formatDuration(legDurationMinutes, t)}
</span>
</div>
) : (
@@ -147,7 +153,7 @@ const ItinerarySummary = ({
<span>
{t("planner.walk")}: {formatDistance(walkTotals.meters)}
{walkTotals.minutes
- ? ` • ${walkTotals.minutes} ${t("estimates.minutes")}`
+ ? ` • ${formatDuration(walkTotals.minutes, t)}`
: ""}
</span>
<span className="flex items-center gap-3">
@@ -156,12 +162,14 @@ const ItinerarySummary = ({
{cashFare === "0.00"
? t("planner.free")
: t("planner.fare", { amount: cashFare })}
+ {itinerary.cashFareIsTotal ? "" : "++"}
</span>
<span className="flex items-center gap-1 text-muted">
<CreditCard className="w-4 h-4" />
{cardFare === "0.00"
? t("planner.free")
: t("planner.fare", { amount: cardFare })}
+ {itinerary.cashFareIsTotal ? "" : "++"}
</span>
</span>
</div>
@@ -206,83 +214,39 @@ const ItineraryDetail = ({
// Create GeoJSON for all markers
const markersGeoJson = useMemo(() => {
const features: any[] = [];
- const origin = itinerary.legs[0]?.from;
- const destination = itinerary.legs[itinerary.legs.length - 1]?.to;
-
- // Origin marker (red)
- if (origin?.lat && origin?.lon) {
- features.push({
- type: "Feature",
- geometry: { type: "Point", coordinates: [origin.lon, origin.lat] },
- properties: { type: "origin", name: origin.name || "Origin" },
- });
- }
-
- // Destination marker (green)
- if (destination?.lat && destination?.lon) {
- features.push({
- type: "Feature",
- geometry: {
- type: "Point",
- coordinates: [destination.lon, destination.lat],
- },
- properties: {
- type: "destination",
- name: destination.name || "Destination",
- },
- });
- }
- // Collect unique stops with their roles (board, alight, transfer)
- const stopsMap: Record<
- string,
- { lat: number; lon: number; name: string; type: string }
- > = {};
+ // Add points for each leg transition
itinerary.legs.forEach((leg, idx) => {
- if (leg.mode !== "WALK") {
- // Boarding stop
- if (leg.from?.lat && leg.from?.lon) {
- const key = `${leg.from.lat},${leg.from.lon}`;
- if (!stopsMap[key]) {
- const isTransfer =
- idx > 0 && itinerary.legs[idx - 1].mode !== "WALK";
- stopsMap[key] = {
- lat: leg.from.lat,
- lon: leg.from.lon,
- name: leg.from.name || "",
- type: isTransfer ? "transfer" : "board",
- };
- }
- }
- // Alighting stop
- if (leg.to?.lat && leg.to?.lon) {
- const key = `${leg.to.lat},${leg.to.lon}`;
- if (!stopsMap[key]) {
- const isTransfer =
- idx < itinerary.legs.length - 1 &&
- itinerary.legs[idx + 1].mode !== "WALK";
- stopsMap[key] = {
- lat: leg.to.lat,
- lon: leg.to.lon,
- name: leg.to.name || "",
- type: isTransfer ? "transfer" : "alight",
- };
- }
- }
+ // Add "from" point of the leg
+ if (leg.from?.lat && leg.from?.lon) {
+ features.push({
+ type: "Feature",
+ geometry: {
+ type: "Point",
+ coordinates: [leg.from.lon, leg.from.lat],
+ },
+ properties: {
+ type: idx === 0 ? "origin" : "transfer",
+ name: leg.from.name || "",
+ index: idx.toString(),
+ },
+ });
}
- });
- // Add stop markers
- Object.values(stopsMap).forEach((stop) => {
- features.push({
- type: "Feature",
- geometry: { type: "Point", coordinates: [stop.lon, stop.lat] },
- properties: { type: stop.type, name: stop.name },
- });
- });
+ // If it's the last leg, also add the "to" point
+ if (idx === itinerary.legs.length - 1 && leg.to?.lat && leg.to?.lon) {
+ features.push({
+ type: "Feature",
+ geometry: { type: "Point", coordinates: [leg.to.lon, leg.to.lat] },
+ properties: {
+ type: "destination",
+ name: leg.to.name || "",
+ index: (idx + 1).toString(),
+ },
+ });
+ }
- // Add intermediate stops
- itinerary.legs.forEach((leg) => {
+ // Add intermediate stops
leg.intermediateStops?.forEach((stop) => {
features.push({
type: "Feature",
@@ -389,7 +353,9 @@ const ItineraryDetail = ({
zoom: 13,
}}
showTraffic={false}
- attributionControl={false}
+ showGeolocate={true}
+ showNavigation={true}
+ attributionControl={true}
>
<Source id="route" type="geojson" data={routeGeoJson as any}>
<Layer
@@ -411,69 +377,36 @@ const ItineraryDetail = ({
{/* All markers as GeoJSON layers */}
<Source id="markers" type="geojson" data={markersGeoJson as any}>
- {/* Outer circle for origin/destination markers */}
+ {/* Intermediate stops (smaller white dots) - rendered first to be at the bottom */}
<Layer
- id="markers-outer"
- type="circle"
- filter={[
- "in",
- ["get", "type"],
- ["literal", ["origin", "destination"]],
- ]}
- paint={{
- "circle-radius": [
- "interpolate",
- ["linear"],
- ["zoom"],
- 10,
- 6,
- 16,
- 8,
- 20,
- 10,
- ],
- "circle-color": [
- "case",
- ["==", ["get", "type"], "origin"],
- "#dc2626",
- "#16a34a",
- ],
- "circle-stroke-width": 2,
- "circle-stroke-color": "#ffffff",
- }}
- />
- {/* Inner circle for origin/destination markers */}
- <Layer
- id="markers-inner"
+ id="markers-intermediate"
type="circle"
- filter={[
- "in",
- ["get", "type"],
- ["literal", ["origin", "destination"]],
- ]}
+ filter={["==", ["get", "type"], "intermediate"]}
paint={{
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
10,
- 2,
- 16,
3,
+ 16,
+ 5,
20,
- 4,
+ 7,
],
"circle-color": "#ffffff",
+ "circle-stroke-width": 1.5,
+ "circle-stroke-color": "#6b7280",
}}
/>
- {/* Stop markers (board, alight, transfer) */}
+ {/* Outer circle for all numbered markers */}
<Layer
- id="markers-stops"
+ id="markers-outer"
type="circle"
filter={[
"in",
["get", "type"],
- ["literal", ["board", "alight", "transfer"]],
+ ["literal", ["origin", "destination", "transfer"]],
]}
paint={{
"circle-radius": [
@@ -481,44 +414,51 @@ const ItineraryDetail = ({
["linear"],
["zoom"],
10,
- 4,
+ 8,
16,
- 6,
+ 10,
20,
- 7,
+ 12,
],
"circle-color": [
"case",
- ["==", ["get", "type"], "board"],
+ ["==", ["get", "type"], "origin"],
+ "#dc2626",
+ ["==", ["get", "type"], "destination"],
+ "#16a34a",
"#3b82f6",
- ["==", ["get", "type"], "alight"],
- "#a855f7",
- "#f97316",
],
"circle-stroke-width": 2,
"circle-stroke-color": "#ffffff",
}}
/>
- {/* Intermediate stops (smaller white dots) */}
+ {/* Numbers for markers */}
<Layer
- id="markers-intermediate"
- type="circle"
- filter={["==", ["get", "type"], "intermediate"]}
- paint={{
- "circle-radius": [
+ id="markers-labels"
+ type="symbol"
+ filter={[
+ "in",
+ ["get", "type"],
+ ["literal", ["origin", "destination", "transfer"]],
+ ]}
+ layout={{
+ "text-field": ["get", "index"],
+ "text-font": ["Open Sans Bold", "Arial Unicode MS Bold"],
+ "text-size": [
"interpolate",
["linear"],
["zoom"],
10,
- 2,
+ 8,
16,
- 3,
+ 10,
20,
- 4,
+ 12,
],
- "circle-color": "#ffffff",
- "circle-stroke-width": 1,
- "circle-stroke-color": "#9ca3af",
+ "text-allow-overlap": true,
+ }}
+ paint={{
+ "text-color": "#ffffff",
}}
/>
</Source>
@@ -590,12 +530,14 @@ const ItineraryDetail = ({
</span>
<span>•</span>
<span>
- {(
- (new Date(leg.endTime).getTime() -
- new Date(leg.startTime).getTime()) /
- 60000
- ).toFixed(0)}{" "}
- {t("estimates.minutes")}
+ {formatDuration(
+ Math.round(
+ (new Date(leg.endTime).getTime() -
+ new Date(leg.startTime).getTime()) /
+ 60000
+ ),
+ t
+ )}
</span>
<span>•</span>
<span>{formatDistance(leg.distanceMeters)}</span>
@@ -654,8 +596,8 @@ const ItineraryDetail = ({
<span className="flex-1 truncate">
{circ.route}
</span>
- <span className="font-semibold text-emerald-600 dark:text-emerald-400">
- {minutes} {t("estimates.minutes")}
+ <span className="font-semibold text-primary-600 dark:text-primary-400">
+ {formatDuration(minutes, t)}
{circ.realTime && " 🟢"}
</span>
</div>
@@ -735,6 +677,7 @@ export default function PlannerPage() {
const location = useLocation();
const {
plan,
+ loading,
searchRoute,
clearRoute,
searchTime,
@@ -815,6 +758,13 @@ export default function PlannerPage() {
cardBackground="bg-transparent"
/>
+ {loading && !plan && (
+ <div className="flex flex-col items-center justify-center py-12">
+ <div className="w-8 h-8 border-4 border-primary-500 border-t-transparent rounded-full animate-spin mb-4"></div>
+ <p className="text-muted">{t("planner.searching")}</p>
+ </div>
+ )}
+
{plan && (
<div>
<div className="flex justify-between items-center my-4">
diff --git a/src/frontend/app/tailwind-full.css b/src/frontend/app/tailwind-full.css
index 1767d61..e7c4dd3 100644
--- a/src/frontend/app/tailwind-full.css
+++ b/src/frontend/app/tailwind-full.css
@@ -1,3 +1,31 @@
@import "tailwindcss";
+@theme {
+ --color-primary: var(--button-background-color);
+ --color-background: var(--background-color);
+ --color-text: var(--text-color);
+ --color-subtitle: var(--subtitle-color);
+ --color-border: var(--border-color);
+ --color-surface: var(--message-background-color);
+
+ --font-display: var(--font-display);
+ --font-sans: var(--font-ui);
+
+ /* Semantic colors for easier migration from slate/gray */
+ --color-muted: var(--subtitle-color);
+ --color-accent: var(--button-background-color);
+
+ /* Generated-like palette using color-mix for flexibility */
+ --color-primary-50: color-mix(in oklab, var(--button-background-color) 5%, white);
+ --color-primary-100: color-mix(in oklab, var(--button-background-color) 10%, white);
+ --color-primary-200: color-mix(in oklab, var(--button-background-color) 20%, white);
+ --color-primary-300: color-mix(in oklab, var(--button-background-color) 40%, white);
+ --color-primary-400: color-mix(in oklab, var(--button-background-color) 60%, white);
+ --color-primary-500: var(--button-background-color);
+ --color-primary-600: color-mix(in oklab, var(--button-background-color) 80%, black);
+ --color-primary-700: color-mix(in oklab, var(--button-background-color) 60%, black);
+ --color-primary-800: color-mix(in oklab, var(--button-background-color) 40%, black);
+ --color-primary-900: color-mix(in oklab, var(--button-background-color) 20%, black);
+}
+
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));