Skip to content

ui(polish): emoji-free + no login + Storyboard redesign + Reel multi-size + map geocoding#8

Merged
hoainho merged 8 commits into
mainfrom
ui-polish-mobile-first
Jun 15, 2026
Merged

ui(polish): emoji-free + no login + Storyboard redesign + Reel multi-size + map geocoding#8
hoainho merged 8 commits into
mainfrom
ui-polish-mobile-first

Conversation

@hoainho

@hoainho hoainho commented May 28, 2026

Copy link
Copy Markdown
Owner

Full UI polish sweep per user direction.

What changed

1. Remove all emoji from rendered UI

  • Added 16 new stroke SVG icons to components/icons.tsx (Home, Cog, User, Film, LayoutList, LayoutGrid, Smartphone, Wand, Feather, LogIn, Route, Mic, Record, Mail, Sprout, Tree, Package, Trash, Languages).
  • Patched 14 components — zero emoji remain in TSX render paths.
  • Verified via Python unicode range scan.

2. Remove 'Đăng nhập' / login flow entirely

  • MoodTrip is now fast-food / fast-use per user direction.
  • AuthModal removed from App.tsx (imports, state, render).
  • MigrationBanner removed (Supabase migration no longer needed).
  • Top-right nav: 3 icon buttons (Về quê / Thế giới / Cài đặt), each unconditional.
  • PublicShareButton refactored to use local hash-based generateShareUrl() (no auth needed).
  • SharedTripView's 'Đăng nhập để remix' replaced with direct local fork.
  • All persistence routes through localStorage.

3. Storyboard view redesign

User: 'design color and layout bad, make user hard to read, colors are inconsistent, distracting, and lack clear highlights.'

  • Replaced 4-different-gradient-per-card with single coherent palette: white card + 3px colored LEFT RAIL per time-of-day.
  • Time-of-day uses icon (Coffee/Sun/CloudSun/Moon) instead of competing gradient backdrop.
  • Clear one-scan hierarchy: badge+time | activity title | trending reason | venue | cost.
  • Reduced motion timings.

4. Trip Reel polish + multi-format

User: 'size of reel better, polish it sparkling, glittering, and professional. and resize it to fit with Facebook, Tiktok size.'

  • 3 canonical sizes via tab switcher:
    • Reels / Story 1080×1920 (TikTok · IG Reels · FB Story)
    • Feed Portrait 1080×1350 (IG Feed · FB Feed)
    • Square 1080×1080 (IG Feed · FB Post)
  • Sparkle particles (seeded, density scales with canvas area) — gives the sparkling/glittering feel.
  • textGlow SVG filter on destination heading.
  • Vignette gradient for focus.
  • 6 deterministic palettes keyed by destination hash.
  • PNG export added (canvas-based raster) in addition to SVG.
  • Body scroll lock + Escape-to-close + focus trap.

5. TripMap data fix

User: 'About Bản Đồ Hành Trình I dont see any data, need to make sure we have data to show and guide user how to road.'

  • Root cause: real Gemini responses embed venue names in google_maps_link query strings without coordinates → regex finds nothing → empty state.
  • New services/geocoder.ts: Nominatim (OSM) public geocoder with 30-day localStorage cache, 1.1s rate-limit, countrycodes=vn bias.
  • TripMap falls back to geocoder when google_maps_link has no coords.
  • UI: progress pill 'Đang tìm vị trí · N/total' during geocoding.
  • DAY-COLORED markers (7-color palette), numbered by order within day.
  • ROUTE LINES drawn between activities of the same day (dashed, per-day color) — visual 'road' guide.
  • Empty state differentiates 'no venues' vs 'geocoding failed'.
  • Added 'Chỉ đường' (Directions) link in selected venue popup.

Infrastructure

  • New hooks/useBodyScrollLock.ts (scrollbar-width-aware).
  • New hooks/useEscapeKey.ts.
  • index.css: .pb-safe-plus-4, .bottom-safe, .touch-target (44px WCAG), .sr-only, body.modal-open, :focus-visible ring.

Verification

  • npx tsc --noEmit — zero errors.
  • Container has no working browser (aarch64 vs x86_64 mismatch + missing libs). Visual verification required on user's Mac.

How to test

