refactor(report): full UX audit + accessibility pass
Deploy HiHala Dashboard / deploy (push) Successful in 11s

UI/UX redesign:
- Module cards with master toggle + badge state for all report sections
- BreakdownModule with indeterminate checkbox and metric pill sub-toggles
- PillGroup replaces all text toggles and <select> (Language, VAT,
  Confidentiality, Trend metric, Orientation) for full visual consistency
- Visual orientation picker (portrait/landscape card buttons)
- Comparison period in accent-tinted block, revealed contextually
- Footer meta strip: section count, date range, orientation, comparison flag
- Removed generic subtitle copy

Accessibility (audit findings C1–C3, H2, H6, L1–L2):
- aria-pressed on all PillGroup and orientation buttons
- role="group" + aria-label on every pill group and orientation row
- aria-hidden on decorative module badges and footer separator dots
- :focus-visible on rf-metric-pill, rf-orient-btn, rf-upload-btn, rf-remove-btn
- aria-label on upload/remove logo buttons
- Semantic <h2> elements replace <div> group labels
- alert() replaced with inline role="alert" error messages in footer + logo field
- aria-live="polite" sr-only region for PDF generation status
- aria-busy on generate button during PDF creation

Dark mode & theming (H1):
- All rgba(37,99,235,...) hard-codes replaced with color-mix(in srgb,
  var(--accent) N%, transparent) so tints follow the accent token in dark mode
- rf-module-header:hover uses var(--hover) instead of rgba(0,0,0,0.02)

Performance (H8):
- getUniqueMuseums/getUniqueChannels wrapped in useMemo([data])

PDF fixes:
- ▲/▼ Unicode glyphs (outside Helvetica Latin-1 range) replaced with +/- prefix
- Chart width adapts to orientation via CHART_W constant
- Y-axis labels added to trend chart (padL 38pt)

Responsive (H4–H5):
- rf-metric-pill touch target increased to 8px/14px on mobile
- Mobile footer shows section count only; period/orientation details hide

Cleanup (M3):
- Removed dead CSS: rf-toggle, rf-toggle-opt, rf-section-title,
  rf-check-h-group, rf-inline-row (7 rules)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-04-29 09:41:38 +03:00
