ui(polish): emoji-free + no login + Storyboard redesign + Reel multi-size + map geocoding#8
Conversation
…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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
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.
| (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); | ||
| })(); |
There was a problem hiding this comment.
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);
}
}
})();
| await new Promise<void>((resolve, reject) => { | ||
| img.onload = () => resolve(); | ||
| img.onerror = () => reject(new Error('Image load failed')); | ||
| }); |
There was a problem hiding this comment.
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.
| 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; | |
| } |
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
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 '&' 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
…, bỏ emoji→icon, polish UI mobile/reel
Full UI polish sweep per user direction.
What changed
1. Remove all emoji from rendered UI
components/icons.tsx(Home, Cog, User, Film, LayoutList, LayoutGrid, Smartphone, Wand, Feather, LogIn, Route, Mic, Record, Mail, Sprout, Tree, Package, Trash, Languages).2. Remove 'Đăng nhập' / login flow entirely
generateShareUrl()(no auth needed).3. Storyboard view redesign
User: 'design color and layout bad, make user hard to read, colors are inconsistent, distracting, and lack clear highlights.'
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.'
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.'
services/geocoder.ts: Nominatim (OSM) public geocoder with 30-day localStorage cache, 1.1s rate-limit, countrycodes=vn bias.Infrastructure
hooks/useBodyScrollLock.ts(scrollbar-width-aware).hooks/useEscapeKey.ts.index.css:.pb-safe-plus-4,.bottom-safe,.touch-target(44px WCAG),.sr-only,body.modal-open,:focus-visiblering.Verification
npx tsc --noEmit— zero errors.How to test
Walk: Hero → Khám phá → Manual → submit → Result. Try the Reel modal, tab through formats, download PNG. Check map renders markers + route lines.