cd ~/Documents/personal/moodtripV2
git fetch
git checkout ui-polish-mobile-first
npm install
npm run dev

Walk: Hero → Khám phá → Manual → submit → Result. Try the Reel modal, tab through formats, download PNG. Check map renders markers + route lines.

…ize + map geocoding

This is the user-requested UI polish sweep. Five concrete improvements:

1. REMOVE ALL EMOJI from rendered UI; replace with stroke SVG icons.
   - Added 16 new icons to components/icons.tsx (Home, Cog, User, Film,
     LayoutList, LayoutGrid, Smartphone, Wand, Feather, LogIn, Route,
     Mic, Record, Mail, Sprout, Tree, Package, Trash, Languages).
   - Patched: App.tsx (top nav), TripHeroBanner (Reel CTA),
     TripViewModeToggle (3 mode pills), CardPullOnboarding (Rút bài
     button), PWAInstallPrompt (install card), SongDiRecorder (mic +
     record dot), PersonalWorldBadge + PersonalWorldScene (tree/sprout),
     DataPortabilityPanel (package + trash + check), RegionDialectSelector
     (languages icon), SundayDreamBanner (coffee + fire), TripReelModal
     (copy/check/sparkle/download), SharedTripView (wand/check).
   - Zero emoji remain in TSX render paths (verified via Python
     unicode range scan).

