Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1c678bd
feat(web): dashboard design-system base (MetricInfo, fullscreen, toke…
StanislavBG Jun 28, 2026
24af313
fix(web): restore bids+page cache-key params (CWE-349 #56 guard)
StanislavBG Jun 28, 2026
6ccae13
fix(web): restore CWE-349 cache-key drift guard + risk-box styles (re…
StanislavBG Jun 29, 2026
cf06f21
test(web): restore behavioral cache-key assert for keyed params
StanislavBG Jul 2, 2026
957ad75
fix(web): metric-info popover text can never overflow the card
StanislavBG Jul 3, 2026
7c7e56a
fix(web): SSR-safe layout effect, live popover clamp, aria-expanded sync
StanislavBG Jul 10, 2026
9f2f4b1
fix(web): address PR review feedback on MetricInfo, cache-key, overru…
StanislavBG Jul 11, 2026
a03348e
fix(web): run prettier on files flagged by CI lint check
StanislavBG Jul 11, 2026
3e253bd
Merge remote-tracking branch 'origin/main' into pr/dash-base
StanislavBG Jul 11, 2026
eb7a2e1
fix(web): idempotent popover clamp, cover hover/focus reveal (PR #169…
StanislavBG Jul 11, 2026
bc45f82
fix(web): address round-3 ydimitrof review threads on PR #169
StanislavBG Jul 18, 2026
bf76e45
Merge remote-tracking branch 'origin/main' into pr/dash-base
StanislavBG Jul 18, 2026
ffd9d84
fix(web): drop stray aria-expanded + passive scroll listener in Metri…
StanislavBG Jul 20, 2026
cf53ad4
fix(web): drop invalid passive option from removeEventListener in Met…
StanislavBG Jul 21, 2026
bc790e9
fix(web): drop cheater test, correct index-coverage comment, name cla…
StanislavBG Jul 21, 2026
f2f5c41
build(deps): bump sharp to ^0.35.0 (GHSA-f88m-g3jw-g9cj)
StanislavBG Jul 22, 2026
3619625
style(web): prettier-format metric-info-clamp.ts
StanislavBG Jul 22, 2026
94b6fe8
test(web): assert stale allow-list entries, not just log them
StanislavBG Jul 26, 2026
f960f4e
build(deps): patch postcss/valibot CVEs, suppress unrelated react-rou…
StanislavBG Jul 27, 2026
d6202aa
build: merge origin/main into pr/dash-base, resolve conflicts
StanislavBG Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions apps/web/app/components/FullscreenButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { useCallback, useEffect, useRef, useState } from 'react';

/**
* Toggle the native Fullscreen API on a container ref. SSR-safe: the listener and the
* `document` reads only run in the browser effect. `requestFullscreen` is feature-detected,
* so the button no-ops gracefully where the API is unavailable.
*/
export function useFullscreen<T extends HTMLElement>() {
Comment thread
StanislavBG marked this conversation as resolved.
Outdated
Comment thread
StanislavBG marked this conversation as resolved.
Outdated
const ref = useRef<T>(null);
const [isFullscreen, setIsFullscreen] = useState(false);

useEffect(() => {
const onChange = () => setIsFullscreen(document.fullscreenElement === ref.current);
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);

const toggle = useCallback(() => {
const el = ref.current;
if (!el) return;
if (document.fullscreenElement) {
document.exitFullscreen?.();
} else {
el.requestFullscreen?.().catch((err) =>
console.debug('[fullscreen] requestFullscreen failed', err),
);
}
}, []);

return { ref, isFullscreen, toggle };
}

export function FullscreenButton({ active, onToggle }: { active: boolean; onToggle: () => void }) {
Comment thread
StanislavBG marked this conversation as resolved.
Comment thread
StanislavBG marked this conversation as resolved.
return (
<button
type="button"
className="fs-btn"
onClick={onToggle}
aria-pressed={active}
aria-label={active ? 'Изход от цял екран' : 'Разгледай графиката на цял екран'}
title={active ? 'Изход от цял екран' : 'На цял екран'}
>
<svg
aria-hidden="true"
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
{active ? (
<>
<path d="M6 2v4H2" />
<path d="M10 2v4h4" />
<path d="M6 14v-4H2" />
<path d="M10 14v-4h4" />
</>
) : (
<>
<path d="M2 6V2h4" />
<path d="M14 6V2h-4" />
<path d="M2 10v4h4" />
<path d="M14 10v4h-4" />
</>
)}
</svg>
<span>{active ? 'Изход' : 'Цял екран'}</span>
</button>
);
}
164 changes: 164 additions & 0 deletions apps/web/app/components/MetricInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { clampPopoverShift } from './metric-info-clamp';

// useLayoutEffect warns "does nothing on the server" under SSR; fall back to useEffect there since
// there is no layout to read/flush before paint on the server anyway.
const useIsoLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect;

// A small ⓘ affordance next to a metric label. For pointer users it reveals an elegant popover on
// hover or keyboard focus (pure CSS `:hover` / `:focus-within`). Because hover does not exist on
// touch, a click also toggles the popover open via an `is-open` class — and an outside-click or Esc
// closes it again. The button carries the full text as its aria-label, so screen-reader users get the
// same information without the visual popover (which is aria-hidden). SSR-safe: the initial render is
// closed and the toggle/effects only run on the client.
export function MetricInfo({
Comment thread
StanislavBG marked this conversation as resolved.
title,
summary,
readout,
align = 'start',
}: {
title: string;
summary: string;
// Plain string so the readout is always reflected verbatim into the aria-label (all callers pass a
// string — the screen-reader text must never silently drop a non-string interpretation).
readout?: string;
// Which edge the popover anchors to — use 'end' for right-most metrics so it doesn't clip.
align?: 'start' | 'end';
}) {
const aria = readout ? `${title}. ${summary} ${readout}`.trim() : `${title}. ${summary}`;
const [open, setOpen] = useState(false);
const ref = useRef<HTMLSpanElement>(null);
const popRef = useRef<HTMLSpanElement>(null);
// Horizontal shift (px) that keeps the click-opened popover inside the viewport on small screens
// (mobile audit: at 320px the fixed-width popover clips off-screen for edge-column metrics).
const [shift, setShift] = useState(0);
// Mirrors `shift` synchronously so `recompute` always reads the value actually applied to the
// DOM right now, not a stale render closure — see the idempotency note in `recompute` below.
const shiftRef = useRef(0);
// Keyboard focus (no click) also reveals the popover via CSS `:focus-within`; track it so
// `aria-expanded` matches what's actually visible, not just the click-toggled `open` state.
const [focused, setFocused] = useState(false);
// Mouse hover also reveals the popover via CSS `:hover` — track it for the same reason, and so
// Esc can dismiss a hover-only-opened popover (ARIA tooltip pattern: Esc closes it regardless
// of which trigger revealed it).
const [hovered, setHovered] = useState(false);
// Force-hides the popover (via the `is-dismissed` CSS override) after Esc, even while the mouse
// is still hovering or the trigger still has focus. Clears once the trigger state that caused it
// to show goes away, so the popover can reopen normally afterward.
const [dismissed, setDismissed] = useState(false);
const wouldBeVisible = open || focused || hovered;
const visible = wouldBeVisible && !dismissed;

useIsoLayoutEffect(() => {
if (!visible) {
shiftRef.current = 0;
setShift(0);
return;
}
const pop = popRef.current;
const recompute = () => {
Comment thread
StanislavBG marked this conversation as resolved.
if (!pop) return;
// getBoundingClientRect() reflects the *currently applied* `translate`, so subtract the
// shift already in effect to recover the popover's unshifted natural position before
// clamping again — otherwise each recompute clamps an already-shifted rect and a
// resize/scroll can cancel or compound the previous shift instead of converging.
const rect = pop.getBoundingClientRect();
Comment thread
StanislavBG marked this conversation as resolved.
const naturalRect = {
left: rect.left - shiftRef.current,
right: rect.right - shiftRef.current,
};
const vw = document.documentElement.clientWidth;
const next = clampPopoverShift(naturalRect, vw);
shiftRef.current = next;
setShift(next);
};
recompute();
if (!pop) return;
// Re-clamp on resize/scroll while visible (click, hover, or keyboard focus) so the popover
// can't drift out of the viewport; rAF coalesces bursts of scroll events into at most one
// recompute per frame.
let raf = 0;
const onViewportChange = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
recompute();
});
};
window.addEventListener('resize', onViewportChange);
window.addEventListener('scroll', onViewportChange, true);
Comment thread
StanislavBG marked this conversation as resolved.
Outdated
return () => {
if (raf) cancelAnimationFrame(raf);
window.removeEventListener('resize', onViewportChange);
window.removeEventListener('scroll', onViewportChange, true);
};
}, [visible]);

// Close on outside-click while open (touch path — pointer users rely on CSS hover/focus).
useEffect(() => {
if (!open) return;
const onPointer = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('pointerdown', onPointer);
return () => document.removeEventListener('pointerdown', onPointer);
}, [open]);

// Esc closes the popover whenever it's visible — via click-open, keyboard focus, or mouse hover
// — not just the click-toggled `open` state. Blurring the active element also drops CSS
// `:focus-within`, and `dismissed` overrides `:hover` until the pointer actually leaves.
useEffect(() => {
if (!wouldBeVisible) return;
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
setOpen(false);
setDismissed(true);
// Only blur if focus is actually inside this popover's trigger — otherwise a hover-only
// dismiss here would steal focus from an unrelated input elsewhere on the page.
const active = document.activeElement;
if (active instanceof HTMLElement && ref.current?.contains(active)) active.blur();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [wouldBeVisible]);

// Once every trigger that made the popover visible has cleared, drop the dismissal so hovering
// or focusing again reopens it normally.
useEffect(() => {
if (!wouldBeVisible && dismissed) setDismissed(false);
}, [wouldBeVisible, dismissed]);

return (
<span
className={`metric-info${open ? ' is-open' : ''}${dismissed ? ' is-dismissed' : ''}`}
ref={ref}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<button
type="button"
className="metric-info-btn"
Comment thread
StanislavBG marked this conversation as resolved.
aria-label={aria}
aria-expanded={visible}
Comment thread
StanislavBG marked this conversation as resolved.
Outdated
onClick={() => setOpen((v) => !v)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Клик за затваряне може да не скрие popover-а визуално в реален браузър. Кликването върху <button> в Chrome/Firefox дава фокус на бутона, така че .metric-info:focus-within .metric-info-pop (components.css) остава активно дори след като is-open бъде премахнат — popover-ът остава видим, докато фокусът не напусне (напр. отместване на мишката извън елемента при все още фокусиран бутон). При touch устройства (iOS Safari) tap обикновено не фокусира бутон, така че там работи, но на desktop поведението е непоследователно. Обмислете при затваряне (когато преминава от open→closed) също да се извика blur на бутона, или да се синхронизира видимостта изцяло през JS състояние вместо да се разчита на :focus-within.

onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
>
<span className="metric-info-glyph" aria-hidden="true">
</span>
</button>
<span
className={`metric-info-pop${align === 'end' ? ' is-end' : ''}`}
aria-hidden="true"
ref={popRef}
// `translate` composes with the CSS `transform` reveal transition instead of replacing it
style={shift !== 0 ? { translate: `${shift}px 0` } : undefined}
>
<span className="metric-info-title">{title}</span>
<span className="metric-info-summary">{summary}</span>
{readout ? <span className="metric-info-readout">{readout}</span> : null}
</span>
</span>
);
}
36 changes: 36 additions & 0 deletions apps/web/app/components/metric-info-clamp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { clampPopoverShift } from './metric-info-clamp';

describe('clampPopoverShift', () => {
// At a 320px viewport the pop's CSS caps its width to `100vw - 16px` (304px), so a
// start-aligned popover (left: 0) rendered at the left edge of a narrow card already fits and
// needs no shift.
it('leaves a start-aligned popover that already fits untouched', () => {
expect(clampPopoverShift({ left: 8, right: 304 }, 320)).toBe(0);
});

// An end-aligned popover on a narrow (320px) viewport anchors to the card's right edge, so its
// `right` can exceed the viewport — this is the case the mobile audit found clipping off-screen.
it('shifts an end-aligned popover left until it clears the right inset', () => {
// Card sits near the right edge: pop occupies [96, 328] before clamping.
expect(clampPopoverShift({ left: 96, right: 328 }, 320)).toBe(-16);
});

// The left-edge clamp must win over the right-edge clamp when both would fire, so a popover
// wider than the available space never gets pushed past the left inset while chasing the right
// one — matches the pop's `max-width: min(320px, calc(100vw - 16px))` CSS guarantee.
it('prioritizes the left clamp when both edges would otherwise clip', () => {
// Popover (340px) is wider than the 320px viewport's 304px (100vw - 16) budget, so both edge
// checks genuinely fire: the right clamp alone would want dx=-18, but the left clamp then
// overrides it entirely, landing on 18.
const rect = { left: -10, right: 330 };
const vw = 320;
expect(rect.right > vw - 8).toBe(true); // right-edge clamp condition fires
expect(rect.left + (vw - 8 - rect.right) < 8).toBe(true); // left-edge clamp condition also fires
expect(clampPopoverShift(rect, vw)).toBe(18);
});

it('is a no-op for a centered popover with room on both sides', () => {
expect(clampPopoverShift({ left: 40, right: 280 }, 320)).toBe(0);
});
});
15 changes: 15 additions & 0 deletions apps/web/app/components/metric-info-clamp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Pure clamp math, split out of MetricInfo.tsx so it's unit-testable without a DOM/JSX transform:
// how far (px) to shift the popover so it stays within an 8px inset of the viewport on both
// edges. Narrow viewports (e.g. 320px) can trigger both the right-edge and left-edge clamps in
// the same computation when the popover is wider than the available space — the left clamp
// always wins in that case, matching the pop's `max-width: min(320px, calc(100vw - 16px))` CSS,
// which guarantees the popover itself never exceeds `viewportWidth - 16`.
export function clampPopoverShift(
rect: { left: number; right: number },
viewportWidth: number,
): number {
let dx = 0;
if (rect.right > viewportWidth - 8) dx = viewportWidth - 8 - rect.right;
if (rect.left + dx < 8) dx = 8 - rect.left;
Comment thread
StanislavBG marked this conversation as resolved.
Outdated
return Math.round(dx);
}
Loading