This is a bilingual (EN ↔︎ हिं) single-file family tree app (index.html). Canvas renders the tree; a fixed header holds title + centred search + action buttons. A slide-up drawer shows member profiles.
The previous session fixed all header layout bugs. This session applies a list of agreed polish improvements — do them all, in the order listed. Do not touch the canvas/tree rendering code (buildLayout, render, loadData, getNodeWidth, textWidthCache).
Chauhan Kul / चौहान कुल) is stable in width across languages ✅Description, Find Relation, PDF) are fixed-width and uniform ✅Location: #overflow-popover CSS (around line 599) and its .open state (line 614).
Current: Popover snaps open instantly with display: none → display: flex.
Fix: Replace the display toggle with an opacity + transform animation. The JS toggles .open — keep that. Change CSS so .open animates in:
#overflow-popover {
/* keep existing properties, but add: */
opacity: 0;
transform: translateY(-6px) scale(0.97);
pointer-events: none;
transition: opacity 140ms ease, transform 140ms ease;
/* remove display:none — use visibility instead so transition plays */
display: flex; /* always flex */
visibility: hidden;
}
#overflow-popover.open {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
visibility: visible;
}Note:
display: noneprevents CSS transitions from firing. Switch tovisibility: hidden+opacity: 0so the transition plays. The existing JS (classList.toggle('open')) needs no changes.
/ alongside ⌘KLocation: HTML around line 1963 — the .search-kbd span inside .search-wrap.
Current:
<span class="search-kbd" aria-hidden="true">⌘K</span>Fix: Show both shortcuts. Hide on mobile (already done via @media (max-width: 768px) { .search-kbd { display: none; } }):
<span class="search-kbd" aria-hidden="true">⌘K /</span>The / is the standard browser/app search shortcut for non-Mac users. adds a small visual gap between them.
Context: #lang-switching is a full-screen overlay element that already exists in the DOM (around line 2168) and is shown briefly during language switch. It may flash too quickly to register.
Location: JS function that handles the toggle (search for lang-switching or langSwitching in the script — around line 3546 area). Also the #lang-switching CSS.
Current behaviour: The overlay appears and is removed very quickly, or its transition is too subtle.
Fix: Ensure the #lang-switching element: 1. Fades in over ~100ms when shown (add opacity transition to its CSS) 2. Shows for at least 200ms before the UI text updates 3. Fades out over ~150ms
Check how it’s currently shown/hidden in JS (look for langSwitching.classList or langSwitching.style). If it’s toggled with a class, add a CSS transition. If it’s instant, add a short setTimeout so it’s visible before updateUIText() runs.
Do not change the
updateUIText()function itself or the lang toggle logic — only the timing/visibility of the overlay.
Location: .drawer-chain CSS (around line 939) and .chain-btn (around line 947).
Current: display: flex; flex-wrap: wrap — chain wraps but separators (› or similar) between buttons may break awkwardly across lines.
What the chain looks like in JS: Look for where d-chain (dChain) is populated in JS — it builds <button class="chain-btn"> elements with separators between them. Find how separators are added (text nodes, ::after pseudo-elements, or <span> elements).
Fix: Wrap each name + separator pair together so the separator never orphans at the start of a line. If separators are text nodes or inline spans between buttons, wrap each (button + separator) in a <span style="display:inline-flex; align-items:center; flex-shrink:0">. Do this in JS where d-chain is built.
If the separator is a CSS ::after pseudo-element on .chain-btn, add white-space: nowrap only to the last chain-btn to suppress trailing separator, and use display: inline-flex on .drawer-chain children.
Location: .drawer-header HTML (around line 2114), and .drawer-name / .drawer-gen-badge CSS (around lines 835–872).
Current HTML:
<div class="drawer-header">
<div class="drawer-name" id="drawer-name"></div>
<div class="drawer-gen-badge" id="drawer-gen-badge"></div>
<button class="drawer-close" id="drawer-close">…</button>
</div>The drawer-header uses CSS grid (grid-column/grid-row on children). Name is row 1 col 1, badge is row 2 col 1, close is col 2 rows 1–2.
Fix: Move the badge inline to the right of the name, on the same row. Change the header to flexbox:
<div class="drawer-header">
<div class="drawer-name-row">
<div class="drawer-name" id="drawer-name"></div>
<div class="drawer-gen-badge" id="drawer-gen-badge"></div>
</div>
<button class="drawer-close" id="drawer-close">…</button>
</div>CSS:
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
/* keep existing padding */
}
.drawer-name-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
flex: 1;
}
.drawer-gen-badge {
/* remove grid-column/grid-row */
margin-top: 0; /* no longer needs top margin */
flex-shrink: 0;
}Check the existing .drawer-header CSS for its grid definition — remove display: grid and grid-template-columns from it.
Location: .children-pills CSS (around line 1021).
Current: display: flex; flex-wrap: wrap; gap: 0.35rem — pills wrap freely, resulting in ragged rows.
Fix: Switch to a CSS grid with auto-fill columns:
.children-pills {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 0.35rem;
}
.child-pill {
/* add: */
text-align: center;
}This gives even columns that fill the available width — cleaner than free-wrap. The minmax(90px, 1fr) accommodates short names without making columns too wide. Adjust 90px if names are typically longer in Hindi (try 100px for Hindi).
Location: render() function in JS (around line 2877), specifically the node-drawing loop (around line 2918).
Current: Selected node (isSel) gets a filled accent background and white text — visually distinct but static.
Fix: After drawing the selected node’s fill and stroke, draw a second larger rounded-rect ring that pulses via requestAnimationFrame. The simplest approach that doesn’t require restructuring render():
Add a module-level variable:
let ringPhase = 0; // 0..1, driven by rAFIn the render() function, after the node loop, add a second pass for the selected node only:
if (selectedId) {
const pos = layoutCache.get(selectedId);
if (pos) {
ringPhase = (ringPhase + 0.03) % 1;
const pulse = Math.sin(ringPhase * Math.PI * 2) * 0.5 + 0.5; // 0..1
const expand = 4 + pulse * 3;
ctx.strokeStyle = css.accent;
ctx.lineWidth = (1.5 + pulse) / scale;
ctx.globalAlpha = 0.35 + pulse * 0.3;
ctx.beginPath();
ctx.roundRect(
pos.x - expand / 2,
pos.y - expand / 2,
pos.w + expand,
pos.h + expand,
6
);
ctx.stroke();
ctx.globalAlpha = 1;
}
}
ctx.restore();Remove the existing ctx.restore() that closes the render() function and replace it with this block (which ends with ctx.restore()).
Then ensure render() is called on each animation frame when a node is selected. Look for the rAF / animation loop — there may already be one. If render is only called on events (pan, zoom, resize), add:
function animLoop() {
if (selectedId) {
render();
}
requestAnimationFrame(animLoop);
}
requestAnimationFrame(animLoop);Be careful: if render is already called from a rAF loop, don’t add another one — just ensure
ringPhaseis incremented inside render whenselectedIdis set. Check for existingrequestAnimationFramecalls in the script before adding.
Location: .float-btns HTML (around line 2087) and its CSS (around line 649).
Current: Zoom in/out buttons and fit button — no zoom % shown.
Fix: Add a small label between the zoom group and the fit button:
HTML (insert between </div> closing .float-zoom-group and the fit <button>):
<div class="zoom-level" id="zoom-level" aria-live="polite" aria-atomic="true">100%</div>CSS:
.zoom-level {
font-family: var(--font-mono);
font-size: 0.58rem;
color: var(--ink-muted);
text-align: center;
letter-spacing: 0.04em;
opacity: 0.75;
pointer-events: none;
min-width: 2.8rem;
}JS: update it whenever scale changes. Find every place scale is mutated (search for scale = and scale *= in the script — there are ~4 places: zoom in/out buttons, pinch gesture, fitToScreen). After each mutation, add:
const zoomLevelEl = document.getElementById('zoom-level');
if (zoomLevelEl) zoomLevelEl.textContent = Math.round(scale * 100) + '%';Or define a one-liner helper near the top of the script:
function updateZoomLabel() {
const el = document.getElementById('zoom-level');
if (el) el.textContent = Math.round(scale * 100) + '%';
}And call updateZoomLabel() after each scale change.
Location: .page-header CSS (around line 77) and JS (add an IntersectionObserver or scroll listener on the canvas/window).
Current: Header has a static box-shadow: 0 6px 24px rgba(30,24,20,0.04) — very subtle, always on.
Fix: Make the shadow appear only when the user has panned/zoomed (i.e. the tree is not at its default fit position). Since this is a canvas app (no scrollable DOM), use a JS flag instead of a scroll event.
Add a CSS class:
.page-header.scrolled {
box-shadow: 0 4px 20px rgba(30,24,20,0.13);
border-bottom-color: color-mix(in srgb, var(--border) 80%, var(--accent));
}In JS, after any pan or zoom interaction, add/remove the class:
function updateHeaderShadow() {
const isDefault = (Math.abs(panX - defaultPanX) < 5 &&
Math.abs(panY - defaultPanY) < 5 &&
Math.abs(scale - defaultScale) < 0.05);
document.querySelector('.page-header').classList.toggle('scrolled', !isDefault);
}Store defaultPanX, defaultPanY, defaultScale when fitToScreen() runs (or after the initial render). Call updateHeaderShadow() at the end of each pan/zoom handler.
If storing default values is complex, a simpler proxy: just add
.scrolledwhenever the user pans or pinches (any pointer/touch move on canvas), and remove it whenfitToScreenis called.
Location: #graph-canvas CSS (around line 557) and the focus-visible rule (around line 432).
Current: Canvas has no visible focus ring — #graph-canvas is not in the :where(...) focus-visible selector list.
Fix: Add to the existing focus-visible rule:
#graph-canvas:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}Also ensure the canvas is keyboard-navigable. Check if it has tabindex set — if not, add tabindex="0" to the <canvas> element in HTML. It already has role="img" which is correct.
buildLayout(), render() internals beyond the ring addition in item 7loadData(), getNodeWidth(), textWidthCacheupdateUIText() language switch functiondocument.fonts.ready) and double-encode fixesrestoreFromURL / updateURL functions#share-confirm anchored tooltip (already implemented)--font-serif: 'Cormorant Garamond', Georgia, serif; /* English titles, drawer name */
--font-mono: 'IBM Plex Mono', 'Courier New', monospace; /* UI labels, badges */
--font-dev: 'Noto Serif Devanagari', serif; /* Hindi text */--accent: /* warm brown — used for selected state, active UI */
--accent-light: /* lighter brown — highlights */
--node-self: /* darkest brown — selected canvas node fill */
--border: /* light warm grey */
--border-dark: /* darker warm grey — canvas edges */
--card: /* near-white warm — node fill */
--bg: /* page background */
--soft: /* very light warm — hover backgrounds */
--ink: /* near-black — primary text */
--ink-muted: /* grey — secondary text */