2. REMOVE 'Đăng nhập' / LOGIN entirely. MoodTrip is fast-food / fast-use
   per user direction.
   - Deleted AuthModal usage from App.tsx (imports, state,
     authModalOpen, setAuthModalOpen, render block).
   - Removed MigrationBanner from App.tsx (it migrates LocalStorage →
     Supabase which is now never needed).
   - Top-right nav: 3 icon buttons (Về quê / Thế giới / Cài đặt), each
     unconditional — Cài đặt opens DataPortabilityPanel directly instead
     of redirecting to login.
   - PublicShareButton: refactored to use the existing local
     generateShareUrl() (hash-based URL share, no Supabase RLS needed).
     Removed onRequestSignIn prop entirely.
   - SharedTripView: refactored handleFork to a local clone (no
     forkTrip() Supabase call); no auth gate.
   - Auth-gated branches in App.tsx (lastFormData persist, savePreferences,
     saveTrip) remain but stay dormant since user is always null —
     services already handle null user gracefully.
   - All persistence flows now route through localStorage exclusively
     (matches user's 'save all to local machine' requirement).

3. STORYBOARD VIEW REDESIGN. User reported colors inconsistent,
   distracting, no clear highlights, hard to read.
   - Replaced the 4-different-gradient-per-card approach (amber/sky/
     violet/indigo competing for attention) with a single coherent
     palette: white card on dark bg + a 3px colored LEFT RAIL signaling
     time-of-day.
   - Time-of-day uses an icon (IconCoffee morning / IconSun noon /
     IconCloudSun afternoon / IconMoon evening) instead of a colored
     gradient backdrop that competed with content.
   - Clear hierarchy: top row = time-of-day badge + clock + time
     (+ Trending chip right-aligned), then activity title, then optional
     trending reason, then venue, then cost. One scan path top-to-bottom.
   - Trending reason moved into its own row with IconSparkles instead of
     being inline with title.
   - Reduced motion + delay (24px→16px enter, 0.55s→0.45s, 0.1s→0.08s
     stagger).

4. TRIP REEL POLISH + MULTI-FORMAT for FB/TikTok/IG.
   - Added FORMATS object with 3 canonical sizes: story 1080x1920 (9:16
     TikTok/IG Reels/FB Story), portrait 1080x1350 (4:5 FB/IG Feed),
     square 1080x1080 (1:1 FB Post/IG Feed). Tabs at top of modal to
     switch between them; preview updates live.
   - Added SPARKLE PARTICLES (per-format seeded for stability): random
     small white circles + larger 8-point sparkle stars layered between
     bg gradients and content. Density scales with canvas area.
   - Added textGlow SVG filter for the destination heading (gaussian
     blur + merge — gives the sparkle/glittering feel user asked for).
   - Added vignette gradient (glow3) at low opacity for professional
     focus pull toward content.
   - Color palette: 6 deterministic palettes keyed by destination name
     hash (each has c1+c2+c3 gradient + accent + ambient glow tint).
   - Added DOWNLOAD PNG (canvas-based raster export) in addition to SVG.
     Filename includes size: moodtrip-da-lat-story-1080x1920.png.
   - Added body scroll lock + Escape-to-close + focus trap via shared
     hooks/useBodyScrollLock + hooks/useEscapeKey.
   - Replaced all emoji (📋 → IconCopy, ✓ → IconCheck, ✨ → IconSparkles).
   - Removed '✨' from 'Tạo bởi Mơ' SVG signature; replaced with a
     subtle layout glow.

5. TRIPMAP DATA FIX — markers were missing because real Gemini responses
   embed venue names in google_maps_link query strings (no coordinates).
   - New services/geocoder.ts: Nominatim (OSM) public geocoder with
     30-day localStorage cache, 1.1s rate-limit per Nominatim TOS,
     countrycodes=vn bias for Vietnamese place names.
   - TripMap.tsx: tries parseLatLng() first (free, fast); for venues
     missing coords, falls back to geocodeBatch() (sequential, polite).
   - UI feedback during geocoding: 'Đang tìm vị trí địa điểm…'
     spinner with N/total progress pill in top-left.
   - Empty-state messaging improved: differentiates 'no venues at all'
     vs 'venues exist but geocoding failed'.
   - DAY-COLORED markers (7-color palette): each day gets its own
     consistent color, numbered by order within the day.
   - ROUTE LINES drawn between activities of the same day (dashed
     line, per-day color, opacity 0.75) — gives user the visual 'road'
     they asked for.
   - Selected venue popup gains a 'Chỉ đường' (Directions) link to
     OSM directions in addition to Google Maps and TikTok.

INFRASTRUCTURE:
- New hooks/useBodyScrollLock.ts (scrollbar-width-aware, restores prev
  overflow and padding-right on unmount).
- New hooks/useEscapeKey.ts.
- index.css additions: .pb-safe-plus-4, .bottom-safe, .touch-target
  (44px WCAG minimum), .sr-only, body.modal-open scroll lock helper,
  :focus-visible ring (modern browsers only, no fallback bug).

TYPECHECK: clean (npx tsc --noEmit zero errors).

BROWSER-VERIFIED: container has no working browser — patches reach disk
typeclean. User to pull on Mac and visual-verify.
@vercel

vercel Bot commented May 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
moodtrip Ready Ready Preview, Comment Jun 15, 2026 9:11am

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request enhances the application's mapping capabilities by introducing a batch geocoding service (services/geocoder.ts) that resolves venue coordinates and displays routes on TripMap.tsx. It also significantly improves accessibility and UI consistency across multiple components by replacing emojis with SVG icons, ensuring WCAG-compliant touch targets, and introducing hooks for modal scroll locking and escape key handling. The review feedback highlights critical improvement opportunities, including adding robust error handling to the geocoding useEffect in TripMap.tsx to prevent loading hangs, catching potential promise rejections during PNG downloads in TripReelModal.tsx, and optimizing the geocoder cache to avoid redundant disk I/O operations.

Comment thread components/TripMap.tsx
Comment on lines +37 to +62
(async () => {
const initial = resolveVenues(itinerary);
setVenues(initial);

const missing = initial.filter((v) => v.lat == null || v.lng == null);
if (missing.length === 0) return;

setGeocoding(true);
setGeocodeProgress({ done: 0, total: missing.length });
const lookups = missing.map((v) => ({ venue: v.name, destination: itinerary.destination }));
const results = await geocodeBatch(lookups, (done, total) => {
if (!cancelled) setGeocodeProgress({ done, total });
});
if (cancelled) return;

const enriched = initial.map((v) => {
if (v.lat != null && v.lng != null) return v;
const key = `${v.name.toLowerCase().trim()}|${itinerary.destination.toLowerCase().trim()}`;
const hit = results.get(key);
if (hit) return { ...v, lat: hit.lat, lng: hit.lng };
return v;
});
setVenues(enriched);
setGeocoding(false);
setGeocodeProgress(null);
})();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The asynchronous geocoding process inside the useEffect hook lacks error handling. If geocodeBatch or any other operation throws an error, the geocoding state will remain true and geocodeProgress will not be cleared, leaving the UI stuck in a permanent loading state. Wrapping the asynchronous operations in a try...catch...finally block ensures that the loading states are always reset correctly.

    (async () => {
      try {
        const initial = resolveVenues(itinerary);
        setVenues(initial);

        const missing = initial.filter((v) => v.lat == null || v.lng == null);
        if (missing.length === 0) return;

        setGeocoding(true);
        setGeocodeProgress({ done: 0, total: missing.length });
        const lookups = missing.map((v) => ({ venue: v.name, destination: itinerary.destination }));
        const results = await geocodeBatch(lookups, (done, total) => {
          if (!cancelled) setGeocodeProgress({ done, total });
        });
        if (cancelled) return;

        const enriched = initial.map((v) => {
          if (v.lat != null && v.lng != null) return v;
          const key = `${v.name.toLowerCase().trim()}|${itinerary.destination.toLowerCase().trim()}`;
          const hit = results.get(key);
          if (hit) return { ...v, lat: hit.lat, lng: hit.lng };
          return v;
        });
        setVenues(enriched);
      } catch (err) {
        console.error('[TripMap] Geocoding failed:', err);
      } finally {
        if (!cancelled) {
          setGeocoding(false);
          setGeocodeProgress(null);
        }
      }
    })();

Comment on lines +353 to +356
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('Image load failed'));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The promise wrapping the image loading does not have a try...catch block around its await call in handleDownloadPng. If the image fails to load (triggering img.onerror), the promise will reject, causing an unhandled promise rejection. Wrap the await in a try...catch block to handle the error gracefully.