parent 648365348f
commit c858075232
6 changed files with 1239 additions and 426 deletions
+58 -18
View File
@@ -1,6 +1,19 @@
import React from 'react';
import { Svg, Line, Polyline, Rect, Text as SvgText, G } from '@react-pdf/renderer';
export const CHART_PALETTE = [
'#2563eb', '#0891b2', '#7c3aed', '#059669',
'#d97706', '#dc2626', '#db2777', '#f59e0b',
'#10b981', '#6366f1', '#0284c7', '#65a30d',
];
function fmtAxis(v: number): string {
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (v >= 10_000) return `${Math.round(v / 1_000)}K`;
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}K`;
return String(Math.round(v));
}
interface TrendChartProps {
labels: string[];
current: number[];
@@ -10,10 +23,11 @@ interface TrendChartProps {
height?: number;
}
export function PdfTrendChart({ labels, current, previous, color, width = 460, height = 140 }: TrendChartProps) {
export function PdfTrendChart({ labels, current, previous, color, width = 470, height = 155 }: TrendChartProps) {
const allValues = [...current, ...(previous ?? [])].filter(v => v > 0);
const max = allValues.length > 0 ? Math.max(...allValues) : 1;
const padL = 8, padR = 8, padT = 8, padB = 18; // padB=18 leaves room for x-axis labels
// padL wide enough for y-axis labels like "1.2M"
const padL = 38, padR = 8, padT = 10, padB = 20;
const w = width - padL - padR;
const h = height - padT - padB;
@@ -27,20 +41,38 @@ export function PdfTrendChart({ labels, current, previous, color, width = 460, h
return (
<Svg width={width} height={height}>
{gridLines.map(f => (
<Line key={f}
x1={padL} y1={sy(max * f).toFixed(1)}
x2={width - padR} y2={sy(max * f).toFixed(1)}
stroke="#e2e8f0" strokeWidth={0.5} />
))}
{/* Baseline */}
<Line x1={padL} y1={(padT + h).toFixed(1)} x2={width - padR} y2={(padT + h).toFixed(1)}
stroke="#cbd5e1" strokeWidth={0.75} />
{/* Grid lines + Y-axis labels */}
{gridLines.map(f => {
const yPos = sy(max * f);
return (
<G key={f}>
<Line x1={padL} y1={yPos.toFixed(1)} x2={width - padR} y2={yPos.toFixed(1)}
stroke="#e2e8f0" strokeWidth={0.5} />
<SvgText x={(padL - 5).toFixed(1)} y={(yPos + 2.5).toFixed(1)}
fill="#94a3b8" style={{ fontSize: 6.5, textAnchor: 'end' }}>
{fmtAxis(max * f)}
</SvgText>
</G>
);
})}
{/* Comparison line (dashed) */}
{previous && previous.some(v => v > 0) && (
<Polyline points={toPoints(previous)}
stroke="#94a3b8" strokeWidth={1.5} strokeDasharray="4 3" fill="none" />
)}
{/* Current period line */}
{current.some(v => v > 0) && (
<Polyline points={toPoints(current)}
stroke={color} strokeWidth={2.5} fill="none" />
)}
{/* X-axis week labels */}
{labels
.filter((_, i) => labels.length <= 8 || i % Math.ceil(labels.length / 8) === 0)
.map((label) => {
@@ -60,15 +92,15 @@ export function PdfTrendChart({ labels, current, previous, color, width = 460, h
interface HBarChartProps {
items: Array<{ name: string; value: number }>;
color: string;
usepalette?: boolean;
width?: number;
}
export function PdfHBarChart({ items, color, width = 460 }: HBarChartProps) {
const barH = 16;
export function PdfHBarChart({ items, color, usepalette = false, width = 470 }: HBarChartProps) {
const barH = 17;
const gap = 10;
const labelW = 150;
const valueW = 70;
const barAreaW = width - labelW - valueW - 8;
const labelW = 160;
const barAreaW = width - labelW - 20;
const max = Math.max(...items.map(i => i.value), 1);
const totalH = items.length * (barH + gap);
@@ -77,13 +109,21 @@ export function PdfHBarChart({ items, color, width = 460 }: HBarChartProps) {
{items.map((item, i) => {
const y = i * (barH + gap);
const bw = Math.max((item.value / max) * barAreaW, 2);
const shortName = item.name.length > 22 ? item.name.slice(0, 22) + '…' : item.name;
const shortName = item.name.length > 26 ? item.name.slice(0, 26) + '…' : item.name;
const valueStr = item.value.toLocaleString('en-SA', { maximumFractionDigits: 0 });
const barColor = usepalette ? CHART_PALETTE[i % CHART_PALETTE.length] : color;
const isShort = bw < 48;
return (
<G key={item.name}>
<SvgText x={0} y={y + barH - 4} fill="#334155" style={{ fontSize: 8 }}>{shortName}</SvgText>
<Rect x={labelW} y={y} width={bw} height={barH} fill={color} rx={3} />
<SvgText x={labelW + bw + 4} y={y + barH - 4} fill="#64748b" style={{ fontSize: 8 }}>{valueStr}</SvgText>
<G key={item.name + i}>
<SvgText x={0} y={y + barH - 4} fill="#334155" style={{ fontSize: 8.5 }}>{shortName}</SvgText>
<Rect x={labelW} y={y} width={bw} height={barH} fill={barColor} rx={3} />
{isShort ? (
<SvgText x={labelW + bw + 6} y={y + barH - 4} fill="#334155"
style={{ fontSize: 8.5 }}>{valueStr}</SvgText>
) : (
<SvgText x={labelW + bw - 6} y={y + barH - 4} fill="#ffffff"
style={{ fontSize: 8.5, textAnchor: 'end' }}>{valueStr}</SvgText>
)}
</G>
);
})}