-
{
- onChange(e.target.value);
- setOpen(true);
- }}
- onFocus={() => setOpen(true)}
- onBlur={() => setOpen(false)}
- onKeyDown={(e) => {
- if (!showDropdown) return;
- if (e.key === "ArrowDown") {
- e.preventDefault();
- setActiveIdx((i) => Math.min(i + 1, results.length - 1));
- } else if (e.key === "ArrowUp") {
- e.preventDefault();
- setActiveIdx((i) => Math.max(i - 1, 0));
- } else if (e.key === "Enter") {
- e.preventDefault();
- pick(results[activeIdx] ?? results[0]);
- } else if (e.key === "Escape") {
- setOpen(false);
+
+
+
+ {
+ onChange(e.target.value);
+ setOpen(true);
+ setActiveIdx(0);
+ }}
+ onFocus={() => setOpen(true)}
+ onBlur={() => setOpen(false)}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ setOpen(false);
+ inputRef.current?.blur();
+ }
+ if (!showDropdown || !results.length) return;
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
+ e.preventDefault();
+ setActiveIdx((i) =>
+ Math.max(0, Math.min(i + (e.key === "ArrowDown" ? 1 : -1), results.length - 1)),
+ );
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ pick(results[activeIdx] ?? results[0]);
+ }
+ }}
+ role="combobox"
+ aria-autocomplete="list"
+ aria-expanded={showDropdown}
+ aria-controls="search-results"
+ aria-activedescendant={
+ showDropdown && results.length ? `search-option-${results[activeIdx]?.id}` : undefined
}
- }}
- role="combobox"
- aria-expanded={showDropdown}
- aria-controls="search-results"
- className="w-full pl-4 pr-10 py-2.5 rounded-lg bg-white border border-gray-300 shadow-md text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-300"
- />
-
-
+ />
+ {value ? (
+
+ ) : (
+ ⌘ K
+ )}
+
{showDropdown && (
-
- {results.map((poi, i) => (
- -
-
-
- ))}
-
+
+
Places on campus
+ {/* biome-ignore lint/a11y/useSemanticElements: custom combobox uses aria-activedescendant */}
+
+ {results.map((poi, i) => {
+ const Icon = getCategoryIcon(poi.cat);
+ return (
+
+ );
+ })}
+
+ {!results.length && (
+
+ No places match “{value.trim()}”. Try a building or library name.
+
+ )}
+
)}
);
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 4fb05c1..a8b178e 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -1,328 +1,333 @@
@import "tailwindcss";
@import "mapbox-gl/dist/mapbox-gl.css";
-html,
-body,
-#root {
- height: 100%;
- margin: 0;
- font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-}
-
-/* Lock the page: the app is a fixed full-screen map — no document scrolling,
- no iOS rubber-band bounce (which showed white past the page edges), and no
- browser pinch-zoom of the UI. The Mapbox canvas manages its own gestures. */
-html {
- background: #1c1c1e;
- overflow: hidden;
- overscroll-behavior: none;
-}
-body {
- position: fixed;
- inset: 0;
- width: 100%;
- overflow: hidden;
- overscroll-behavior: none;
- touch-action: pan-x pan-y;
- -webkit-text-size-adjust: 100%;
-}
-
-/* ── Shared detail panel ────────────────────────── */
-.detail-panel {
- position: absolute;
- bottom: 1rem;
- right: 1rem;
- z-index: 20;
- width: 380px;
- max-height: 70vh;
- background: white;
- border-radius: 0.75rem;
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15), 0 1px 3px rgba(0, 0, 0, 0.08);
- border: 1px solid #e5e7eb;
- overflow: hidden;
- display: flex;
- flex-direction: column;
-}
-
-/* Mobile: full-width bottom sheet */
-@media (max-width: 640px) {
- .detail-panel {
- left: 0;
- right: 0;
- bottom: 0;
- width: 100%;
- max-height: 55vh;
- border-radius: 1rem 1rem 0 0;
- border-bottom: none;
- }
-}
-
-/* ── Directions sheet ──────────────────────────────── */
-.directions-sheet-wrap {
- position: absolute;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 30;
- display: flex;
- justify-content: center;
- pointer-events: none;
-}
-
-.directions-sheet {
- pointer-events: auto;
- touch-action: none;
- width: 100%;
- background: #1c1c1e;
- border-radius: 1rem 1rem 0 0;
- padding: 0 1rem calc(1rem + env(safe-area-inset-bottom));
- box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.35);
- animation: sheet-up 300ms cubic-bezier(0.32, 0.72, 0.3, 1);
- transition: transform 220ms cubic-bezier(0.32, 0.72, 0.3, 1);
-}
-
-/* Turn-by-turn steps: collapsed on mobile until the sheet is expanded
- (swipe up or tap the handle); always open on desktop. */
-.steps-wrap {
- display: grid;
- grid-template-rows: 0fr;
- transition: grid-template-rows 320ms cubic-bezier(0.32, 0.72, 0.3, 1);
-}
-.steps-wrap.steps-open {
- grid-template-rows: 1fr;
-}
-.steps-inner {
- overflow: hidden;
- min-height: 0;
-}
-.steps-list {
- max-height: 40dvh;
- overflow-y: auto;
- overscroll-behavior: contain;
- touch-action: pan-y;
-}
-@media (min-width: 640px) {
- .steps-wrap {
- grid-template-rows: 1fr;
- }
- .steps-list {
- max-height: 13rem;
- }
-}
-@media (prefers-reduced-motion: reduce) {
- .steps-wrap,
- .directions-sheet {
- transition: none;
- }
-}
+:root {
+ --surface: oklch(98.5% 0.009 85);
+ --surface-soft: oklch(96% 0.012 85);
+ --ink: oklch(28% 0.018 70);
+ --muted: oklch(49% 0.022 75);
+ --faint: oklch(65% 0.022 75);
+ --line: oklch(89% 0.018 85);
+ --accent: oklch(51% 0.133 44);
+ --accent-soft: oklch(94% 0.036 65);
+ --green: oklch(46% 0.051 145);
+ --shadow: 0 8px 32px oklch(28% 0.03 70 / 9%), 0 2px 6px oklch(28% 0.03 70 / 4%);
+ --ease: cubic-bezier(0.22, 1, 0.36, 1);
+ --space-sm: 8px;
+ color: var(--ink);
+ font-family: "Manrope", sans-serif;
+ font-synthesis: none;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+}
+html, body, #root { height: 100%; margin: 0; }
+html { background: var(--surface-soft); overflow: hidden; overscroll-behavior: none; }
+body { position: fixed; inset: 0; overflow: hidden; overscroll-behavior: none; -webkit-text-size-adjust: 100%; }
+button, input { font: inherit; }
+button, a, input { -webkit-tap-highlight-color: transparent; }
+button { cursor: pointer; }
+button:disabled { cursor: default; opacity: 0.55; }
+button, a { transition: color 160ms var(--ease), background-color 160ms var(--ease), border-color 160ms var(--ease), transform 160ms var(--ease); }
+button:active:not(:disabled) { transform: scale(0.97); }
+:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
+.detail-panel:focus { outline: none; }
+h1, h2, h3, p { margin: 0; }
+h1, h2, .brand-name, .event-title { font-family: "Bricolage Grotesque", sans-serif; }
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+svg { flex-shrink: 0; }
+::selection { background: var(--accent-soft); }
+* { scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
+.map-app, .campus-map { position: relative; width: 100%; height: 100%; }
+.map-app { overflow: hidden; }
+.campus-map { background: #eae9df; }
+.campus-map > .mapboxgl-map { opacity: 0; transition: opacity 500ms var(--ease); }
+.campus-map.is-loaded > .mapboxgl-map { opacity: 1; }
+.map-loading { position: absolute; inset: 0; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; color: var(--muted); font-size: 14px; }
+.map-loading .primary-button { width: auto; min-width: 120px; }
+.map-loading-symbol { color: var(--accent); animation: loading-breathe 1.6s ease-in-out infinite; }
+.map-context { position: absolute; top: 28px; right: 28px; display: flex; align-items: center; gap: 8px; border: 1px solid oklch(89% 0.018 85 / 75%); border-radius: 24px; background: var(--surface); padding: 10px 16px; color: var(--muted); font-size: 11px; font-weight: 600; pointer-events: none; }
+.map-workspace { position: absolute; top: 24px; left: 24px; width: 360px; z-index: 10; display: flex; flex-direction: column; gap: 16px; max-height: calc(100dvh - 68px); pointer-events: none; animation: arrive 550ms var(--ease) both; }
+.map-workspace > * { pointer-events: auto; }
+.is-searching .map-workspace { z-index: 40; }
+.app-brand { display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: var(--surface); border: 1px solid var(--line); border-radius: 16px; box-shadow: var(--shadow); }
+.brand-mark { width: 42px; height: 42px; display: grid; place-items: center; background: var(--accent); color: var(--surface); border-radius: 13px; }
+.brand-name { display: block; font-size: 25px; font-weight: 700; line-height: 1.1; letter-spacing: -1px; }
+.brand-period { color: var(--accent); }
+.brand-subtitle { display: block; font-size: 10px; color: var(--muted); margin-top: 4px; letter-spacing: 0.03em; }
+.brand-byline { margin-left: auto; display: inline-flex; align-items: center; justify-content: center; gap: 3px; min-height: 44px; font-size: 10px; color: var(--muted); white-space: nowrap; }
+.brand-byline:hover { color: var(--accent); }
+.map-search-tools { position: relative; display: flex; flex-direction: column; gap: 10px; }
+.search-bar { position: relative; z-index: 3; }
+.search-field { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 4px 8px 4px 18px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); color: var(--accent); transition: border-color 180ms var(--ease), box-shadow 180ms var(--ease); }
+.search-field:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent) 12%, transparent), var(--shadow); }
+.search-field input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 15px; font-weight: 500; }
+.search-field input::placeholder { color: var(--muted); }
+.search-field input::-webkit-search-cancel-button { display: none; }
+kbd { display: inline-flex; gap: 4px; padding: 5px 7px; margin-right: 8px; color: var(--muted); font-family: inherit; font-size: 10px; border: 1px solid var(--line); border-radius: 5px; white-space: nowrap; }
+.icon-button { display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; flex-shrink: 0; border: 0; border-radius: 10px; background: transparent; color: var(--muted); }
+.icon-button:hover { background: var(--surface-soft); color: var(--ink); }
+.icon-button.is-active { color: var(--accent); background: var(--accent-soft); }
+.search-dropdown { position: absolute; left: 0; right: 0; top: calc(100% + 8px); background: var(--surface); border: 1px solid var(--line); border-radius: 16px; padding: 16px 8px 8px; box-shadow: var(--shadow); max-height: min(480px, 65dvh); overflow-y: auto; animation: arrive 220ms var(--ease); }
+.search-dropdown > .eyebrow { padding: 0 12px; }
+.search-dropdown [role="listbox"] { padding-top: 8px; }
+.eyebrow { display: flex; align-items: center; gap: 6px; font-size: 9px; line-height: 1.5; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); }
+.filter-strip { display: flex; align-items: center; gap: 6px; }
+.quick-filters { display: flex; align-items: center; gap: 6px; flex: 1; overflow-x: auto; padding: 4px 1px; scrollbar-width: none; }
+.quick-filters::-webkit-scrollbar { display: none; }
+.filter-chip { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; min-height: 38px; padding: 0 12px; border-radius: 20px; border: 1px solid var(--line); background: var(--surface); color: var(--muted); font-size: 11px; font-weight: 600; white-space: nowrap; }
+.filter-chip:hover { border-color: var(--faint); color: var(--ink); }
+.filter-chip.is-active { background: var(--ink); border-color: var(--ink); color: var(--surface); }
+.chip-count { display: grid; place-items: center; min-width: 16px; height: 16px; padding: 0 3px; background: var(--accent); color: var(--surface); border-radius: 8px; font-size: 9px; }
+.filter-more { width: 40px; background: var(--surface); border: 1px solid var(--line); box-shadow: 0 2px 5px oklch(28% 0.03 70 / 5%); }
+.discovery-panel, .category-panel { overflow: hidden; min-height: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 18px; box-shadow: var(--shadow); animation: arrive 350ms var(--ease) both; }
+.discovery-panel { overflow-y: auto; }
+.app-brand, .map-search-tools { flex-shrink: 0; }
+.discovery-intro { display: flex; align-items: center; min-height: 56px; padding: 12px 20px; cursor: pointer; list-style: none; }
+.discovery-intro::-webkit-details-marker { display: none; }
+.discovery-intro:hover { background: var(--surface-soft); }
+.discovery-chevron { color: var(--muted); transition: transform 240ms var(--ease); }
+.discovery-panel:not([open]) .discovery-chevron { transform: rotate(-90deg); }
+.discovery-title { display: grid; flex: 1; align-items: center; text-align: left; }
+.discovery-title > span { grid-area: 1 / 1; transition: opacity 150ms var(--ease), transform 150ms var(--ease), visibility 150ms; }
+.discovery-title-compact { display: inline-flex; align-items: center; gap: 8px; font-family: "Manrope", sans-serif; font-size: 14px; font-weight: 650; line-height: 24px; letter-spacing: 0; }
+.discovery-panel[open] .discovery-title-compact, .discovery-panel:not([open]) .discovery-title-expanded { opacity: 0; visibility: hidden; transform: translateY(-4px); }
+.discovery-panel[open] .discovery-title-expanded, .discovery-panel:not([open]) .discovery-title-compact { opacity: 1; visibility: visible; transform: translateY(0); }
+.popular-places .place-row { min-height: 52px; padding-top: 8px; padding-bottom: 8px; }
+.has-detail .discovery-panel, .is-searching .discovery-panel { display: none; }
+@supports (interpolate-size: allow-keywords) {
+ .discovery-panel::details-content { interpolate-size: allow-keywords; overflow: hidden; block-size: 0; transition: block-size 260ms var(--ease), content-visibility 260ms; transition-behavior: allow-discrete; }
+ .discovery-panel[open]::details-content { block-size: auto; }
+}
+.discovery-intro h1 { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 26px; font-weight: 600; letter-spacing: -1px; line-height: 1.15; }
+.popular-places { padding: 8px 12px 12px; }
+.place-row { width: 100%; display: flex; align-items: center; gap: 12px; padding: 12px; border: 0; border-radius: 10px; background: transparent; text-align: left; }
+.place-row:hover, .place-row.is-highlighted { background: var(--surface-soft); }
+.place-icon { display: grid; place-items: center; flex-shrink: 0; width: 36px; height: 40px; color: var(--muted); }
+.place-row-copy { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 4px; }
+.place-row-copy strong { font-size: 12px; line-height: 1.4; font-weight: 650; color: var(--ink); }
+.place-row-copy > span { font-size: 10px; color: var(--muted); }
+.row-arrow { color: var(--faint); transition: transform 200ms var(--ease), color 200ms var(--ease); }
+.place-row:hover .row-arrow { transform: translate(2px, -2px); color: var(--accent); }
+.discovery-footer { display: flex; align-items: center; gap: 8px; border-top: 1px solid var(--line); padding: 16px 24px; color: var(--muted); font-size: 9px; }
+.section-heading { display: flex; align-items: center; justify-content: space-between; padding: 20px 16px 16px 24px; gap: 10px; border-bottom: 1px solid var(--line); }
+.section-heading h2 { font-size: 21px; margin-top: 0; letter-spacing: -0.5px; }
+.category-panel { display: flex; flex-direction: column; }
+.category-list { display: flex; flex-direction: column; gap: var(--space-sm); overflow-y: auto; padding: 12px; overscroll-behavior: contain; }
+.category-row { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 46px; padding: 8px 12px; border: 0; border-radius: 9px; background: transparent; color: var(--muted); font-size: 12px; text-align: left; }
+.category-row:hover { background: var(--surface-soft); }
+.category-row.is-active { background: var(--accent-soft); color: var(--accent); }
+.category-row > span:nth-child(2) { flex: 1; }
+.category-count { font-size: 10px; font-variant-numeric: tabular-nums; }
+.category-check { display: grid; place-items: center; width: 18px; height: 18px; border: 1px solid var(--line); border-radius: 5px; }
+.category-check svg { animation: check-arrive 160ms var(--ease); }
+.category-actions { display: flex; align-items: center; gap: 16px; }
+.category-actions .text-button { min-height: 44px; }
+.is-active .category-check { background: var(--accent); border-color: var(--accent); color: var(--surface); }
+.category-footer { display: flex; align-items: center; justify-content: space-between; padding: 10px 24px; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; }
+.text-button { background: transparent; border: 0; color: var(--accent); font-size: inherit; font-weight: 700; padding: 12px 0; }
+.text-button:hover { text-decoration: underline; }
+.detail-panel { position: absolute; right: 88px; bottom: 32px; z-index: 20; display: flex; flex-direction: column; width: 380px; max-height: calc(100dvh - 112px); background: var(--surface); border: 1px solid var(--line); border-radius: 20px; overflow: hidden; box-shadow: var(--shadow); transition: transform 250ms var(--ease); }
+.detail-panel.is-entering { animation: panel-arrive 350ms var(--ease) backwards; }
+.detail-header, .detail-actions { flex-shrink: 0; }
+.detail-header { display: flex; align-items: flex-start; gap: 12px; padding: 24px 16px 20px 24px; }
+.detail-header > .icon-button { margin-top: -7px; margin-right: -4px; }
+.detail-category-icon { color: var(--accent); padding-top: 6px; }
+.detail-heading { flex: 1; min-width: 0; }
+.detail-heading h2 { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3; overflow: hidden; font-size: 23px; font-weight: 600; line-height: 1.2; letter-spacing: -0.5px; margin-top: 5px; overflow-wrap: anywhere; }
+.detail-content { overflow-y: auto; min-height: 0; flex: 1; padding: 0 24px 24px; overscroll-behavior: contain; animation: arrive 250ms var(--ease); }
+.detail-subtitle { font-size: 13px; color: var(--muted); margin-bottom: 16px; }
+.detail-description, .event-body { font-size: 13px; line-height: 1.8; color: var(--muted); overflow-wrap: anywhere; }
+.place-photo { display: block; width: 100%; aspect-ratio: 16/10; object-fit: cover; border-radius: 12px; margin-bottom: 20px; }
+.place-photo[hidden] { display: none; }
+.place-facts { display: flex; flex-direction: column; gap: 16px; margin-top: 20px; font-size: 12px; }
+.place-facts > div, .place-facts > a { display: flex; align-items: flex-start; gap: 12px; color: var(--muted); line-height: 1.6; overflow-wrap: anywhere; }
+.place-facts > a { color: var(--accent); min-height: 28px; }
+.place-facts svg { margin-top: 2px; }
+.accessible-note { color: var(--green) !important; }
+.detail-actions { padding: 16px 24px 20px; border-top: 1px solid var(--line); background: var(--surface); }
+.primary-button { width: 100%; min-height: 46px; display: flex; align-items: center; justify-content: center; gap: 9px; padding: 12px 20px; border: 1px solid transparent; border-radius: 11px; background: var(--accent); color: var(--surface); font-size: 13px; font-weight: 700; }
+.primary-button:hover { background: color-mix(in oklch, var(--accent) 85%, var(--ink)); }
+.meal-tabs { display: flex; gap: 4px; margin: 0 24px 20px; padding: 4px; border: 0; border-radius: 10px; background: var(--surface-soft); flex-shrink: 0; overflow-x: auto; }
+.meal-tabs button { flex: 1; padding: 10px 12px; min-height: 40px; border: 0; background: transparent; border-radius: 7px; color: var(--muted); font-size: 11px; font-weight: 600; }
+.meal-tabs button.is-active { background: var(--surface); color: var(--ink); box-shadow: 0 1px 4px oklch(28% 0.03 70 / 9%); }
+.menu-date { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 11px; padding: 2px 0 18px; }
+.menu-station { padding: 16px 0; border-top: 1px solid var(--line); }
+.menu-station h3 { color: var(--green); font-size: 12px; font-weight: 700; margin-bottom: 10px; }
+.menu-station ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
+.menu-station li { font-size: 13px; line-height: 1.5; color: var(--muted); }
+.event-title { margin-bottom: 12px; font-size: 19px; font-weight: 600; line-height: 1.25; letter-spacing: -0.3px; overflow-wrap: anywhere; }
+.event-meta { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 8px 16px; color: var(--muted); font-size: 11px; margin-bottom: 20px; }
+.event-meta > span { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); }
+.event-body { white-space: pre-line; }
+.event-photo { display: block; width: 100%; height: auto; border-radius: 10px; margin-top: 16px; }
+.event-source { border-top: 1px solid var(--line); margin-top: 24px; padding-top: 16px; font-size: 11px; color: var(--muted); }
+.event-source a { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; }
+.event-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; padding: 16px 8px; text-align: left; border: 0; border-top: 1px solid var(--line); border-radius: 0; background: transparent; }
+.event-row:hover { background: var(--surface-soft); }
+.event-row > span { display: flex; flex-direction: column; gap: 8px; }
+.event-row strong { color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1.5; }
+.event-row span span { color: var(--muted); font-size: 11px; }
+.event-row svg { color: var(--accent); }
+.empty-state { font-size: 13px; color: var(--muted); line-height: 1.7; padding: 16px 12px; }
+.detail-content > .empty-state { padding: 8px 0 16px; }
+.places-skeleton { padding: 16px 24px; display: flex; flex-direction: column; gap: 16px; }
+.places-skeleton span { height: 28px; border-radius: 6px; background: var(--surface-soft); animation: loading-breathe 1.6s ease-in-out infinite; }
+.places-skeleton span:nth-child(2) { width: 80%; animation-delay: 100ms; }
+.places-skeleton span:nth-child(3) { width: 65%; animation-delay: 200ms; }
+.sheet-handle, .sheet-handle-button { display: none; }
+.detail-panel.is-dragging { transition: none; }
+.directions-sheet { z-index: 30; }
+.directions-content { padding: 0 24px 24px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
+.route-summary { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; line-height: 1.6; margin-bottom: 20px; }
+.travel-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+.travel-mode { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); color: var(--muted); text-align: left; }
+.travel-mode > span { display: flex; flex-direction: column; gap: 4px; font-size: 10px; }
+.travel-mode strong { font-size: 16px; font-weight: 700; font-variant-numeric: tabular-nums; }
+.travel-mode.is-active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
+.travel-mode:hover:not(:disabled) { border-color: var(--accent); }
+.route-error > p { margin-top: 12px; font-size: 12px; line-height: 1.7; color: var(--muted); }
+.steps-toggle { display: none; }
+.steps-wrap { display: grid; grid-template-rows: 1fr; }
+.steps-inner { min-height: 0; overflow: hidden; }
+.steps-list { padding: 0; margin: 20px 0 0; list-style: none; max-height: 32dvh; overflow-y: auto; overscroll-behavior: contain; touch-action: pan-y; }
+.steps-list li { display: flex; align-items: baseline; gap: 10px; padding: 14px 0; border-top: 1px solid var(--line); font-size: 12px; line-height: 1.6; color: var(--muted); }
+.step-number { width: 20px; flex-shrink: 0; font-size: 10px; color: var(--accent); font-variant-numeric: tabular-nums; }
+.step-distance { margin-left: auto; flex-shrink: 0; color: var(--muted); font-size: 10px; font-variant-numeric: tabular-nums; }
+.loading-spinner { animation: spin 900ms linear infinite; }
+.map-marker { position: relative; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; background: transparent; color: var(--marker-color, var(--accent)); }
+.marker-disc { position: relative; display: grid; place-items: center; width: 29px; height: 29px; background: var(--surface); border: 1.5px solid color-mix(in oklch, currentColor 35%, var(--surface)); border-radius: 50%; box-shadow: 0 2px 5px oklch(28% 0.03 70 / 12%); transition: transform 200ms var(--ease), background-color 200ms var(--ease), box-shadow 200ms var(--ease); }
+.map-marker:hover .marker-disc, .map-marker:focus-visible .marker-disc { transform: translateY(-2px); box-shadow: 0 4px 12px oklch(28% 0.03 70 / 16%); }
+.map-marker[aria-pressed="true"] .marker-disc { background: currentColor; outline: 4px solid color-mix(in oklch, currentColor 18%, transparent); transform: scale(1.1); }
+.map-marker[aria-pressed="true"] svg { color: var(--surface); }
+.food-marker { color: var(--accent); }
+.food-marker .marker-disc { width: 34px; height: 34px; border-color: currentColor; }
+.dining-marker { color: var(--green); }
+.club-marker { color: #796249; }
+.marker-activity { position: absolute; top: -2px; right: -1px; width: 7px; height: 7px; border: 1.5px solid var(--surface); background: var(--accent); border-radius: 50%; }
+.marker-tooltip { position: absolute; bottom: 100%; left: 50%; transform: translate(-50%, 4px); opacity: 0; pointer-events: none; padding: 7px 10px; background: var(--ink); color: var(--surface); border-radius: 7px; font: 600 10px "Manrope", sans-serif; white-space: nowrap; transition: transform 180ms var(--ease), opacity 180ms var(--ease); }
+.map-marker:hover .marker-tooltip, .map-marker:focus-visible .marker-tooltip { opacity: 1; transform: translate(-50%, 0); }
+.mapboxgl-marker:has(.food-marker) { z-index: 1; }
+.mapboxgl-marker:has(.map-marker:hover), .mapboxgl-marker:has(.map-marker[aria-pressed="true"]) { z-index: 2; }
+.route-origin-dot { width: 16px; height: 16px; background: #4e6679; border: 3px solid var(--surface); border-radius: 50%; box-shadow: 0 0 0 5px #4e667925; }
+.route-destination { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 50%; background: var(--accent); color: var(--surface); border: 3px solid var(--surface); box-shadow: var(--shadow); }
+.mapboxgl-ctrl-bottom-right { right: 24px; bottom: 32px; }
+.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl { margin: 0 0 8px; }
+.mapboxgl-ctrl-group { border: 1px solid var(--line); background: var(--surface); box-shadow: var(--shadow) !important; border-radius: 12px; overflow: hidden; }
+.mapboxgl-ctrl-group button { width: 42px; height: 44px; }
+.mapboxgl-ctrl-group button + button { border-top: 1px solid var(--line); }
+.mapboxgl-ctrl-group button:hover { background: var(--surface-soft); }
+.mapboxgl-ctrl-group button:focus-visible { outline-offset: -4px; }
+.mapboxgl-ctrl-icon { opacity: 0.68; }
+.reset-map-control { position: absolute; right: 24px; bottom: 194px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); }
+.reset-map-control .icon-button { width: 42px; }
+.mapboxgl-ctrl-attrib { background: var(--surface) !important; color: var(--muted); font-size: 9px; }
+.mapboxgl-ctrl-logo { opacity: 0.65; }
+.t-icon-swap { display: inline-grid; }
+.t-icon-swap .t-icon { grid-area: 1 / 1; display: grid; place-items: center; transition: opacity 250ms ease-in-out, filter 250ms ease-in-out, transform 250ms ease-in-out; }
+.t-icon-swap[data-state="a"] .t-icon[data-icon="b"], .t-icon-swap[data-state="b"] .t-icon[data-icon="a"] { opacity: 0; filter: blur(2px); transform: scale(0.25); }
+@keyframes check-arrive { from { opacity: 0; transform: scale(0.65); } to { opacity: 1; transform: scale(1); } }
+@keyframes arrive { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
+@keyframes sheet-arrive { from { transform: translateY(100%); } to { transform: translateY(0); } }
+@keyframes panel-arrive { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
+@keyframes loading-breathe { 50% { opacity: 0.4; } }
+@keyframes spin { to { transform: rotate(360deg); } }
-@media (min-width: 640px) {
- .directions-sheet {
- width: 420px;
- margin-bottom: 1.5rem;
- border-radius: 1rem;
- box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.25);
- }
-}
+@media (max-height: 720px) and (min-width: 768px) {
+ .map-workspace { gap: 12px; top: 16px; }
-@keyframes sheet-up {
- from {
- transform: translateY(110%);
- }
- to {
- transform: translateY(0);
- }
+ .place-row { padding-top: 8px; padding-bottom: 8px; }
+ .discovery-footer { padding-top: 12px; padding-bottom: 12px; }
+}
+@media (min-width: 768px) and (max-width: 1050px) {
+ .map-workspace { width: 320px; left: 16px; }
+ .detail-panel { width: 340px; right: 76px; }
+ .filter-chip { padding: 0 8px; }
+ .map-context { right: 16px; }
+ .mapboxgl-ctrl-bottom-right, .reset-map-control { right: 16px; }
+}
+@media (width < 768px) {
+ .map-workspace { top: max(12px, env(safe-area-inset-top)); left: 12px; right: 12px; width: auto; height: calc(100dvh - max(12px, env(safe-area-inset-top)) - 12px); max-height: none; gap: 12px; }
+ .app-brand { padding: 9px 12px; border-radius: 14px; }
+ .brand-mark { width: 36px; height: 36px; border-radius: 11px; }
+ .brand-mark svg { width: 21px; }
+ .brand-name { font-size: 23px; }
+ .brand-subtitle { font-size: 9px; margin-top: 2px; }
+ .search-field { min-height: 54px; }
+ .search-field input { font-size: 16px; }
+ kbd { display: none; }
+ .map-search-tools { gap: 6px; }
+ .filter-chip { min-height: 40px; font-size: 12px; }
+ .filter-more { width: 44px; }
+ .map-context { display: none; }
+ .discovery-panel { position: absolute; left: 0; right: 0; bottom: calc(12px + env(safe-area-inset-bottom)); }
+ .discovery-intro { padding: 12px 18px; }
+ .discovery-intro h1 { font-size: 23px; }
+ .discovery-footer { display: none; }
+ .popular-places { display: flex; gap: var(--space-sm); overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scroll-padding-inline: 12px; padding: 0 12px 12px; scrollbar-width: none; }
+ .popular-places .place-row { flex: 0 0 auto; width: auto; scroll-snap-align: start; gap: 8px; padding: 8px 10px; }
+ .popular-places .place-icon { height: 32px; }
+ .place-row-copy strong { font-size: 13px; }
+ .place-row-copy > span { font-size: 11px; }
+ .place-icon { width: 32px; }
+ .category-panel { max-height: calc(100dvh - 228px); }
+ .category-row { min-height: 48px; font-size: 14px; }
+ .category-count { font-size: 12px; }
+ .category-footer { font-size: 13px; }
+ .section-heading h2 { font-size: 22px; }
+ .detail-panel { left: 0; right: 0; bottom: 0; width: 100%; max-height: min(65dvh, calc(100dvh - 220px)); border-radius: 22px 22px 0 0; border-bottom: 0; padding-bottom: env(safe-area-inset-bottom); }
+ .detail-panel.is-entering { animation: sheet-arrive 350ms var(--ease) backwards; }
+ .detail-panel.is-expanded { max-height: min(86dvh, calc(100dvh - 100px)); }
+ .detail-header { padding: 20px 16px 20px 24px; }
+ .detail-heading h2 { font-size: 25px; }
+ .detail-heading .eyebrow { font-size: 10px; }
+ .detail-content { font-size: 14px; }
+ .detail-description, .event-body, .menu-station li, .event-row strong { font-size: 14px; }
+ .place-facts, .detail-subtitle { font-size: 14px; }
+ .place-facts > a { min-height: 44px; }
+ .primary-button { min-height: 48px; font-size: 14px; }
+ .menu-date, .event-meta { font-size: 12px; }
+ .menu-station h3 { font-size: 14px; }
+ .meal-tabs button { font-size: 12px; min-height: 44px; }
+ .sheet-drag-area { touch-action: none; flex-shrink: 0; cursor: grab; user-select: none; }
+ .is-dragging .sheet-drag-area { cursor: grabbing; }
+ .sheet-handle-button { display: grid; place-items: center; width: 100%; height: 44px; border: 0; background: transparent; }
+ .sheet-handle-button .sheet-handle { display: block; width: 32px; height: 4px; background: var(--line); border-radius: 4px; }
+ .sheet-drag-area .detail-header { padding-top: 4px; }
+ .route-summary { font-size: 13px; }
+ .travel-mode > span { font-size: 12px; }
+ .travel-mode strong { font-size: 17px; }
+ .route-error > p { font-size: 13px; }
+ .steps-wrap { grid-template-rows: 0fr; visibility: hidden; }
+ .steps-wrap.steps-open { grid-template-rows: 1fr; visibility: visible; }
+ .steps-toggle { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 44px; border: 0; background: transparent; color: var(--muted); font-size: 13px; margin-top: 8px; }
+ .steps-toggle svg { transition: transform 200ms var(--ease); }
+ .steps-toggle svg.is-expanded { transform: rotate(180deg); }
+ .steps-list { max-height: 24dvh; margin-top: 4px; }
+ .steps-list li { font-size: 13px; }
+ .mapboxgl-ctrl-bottom-right { right: 12px; bottom: calc(176px + env(safe-area-inset-bottom)); }
+ .reset-map-control { right: 12px; bottom: calc(338px + env(safe-area-inset-bottom)); }
+ .has-detail .mapboxgl-ctrl-bottom-right { bottom: auto; top: 204px; display: flex; align-items: center; gap: 8px; }
+ .has-detail .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl-group { display: flex; }
+ .has-detail .mapboxgl-ctrl-group button + button { border-top: 0; border-left: 1px solid var(--line); }
+ .has-detail .reset-map-control { display: none; }
+ .mapboxgl-ctrl-attrib { bottom: 0; }
+}
+@media (width < 768px) and (max-height: 550px) {
+ .detail-heading h2 { font-size: 21px; -webkit-line-clamp: 2; line-clamp: 2; }
+ .app-brand { padding: 4px 8px; }
+ .brand-name { font-size: 20px; }
+ .brand-subtitle { display: none; }
+ .brand-mark { width: 28px; height: 28px; }
+ .brand-mark svg { width: 18px; }
+ .detail-panel { left: auto; right: 12px; bottom: 12px; width: 46%; max-height: calc(100dvh - 32px); border-radius: 16px; }
+ .detail-panel.is-expanded { max-height: calc(100dvh - 32px); }
+ .map-workspace { right: auto; width: 46%; }
+ .category-panel { max-height: calc(100dvh - 130px); }
+ .mapboxgl-ctrl-bottom-right { bottom: 16px; }
+ .reset-map-control { bottom: 178px; }
+ .has-detail .mapboxgl-ctrl-bottom-right { display: flex; left: 12px; right: auto; top: auto; bottom: 12px; }
}
-
@media (prefers-reduced-motion: reduce) {
- .directions-sheet {
- animation: none;
- }
-}
-
-/* User's position at the start of a route */
-.route-origin-dot {
- width: 15px;
- height: 15px;
- border-radius: 50%;
- background: #2563eb;
- border: 3px solid white;
- box-shadow: 0 1px 6px rgba(0, 0, 0, 0.35);
-}
-
-/* Clean up MapBox popup defaults */
-.mapboxgl-popup-content {
- padding: 0 !important;
- border-radius: 12px !important;
- box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.06) !important;
- overflow: hidden;
-}
-.mapboxgl-popup-close-button {
- font-size: 18px;
- padding: 4px 8px;
- color: #94a3b8;
-}
-.mapboxgl-popup-close-button:hover {
- color: #1e293b;
- background: transparent;
-}
-.mapboxgl-popup-tip {
- border-top-color: white !important;
-}
-
-/* ── Free food markers ────────────────────────────── */
-.freefood-marker-wrapper {
- display: flex;
- flex-direction: column;
- align-items: center;
- cursor: pointer;
- filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.25));
-}
-
-.freefood-marker {
- width: 36px;
- height: 36px;
- border-radius: 50%;
- background: linear-gradient(135deg, #f97316, #f59e0b);
- border: 2.5px solid white;
- display: flex;
- align-items: center;
- justify-content: center;
- animation: food-pulse 2.5s ease-in-out infinite;
- transition: transform 0.15s ease;
-}
-
-.freefood-marker-wrapper:hover .freefood-marker {
- transform: scale(1.2);
-}
-
-.freefood-marker-selected {
- transform: scale(1.2);
- box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.4);
- animation: none;
-}
-
-.freefood-marker-stem {
- width: 2px;
- height: 6px;
- background: white;
- border-radius: 0 0 1px 1px;
-}
-
-@keyframes food-pulse {
- 0%,
- 100% {
- box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.45);
- }
- 50% {
- box-shadow: 0 0 0 10px rgba(249, 115, 22, 0);
- }
-}
-
-/* ── Eating club markers ─────────────────────────── */
-.eating-club-marker {
- position: relative;
- width: 28px;
- height: 28px;
- border-radius: 50%;
- background: white;
- border: 2px solid #d4a574;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
- transition: transform 0.15s ease, border-color 0.15s ease;
-}
-
-.eating-club-marker:hover {
- transform: scale(1.15);
- border-color: #c4844a;
-}
-
-.eating-club-marker-selected {
- transform: scale(1.2);
- border-color: #c4844a;
- box-shadow: 0 0 0 3px rgba(212, 165, 116, 0.4);
-}
-
-.eating-club-badge {
- position: absolute;
- top: -4px;
- right: -4px;
- min-width: 16px;
- height: 16px;
- border-radius: 8px;
- background: #ef4444;
- color: white;
- font-size: 10px;
- font-weight: 700;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0 4px;
- line-height: 1;
-}
-
-/* ── Dining hall markers — residential (main dhalls) ── */
-.dining-marker-residential {
- width: 38px;
- height: 38px;
- border-radius: 10px;
- background: linear-gradient(135deg, #059669, #047857);
- border: 2.5px solid white;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.25));
- transition: transform 0.15s ease;
- position: relative;
-}
-
-.dining-marker-residential::after {
- content: "";
- position: absolute;
- bottom: -5px;
- left: 50%;
- transform: translateX(-50%);
- width: 0;
- height: 0;
- border-left: 5px solid transparent;
- border-right: 5px solid transparent;
- border-top: 5px solid white;
-}
-
-.dining-marker-residential:hover {
- transform: scale(1.15);
-}
-
-.dining-marker-residential.dining-marker-selected {
- transform: scale(1.15);
- box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.4);
-}
-
-/* ── Dining hall markers — retail (cafés) ──────────── */
-.dining-marker-retail {
- width: 26px;
- height: 26px;
- border-radius: 50%;
- background: white;
- border: 2px solid #059669;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.15));
- transition: transform 0.15s ease;
-}
-
-.dining-marker-retail:hover {
- transform: scale(1.15);
-}
-
-.dining-marker-retail.dining-marker-selected {
- transform: scale(1.15);
- border-color: #047857;
- box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.3);
+ *, *::before, *::after, *::details-content { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }
}
diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts
index af8fc69..03914fe 100644
--- a/apps/web/src/types.ts
+++ b/apps/web/src/types.ts
@@ -74,3 +74,10 @@ export interface FreefoodPost {
lat: number;
lng: number;
}
+
+export type Selection =
+ | { kind: "poi"; data: POI }
+ | { kind: "freefood"; data: FreefoodPost }
+ | { kind: "club"; data: EatingClub }
+ | { kind: "dining"; data: DiningHallMenu }
+ | null;
diff --git a/apps/web/src/utils/api.ts b/apps/web/src/utils/api.ts
new file mode 100644
index 0000000..77ab587
--- /dev/null
+++ b/apps/web/src/utils/api.ts
@@ -0,0 +1,15 @@
+export async function getJSON
(url: string, init?: RequestInit): Promise {
+ const response = await fetch(url, init);
+ if (!response.ok) throw new Error(`${url} returned ${response.status}`);
+ return response.json();
+}
+
+// image lists arrive as JSON strings from the mail feeds
+export function parseList(json: string | null | undefined): string[] {
+ try {
+ const parsed = JSON.parse(json || "[]");
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
diff --git a/apps/web/src/utils/categories.ts b/apps/web/src/utils/categories.ts
index 563f820..559b426 100644
--- a/apps/web/src/utils/categories.ts
+++ b/apps/web/src/utils/categories.ts
@@ -1,67 +1,77 @@
-const CATEGORY_COLORS: Record = {
- Building: "#4a7c59",
- University: "#4a7c59",
- "Academic Department": "#2563eb",
- "Administrative Department": "#6366f1",
- College: "#b91c1c",
- Dormitory: "#9333ea",
- Library: "#0891b2",
- "Dining Hall": "#ea580c",
- Cafe: "#d97706",
- Restaurant: "#dc2626",
- "Food Court": "#ea580c",
- "Convenience Store": "#65a30d",
- "Parking Lot": "#3b82f6",
- "Bus Stop": "#0ea5e9",
- "Train Station": "#0284c7",
- "Emergency Phone": "#dc2626",
- "Public Art": "#ec4899",
- "Events Venue": "#8b5cf6",
- Theater: "#a855f7",
- Gallery: "#d946ef",
- Museum: "#c026d3",
- "Sports Center": "#16a34a",
- "Fitness Center": "#22c55e",
- "Swimming Pool": "#06b6d4",
- Park: "#15803d",
- Entrance: "#64748b",
- Ramp: "#64748b",
- Steps: "#94a3b8",
- Information: "#0ea5e9",
- Shop: "#8b5cf6",
-};
+import {
+ BookOpen,
+ Building2,
+ Bus,
+ Coffee,
+ Dumbbell,
+ GraduationCap,
+ House,
+ Info,
+ Landmark,
+ type LucideIcon,
+ MapPin,
+ Palette,
+ ParkingCircle,
+ Phone,
+ Pizza,
+ Shield,
+ ShoppingBag,
+ TrainFront,
+ Trees,
+ Users,
+ Utensils,
+ Waves,
+} from "lucide-react";
-const CATEGORY_ICONS: Record = {
- Building: "🏛",
- University: "🏛",
- "Academic Department": "📚",
- "Administrative Department": "🏢",
- College: "🛡",
- Dormitory: "🏠",
- Library: "📖",
- "Dining Hall": "🍽",
- Cafe: "☕",
- Restaurant: "🍴",
- "Parking Lot": "P",
- "Bus Stop": "🚌",
- "Train Station": "🚂",
- "Emergency Phone": "📞",
- "Public Art": "🎨",
- "Events Venue": "🎭",
- Theater: "🎭",
- Gallery: "🖼",
- Museum: "🏛",
- "Sports Center": "⚽",
- "Fitness Center": "💪",
- Park: "🌳",
- Entrance: "🚪",
- Information: "ℹ",
+const CATEGORY_ICONS: Record = {
+ Building: Building2,
+ University: Landmark,
+ "Academic Department": GraduationCap,
+ "Administrative Department": Building2,
+ College: Shield,
+ Dormitory: House,
+ Library: BookOpen,
+ "Dining Hall": Utensils,
+ Cafe: Coffee,
+ Restaurant: Utensils,
+ "Parking Lot": ParkingCircle,
+ "Bus Stop": Bus,
+ "Train Station": TrainFront,
+ "Emergency Phone": Phone,
+ "Public Art": Palette,
+ Museum: Landmark,
+ "Events Venue": Users,
+ Theater: Users,
+ Athletics: Dumbbell,
+ "Sports Center": Dumbbell,
+ "Fitness Center": Dumbbell,
+ "Swimming Pool": Waves,
+ Park: Trees,
+ Shop: ShoppingBag,
+ Information: Info,
+ "@dining": Utensils,
+ "@freefood": Pizza,
+ "@clubs": Users,
};
+export const QUICK_CATEGORIES = [
+ { name: "@dining", label: "Dining" },
+ { name: "@freefood", label: "Free food" },
+ { name: "@clubs", label: "Clubs" },
+];
+
+export function getCategoryLabel(category: string): string {
+ return QUICK_CATEGORIES.find((c) => c.name === category)?.label ?? category;
+}
+
export function getCategoryColor(category: string | null | undefined): string {
- return CATEGORY_COLORS[category ?? ""] ?? "#64748b";
+ if (["@dining", "Dining Hall", "Cafe"].includes(category ?? "")) return "#47644b";
+ if (["@freefood", "Restaurant"].includes(category ?? "")) return "#ad4d20";
+ if (["@clubs", "College", "Dormitory"].includes(category ?? "")) return "#796249";
+ if (category === "Library") return "#4e6679";
+ return "#62695b";
}
-export function getCategoryIcon(category: string | null | undefined): string {
- return CATEGORY_ICONS[category ?? ""] ?? "•";
+export function getCategoryIcon(category: string | null | undefined): LucideIcon {
+ return CATEGORY_ICONS[category ?? ""] ?? MapPin;
}
diff --git a/apps/web/src/utils/mapStyle.ts b/apps/web/src/utils/mapStyle.ts
new file mode 100644
index 0000000..a19eacc
--- /dev/null
+++ b/apps/web/src/utils/mapStyle.ts
@@ -0,0 +1,100 @@
+import type { StyleSpecification } from "mapbox-gl";
+
+const FILL_COLORS: Record = {
+ water: "#b9d2d1",
+ "road-polygon": "#faf7ef",
+ "detail-polygon": "#d4decb",
+ "buildings-shadow": "rgba(70, 61, 48, 0.08)",
+ underpass: "#ddd5c7",
+};
+
+export function polishMapStyle(style: StyleSpecification): StyleSpecification {
+ for (const layer of style.layers) {
+ if (layer.type === "background") layer.paint = { "background-color": "#eae9df" };
+ if (layer.type === "fill") {
+ if (layer.id === "Constuction Fill") {
+ layer.paint = { "fill-color": "#e2dccb", "fill-opacity": 0.7 };
+ } else if (FILL_COLORS[layer.id]) {
+ layer.paint = { ...layer.paint, "fill-color": FILL_COLORS[layer.id] };
+ } else if (layer.id === "landuse copy") {
+ layer.paint = {
+ ...layer.paint,
+ "fill-color": [
+ "match",
+ ["get", "class"],
+ "grass",
+ "#dce5d0",
+ "pitch",
+ "#d1dec5",
+ "sand",
+ "#e8dfcb",
+ "#e6eadb",
+ ],
+ };
+ } else if (layer.id === "buildings") {
+ layer.paint = {
+ ...layer.paint,
+ "fill-color": [
+ "match",
+ ["get", "style_rank"],
+ [1],
+ "#e5cbb2",
+ [2],
+ "#d6cfc3",
+ [3],
+ "#e2ddd3",
+ "rgba(0,0,0,0)",
+ ],
+ };
+ }
+ }
+ if (layer.type === "line") {
+ const color =
+ layer.id.includes("buildings") || layer.id.includes("underpass")
+ ? "#b4aa99"
+ : layer.id.includes("road")
+ ? "#f7f3e9"
+ : "#b9c3ac";
+ layer.paint = { ...layer.paint, "line-color": color };
+ if (layer.id === "Inclines") layer.paint["line-opacity"] = 0.25;
+ }
+ if (layer.type === "symbol") {
+ if (
+ ["public-art", "princeton_line", "bus_stops", "place-label", "quarter-label"].includes(
+ layer.id,
+ )
+ ) {
+ layer.layout = { ...layer.layout, visibility: "none" };
+ continue;
+ }
+ layer.paint = {
+ ...layer.paint,
+ "text-color": layer.id === "greenspace_label" ? "#647459" : "#5c6055",
+ "text-halo-color": "#f5f3e9",
+ "text-halo-width": 1.2,
+ };
+ if (layer.id === "poi-label") {
+ layer.layout = {
+ ...layer.layout,
+ "icon-image": "",
+ "text-font": ["Open Sans Regular", "Arial Unicode MS Regular"],
+ "text-size": ["interpolate", ["linear"], ["zoom"], 14, 9, 16, 11, 19, 15],
+ "text-transform": "none",
+ "text-anchor": "center",
+ "text-justify": "center",
+ "text-field": ["get", "name"],
+ "text-padding": 4,
+ "text-max-width": 8,
+ };
+ } else if (["road-label", "greenspace_label", "waterway-label"].includes(layer.id)) {
+ layer.layout = {
+ ...layer.layout,
+ "text-size": ["interpolate", ["linear"], ["zoom"], 14, 8, 19, 13],
+ "text-font": ["Open Sans Regular", "Arial Unicode MS Regular"],
+ "text-letter-spacing": 0.06,
+ };
+ }
+ }
+ }
+ return style;
+}
diff --git a/apps/web/tests/mapStyle.test.ts b/apps/web/tests/mapStyle.test.ts
new file mode 100644
index 0000000..b2eb84a
--- /dev/null
+++ b/apps/web/tests/mapStyle.test.ts
@@ -0,0 +1,43 @@
+import { expect, test } from "bun:test";
+import type { StyleSpecification } from "mapbox-gl";
+import spec from "mapbox-gl/dist/style-spec/index.cjs";
+import { polishMapStyle } from "../src/utils/mapStyle";
+
+test("campus restyling stays valid and preserves map data and label visibility rules", () => {
+ const style: StyleSpecification = {
+ version: 8,
+ glyphs: "mapbox://fonts/mapbox/{fontstack}/{range}.pbf",
+ sources: { campus: { type: "geojson", data: { type: "FeatureCollection", features: [] } } },
+ layers: [
+ { id: "background", type: "background" },
+ { id: "buildings", type: "fill", source: "campus" },
+ { id: "road-polygon", type: "fill", source: "campus" },
+ {
+ id: "Constuction Fill",
+ type: "fill",
+ source: "campus",
+ paint: { "fill-pattern": "construction" },
+ },
+ {
+ id: "poi-label",
+ type: "symbol",
+ source: "campus",
+ minzoom: 14,
+ filter: ["==", ["get", "style_rank"], 1],
+ paint: { "text-opacity": ["step", ["zoom"], 0, 16, 1] },
+ layout: { "icon-image": "landmark", "text-field": ["get", "name"] },
+ },
+ { id: "public-art", type: "symbol", source: "campus" },
+ ],
+ };
+ const sources = structuredClone(style.sources);
+ const filter = structuredClone(style.layers[4].filter);
+ const result = polishMapStyle(style);
+ expect(spec.validate(result).map((error: { message: string }) => error.message)).toEqual([]);
+ expect(result.sources).toEqual(sources);
+ expect(result.layers[4].filter).toEqual(filter);
+ expect(result.layers[4].minzoom).toBe(14);
+ expect(result.layers[4].paint?.["text-opacity"]).toEqual(["step", ["zoom"], 0, 16, 1]);
+ expect(result.layers[3].paint).not.toHaveProperty("fill-pattern");
+ expect(result.layers[5].layout?.visibility).toBe("none");
+});
diff --git a/apps/web/tests/mobileSheets.browser.js b/apps/web/tests/mobileSheets.browser.js
new file mode 100644
index 0000000..ed93231
--- /dev/null
+++ b/apps/web/tests/mobileSheets.browser.js
@@ -0,0 +1,91 @@
+// Run with agent-browser eval --stdin in an open mobile detail or directions sheet
+(async () => {
+ const panel = document.querySelector(".detail-panel");
+ const handle = panel?.querySelector(".sheet-handle-button");
+ if (!panel || !handle || !matchMedia("(width < 768px)").matches)
+ throw new Error("Open a sheet at a mobile viewport first");
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+ const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
+ const assert = (condition, message) => {
+ if (!condition) throw new Error(message);
+ };
+ const offset = () => {
+ const transform = getComputedStyle(panel).transform;
+ return transform === "none" ? 0 : new DOMMatrixReadOnly(transform).m42;
+ };
+ const expanded = () => handle.getAttribute("aria-expanded") === "true";
+ const pointer = (type, y) =>
+ handle.dispatchEvent(
+ new PointerEvent(type, {
+ bubbles: true,
+ pointerId: 12,
+ pointerType: "touch",
+ isPrimary: true,
+ button: 0,
+ clientY: y,
+ }),
+ );
+ // Synthetic pointers cannot acquire native capture
+ const capture = ["setPointerCapture", "hasPointerCapture", "releasePointerCapture"];
+ const original = capture.map((name) => Element.prototype[name]);
+ for (const name of capture) Element.prototype[name] = () => false;
+ const swipe = async (delta, cancel = false) => {
+ pointer("pointerdown", 400);
+ pointer("pointermove", 400 + delta);
+ await frame();
+ assert(
+ Math.abs(offset() - (delta > 0 ? delta : delta * 0.2)) < 2,
+ "Sheet must follow the finger",
+ );
+ pointer(cancel ? "pointercancel" : "pointerup", 400 + delta);
+ handle.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
+ await frame();
+ };
+ try {
+ await wait(400);
+ if (expanded()) {
+ handle.click();
+ await wait(320);
+ }
+ const collapsedHeight = panel.getBoundingClientRect().height;
+ await swipe(-80, true);
+ assert(!expanded(), "Cancelled gesture must preserve expansion");
+ await wait(320);
+ assert(Math.abs(offset()) < 1, "Cancelled gesture must return to rest");
+ await swipe(-80);
+ assert(expanded(), "Swipe up must expand without a duplicate click");
+ await wait(320);
+ assert(
+ panel.getBoundingClientRect().height >= collapsedHeight - 1,
+ "Expansion must not shrink the sheet",
+ );
+ if (panel.matches(".directions-sheet") && panel.querySelector(".steps-wrap"))
+ assert(
+ panel.querySelector(".steps-wrap.steps-open"),
+ "Directions expansion must reveal route steps",
+ );
+ await swipe(80);
+ assert(
+ !expanded() && panel.isConnected,
+ "Short downward swipe must collapse an expanded sheet",
+ );
+ await wait(320);
+ handle.click();
+ await frame();
+ assert(expanded(), "Handle must support keyboard activation");
+ handle.click();
+ await wait(320);
+ await swipe(24);
+ assert(!expanded() && panel.isConnected, "Short drag must not dismiss or toggle the sheet");
+ await wait(320);
+ assert(Math.abs(offset()) < 1, "Short drag must snap back without replaying entrance");
+ await swipe(80);
+ await wait(280);
+ assert(!panel.isConnected, "Downward swipe must dismiss a collapsed sheet");
+ return { passed: true, directions: panel.matches(".directions-sheet") };
+ } finally {
+ capture.forEach((name, index) => {
+ Element.prototype[name] = original[index];
+ });
+ }
+})();
diff --git a/apps/web/tests/panelResize.browser.js b/apps/web/tests/panelResize.browser.js
new file mode 100644
index 0000000..b223430
--- /dev/null
+++ b/apps/web/tests/panelResize.browser.js
@@ -0,0 +1,24 @@
+// Run with agent-browser eval --stdin after opening a place detail panel
+(async () => {
+ const panel = document.querySelector(".detail-panel");
+ const content = panel?.querySelector(".detail-content");
+ if (!panel || !content) throw new Error("Open a place detail panel first");
+ const originalMaxHeight = content.style.maxHeight;
+ const before = panel.getBoundingClientRect().height;
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ try {
+ content.style.maxHeight = "24px";
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ const resize = panel
+ .getAnimations()
+ .find((animation) => animation.effect.getKeyframes().some((frame) => frame.height));
+ if (reduced && resize) throw new Error("Panel resize ignores reduced motion");
+ if (!reduced && !resize) throw new Error("Panel content resized without a transition");
+ if (resize) await resize.finished;
+ const after = panel.getBoundingClientRect().height;
+ if (after >= before) throw new Error("Panel did not adapt to the shorter content");
+ return { passed: true, reducedMotion: reduced, before, after };
+ } finally {
+ content.style.maxHeight = originalMaxHeight;
+ }
+})();
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 3d71ef6..a5924c1 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -7,7 +7,10 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
- "/api": "http://localhost:3001",
+ "/api": {
+ target: process.env.TIGERMAP_API_PROXY || "http://localhost:3001",
+ changeOrigin: true,
+ },
},
},
});