Suggested change
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('Image load failed'));
});
try {
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('Image load failed'));
});
} catch (err) {
console.error('[ReelModal] Failed to load SVG image for PNG conversion:', err);
return;
}

Comment thread services/geocoder.ts
Comment on lines +21 to +37
function readCache(): Record<string, CacheEntry> {
try {
const raw = localStorage.getItem(CACHE_LS_KEY);
if (!raw) return {};
return JSON.parse(raw) as Record<string, CacheEntry>;
} catch {
return {};
}
}

function writeCache(cache: Record<string, CacheEntry>): void {
try {
localStorage.setItem(CACHE_LS_KEY, JSON.stringify(cache));
} catch (err) {
console.warn('[geocoder] cache write failed', err);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In geocodeBatch, geocode is called sequentially for each item. Since geocode calls readCache and writeCache on every invocation, this results in redundant localStorage.getItem / JSON.parse and localStorage.setItem / JSON.stringify operations on every single geocoding request.

Using an in-memory cache variable to read once at the start and write back to localStorage only when updates occur will significantly improve performance and reduce disk I/O.

let memoryCache: Record<string, CacheEntry> | null = null;

function getCache(): Record<string, CacheEntry> {
  if (!memoryCache) {
    try {
      const raw = localStorage.getItem(CACHE_LS_KEY);
      memoryCache = raw ? (JSON.parse(raw) as Record<string, CacheEntry>) : {};
    } catch {
      memoryCache = {};
    }
  }
  return memoryCache;
}

function writeCache(cache: Record<string, CacheEntry>): void {
  memoryCache = cache;
  try {
    localStorage.setItem(CACHE_LS_KEY, JSON.stringify(cache));
  } catch (err) {
    console.warn('[geocoder] cache write failed', err);
  }
}

…t wrapping

USER BUGS FIXED:
1. 'M\u01a1 ch\u01b0a \u0111\u1ecbnh v\u1ecb \u0111\u01b0\u1ee3c 12 \u0111\u1ecba \u0111i\u1ec3m' \u2014 all venues failed to geocode.
2. 'title over long make Spilled out frame... text in reel is cut off and overlap'.

ROOT CAUSE OF GEOCODER FAILURE (verified via direct API probes):
Nominatim's structured parser returns [] when the query has 4 comma-
separated tokens. Real Gemini destination is 'Đà Lạt, Việt Nam', so
the code built 'Cafe Tùng, Đà Lạt, Việt Nam, Vietnam' which Nominatim
interprets as housenumber+street+city+country and finds nothing.
Even simpler venues like 'Lẩu gà lá é Tao Ngộ' fail with the same
4-token shape.

Direct curl confirmed: Nominatim returns [] for the actual query
shape we sent, but Photon (photon.komoot.io, same OSM data, better
fuzzy matching) resolves the same venues correctly.

GEOCODER REWRITE (services/geocoder.ts):
- Primary: Photon (photon.komoot.io/api). Same OSM source, fuzzy
  matching, no User-Agent requirement, CORS-friendly, no hard rate
  limit beyond fair-use. Wins on every test case.
- Fallback: Nominatim with cleaned query (strip ', Việt Nam' /
  'Vietnam' / 'VN' country noise from destination).
- Multiple query candidates per venue ('venue dest', 'venue, dest',
  'venue alone'). Tries each through both providers.
- New geocodeDestination(): always geocodes the destination city
  FIRST as a guaranteed map center.
- jitterAround(): golden-angle spiral placement around a center for
  fallback markers.
- Concurrency: 4 parallel workers via geocodeBatch (Photon has no
  hard rate limit; Nominatim still 1.1s when it's reached).
- Cache bumped to v2 (clears stale failed lookups from v1).

TRIPMAP STRATEGY (components/TripMap.tsx):
- Run geocodeDestination + geocodeBatch in parallel via Promise.all.
- If a venue can't be geocoded after both providers, place it at
  destination-center + per-index jitter (golden-angle, 5-7m radius),
  marked .approximate=true.
- Approximate markers: dashed white border + 72% opacity (vs solid
  border + 100% for exact).
- New chip in top-right: 'N chính xác · M ước lượng'.
- New amber info pill explains the dashed-border marker style.
- Selected venue popup: amber-tinted notice when approximate.
- Empty-state copy updated for the rare case where even destination
  geocoding fails (network down) — now mentions 'kết nối tới dịch
  vụ bản đồ bị gián đoạn' instead of blaming Mơ.

VENUE RESOLVER:
- ResolvedVenue.approximate?: boolean added.

REEL TEXT OVERFLOW FIX (components/TripReelModal.tsx):
- New estimateTextWidth() — heuristic per-char width for Inter and
  Georgia separately, accounts for Vietnamese diacritic chars (~1.05x).
- New wrapText() — word-boundary wrapping with pixel-width measure +
  maxLines + ellipsis on the last line.
- New fitTextToBox() — for the destination heading: decreases font
  size in 4px steps until ALL wrapped lines fit the target width.
- New renderTspans() — emits <tspan x dy> per line.
- ALL three layouts (Story 1080x1920, Portrait 1080x1350, Square
  1080x1080) now use:
  - Destination heading: wraps to max 2-3 lines + auto-shrinks.
    Story: 88-170px range. Portrait: 72-130px range. Square: 60-120px range.
  - Highlight title: wraps to 2 lines max with ellipsis. No more
    arbitrary char-count truncation.
  - Highlight venue: wraps to 1 line with ellipsis.
  - Highlight CARD HEIGHT now variable: computed from wrapped line
    counts. No more text overflow into the next card.
- Cost cell font-size now tier-based (>14 chars → 28-32, >10 → 32-38,
  else 36-40) instead of binary.

VERIFICATION:
- npx tsc --noEmit clean.
- Geocoder verified via direct curl probes against Photon AND Nominatim:
  Photon resolves Cafe Tùng, Lẩu gà lá é Tao Ngộ, Vườn dâu Biofresh
  cases that Nominatim returns [] for.
- Reel text wrapping verified by reading wrapText() output for
  Vietnamese venues like 'Cà phê sáng bên Hồ Xuân Hương' against
  the highlight title fontSize/width budget.

NOT VERIFIED IN CONTAINER:
- Live browser rendering (no working browser in this env). User
  to pull on Mac.
…ouble-escape

USER BUG: 'title over long make Spilled out frame... text in reel is
cut off and overlap'. Previous heuristic-only measurement underestimated
Vietnamese diacritic width.

ROOT CAUSES:
1. estimateTextWidth used a static character-width heuristic that did
   not match what the SVG renderer actually drew. For Vietnamese chars
   the heuristic was 5-15% too low → 'fits' returned true for lines
   that overflowed visually.
2. dest = escapeXml(itinerary.destination) was passed into fitTextToBox.
   '&' chars in destinations expand to '&amp;' which the measurer
   counts as 5 chars even though only 1 char renders → fit calculation
   used wrong width.
3. Highlight cards added cursorY += cardH unconditionally. With 4 cards
   that produce wrapped titles, total height exceeded the available
   space and the last card overlapped the footer or the next layout
   block.

FIXES:
- Replaced static-only estimateTextWidth with a measurement that uses
  HTML Canvas measureText() when running in the browser. The same font
  family ('Inter, Be Vietnam Pro, system-ui, sans-serif') is loaded
  in canvas as in SVG, so the measurement matches what gets rendered.
  Falls back to a tightened heuristic (vietnamese 0.62, upper 0.72)
  when no DOM is available (Node test).
- Added SAFETY_MARGIN = 0.92: widthFits() returns true only if the
  measured width is at most 92% of the available box. Absorbs sub-pixel
  drift and font-loading variance.
- Binary-search truncateToWidth (was linear): finds the largest
  prefix that fits with an ellipsis. Faster + more precise than the
  previous one-char-at-a-time loop.
- fitTextToBox step size 4 → 2 (finer-grained shrinking).
- Word that itself overflows the line width: now truncates as a single
  line rather than producing an empty entry then trying to fit the
  whole thing.
- Final fallback in fitTextToBox: even at minFontSize, every line is
  truncated to fit. No more leaked overflow.
- Stopped pre-escaping dest before passing to layout. escapeXml now
  happens only at the final tspan emission (renderTspans + the inline
  template literals). The wrapper measures the visible text.
- Each layout (Story/Portrait/Square) now computes a HIGHLIGHTS_BUDGET
  (the vertical pixel range available between the ✦ ĐIỂM NHẤN header
  and the 'Tạo bởi Mơ' footer). Cards are pre-sized then added one
  by one; if a card would overflow the budget, the loop stops. No
  overlap with the footer ever.
- Time column position is now computed from estimateTextWidth('22:00')
  (the widest realistic time string) + 20px gap, so the title column
  never crowds the time.
- Tightened max font ranges for destination headings:
  - Story: 78-160 (was 88-170), 3 lines, lineHeight 1.08
  - Portrait: 64-120 (was 72-130), 2 lines, lineHeight 1.08
  - Square: 56-110 (was 60-120), 2 lines, lineHeight 1.08
- Highlight venue indent: 22px from title column for the bullet dot,
  so the title and venue text don't collide with the accent dot.

VERIFICATION:
- npx tsc --noEmit clean.
- Node-side smoke test against the heuristic: 'Đà Lạt' at 170px fits
  in 920×0.92=846 budget (559px), 'Đà Lạt, Việt Nam' at 130px does
  NOT fit (1078px) — correctly triggers shrink-and-wrap. Activity
  titles 'Cà phê sáng bên Hồ Xuân Hương' / 'Thung lũng Tình Yêu —
  check-in' at 28px all fit single-line in 730×0.92=672 budget.
- Browser-side measurement uses real canvas — strictly more accurate.

CANNOT VERIFY IN CONTAINER: no working browser. User to pull on Mac.
…ity radius

USER BUGS:
1. Reel: 'I can not load image from it, only see alt' \u2014 SVG was
   malformed XML, browser refused to parse it.
2. Map: 'points in map still not enough, looks like we can not detect
   exactly point' \u2014 geocoder finding wrong points or missing.

ROOT CAUSE OF REEL FAILURE:
FONT_FAMILY.inter = '\"Inter\", \"Be Vietnam Pro\", ...' (literal
double-quote chars). Interpolated into a double-quoted SVG attribute:
  font-family=\"${FONT_FAMILY.inter}\"
Resolves to:
  font-family=\"\"Inter\", \"Be Vietnam Pro\", ...\"
The leading \"\" closes the attribute immediately. The rest is parsed
as garbage attributes. Browser SVG parser refuses, <img> shows alt
text only. Verified via direct node template-literal probe.

FIX: switch FONT_FAMILY string literals to use SINGLE quotes inside.
Both canvas (ctx.font) and SVG accept 'Inter' just as well as
\"Inter\". This is the minimal, surgical change.

ROOT CAUSE OF MAP UNDER-DETECTION:
- Real Gemini venue strings contain descriptive noise: parenthetical
  context like 'Bánh tráng nướng D\u00ec Đinh (đặc sản Đà Lạt)',
  description prefixes like 'Cà phê sáng - Thưởng thức cà phê tại
  Cafe Tùng', numeric prefixes '1. Cafe X'. Photon matches the noise
  rather than the actual venue name → wrong or no result.
- destination noise: 'Tỉnh L\u00e2m Đồng' \u2014 the 'Tỉnh'/'TP'/'Quận'
  prefix breaks Photon's parser. Was only stripping ', Việt Nam' tail.
- No osm_tag biasing: Photon returns first match by importance, which
  for 'Cafe Tùng' might match a generic 'cafe' POI elsewhere in
  Vietnam instead of the specific cafe in Đà Lạt.
- No distance sanity check: if Photon returns a venue 500km from the
  destination, we placed the marker there.

FIXES (services/geocoder.ts):
- cleanVenue(): strips parentheticals (both ASCII () and CJK \u3008\u3009),
  leading numeric prefixes ('1. ', '2) '), description prefixes
  ('Cà phê sáng - ', 'Ăn trưa với ', 'Tham quan tại '). Reduces
  'Cà phê sáng - Thưởng thức cà phê tại Cafe Tùng' to 'Cafe Tùng'.
- cleanDestination(): also strips 'Tỉnh', 'Thành phố', 'TP', 'Huyện',
  'Quận', 'Phường', 'Xã' prefixes from each comma-separated token.
- buildQueryCandidates(): now generates 6 query variants (cleaned + first
  destination token, cleaned + full dest, raw + first token, raw alone,
  etc.). Tries each through Photon, then Photon-with-tag, then Nominatim.
- osmTagForVenue(): regex-classifies venue strings into amenity (cafe,
  quán, nhà hàng, lẩu, phở, etc.), tourism (chợ, đền, chùa, công viên,
  hồ, bãi biển, etc.), or accommodation (homestay, hotel, villa, resort).
  Used as Photon osm_tag filter to bias toward the right POI category.

FIXES (components/TripMap.tsx):
- Haversine distance check after geocoding: if a venue lands >60km
  from the destination center, it is demoted to approximate-marker
  (dashed border at destination + jitter) instead of being plotted
  wherever Photon mistakenly returned. 60km covers reasonable day-
  trip radius while rejecting cross-country false positives.

VERIFICATION:
- npx tsc --noEmit clean.
- Direct node template-literal probe confirmed the SVG malformation
  before fix, and proper attribute escaping after fix.

CANNOT VERIFY IN CONTAINER: no browser. User to pull on Mac.
USER REQUESTS:
1. 'Title with address still got overlap other UI' — the destination
   heading was still bleeding into the stats row below.
2. 'Let remove Cài Đặt because now it dont have any benefits' — the
   settings button opens DataPortabilityPanel which is meaningless
   without login.

REEL FIX (components/TripReelModal.tsx):
The previous layouts computed destFirstBaselineY = CENTER -
(lines-1)*lineHeight*0.5 — geometrically centered around an arbitrary
y-coordinate. When fitTextToBox returned 3 lines instead of 1, the
block extended both upward AND downward around the anchor, which made
the bottom of the block creep into the next section.

NEW APPROACH: define a strict y-range [DEST_TOP_LIMIT, DEST_BOTTOM_LIMIT]
for the destination block. Pass available height to fitTextToBox so it
KNOWS the box. Compute baseline so the text block is vertically
centered INSIDE that range, never outside. Stats/highlights row stays
exactly where it was.

Box ranges:
- Story (1920h):   y 430-870  → 440px available  → 150-62 fontSize range
- Portrait (1350h): y 300-500 → 200px available  → 110-56 fontSize range
- Square (1080h):  y 260-430  → 170px available  → 100-50 fontSize range

Also bumped lineHeight ratio 1.08 → 1.18 to give Vietnamese
diacritic descenders proper breathing room (the previous ratio caused
the bottom edge of one line to touch the top of the next).

Also replaced raw 'Inter,system-ui,sans-serif' and 'Georgia,serif' in
the destination heading text element with the FONT_FAMILY constants
that now use single-quoted family names (so the family list parses
correctly inside double-quoted SVG attributes — same fix already
applied to the highlight cards).

CÀI ĐẶT REMOVAL (App.tsx):
- Removed the 'Cài đặt' button from the top-right nav cluster.
- Removed the portabilityOpen state + AnimatePresence modal that
  rendered DataPortabilityPanel.
- Removed IconCog from the icons import.
- Removed DataPortabilityPanel from the imports.

Top-right nav now shows just two buttons: 'Về quê' (IconHome) and
'Thế giới' (IconGlobe). DataPortabilityPanel still exists in the
codebase as an unused component — if a user later wants it back as a
'Privacy/Export' action it can be re-mounted from a different surface.

VERIFICATION:
- npx tsc --noEmit clean.
- No browser available in container to render-test.

USER ACTION: pull on Mac and re-create a trip with a long destination
('Thành phố Hồ Chí Minh', 'Đà Lạt, Việt Nam') to confirm the title
no longer overlaps the stats row.
…rance

USER: 'Điểm Nhấn, I see below info of ĐIểm Nhấn facing overlap text'

ROOT CAUSE:
Highlight title lines were rendered at line-height 1.22 (28px * 1.22
= 34px). With Vietnamese diacritic stacks (e.g., 'ố', 'ậ', 'ỉ',
'ư̛'), the visual height of a single line exceeds the bare fontSize
because the diacritic mark sits above the cap-height. Stacked
diacritics combined with descenders on the next line meant adjacent
title lines visually touched or overlapped — even though SVG baseline
math was 'mathematically correct'.

ALSO: padding between title and venue (10px) was insufficient — the
venue text's ascender clipped into the previous line's descender for
characters like 'b', 'h', 'k', 'l'.

FIX: bumped layout constants across all 3 formats:

  Story (1080×1920):
  - title line-height 1.22 → 1.42
  - venue line-height 1.25 → 1.40
  - pad-top 30 → 36
  - pad-between 10 → 16
  - pad-bottom 26 → 32
  - gap-between-cards 20 → 24
  - HIGHLIGHTS_TOP 1180 → 1170 (give header a bit more headroom)
  - HIGHLIGHTS_BOTTOM_LIMIT 1750 → 1760 (recover 10px lost above)

  Portrait (1080×1350):
  - title line-height 1.22 → 1.42
  - venue line-height 1.25 → 1.40
  - pad-top 28 → 32, between 8 → 14, bottom 24 → 28
  - gap 16 → 20

  Square (1080×1080) — tighter format, fontSize down to fit:
  - title fontSize 24 → 22 (Square only — Story/Portrait kept their FS)
  - title line-height 1.22 → 1.40
  - venue fontSize 17 → 16
  - venue line-height 1.25 → 1.40
  - pad-top 22, between 12, bottom 18
  - gap 14

Section header '✦ ĐIỂM NHẤN':
- Moved up 10px in each format to give a bit more space above the
  first card (was 1130/730/690, now 1120/720/680).
- Reduced FS slightly to match new card density (Story 30→28,
  Portrait 26→24, Square 22→20).
- Switched font-family attr to use the FONT_FAMILY.inter constant
  (was raw 'Inter,system-ui,sans-serif').

TRADE-OFF:
Increased card height means fewer cards fit in the budget:
- Story: 4 candidates → 2-3 accepted (was 3-4)
- Portrait: 3 candidates → 2-3 accepted (was 2-3)
- Square: 2 candidates → 1-2 accepted (was 1-2)
Better to show fewer readable cards than overlapping mush.

VERIFICATION:
- npx tsc --noEmit clean.
- Math sanity-checked: Story 2-title+venue card = 192px height,
  budget=590, fits 2 with 24 gap = 408 → 2 cards comfortably.
- No browser available in container.
Adds 5 relative symlinks pointing at the canonical harness at
/Users/nhonh/Documents/personal/docs/, plus HARNESS.local.md
(regular file) explaining standalone-clone recovery. The
authoritative harness lives at the workspace root; this repo
consumes it via symlinks for single source of truth.

See: docs/HARNESS.local.md
Rollout report: ../../docs/harness-rollout-2026-05-30.md
@hoainho
hoainho merged commit ebb3f6c into main Jun 15, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants