Sai Prakash

Handover: Layout & Polish Improvements

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).


What Is Already Working (Do Not Regress)


Improvements to Apply

1 — Overflow menu: slide-in entry animation

Location: #overflow-popover CSS (around line 599) and its .open state (line 614).

Current: Popover snaps open instantly with display: nonedisplay: 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: none prevents CSS transitions from firing. Switch to visibility: hidden + opacity: 0 so the transition plays. The existing JS (classList.toggle('open')) needs no changes.


2 — Search hint: show / alongside ⌘K

Location: 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 &nbsp;/</span>

The / is the standard browser/app search shortcut for non-Mac users. &nbsp; adds a small visual gap between them.


3 — Language toggle: transition feedback

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.


4 — Ancestor chain: wrapping fix for long lineages

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.


5 — Generation badge: move inline with name

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.


6 — Children: grid layout instead of wrapping flex

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).


7 — Selected node: pulsing ring on canvas

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 rAF

In 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 ringPhase is incremented inside render when selectedId is set. Check for existing requestAnimationFrame calls in the script before adding.


8 — Zoom level indicator

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.


9 — Header scroll shadow

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 .scrolled whenever the user pans or pinches (any pointer/touch move on canvas), and remove it when fitToScreen is called.


10 — Canvas keyboard focus style

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.


What NOT to Touch


Font Variables (for Reference)

--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 */

Key CSS Variables (for Reference)

--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 */