aboutsummaryrefslogtreecommitdiff
path: root/src/frontend
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2025-12-24 17:53:32 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2025-12-24 17:53:32 +0100
commit9ed46bea58dbb81ceada2a957fd1db653fb21e52 (patch)
treef1cb09ad15571abbfae1c59105952330ec3a0502 /src/frontend
parent4a866f5352a51916ddb9849b2d68213856196c9c (diff)
Implement new UI styles
Diffstat (limited to 'src/frontend')
-rw-r--r--src/frontend/app/components/PullToRefresh.css10
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationCard.css212
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx467
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.css13
-rw-r--r--src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx58
-rw-r--r--src/frontend/app/components/ThemeColorManager.tsx2
-rw-r--r--src/frontend/app/components/arrivals/ArrivalCard.css2
-rw-r--r--src/frontend/app/components/layout/NavBar.module.css30
-rw-r--r--src/frontend/app/components/layout/NavBar.tsx4
-rw-r--r--src/frontend/app/components/map/StopSummarySheet.css46
-rw-r--r--src/frontend/app/components/map/StopSummarySheet.tsx2
-rw-r--r--src/frontend/app/root.css115
-rw-r--r--src/frontend/app/root.tsx4
-rw-r--r--src/frontend/app/routes/favourites.tsx16
-rw-r--r--src/frontend/app/routes/home.tsx14
-rw-r--r--src/frontend/app/routes/lines.tsx4
-rw-r--r--src/frontend/app/routes/map.tsx4
-rw-r--r--src/frontend/app/routes/planner.tsx20
-rw-r--r--src/frontend/app/routes/settings.tsx30
-rw-r--r--src/frontend/app/routes/stops-$id.tsx8
-rw-r--r--src/frontend/app/tailwind.css28
-rw-r--r--src/frontend/package-lock.json10
-rw-r--r--src/frontend/package.json1
-rw-r--r--src/frontend/public/manifest.webmanifest4
24 files changed, 212 insertions, 892 deletions
diff --git a/src/frontend/app/components/PullToRefresh.css b/src/frontend/app/components/PullToRefresh.css
index 3e8f802..910d201 100644
--- a/src/frontend/app/components/PullToRefresh.css
+++ b/src/frontend/app/components/PullToRefresh.css
@@ -20,21 +20,21 @@
width: 40px;
height: 40px;
border-radius: 50%;
- background: var(--surface-color, #f8f9fa);
- border: 2px solid var(--border-color, #e9ecef);
+ background: var(--message-background-color);
+ border: 2px solid var(--border-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.refresh-icon-container.active {
- background: var(--primary-color, #007bff);
- border-color: var(--primary-color, #007bff);
+ background: var(--primary-color);
+ border-color: var(--primary-color);
}
.refresh-icon {
width: 20px;
height: 20px;
- color: var(--text-secondary, #6c757d);
+ color: var(--text-secondary);
transition: color 0.2s ease;
}
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css
deleted file mode 100644
index d9ed38f..0000000
--- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.css
+++ /dev/null
@@ -1,212 +0,0 @@
-@import "../../tailwind.css";
-
-.consolidated-circulation-card {
- all: unset;
- flex: 0 0 auto;
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- background-color: var(--message-background-color);
- border-radius: 12px;
- border: 1px solid var(--border-color);
- padding: 0.65rem 0.85rem;
- transition: all 0.2s ease;
-}
-
-.consolidated-circulation-card.has-gps {
- cursor: pointer;
-}
-
-.consolidated-circulation-card.no-gps {
- cursor: not-allowed;
- opacity: 0.7;
-}
-
-.consolidated-circulation-card.has-gps:hover {
- box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
- border-color: var(--button-background-color);
- background-color: color-mix(
- in oklab,
- var(--button-background-color) 5%,
- var(--message-background-color)
- );
-}
-
-.consolidated-circulation-card.has-gps:active {
- transform: scale(0.98);
-}
-
-.consolidated-circulation-card:disabled {
- pointer-events: none;
-}
-
-.consolidated-circulation-card .card-row {
- display: flex;
- align-items: center;
- gap: 0.65rem;
-}
-
-.consolidated-circulation-card .card-row.main {
- min-height: 48px;
-}
-
-.consolidated-circulation-card .line-info {
- flex-shrink: 0;
-}
-
-.consolidated-circulation-card .route-info {
- flex: 1;
- min-width: 0;
-}
-
-.consolidated-circulation-card .route-info strong {
- color: var(--text-color);
- overflow: hidden;
- text-overflow: ellipsis;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- line-clamp: 2;
- -webkit-box-orient: vertical;
- line-height: 1.25;
-}
-
-.consolidated-circulation-card .eta-badge {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 0.3rem 0.45rem;
- border-radius: 12px;
-}
-
-.consolidated-circulation-card .eta-text {
- display: flex;
- flex-direction: column;
- align-items: center;
- line-height: 1;
-}
-
-.consolidated-circulation-card .eta-value {
- font-size: 1.15rem;
- font-weight: 700;
-}
-
-.consolidated-circulation-card .eta-unit {
- font-size: 0.65rem;
- text-transform: uppercase;
- letter-spacing: 0.08em;
-}
-
-.consolidated-circulation-card .eta-badge.time-running {
- background: rgba(34, 197, 94, 0.12);
- color: #1a9e56;
-}
-
-.consolidated-circulation-card .eta-badge.time-delayed {
- background: rgba(255, 106, 0, 0.12);
- color: #d06100;
-}
-
-.consolidated-circulation-card .eta-badge.time-scheduled {
- background: rgba(11, 61, 145, 0.12);
- color: #0b3d91;
-}
-
-[data-theme="dark"] .consolidated-circulation-card .eta-badge.time-scheduled {
- color: #8fb4ff;
-}
-
-.consolidated-circulation-card .card-row.meta {
- justify-content: flex-start;
- flex-wrap: wrap;
- gap: 0.4rem;
-}
-
-.meta-chip {
- font-size: 0.75rem;
- padding: 0.2rem 0.55rem;
- border-radius: 999px;
- background: rgba(0, 0, 0, 0.03);
-
- @apply flex items-center justify-center gap-1 shrink-0 bg-gray-200/30 dark:bg-gray-600/30;
-}
-
-.meta-chip.delay-ok {
- @apply bg-green-600/80 dark:bg-green-600/30 border-green-500 dark:border-green-700 text-white dark:text-green-200;
-}
-
-.meta-chip.delay-warn {
- @apply bg-amber-600/80 dark:bg-yellow-600/30 border-yellow-500 dark:border-yellow-700 text-white dark:text-yellow-200;
-}
-
-.meta-chip.delay-critical {
- @apply bg-red-400/80 dark:bg-red-600/30 border-red-500 dark:border-red-700 text-white;
-}
-
-.meta-chip.delay-early {
- @apply bg-blue-400/80 dark:bg-blue-600/30 border-blue-500 dark:border-blue-700 text-white dark:text-blue-200;
-}
-
-/* GPS Indicator */
-.gps-indicator {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 20px;
- height: 20px;
- flex-shrink: 0;
- position: relative;
-}
-
-.gps-pulse {
- position: absolute;
- width: 8px;
- height: 8px;
- background: #22c55e;
- border-radius: 50%;
- box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
- animation: gpsPulse 2s ease-in-out infinite;
-}
-
-.gps-pulse.previous-trip {
- background: #ff9800;
- box-shadow: 0 0 0 2px rgba(255, 152, 0, 0.2);
- animation: gpsPulseOrange 2s ease-in-out infinite;
-}
-
-@keyframes gpsPulse {
- 0% {
- box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
- }
- 50% {
- box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.1);
- }
- 100% {
- box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
- }
-}
-
-@keyframes gpsPulseOrange {
- 0% {
- box-shadow: 0 0 0 2px rgba(255, 152, 0, 0.2);
- }
- 50% {
- box-shadow: 0 0 0 6px rgba(255, 152, 0, 0.1);
- }
- 100% {
- box-shadow: 0 0 0 2px rgba(255, 152, 0, 0.2);
- }
-}
-
-@media (max-width: 480px) {
- .consolidated-circulation-card {
- padding: 0.65rem 0.75rem;
- }
-
- .consolidated-circulation-card .card-row {
- gap: 0.5rem;
- }
-
- .consolidated-circulation-card .eta-badge {
- padding: 0.25rem 0.4rem;
- }
-}
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx
deleted file mode 100644
index 679345f..0000000
--- a/src/frontend/app/components/Stops/ConsolidatedCirculationCard.tsx
+++ /dev/null
@@ -1,467 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from "react";
-import Marquee from "react-fast-marquee";
-import { useTranslation } from "react-i18next";
-import LineIcon from "~components/LineIcon";
-import { type ConsolidatedCirculation } from "~routes/stops-$id";
-
-import { AlertTriangle, LocateIcon } from "lucide-react";
-import "./ConsolidatedCirculationCard.css";
-
-interface ConsolidatedCirculationCardProps {
- estimate: ConsolidatedCirculation;
- onMapClick?: () => void;
- readonly?: boolean;
- reduced?: boolean;
- driver?: string;
-}
-
-// Utility function to parse service ID and get the turn number
-const parseServiceId = (serviceId: string): string => {
- const parts = serviceId.split("_");
- if (parts.length === 0) return "";
-
- const lastPart = parts[parts.length - 1];
- if (lastPart.length < 6) return "";
-
- const last6 = lastPart.slice(-6);
- const lineCode = last6.slice(0, 3);
- const turnCode = last6.slice(-3);
-
- // Remove leading zeros from turn
- const turnNumber = parseInt(turnCode, 10).toString();
-
- // Parse line number with special cases
- const lineNumber = parseInt(lineCode, 10);
- let displayLine: string;
-
- switch (lineNumber) {
- case 1:
- displayLine = "C1";
- break;
- case 3:
- displayLine = "C3";
- break;
- case 30:
- displayLine = "N1";
- break;
- case 33:
- displayLine = "N4";
- break;
- case 8:
- displayLine = "A";
- break;
- case 101:
- displayLine = "H";
- break;
- case 150:
- displayLine = "REF";
- break;
- case 500:
- displayLine = "TUR";
- break;
- case 201:
- displayLine = "U1";
- break;
- case 202:
- displayLine = "U2";
- break;
- default:
- displayLine = `L${lineNumber}`;
- }
-
- return `${displayLine}-${turnNumber}`;
-};
-
-const AutoMarquee = ({ text }: { text: string }) => {
- const containerRef = useRef<HTMLDivElement>(null);
- const [shouldScroll, setShouldScroll] = useState(false);
-
- useEffect(() => {
- const el = containerRef.current;
- if (!el) return;
-
- const checkScroll = () => {
- // 9px per char for text-sm font-mono is a safe upper bound estimate
- // (14px * 0.6 = 8.4px)
- const charWidth = 9;
- const availableWidth = el.offsetWidth;
- const textWidth = text.length * charWidth;
-
- setShouldScroll(textWidth > availableWidth);
- };
-
- checkScroll();
-
- const observer = new ResizeObserver(checkScroll);
- observer.observe(el);
-
- return () => observer.disconnect();
- }, [text]);
-
- if (shouldScroll) {
- return (
- <div ref={containerRef} className="w-full overflow-hidden">
- <Marquee speed={60} gradient={false}>
- <div className="mr-64 text-sm font-mono">{text}</div>
- </Marquee>
- </div>
- );
- }
-
- return (
- <div
- ref={containerRef}
- className="w-full overflow-hidden text-sm font-mono truncate"
- >
- {text}
- </div>
- );
-};
-
-export const ConsolidatedCirculationCard: React.FC<
- ConsolidatedCirculationCardProps
-> = ({ estimate, onMapClick, readonly, reduced, driver }) => {
- const { t } = useTranslation();
-
- const formatDistance = (meters: number) => {
- if (meters > 1024) {
- return `${(meters / 1000).toFixed(1)} km`;
- }
- return `${meters} ${t("estimates.meters", "m")}`;
- };
-
- const getTripIdDisplay = (tripId: string): string => {
- const parts = tripId.split("_");
- return parts.length > 1 ? parts[1] : tripId;
- };
-
- const etaMinutes =
- estimate.realTime?.minutes ?? estimate.schedule?.minutes ?? null;
-
- let etaValue: string;
- let etaUnit: string;
-
- if (etaMinutes === null) {
- etaValue = "--";
- etaUnit = t("estimates.minutes", "min");
- } else {
- const isRenfe = driver === "renfe";
- const isLongWait = etaMinutes > 60;
-
- if (isRenfe || isLongWait) {
- const now = new Date();
- const arrivalTime = new Date(now.getTime() + etaMinutes * 60 * 1000);
- etaValue = arrivalTime.toLocaleTimeString([], {
- hour: "2-digit",
- minute: "2-digit",
- hour12: false,
- });
- etaUnit = "";
- } else {
- etaValue = Math.max(0, Math.round(etaMinutes)).toString();
- etaUnit = t("estimates.minutes", "min");
- }
- }
-
- const timeClass = useMemo(() => {
- if (estimate.realTime && estimate.schedule?.running) {
- return "time-running";
- }
- if (estimate.realTime && !estimate.schedule) {
- return "time-running";
- }
- if (estimate.realTime && !estimate.schedule?.running) {
- return "time-delayed";
- }
- return "time-scheduled";
- }, [estimate.realTime, estimate.schedule]);
-
- const delayChip = useMemo(() => {
- if (!estimate.schedule || !estimate.realTime) {
- return null;
- }
-
- const delta = Math.round(
- estimate.realTime.minutes - estimate.schedule.minutes
- );
- const absDelta = Math.abs(delta);
-
- // On time
- if (delta === 0) {
- return {
- label: reduced ? "OK" : t("estimates.delay_on_time"),
- tone: "delay-ok",
- kind: "delay",
- } as const;
- }
-
- // Delayed
- if (delta > 0) {
- const tone =
- delta <= 2 ? "delay-ok" : delta <= 10 ? "delay-warn" : "delay-critical";
- return {
- label: reduced
- ? `R${delta}`
- : t("estimates.delay_positive", {
- minutes: delta,
- }),
- tone,
- kind: "delay",
- } as const;
- }
-
- // Early
- const tone = absDelta <= 2 ? "delay-ok" : "delay-early";
- return {
- label: reduced
- ? `A${absDelta}`
- : t("estimates.delay_negative", {
- minutes: absDelta,
- }),
- tone,
- kind: "delay",
- } as const;
- }, [estimate.schedule, estimate.realTime, t, reduced]);
-
- const metaChips = useMemo(() => {
- const chips: Array<{
- label: string;
- tone?: string;
- kind?: "regular" | "gps" | "delay" | "warning";
- }> = [];
-
- if (delayChip) {
- chips.push(delayChip);
- }
-
- if (estimate.schedule && driver !== "renfe") {
- chips.push({
- label: `${parseServiceId(estimate.schedule.serviceId)} · ${getTripIdDisplay(
- estimate.schedule.tripId
- )}`,
- kind: "regular",
- });
- }
-
- if (estimate.realTime && estimate.realTime.distance >= 0) {
- chips.push({
- label: formatDistance(estimate.realTime.distance),
- kind: "regular",
- });
- }
-
- if (!reduced) {
- if (estimate.currentPosition) {
- if (estimate.isPreviousTrip) {
- chips.push({ label: t("estimates.previous_trip"), kind: "gps" });
- } else {
- chips.push({ label: t("estimates.bus_gps_position"), kind: "gps" });
- }
- }
-
- if (driver !== "renfe") {
- if (timeClass === "time-delayed") {
- chips.push({
- label: reduced ? "!" : t("estimates.low_accuracy"),
- tone: "warning",
- kind: "warning",
- });
- }
-
- if (timeClass === "time-scheduled") {
- chips.push({
- label: reduced ? "⧗" : t("estimates.no_realtime"),
- tone: "warning",
- kind: "warning",
- });
- }
- }
- }
-
- return chips;
- }, [delayChip, estimate.schedule, estimate.realTime, timeClass, t, reduced]);
-
- // Check if bus has GPS position (live tracking)
- const hasGpsPosition = !!estimate.currentPosition;
- const isRenfe = driver === "renfe";
- const isClickable = hasGpsPosition;
- const looksDisabled = !isClickable && !isRenfe;
-
- const Tag = readonly ? "div" : "button";
- const interactiveProps = readonly
- ? {}
- : {
- onClick: isClickable ? onMapClick : undefined,
- type: "button" as const,
- disabled: !isClickable,
- };
-
- if (reduced) {
- return (
- <Tag
- className={`
- flex-none flex items-center gap-2.5 min-h-12
- bg-(--message-background-color) border border-(--border-color)
- rounded-xl px-3 py-2.5 transition-all
- ${
- readonly
- ? looksDisabled
- ? "opacity-70 cursor-not-allowed"
- : ""
- : isClickable
- ? "cursor-pointer hover:shadow-[0_4px_14px_rgba(0,0,0,0.08)] hover:border-(--button-background-color) hover:bg-[color-mix(in_oklab,var(--button-background-color)_5%,var(--message-background-color))] active:scale-[0.98]"
- : looksDisabled
- ? "opacity-70 cursor-not-allowed"
- : ""
- }
- `.trim()}
- {...interactiveProps}
- >
- <div className="shrink-0 min-w-[7ch]">
- <LineIcon line={estimate.line} mode="pill" />
- </div>
- <div className="flex-1 min-w-0 flex flex-col gap-1">
- <strong className="text-base text-(--text-color) overflow-hidden text-ellipsis line-clamp-2 leading-tight">
- {driver === "renfe" && estimate.schedule?.tripId && (
- <span className="font-mono text-slate-500 mr-1.5 text-sm">
- {estimate.schedule.tripId}
- </span>
- )}
- {driver === "renfe" ? estimate.route.toUpperCase() : estimate.route}
- </strong>
- {metaChips.length > 0 && (
- <div className="flex items-center gap-1.5 flex-wrap">
- {metaChips.map((chip, idx) => {
- let chipColourClasses = "";
- switch (chip.tone) {
- case "delay-ok":
- chipColourClasses =
- "bg-green-600/20 dark:bg-green-600/30 text-green-700 dark:text-green-300";
- break;
- case "delay-warn":
- chipColourClasses =
- "bg-amber-600/20 dark:bg-yellow-600/30 text-amber-700 dark:text-yellow-300";
- break;
- case "delay-critical":
- chipColourClasses =
- "bg-red-400/20 dark:bg-red-600/30 text-red-600 dark:text-red-300";
- break;
- case "delay-early":
- chipColourClasses =
- "bg-blue-400/20 dark:bg-blue-600/30 text-blue-700 dark:text-blue-300";
- break;
- case "warning":
- chipColourClasses =
- "bg-orange-400/20 dark:bg-orange-600/30 text-orange-700 dark:text-orange-300";
- break;
- default:
- chipColourClasses =
- "bg-black/[0.06] dark:bg-white/[0.12] text-[var(--text-color)]";
- }
-
- return (
- <span
- key={`${chip.label}-${idx}`}
- className={`text-xs px-2 py-0.5 rounded-full flex items-center justify-center gap-1 shrink-0 ${chipColourClasses}`}
- >
- {chip.kind === "gps" && (
- <LocateIcon className="w-3 h-3 inline-block" />
- )}
- {chip.kind === "warning" && (
- <AlertTriangle className="w-3 h-3 inline-block" />
- )}
- {chip.label}
- </span>
- );
- })}
- </div>
- )}
- </div>
- <div
- className={`
- inline-flex items-center justify-center px-2 py-1.5 rounded-xl shrink-0
- ${
- timeClass === "time-running"
- ? "bg-green-600/20 dark:bg-green-600/25 text-[#1a9e56] dark:text-[#22c55e]"
- : timeClass === "time-delayed"
- ? "bg-orange-600/20 dark:bg-orange-600/25 text-[#d06100] dark:text-[#fb923c]"
- : "bg-blue-900/20 dark:bg-blue-600/25 text-[#0b3d91] dark:text-[#93c5fd]"
- }
- `.trim()}
- >
- <div className="flex flex-col items-center leading-none">
- <span className="text-lg font-bold leading-none">{etaValue}</span>
- <span className="text-[0.65rem] uppercase tracking-wider mt-0.5 opacity-90">
- {etaUnit}
- </span>
- </div>
- </div>
- </Tag>
- );
- }
-
- return (
- <Tag
- className={`consolidated-circulation-card ${
- readonly
- ? looksDisabled
- ? "no-gps"
- : ""
- : isClickable
- ? "has-gps"
- : looksDisabled
- ? "no-gps"
- : ""
- }`}
- {...interactiveProps}
- >
- <>
- <div className="card-row main">
- <div className="line-info">
- <LineIcon line={estimate.line} mode="pill" />
- </div>
- <div className="route-info">
- <strong>
- {driver === "renfe" && estimate.schedule?.tripId && (
- <span className="font-mono text-slate-500 mr-2 text-[0.9em]">
- {estimate.schedule.tripId}
- </span>
- )}
- {driver === "renfe"
- ? estimate.route.toUpperCase()
- : estimate.route}
- </strong>
- {estimate.nextStreets && estimate.nextStreets.length > 0 && (
- <AutoMarquee text={estimate.nextStreets.join(" — ")} />
- )}
- </div>
- <div className={`eta-badge ${timeClass}`}>
- <div className="eta-text">
- <span className="eta-value">{etaValue}</span>
- <span className="eta-unit">{etaUnit}</span>
- </div>
- </div>
- </div>
-
- {metaChips.length > 0 && (
- <div className="card-row meta">
- {metaChips.map((chip, idx) => (
- <span
- key={`${chip.label}-${idx}`}
- className={`meta-chip ${chip.tone ?? ""}`.trim()}
- >
- {chip.kind === "gps" && (
- <LocateIcon className="w-3 h-3 inline-block" />
- )}
- {chip.kind === "warning" && (
- <AlertTriangle className="w-3 h-3 inline-block" />
- )}
- {chip.label}
- </span>
- ))}
- </div>
- )}
- </>
- </Tag>
- );
-};
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css b/src/frontend/app/components/Stops/ConsolidatedCirculationList.css
deleted file mode 100644
index 044b4a3..0000000
--- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.css
+++ /dev/null
@@ -1,13 +0,0 @@
-.consolidated-circulation-caption {
- font-size: 0.9rem;
- color: var(--subtitle-color);
- text-align: center;
- padding: 0.5rem;
-}
-
-.consolidated-circulation-no-data {
- text-align: center;
- padding: 2rem 1rem;
- color: var(--subtitle-color);
- font-size: 0.95rem;
-}
diff --git a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx b/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
deleted file mode 100644
index eea4582..0000000
--- a/src/frontend/app/components/Stops/ConsolidatedCirculationList.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { useTranslation } from "react-i18next";
-import { type ConsolidatedCirculation } from "~routes/stops-$id";
-import { ConsolidatedCirculationCard } from "./ConsolidatedCirculationCard";
-
-import { useCallback } from "react";
-import "./ConsolidatedCirculationList.css";
-
-interface ConsolidatedCirculationListProps {
- data: ConsolidatedCirculation[];
- onCirculationClick?: (
- estimate: ConsolidatedCirculation,
- index: number
- ) => void;
- reduced?: boolean;
- driver?: string;
-}
-
-export const ConsolidatedCirculationList: React.FC<
- ConsolidatedCirculationListProps
-> = ({ data, onCirculationClick, reduced, driver }) => {
- const { t } = useTranslation();
-
- const sortedData = [...data].sort(
- (a, b) =>
- (a.realTime?.minutes ?? a.schedule?.minutes ?? 999) -
- (b.realTime?.minutes ?? b.schedule?.minutes ?? 999)
- );
-
- const generateKey = useCallback((estimate: ConsolidatedCirculation) => {
- if (estimate.realTime && estimate.schedule) {
- return `rt-${estimate.schedule.tripId}`;
- }
-
- return `sch-${estimate.schedule ? estimate.schedule.tripId : estimate.line + "-" + estimate.route}`;
- }, []);
-
- return (
- <>
- {sortedData.length === 0 ? (
- <div className="consolidated-circulation-no-data">
- {t("estimates.none", "No hay estimaciones disponibles")}
- </div>
- ) : (
- <div className="flex flex-col gap-3">
- {sortedData.map((estimate, idx) => (
- <ConsolidatedCirculationCard
- reduced={reduced}
- driver={driver}
- key={generateKey(estimate)}
- estimate={estimate}
- onMapClick={() => onCirculationClick?.(estimate, idx)}
- />
- ))}
- </div>
- )}
- </>
- );
-};
diff --git a/src/frontend/app/components/ThemeColorManager.tsx b/src/frontend/app/components/ThemeColorManager.tsx
index eba0471..30a5f7a 100644
--- a/src/frontend/app/components/ThemeColorManager.tsx
+++ b/src/frontend/app/components/ThemeColorManager.tsx
@@ -5,7 +5,7 @@ export const ThemeColorManager = () => {
const { resolvedTheme } = useSettings();
useEffect(() => {
- const color = resolvedTheme === "dark" ? "#121212" : "#ffffff";
+ const color = resolvedTheme === "dark" ? "#1A1B26" : "#F7F7FF";
let meta = document.querySelector('meta[name="theme-color"]');
if (!meta) {
diff --git a/src/frontend/app/components/arrivals/ArrivalCard.css b/src/frontend/app/components/arrivals/ArrivalCard.css
index 5835352..0e5af25 100644
--- a/src/frontend/app/components/arrivals/ArrivalCard.css
+++ b/src/frontend/app/components/arrivals/ArrivalCard.css
@@ -13,5 +13,5 @@
}
.time-scheduled {
- @apply bg-blue-900/20 dark:bg-blue-600/25 text-[#0b3d91] dark:text-[#93c5fd];
+ @apply bg-primary/20 dark:bg-primary/25 text-primary dark:text-primary;
}
diff --git a/src/frontend/app/components/layout/NavBar.module.css b/src/frontend/app/components/layout/NavBar.module.css
index 19d0a93..32827c6 100644
--- a/src/frontend/app/components/layout/NavBar.module.css
+++ b/src/frontend/app/components/layout/NavBar.module.css
@@ -28,6 +28,16 @@
color: var(--text-color);
padding: 0.25rem 0;
border-radius: 0.5rem;
+ gap: 4px;
+}
+
+.iconWrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 4px 20px;
+ border-radius: 20px;
+ transition: background-color 0.2s ease;
}
.link svg {
@@ -38,11 +48,27 @@
}
.link span {
- font-size: 13px;
+ font-size: 11px;
line-height: 1;
- font-family: system-ui;
+ font-family: var(--font-ui);
}
.active {
color: var(--button-background-color);
}
+
+.active .iconWrapper {
+ background-color: color-mix(
+ in oklab,
+ var(--button-background-color) 15%,
+ transparent
+ );
+}
+
+[data-theme="dark"] .active .iconWrapper {
+ background-color: color-mix(
+ in oklab,
+ var(--button-background-color) 20%,
+ transparent
+ );
+}
diff --git a/src/frontend/app/components/layout/NavBar.tsx b/src/frontend/app/components/layout/NavBar.tsx
index 9c42987..58228c7 100644
--- a/src/frontend/app/components/layout/NavBar.tsx
+++ b/src/frontend/app/components/layout/NavBar.tsx
@@ -112,7 +112,9 @@ export default function NavBar({ orientation = "horizontal" }: NavBarProps) {
title={item.name}
aria-label={item.name}
>
- <Icon size={24} />
+ <div className={styles.iconWrapper}>
+ <Icon size={24} />
+ </div>
<span>{item.name}</span>
</Link>
);
diff --git a/src/frontend/app/components/map/StopSummarySheet.css b/src/frontend/app/components/map/StopSummarySheet.css
index 5869d41..e39ac07 100644
--- a/src/frontend/app/components/map/StopSummarySheet.css
+++ b/src/frontend/app/components/map/StopSummarySheet.css
@@ -1,3 +1,5 @@
+@import "../../tailwind.css";
+
/* Stop Sheet Styles */
.react-modal-sheet-container {
background-color: var(--background-color) !important;
@@ -29,6 +31,7 @@
}
.stop-sheet-title {
+ font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 600;
color: var(--text-color);
@@ -36,6 +39,7 @@
}
.stop-sheet-id {
+ font-family: var(--font-display);
font-size: 1rem;
color: var(--subtitle-color);
}
@@ -85,6 +89,7 @@
}
.stop-sheet-subtitle {
+ font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 500;
color: var(--text-color);
@@ -92,6 +97,7 @@
}
.stop-sheet-no-estimates {
+ font-family: var(--font-display);
text-align: center;
padding: 32px 16px;
color: var(--subtitle-color);
@@ -183,6 +189,7 @@
}
.stop-sheet-timestamp {
+ font-family: var(--font-ui);
font-size: 0.8rem;
color: var(--subtitle-color);
text-align: center;
@@ -194,35 +201,19 @@
}
.stop-sheet-reload {
- display: inline-flex;
- align-items: center;
- gap: 0.4rem;
- background: transparent;
- border: 1px solid var(--border-color);
- color: var(--text-color);
- padding: 0.5rem 0.75rem;
- border-radius: 6px;
- font-size: 0.85rem;
- cursor: pointer;
- transition: all 0.2s ease;
- flex: 1;
- justify-content: center;
+ @apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl border border-border bg-transparent text-text text-sm font-sans cursor-pointer transition-all flex-1;
}
.stop-sheet-reload:hover:not(:disabled) {
- background: var(--message-background-color);
- border-color: var(--button-background-color);
+ @apply bg-surface border-primary;
}
.stop-sheet-reload:disabled {
- opacity: 0.6;
- cursor: not-allowed;
+ @apply opacity-60 cursor-not-allowed;
}
.reload-icon {
- width: 14px;
- height: 14px;
- transition: transform 0.5s ease;
+ @apply w-4 h-4 transition-transform duration-500;
}
.reload-icon.spinning {
@@ -239,22 +230,11 @@
}
.stop-sheet-view-all {
- display: block;
- padding: 0.5rem 0.75rem;
- background-color: var(--button-background-color);
- color: white;
- text-decoration: none;
- text-align: center;
- border-radius: 6px;
- font-weight: 500;
- font-size: 0.85rem;
- transition: background-color 0.2s ease;
- flex: 2;
+ @apply block px-4 py-2.5 bg-primary text-white no-underline text-center rounded-xl font-semibold text-sm font-sans transition-colors flex-[1.5];
}
.stop-sheet-view-all:hover {
- background-color: var(--button-hover-background-color);
- text-decoration: none;
+ @apply bg-primary/90 no-underline;
}
/* Error display adjustments for sheet */
diff --git a/src/frontend/app/components/map/StopSummarySheet.tsx b/src/frontend/app/components/map/StopSummarySheet.tsx
index 56e80a4..7024f41 100644
--- a/src/frontend/app/components/map/StopSummarySheet.tsx
+++ b/src/frontend/app/components/map/StopSummarySheet.tsx
@@ -26,7 +26,7 @@ export interface StopSheetProps {
};
}
-export const StopSheet: React.FC<StopSheetProps> = ({
+export const StopSummarySheet: React.FC<StopSheetProps> = ({
isOpen,
onClose,
stop,
diff --git a/src/frontend/app/root.css b/src/frontend/app/root.css
index c6d9058..8ac6bf1 100644
--- a/src/frontend/app/root.css
+++ b/src/frontend/app/root.css
@@ -1,35 +1,36 @@
:root {
--colour-scheme: light;
- --background-color: #ffffff;
- --text-color: #333333;
- --subtitle-color: #444444;
- --border-color: #eeeeee;
- --button-background-color: #007bff;
- --button-hover-background-color: #0069d9;
+ --background-color: #f7f7ff;
+ --text-color: #1a1b26;
+ --subtitle-color: #4a4b56;
+ --border-color: #e1e1ef;
+ --button-background-color: #27187e;
+ --primary-color: var(--button-background-color);
+ --button-hover-background-color: #1e1263;
--button-disabled-background-color: #cccccc;
--star-color: #ffcc00;
- --message-background-color: #f8f9fa;
+ --message-background-color: #ffffff;
/* Skeletons */
- --skeleton-base: #f0f0f0;
- --skeleton-highlight: #e0e0e0;
+ --skeleton-base: #eef0f7;
+ --skeleton-highlight: #e1e4f0;
/* Timetable component variables */
--text-primary: var(--text-color);
- --text-secondary: #666666;
+ --text-secondary: #6a6b76;
--surface-future: var(--background-color);
- --surface-next: #eef6ff; /* slightly accented surface for next card */
- --surface-past: hsl(0 0% 90% / 1);
- --accent-next: #1e88e5; /* accent color for next card left border */
+ --surface-next: #eef0ff; /* slightly accented surface for next card */
+ --surface-past: hsl(240 20% 90% / 1);
+ --accent-next: #27187e; /* accent color for next card left border */
--card-border: var(--border-color);
- --card-background: #f8f9fa;
- --service-background: #f0f0f0;
- --service-background-past: #e8e8e8;
+ --card-background: #ffffff;
+ --service-background: #f0f1f9;
+ --service-background-past: #e8e9f0;
/* Alert color variables */
- --alert-info-bg: rgba(59, 130, 246, 0.1);
- --alert-info-border: rgba(59, 130, 246, 0.5);
- --alert-info-text: #1e40af;
+ --alert-info-bg: rgba(39, 24, 126, 0.05);
+ --alert-info-border: rgba(39, 24, 126, 0.2);
+ --alert-info-text: #27187e;
--alert-warning-bg: rgba(255, 152, 0, 0.1);
--alert-warning-border: rgba(255, 152, 0, 0.5);
@@ -41,46 +42,49 @@
/* Error display colors */
--error-icon-color: #e74c3c;
- --error-title-color: #2c3e50;
- --error-message-color: #7f8c8d;
+ --error-title-color: #1a1b26;
+ --error-message-color: #4a4b56;
color-scheme: light;
- font-family:
- ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
- "Segoe UI Symbol", "Noto Color Emoji";
+ --font-display: "Outfit Variable", ui-sans-serif, system-ui, sans-serif;
+ --font-ui: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
+ "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+
+ font-family: var(--font-ui);
}
[data-theme="dark"] {
--colour-scheme: dark;
- --background-color: #121212;
- --text-color: #ffffff;
- --subtitle-color: #bbbbbb;
- --border-color: #444444;
- --button-background-color: #1f93f2;
- --button-hover-background-color: #1872d9;
- --button-disabled-background-color: #555555;
+ --background-color: #1a1b26;
+ --text-color: #f7f7ff;
+ --subtitle-color: #a1a1b0;
+ --border-color: #2d2e3d;
+ --button-background-color: #8b7fff;
+ --primary-color: var(--button-background-color);
+ --button-hover-background-color: #7a6eff;
+ --button-disabled-background-color: #444444;
--star-color: #ffcc00;
- --message-background-color: #333333;
+ --message-background-color: #242533;
/* Skeletons (dark) */
- --skeleton-base: #2a2a2a;
- --skeleton-highlight: #3a3a3a;
+ --skeleton-base: #2d2e3d;
+ --skeleton-highlight: #3d3e4d;
/* Timetable component dark overrides */
--text-primary: var(--text-color);
- --text-secondary: #bbbbbb;
- --surface-future: #1e1e1e;
- --surface-next: #17212b;
- --surface-past: #1a1a1a;
- --accent-next: #64b5f6;
+ --text-secondary: #a1a1b0;
+ --surface-future: #242533;
+ --surface-next: #1e1f2e;
+ --surface-past: #1c1d29;
+ --accent-next: #8b7fff;
--card-border: var(--border-color);
- --card-background: #1e1e1e;
- --service-background: #222222;
- --service-background-past: #1f1f1f;
+ --card-background: #242533;
+ --service-background: #2a2b3a;
+ --service-background-past: #222331;
/* Alert color variables (dark) */
- --alert-info-bg: rgba(59, 130, 246, 0.15);
- --alert-info-border: rgba(59, 130, 246, 0.4);
+ --alert-info-bg: rgba(77, 59, 255, 0.15);
+ --alert-info-border: rgba(77, 59, 255, 0.4);
--alert-info-text: #93c5fd;
--alert-warning-bg: rgba(255, 152, 0, 0.15);
@@ -93,12 +97,21 @@
/* Error display colors (dark) */
--error-icon-color: #e74c3c;
- --error-title-color: #e0e0e0;
- --error-message-color: #b0b0b0;
+ --error-title-color: #f7f7ff;
+ --error-message-color: #a1a1b0;
color-scheme: dark;
}
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-family: var(--font-display);
+}
+
body {
color-scheme: var(--colour-scheme, light);
@@ -118,6 +131,13 @@ body {
overscroll-behavior-y: contain;
}
+button,
+input,
+select,
+textarea {
+ font-family: var(--font-ui);
+}
+
.main-content {
flex: 1;
overflow: auto;
@@ -147,6 +167,7 @@ body {
}
.page-title {
+ font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
@@ -154,6 +175,7 @@ body {
}
.page-subtitle {
+ font-family: var(--font-display);
font-size: 1rem;
font-weight: 500;
color: var(--subtitle-color);
@@ -161,6 +183,7 @@ body {
}
.message {
+ font-family: var(--font-display);
background-color: var(--message-background-color);
padding: 1rem;
border-radius: 0.5rem;
diff --git a/src/frontend/app/root.tsx b/src/frontend/app/root.tsx
index 1354660..656c75c 100644
--- a/src/frontend/app/root.tsx
+++ b/src/frontend/app/root.tsx
@@ -6,7 +6,7 @@ import {
ScrollRestoration,
} from "react-router";
-import "@fontsource-variable/roboto";
+import "@fontsource-variable/outfit";
import type { Route } from "./+types/root";
import "./root.css";
@@ -39,7 +39,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
<link rel="icon" type="image/jpg" href="/logo-512.jpg" />
<link rel="icon" href="/favicon.ico" sizes="64x64" />
<link rel="apple-touch-icon" href="/logo-512.jpg" sizes="512x512" />
- <meta name="theme-color" content="#ffffff" />
+ <meta name="theme-color" content="#F7F7FF" />
<link rel="canonical" href="https://busurbano.costas.dev/" />
<meta
diff --git a/src/frontend/app/routes/favourites.tsx b/src/frontend/app/routes/favourites.tsx
index ff229b2..deb3629 100644
--- a/src/frontend/app/routes/favourites.tsx
+++ b/src/frontend/app/routes/favourites.tsx
@@ -183,19 +183,19 @@ function SpecialPlaceCard({
setLabel,
}: SpecialPlaceCardProps) {
return (
- <div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
+ <div className="bg-surface border border-border rounded-lg p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
<span className="text-2xl" aria-hidden="true">
{icon}
</span>
<div className="flex-1 min-w-0">
- <h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
+ <h3 className="font-semibold text-text mb-1">
{label}
</h3>
{place ? (
- <div className="text-sm text-gray-600 dark:text-gray-400">
- <p className="font-medium text-gray-900 dark:text-gray-100">
+ <div className="text-sm text-muted">
+ <p className="font-medium text-text">
{place.name}
</p>
{place.type === "stop" && place.stopId && (
@@ -272,21 +272,21 @@ function FavouriteStopItem({
};
return (
- <li className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
+ <li className="bg-surface border border-border rounded-lg">
<div className="flex items-stretch justify-between gap-2">
<Link
to={`/stops/${stop.stopId}`}
- className="flex-1 min-w-0 p-3 no-underline hover:bg-gray-50 dark:hover:bg-gray-800/80 rounded-l-lg transition-colors"
+ className="flex-1 min-w-0 p-3 no-underline hover:bg-surface/80 rounded-l-lg transition-colors"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-yellow-500 text-base" aria-label="Favourite">
</span>
- <span className="text-xs text-gray-600 dark:text-gray-400 font-medium">
+ <span className="text-xs text-muted font-medium">
({stop.stopId})
</span>
</div>
- <div className="font-semibold text-gray-900 dark:text-gray-100 mb-2">
+ <div className="font-semibold text-text mb-2">
{StopDataProvider.getDisplayName(stop)}
</div>
<div className="flex flex-wrap gap-1 items-center">
diff --git a/src/frontend/app/routes/home.tsx b/src/frontend/app/routes/home.tsx
index 36565bd..a20ba64 100644
--- a/src/frontend/app/routes/home.tsx
+++ b/src/frontend/app/routes/home.tsx
@@ -241,7 +241,7 @@ export default function StopList() {
<div className="flex flex-col gap-4 py-4 pb-8">
{/* Search Section */}
<div className="w-full px-4">
- <h3 className="text-lg font-semibold mb-2 text-gray-900 dark:text-gray-100">
+ <h3 className="text-lg font-semibold mb-2 text-text">
{t("stoplist.search_label", "Buscar paradas")}
</h3>
<input
@@ -250,11 +250,11 @@ export default function StopList() {
onChange={handleStopSearch}
className="
w-full px-4 py-3 text-base
- border border-gray-300 dark:border-gray-700 rounded-xl
- bg-white dark:bg-gray-800
- text-gray-900 dark:text-gray-100
- placeholder:text-gray-500 dark:placeholder:text-gray-400 placeholder:opacity-80
- focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
+ border border-border rounded-xl
+ bg-surface
+ text-text
+ placeholder:text-muted placeholder:opacity-80
+ focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent
transition-all duration-200
"
/>
@@ -263,7 +263,7 @@ export default function StopList() {
{/* Search Results */}
{searchResults && searchResults.length > 0 ? (
<div className="w-full px-4 flex flex-col gap-2">
- <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
+ <h2 className="text-lg font-semibold text-text">
{t("stoplist.search_results", "Resultados de la búsqueda")}
</h2>
<ul className="list-none p-0 m-0 flex flex-col gap-2 md:grid md:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] lg:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]">
diff --git a/src/frontend/app/routes/lines.tsx b/src/frontend/app/routes/lines.tsx
index acf8a7f..900c543 100644
--- a/src/frontend/app/routes/lines.tsx
+++ b/src/frontend/app/routes/lines.tsx
@@ -24,11 +24,11 @@ export default function LinesPage() {
href={line.scheduleUrl}
target="_blank"
rel="noopener noreferrer"
- className="flex items-center gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow border border-gray-200 dark:border-gray-700"
+ className="flex items-center gap-3 p-4 bg-surface rounded-lg shadow hover:shadow-lg transition-shadow border border-border"
>
<LineIcon line={line.lineNumber} mode="rounded" />
<div className="flex-1 min-w-0">
- <p className="text-sm md:text-md font-semibold text-gray-900 dark:text-gray-100">
+ <p className="text-sm md:text-md font-semibold text-text">
{line.routeName}
</p>
</div>
diff --git a/src/frontend/app/routes/map.tsx b/src/frontend/app/routes/map.tsx
index 1ce9942..517549b 100644
--- a/src/frontend/app/routes/map.tsx
+++ b/src/frontend/app/routes/map.tsx
@@ -16,7 +16,7 @@ import Map, {
import { useNavigate } from "react-router";
import { PlannerOverlay } from "~/components/PlannerOverlay";
import {
- StopSheet,
+ StopSummarySheet,
type StopSheetProps,
} from "~/components/map/StopSummarySheet";
import { APP_CONSTANTS } from "~/config/constants";
@@ -278,7 +278,7 @@ export default function StopMap() {
/>
{selectedStop && (
- <StopSheet
+ <StopSummarySheet
isOpen={isSheetOpen}
onClose={() => setIsSheetOpen(false)}
stop={selectedStop}
diff --git a/src/frontend/app/routes/planner.tsx b/src/frontend/app/routes/planner.tsx
index 9e44425..3d0f703 100644
--- a/src/frontend/app/routes/planner.tsx
+++ b/src/frontend/app/routes/planner.tsx
@@ -125,14 +125,14 @@ const ItinerarySummary = ({
return (
<div
- className="bg-white dark:bg-slate-800 p-4 rounded-lg shadow mb-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-slate-700 border border-gray-200 dark:border-slate-700"
+ className="bg-surface p-4 rounded-lg shadow mb-3 cursor-pointer hover:bg-surface/80 border border-border"
onClick={onClick}
>
<div className="flex justify-between items-center mb-2">
- <div className="font-bold text-lg text-slate-900 dark:text-slate-100">
+ <div className="font-bold text-lg text-text">
{startTime} - {endTime}
</div>
- <div className="text-gray-600 dark:text-gray-400">
+ <div className="text-muted">
{durationMinutes} min
</div>
</div>
@@ -155,10 +155,10 @@ const ItinerarySummary = ({
return (
<React.Fragment key={idx}>
- {idx > 0 && <span className="text-slate-400">›</span>}
+ {idx > 0 && <span className="text-muted/50">›</span>}
{isWalk ? (
- <div className="flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm text-slate-800 whitespace-nowrap">
- <Footprints className="w-4 h-4 text-slate-600" />
+ <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")}
</span>
@@ -176,21 +176,21 @@ const ItinerarySummary = ({
})}
</div>
- <div className="flex items-center justify-between text-sm text-slate-600 dark:text-slate-400 mt-1">
+ <div className="flex items-center justify-between text-sm text-muted mt-1">
<span>
{t("planner.walk")}: {formatDistance(walkTotals.meters)}
{walkTotals.minutes
- ? ` • ${walkTotals.minutes} ${t("estimates.minutes")}`
+ ? ` • ${walkTotals.minutes} {t("estimates.minutes")}`
: ""}
</span>
<span className="flex items-center gap-3">
- <span className="flex items-center gap-1 font-semibold text-slate-700 dark:text-slate-300">
+ <span className="flex items-center gap-1 font-semibold text-text">
<Coins className="w-4 h-4" />
{cashFare === "0.00"
? t("planner.free")
: t("planner.fare", { amount: cashFare })}
</span>
- <span className="flex items-center gap-1 text-slate-600 dark:text-slate-400">
+ <span className="flex items-center gap-1 text-muted">
<CreditCard className="w-4 h-4" />
{cardFare === "0.00"
? t("planner.free")
diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx
index 56df777..c615844 100644
--- a/src/frontend/app/routes/settings.tsx
+++ b/src/frontend/app/routes/settings.tsx
@@ -31,7 +31,7 @@ export default function Settings() {
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Theme Selection */}
<section className="mb-8">
- <h2 className="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
+ <h2 className="text-xl font-semibold mb-4 text-text">
{t("about.theme", "Tema")}
</h2>
<div className="grid grid-cols-3 gap-3 sm:gap-4">
@@ -42,12 +42,12 @@ export default function Settings() {
className={`
p-4 sm:p-6 flex flex-col items-center justify-center gap-2
rounded-lg border-2 transition-all duration-200
- hover:bg-gray-50 dark:hover:bg-gray-800
- focus:outline-none focus:ring focus:ring-blue-500 dark:focus:ring-offset-gray-900
+ hover:bg-surface/50
+ focus:outline-none focus:ring focus:ring-primary/50
${
value === theme
- ? "border-blue-600 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 font-semibold"
- : "border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300"
+ ? "border-primary bg-primary/10 text-primary font-semibold"
+ : "border-border text-muted"
}
`}
>
@@ -62,17 +62,17 @@ export default function Settings() {
<section className="mb-8">
<label
htmlFor="mapPositionMode"
- className="block text-lg font-medium text-gray-900 dark:text-gray-100 mb-3"
+ className="block text-lg font-medium text-text mb-3"
>
{t("about.map_position_mode")}
</label>
<select
id="mapPositionMode"
className="
- w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-700
- bg-white dark:bg-gray-800
- text-gray-900 dark:text-gray-100
- focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
+ w-full px-4 py-3 rounded-lg border border-border
+ bg-surface
+ text-text
+ focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent
transition-colors duration-200
"
value={mapPositionMode}
@@ -87,17 +87,17 @@ export default function Settings() {
<section>
<label
htmlFor="language"
- className="block text-lg font-medium text-gray-900 dark:text-gray-100 mb-3"
+ className="block text-lg font-medium text-text mb-3"
>
{t("about.language", "Idioma")}
</label>
<select
id="language"
className="
- w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-700
- bg-white dark:bg-gray-800
- text-gray-900 dark:text-gray-100
- focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
+ w-full px-4 py-3 rounded-lg border border-border
+ bg-surface
+ text-text
+ focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent
transition-colors duration-200
"
value={i18n.language}
diff --git a/src/frontend/app/routes/stops-$id.tsx b/src/frontend/app/routes/stops-$id.tsx
index 7adcef2..9147302 100644
--- a/src/frontend/app/routes/stops-$id.tsx
+++ b/src/frontend/app/routes/stops-$id.tsx
@@ -181,13 +181,13 @@ export default function Estimates() {
className={`cursor-pointer transition-colors ${
favourited
? "fill-[var(--star-color)] text-[var(--star-color)]"
- : "text-slate-500"
+ : "text-muted"
}`}
onClick={toggleFavourite}
/>
<CircleHelp
- className="text-slate-500 cursor-pointer"
+ className="text-muted cursor-pointer"
onClick={() => setIsHelpModalOpen(true)}
/>
</div>
@@ -205,12 +205,12 @@ export default function Estimates() {
<div>
{isReducedView ? (
<EyeClosed
- className="text-slate-500"
+ className="text-muted"
onClick={() => setIsReducedView(false)}
/>
) : (
<Eye
- className="text-slate-500"
+ className="text-muted"
onClick={() => setIsReducedView(true)}
/>
)}
diff --git a/src/frontend/app/tailwind.css b/src/frontend/app/tailwind.css
index de604f7..7438ac7 100644
--- a/src/frontend/app/tailwind.css
+++ b/src/frontend/app/tailwind.css
@@ -3,4 +3,32 @@
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
+@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] *));
diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json
index 6c8284b..3413c58 100644
--- a/src/frontend/package-lock.json
+++ b/src/frontend/package-lock.json
@@ -8,6 +8,7 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
+ "@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/roboto": "^5.2.8",
"@react-router/node": "^7.9.6",
"@react-router/serve": "^7.9.6",
@@ -1105,6 +1106,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@fontsource-variable/outfit": {
+ "version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.2.8.tgz",
+ "integrity": "sha512-4oUDCZx/Tcz6HZP423w/niqEH31Gks5IsqHV2ZZz1qKHaVIZdj2f0/S1IK2n8jl6Xo0o3N+3RjNHlV9R73ozQA==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@fontsource-variable/roboto": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/roboto/-/roboto-5.2.8.tgz",
diff --git a/src/frontend/package.json b/src/frontend/package.json
index bd55dff..4aaf83f 100644
--- a/src/frontend/package.json
+++ b/src/frontend/package.json
@@ -14,6 +14,7 @@
"checkformat": "prettier --check ."
},
"dependencies": {
+ "@fontsource-variable/outfit": "^5.2.8",
"@fontsource-variable/roboto": "^5.2.8",
"@react-router/node": "^7.9.6",
"@react-router/serve": "^7.9.6",
diff --git a/src/frontend/public/manifest.webmanifest b/src/frontend/public/manifest.webmanifest
index afdab3c..e849bd1 100644
--- a/src/frontend/public/manifest.webmanifest
+++ b/src/frontend/public/manifest.webmanifest
@@ -8,8 +8,8 @@
"display": "standalone",
"orientation": "portrait-primary",
"lang": "es",
- "background_color": "#ffffff",
- "theme_color": "#007bff",
+ "background_color": "#F7F7FF",
+ "theme_color": "#27187E",
"categories": ["productivity"],
"prefer_related_applications": false,
"icons": [