diff --git a/apps/web/app/components/FullscreenButton.tsx b/apps/web/app/components/FullscreenButton.tsx
new file mode 100644
index 000000000..6b6dde87d
--- /dev/null
+++ b/apps/web/app/components/FullscreenButton.tsx
@@ -0,0 +1,45 @@
+// Purely presentational — no consumer wires the native Fullscreen API to this button yet, so no
+// `useFullscreen` hook lives here either (a prior version did, but with zero call sites — dead
+// code). A future caller supplies `active`/`onToggle` however fits its own fullscreen strategy;
+// no webkit-prefix fallback is needed until that caller and its target browser matrix exist.
+export function FullscreenButton({ active, onToggle }: { active: boolean; onToggle: () => void }) {
+ return (
+
+ );
+}
diff --git a/apps/web/app/components/MetricInfo.test.tsx b/apps/web/app/components/MetricInfo.test.tsx
new file mode 100644
index 000000000..a08440ac4
--- /dev/null
+++ b/apps/web/app/components/MetricInfo.test.tsx
@@ -0,0 +1,59 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { MetricInfo } from './MetricInfo';
+
+afterEach(cleanup);
+
+describe('MetricInfo', () => {
+ it('toggles the popover open and closed on click', () => {
+ const { container, getByRole } = render();
+ const root = container.querySelector('.metric-info');
+ const button = getByRole('button');
+
+ expect(root?.className).not.toContain('is-open');
+
+ fireEvent.click(button);
+ expect(root?.className).toContain('is-open');
+
+ fireEvent.click(button);
+ expect(root?.className).not.toContain('is-open');
+ });
+
+ it('closes a hover-opened popover on Escape', () => {
+ const { container } = render();
+ const root = container.querySelector('.metric-info') as HTMLElement;
+
+ fireEvent.mouseEnter(root);
+ expect(root.className).not.toContain('is-dismissed');
+
+ fireEvent.keyDown(document, { key: 'Escape' });
+ expect(root.className).toContain('is-dismissed');
+ });
+
+ it('removes resize/scroll listeners on unmount that were added while visible', () => {
+ const addSpy = vi.spyOn(window, 'addEventListener');
+ const removeSpy = vi.spyOn(window, 'removeEventListener');
+
+ const { getByRole, unmount } = render();
+ fireEvent.click(getByRole('button'));
+
+ const countByType = (calls: unknown[][], type: string) =>
+ calls.filter(([t]) => t === type).length;
+
+ const addedResize = countByType(addSpy.mock.calls, 'resize');
+ const addedScroll = countByType(addSpy.mock.calls, 'scroll');
+ expect(addedResize).toBeGreaterThan(0);
+ expect(addedScroll).toBeGreaterThan(0);
+
+ unmount();
+
+ const removedResize = countByType(removeSpy.mock.calls, 'resize');
+ const removedScroll = countByType(removeSpy.mock.calls, 'scroll');
+ expect(removedResize).toBe(addedResize);
+ expect(removedScroll).toBe(addedScroll);
+
+ addSpy.mockRestore();
+ removeSpy.mockRestore();
+ });
+});
diff --git a/apps/web/app/components/MetricInfo.tsx b/apps/web/app/components/MetricInfo.tsx
new file mode 100644
index 000000000..c332c4510
--- /dev/null
+++ b/apps/web/app/components/MetricInfo.tsx
@@ -0,0 +1,163 @@
+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({
+ 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(null);
+ const popRef = useRef(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
+ // Esc can dismiss a keyboard-opened popover the same as a click- or hover-opened one.
+ 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 = () => {
+ 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();
+ 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, { passive: true, capture: true });
+ return () => {
+ if (raf) cancelAnimationFrame(raf);
+ window.removeEventListener('resize', onViewportChange);
+ window.removeEventListener('scroll', onViewportChange, { capture: 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 (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ >
+
+
+ {title}
+ {summary}
+ {readout ? {readout} : null}
+
+
+ );
+}
diff --git a/apps/web/app/components/metric-info-clamp.test.ts b/apps/web/app/components/metric-info-clamp.test.ts
new file mode 100644
index 000000000..2704c5fd8
--- /dev/null
+++ b/apps/web/app/components/metric-info-clamp.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/app/components/metric-info-clamp.ts b/apps/web/app/components/metric-info-clamp.ts
new file mode 100644
index 000000000..cb36b223b
--- /dev/null
+++ b/apps/web/app/components/metric-info-clamp.ts
@@ -0,0 +1,20 @@
+// 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`.
+// Must match the CSS `max-width: min(320px, calc(100vw - 16px))` inset — keep VIEWPORT_INSET_PX in
+// sync with that `16px` (2×inset) if the popover's CSS inset ever changes.
+const VIEWPORT_INSET_PX = 8;
+
+export function clampPopoverShift(
+ rect: { left: number; right: number },
+ viewportWidth: number,
+): number {
+ let dx = 0;
+ if (rect.right > viewportWidth - VIEWPORT_INSET_PX)
+ dx = viewportWidth - VIEWPORT_INSET_PX - rect.right;
+ if (rect.left + dx < VIEWPORT_INSET_PX) dx = VIEWPORT_INSET_PX - rect.left;
+ return Math.round(dx);
+}
diff --git a/apps/web/app/lib/query-params.ts b/apps/web/app/lib/query-params.ts
index e7b603a30..d29d1c21a 100644
--- a/apps/web/app/lib/query-params.ts
+++ b/apps/web/app/lib/query-params.ts
@@ -1,17 +1,26 @@
// The response-affecting query params, shared by cacheKey (edge cache key) and withParams (link hrefs):
// one list means an unknown param (`?x=poison`) can neither poison the key nor ride a cached link
// (#56 / #197). The cache-key.test.ts drift guard keeps it a complete superset of what the app reads.
+// Some entries below (a, b, by, cohort, cpv, metric) sit ahead of the routes that will read them
+// (/compare, /overruns, /price-anomaly, /contracts cpv filter) — see cache-key.test.ts's
+// info-only "stale entries" check for why that's intentional on this stacked-PR base.
export const CANONICAL_QUERY_PARAMS = new Set([
+ 'a', // /compare — entity A slug
'authority',
+ 'b', // /compare — entity B slug
'bidder',
'bids', // single-bid filter — changes the result set + totals
+ 'by', // /overruns — sort dimension (absolute | percent)
'center',
+ 'cohort', // /price-anomaly — selected CPV cohorts (repeatable); faceting changes the result set
'count',
+ 'cpv', // /contracts — exact 5-digit CPV filter; changes the result set + headline totals
'cursor',
'eu',
'funding',
'g',
'kind',
+ 'metric', // /compare leaderboard dimension
'p',
'page', // keyed unconditionally — harmless over-key when there's no cursor
'procedure',
diff --git a/apps/web/app/styles/components.css b/apps/web/app/styles/components.css
index 6acaffcff..44289dad5 100644
--- a/apps/web/app/styles/components.css
+++ b/apps/web/app/styles/components.css
@@ -583,6 +583,646 @@ tbody td,
opacity: 0.7;
}
+/* ===== trends-dashboard =====
+ Static layout/typography chrome for /trends (routes/trends.tsx + components/TrendComboChart.tsx),
+ moved out of inline `style=` to keep the route CSP-clean (style-src; see docs/review-accessibility).
+ Only genuinely JS-computed values (bar widths, active-state colours, SVG fill/stroke var()s) stay
+ inline. The mono face mirrors the design mock's IBM Plex Mono, falling back to the app token. */
+/* vertical layout (design): a single column — full-width chart → year table → contracts grid */
+.trend-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.trend-col {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ min-width: 0;
+}
+
+.trend-panel {
+ display: flex;
+ flex-direction: column;
+ background: var(--paper-warm);
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+}
+
+.trend-chart-panel {
+ padding: 14px 16px 10px;
+}
+
+.trend-years-panel {
+ padding: 12px 16px;
+}
+
+.trend-rail {
+ padding: 12px 0 0;
+}
+
+/* KPI strip */
+/* combined header: title + lede on the left, KPIs inline on the right, one bordered row (design) */
+.trend-header {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 24px;
+ margin: 0 0 14px;
+ padding-bottom: 14px;
+ border-bottom: 1px solid var(--rule);
+}
+
+.trend-header-main {
+ min-width: 0;
+}
+
+.trend-header-kicker {
+ font: 600 10px/1 var(--font-mono);
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ color: var(--accent);
+}
+
+.trend-header-title {
+ margin: 8px 0 0;
+ font-family: var(--font-serif);
+ font-size: 30px;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+ line-height: 1;
+ color: var(--ink);
+}
+
+.trend-header-title em {
+ font-style: italic;
+ color: var(--accent);
+}
+
+.trend-header-lede {
+ margin: 7px 0 0;
+ max-width: 460px;
+ font-size: 12.5px;
+ line-height: 1.4;
+ color: var(--ink-mid);
+}
+
+.trend-header-kpis {
+ display: flex;
+ flex: none;
+}
+
+.trend-hk {
+ padding: 0 22px;
+ border-left: 1px solid var(--rule);
+}
+
+.trend-hk:last-child {
+ padding-right: 0;
+}
+
+.trend-hk-v {
+ font: 600 25px/1 var(--font-mono);
+ color: var(--ink);
+}
+
+.trend-hk-v--accent {
+ color: var(--accent);
+}
+
+.trend-hk-l {
+ margin-top: 4px;
+ font: 500 9px/1 var(--font-mono);
+ letter-spacing: 0.14em;
+ color: var(--ink-soft);
+}
+
+@media (max-width: 760px) {
+ .trend-header {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 14px;
+ }
+
+ .trend-header-kpis {
+ flex-wrap: wrap;
+ }
+
+ .trend-hk:first-child {
+ padding-left: 0;
+ border-left: none;
+ }
+}
+
+/* filter bar */
+.trend-filterbar {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ flex-wrap: wrap;
+ padding: 10px 14px;
+ background: var(--paper-warm);
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+ margin-bottom: 14px;
+}
+
+.trend-steps {
+ display: flex;
+}
+
+.trend-step {
+ font:
+ 500 10px/1 'IBM Plex Mono',
+ var(--font-mono);
+ letter-spacing: 0.08em;
+ padding: 7px 11px;
+ cursor: pointer;
+}
+
+.trend-filter-form {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+/* both filters are identical bordered chips: uppercase mono caption + borderless select,
+ so they read as one consistent control row with the step toggle (matches the design mock). */
+.trend-filter-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 5px 10px;
+ background: var(--paper-raised);
+ border: 1px solid var(--rule);
+ border-radius: 3px;
+}
+
+.trend-filter-label > span {
+ font: 500 8.5px/1 var(--font-mono);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+
+.trend-filter-label select {
+ font: 500 11px/1 var(--font-mono);
+ color: var(--ink);
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ max-width: 16ch;
+}
+
+.trend-filter-label select:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.trend-year-chip {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font:
+ 500 10px/1 'IBM Plex Mono',
+ var(--font-mono);
+ padding: 7px 10px;
+ background: var(--ink);
+ color: var(--paper);
+ border: none;
+ border-radius: 3px;
+ cursor: pointer;
+}
+
+.trend-total {
+ margin-left: auto;
+ font:
+ 400 11px/1 'IBM Plex Mono',
+ var(--font-mono);
+ color: var(--ink-mid);
+}
+
+.trend-total b {
+ color: var(--ink);
+}
+
+/* panel headers + chart legend */
+.trend-panel-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.trend-panel-title {
+ margin: 0;
+ font-family: var(--font-serif, Georgia, serif);
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.trend-panel-title em {
+ font-style: italic;
+ color: var(--accent);
+}
+
+.trend-hint {
+ font:
+ 400 10px/1 'IBM Plex Mono',
+ var(--font-mono);
+ color: var(--ink-soft);
+}
+
+.trend-legend {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ font:
+ 400 9.5px/1 'IBM Plex Mono',
+ var(--font-mono);
+ color: var(--ink-soft);
+ flex-wrap: wrap;
+}
+
+.trend-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.trend-legend-meta {
+ color: var(--ink-mid);
+}
+
+.trend-sw-box {
+ width: 9px;
+ height: 9px;
+ background: rgb(94 124 139 / 0.55); /* slate — matches the count bars */
+ display: inline-block;
+ border-radius: 1px;
+}
+
+.trend-sw-dashed {
+ width: 14px;
+ border-top: 1.6px dashed var(--accent);
+ display: inline-block;
+}
+
+.trend-sw-line {
+ width: 14px;
+ height: 2.4px;
+ background: var(--ink);
+ display: inline-block;
+ border-radius: 2px;
+}
+
+.trend-chart-body {
+ margin-top: 10px;
+ /* full-width chart in the vertical layout — give it real height (design ≈ 380px) */
+ min-height: 380px;
+}
+
+.trend-chart-panel--full .trend-chart-body {
+ min-height: 0;
+}
+
+.trend-chart-empty {
+ padding: 24px 0;
+}
+
+.trend-callout-p {
+ margin: 0;
+}
+
+/* year table */
+.trend-years {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.trend-years thead tr {
+ font:
+ 500 8.5px/1 'IBM Plex Mono',
+ var(--font-mono);
+ letter-spacing: 0.1em;
+ color: var(--ink-soft);
+}
+
+.trend-years thead th {
+ padding: 7px 8px 6px;
+ text-align: right;
+ border-bottom: 1px solid var(--ink);
+}
+
+.trend-years thead th:first-child {
+ text-align: left;
+ padding-left: 0;
+}
+
+.trend-years thead th:last-child {
+ padding-right: 0;
+}
+
+.trend-years tbody tr {
+ border-bottom: 1px solid var(--rule-soft);
+}
+
+.trend-years td.c-year {
+ padding: 6px 8px 6px 0;
+}
+
+.trend-year-btn {
+ font:
+ 600 12px/1 'IBM Plex Mono',
+ var(--font-mono);
+ color: var(--accent);
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+}
+
+.trend-years td.c-value {
+ text-align: right;
+ padding: 6px 8px;
+ font:
+ 600 12px/1 'IBM Plex Mono',
+ var(--font-mono);
+}
+
+.trend-years td.c-num {
+ text-align: right;
+ padding: 6px 8px;
+ font:
+ 400 11px/1 'IBM Plex Mono',
+ var(--font-mono);
+ color: var(--ink-mid);
+}
+
+.trend-years td.c-share {
+ text-align: right;
+ padding: 6px 8px;
+ font:
+ 400 11px/1 'IBM Plex Mono',
+ var(--font-mono);
+ color: var(--ink-soft);
+}
+
+.trend-years td.c-yoy {
+ padding: 6px 0 6px 8px;
+}
+
+.trend-yoy-cell {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.trend-yoy-bar {
+ height: 5px;
+ border-radius: 3px;
+}
+
+.trend-yoy-pct {
+ font:
+ 500 10.5px/1 'IBM Plex Mono',
+ var(--font-mono);
+ min-width: 52px;
+ text-align: right;
+}
+
+.trend-years-empty {
+ margin-top: 10px;
+}
+
+/* right rail: newest contracts */
+.trend-rail-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ padding: 0 16px 10px;
+ border-bottom: 1px solid var(--rule);
+}
+
+.trend-rail-rss {
+ font:
+ 500 9px/1 'IBM Plex Mono',
+ var(--font-mono);
+ letter-spacing: 0.1em;
+ color: var(--accent);
+}
+
+.trend-rail-submeta {
+ padding: 6px 16px 8px;
+ font:
+ 400 10px/1.4 'IBM Plex Mono',
+ var(--font-mono);
+ color: var(--ink-soft);
+}
+
+/* contracts as a full-width responsive card grid (design), not a narrow side list */
+.trend-rail-list {
+ list-style: none;
+ margin: 0;
+ padding: 8px 16px 16px;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
+ gap: 12px;
+}
+
+.trend-rail-item {
+ padding: 11px 13px;
+ border: 1px solid var(--rule-soft);
+ border-radius: 4px;
+}
+
+.trend-rail-row {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.trend-rail-date {
+ font:
+ 500 9.5px/1 'IBM Plex Mono',
+ var(--font-mono);
+ color: var(--ink-soft);
+}
+
+.trend-rail-val {
+ font:
+ 600 11px/1 'IBM Plex Mono',
+ var(--font-mono);
+ color: var(--ink);
+ white-space: nowrap;
+}
+
+.trend-rail-buyer {
+ margin-top: 5px;
+ font-size: 11.5px;
+ font-weight: 500;
+}
+
+.trend-rail-seller {
+ margin-top: 2px;
+ font-size: 11px;
+ color: var(--ink-mid);
+}
+
+.trend-rail-seller-arrow {
+ color: var(--accent);
+}
+
+/* compact metric tags replacing the verbose subject paragraph — fits more rows */
+.trend-rail-tags {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 7px;
+ margin-top: 5px;
+}
+
+.trend-rail-sector {
+ font: 500 8.5px/1 var(--font-mono);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+
+.trend-rail-eu {
+ font: 600 8px/1 var(--font-mono);
+ letter-spacing: 0.06em;
+ color: var(--accent);
+ border: 1px solid color-mix(in oklch, var(--accent) 40%, transparent);
+ border-radius: 2px;
+ padding: 2px 4px;
+}
+
+.trend-rail-more {
+ margin-left: auto;
+ font: 500 8.5px/1 var(--font-mono);
+ letter-spacing: 0.06em;
+ color: var(--ink-mid);
+}
+
+.trend-rail-empty {
+ padding: 16px;
+}
+
+.trend-rail-end {
+ grid-column: 1 / -1;
+ padding: 12px 16px 16px;
+ font:
+ 400 10px/1 'IBM Plex Mono',
+ var(--font-mono);
+ color: var(--ink-soft);
+ text-align: center;
+}
+
+/* combo chart wrapper + hover tooltip (TrendComboChart.tsx) */
+.trend-chart-wrap {
+ position: relative;
+ width: 100%;
+ /* the design's chart palette — the count series is slate, the € line is tan, the trend is ink, and
+ the forecast/peak/hover are accent. Kept as scoped custom props so the SVG (whose fill/stroke can't
+ take a class) inherits them. Slate is decorative here: every series is also shape- and legend-coded. */
+ --trend-count: 94 124 139; /* slate (rgb channels) — count bars + band */
+ /* same slate as --trend-count above, as a hex string for contexts that can't use rgb()/channels
+ — not darker, and not yet consumed by any call site; darken here if one needs more contrast. */
+ --trend-count-ink: #5e7c8b;
+ --trend-line: #c4b79c; /* tan — actual/forecast € line */
+ --trend-grid: #e7dfcd; /* warm gridlines */
+ --trend-xtick-fc: #9aa7ae; /* forecast x-tick label */
+}
+
+.trend-svg {
+ display: block;
+ overflow: visible;
+}
+
+.trend-tip {
+ position: absolute;
+ pointer-events: none;
+ transform: translate(-50%, -112%);
+ background: var(--ink);
+ color: var(--paper);
+ padding: 7px 10px;
+ border-radius: 3px;
+ white-space: nowrap;
+ z-index: 5;
+ font-family: 'IBM Plex Mono', var(--font-mono);
+}
+
+.trend-tip-head {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 9px;
+ letter-spacing: 0.06em;
+ opacity: 0.8;
+}
+
+.trend-tip-badge {
+ border: 1px solid color-mix(in oklch, var(--accent) 50%, transparent);
+ color: var(--accent);
+ border-radius: 2px;
+ padding: 1px 4px;
+ font-size: 7.5px;
+ letter-spacing: 0.1em;
+}
+
+.trend-tip-row {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ margin-top: 3px;
+}
+
+.trend-tip-row.is-first {
+ margin-top: 5px;
+}
+
+.trend-tip-sw-line {
+ width: 8px;
+ height: 2.4px;
+ background: var(--accent);
+ display: inline-block;
+ border-radius: 2px;
+}
+
+.trend-tip-sw-box {
+ width: 8px;
+ height: 8px;
+ background: rgb(var(--trend-count) / 0.85);
+ display: inline-block;
+ border-radius: 1px;
+}
+
+.trend-tip-sw-hollow {
+ width: 8px;
+ height: 8px;
+ border: 1.5px solid var(--accent);
+ border-radius: 50%;
+ display: inline-block;
+}
+
+.trend-tip-label {
+ font-size: 9px;
+ opacity: 0.8;
+}
+
+.trend-tip-val {
+ margin-left: auto;
+ font-size: 11px;
+ font-weight: 600;
+}
+
/* Top route-progress bar. Was an inline style toggling transform/opacity on the
navigation state; the two states now live in CSS, switched via [data-busy]. */
.route-progress {
@@ -607,6 +1247,379 @@ tbody td,
transform 1.2s ease-out,
opacity 0.1s ease;
}
+
+/* ===== end trends-dashboard ===== */
+
+/* ===== chart-fullscreen ===== */
+.fs-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ margin-left: 8px;
+ padding: 3px 8px;
+ font: 500 9px/1 var(--font-mono);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ink-mid);
+ background: var(--paper);
+ border: 1px solid var(--rule);
+ border-radius: 3px;
+ cursor: pointer;
+ flex: none;
+}
+
+.fs-btn:hover {
+ color: var(--accent);
+ border-color: var(--accent);
+}
+
+.fs-btn:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+/* ===== end chart-fullscreen ===== */
+
+/* ===== trends chart fullscreen modal ===== */
+.trend-fs-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ margin-left: 4px;
+ padding: 5px 9px;
+ font: 500 9px/1 var(--font-mono);
+ letter-spacing: 0.08em;
+ color: var(--ink-mid);
+ background: var(--paper);
+ border: 1px solid var(--rule);
+ border-radius: 3px;
+ cursor: pointer;
+}
+
+.trend-fs-btn:hover {
+ color: var(--accent);
+ border-color: var(--accent);
+}
+
+.trend-fs-backdrop {
+ position: fixed;
+ inset: 0;
+ background: color-mix(in oklch, var(--ink) 50%, transparent);
+ backdrop-filter: blur(2px);
+ z-index: 55;
+}
+
+.trend-chart-panel--full {
+ position: fixed;
+ inset: 28px;
+ z-index: 60;
+ background: var(--paper-warm);
+ border: 1px solid var(--rule);
+ border-radius: 6px;
+ padding: 26px 30px 18px;
+ box-shadow: 0 40px 100px color-mix(in oklch, var(--ink) 40%, transparent);
+ display: flex;
+ flex-direction: column;
+}
+
+.trend-chart-panel--full .trend-chart-body {
+ flex: 1;
+ min-height: 0;
+}
+
+.trend-fs-head {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 24px;
+ padding-bottom: 18px;
+ margin-bottom: 6px;
+ border-bottom: 1px solid var(--rule);
+ flex: none;
+}
+
+.trend-fs-kicker {
+ font: 600 10px/1 var(--font-mono);
+ letter-spacing: 0.2em;
+ color: var(--accent);
+}
+
+.trend-fs-title {
+ margin-top: 9px;
+ margin-bottom: 0;
+ font-family: var(--font-serif);
+ font-size: 30px;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+ line-height: 1;
+}
+
+.trend-fs-title em {
+ font-style: italic;
+ color: var(--accent);
+}
+
+.trend-fs-meta {
+ margin-top: 8px;
+ font: 400 11px/1 var(--font-mono);
+ color: var(--ink-mid);
+}
+
+.trend-fs-head-aside {
+ display: flex;
+ align-items: center;
+ gap: 26px;
+ flex: none;
+}
+
+.trend-fs-kpi {
+ text-align: right;
+}
+
+.trend-fs-kpi-v {
+ font: 600 26px/1 var(--font-mono);
+ color: var(--ink);
+}
+
+.trend-fs-kpi-v--accent {
+ color: var(--accent);
+}
+
+.trend-fs-kpi-l {
+ margin-top: 5px;
+ font: 500 8.5px/1 var(--font-mono);
+ letter-spacing: 0.14em;
+ color: var(--ink-soft);
+}
+
+.trend-fs-close {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 9px 14px;
+ font: 500 10px/1 var(--font-mono);
+ letter-spacing: 0.08em;
+ background: var(--ink);
+ color: var(--paper);
+ border: none;
+ border-radius: 3px;
+ cursor: pointer;
+}
+
+@media (max-width: 720px) {
+ .trend-chart-panel--full {
+ inset: 10px;
+ padding: 16px;
+ }
+
+ .trend-fs-head-aside {
+ gap: 14px;
+ }
+
+ .trend-fs-title {
+ font-size: 22px;
+ }
+}
+
+/* ===== end trends chart fullscreen modal ===== */
+
+/* ===== metric-info popover ===== */
+.metric-info {
+ position: relative;
+ display: inline-flex;
+ vertical-align: middle;
+}
+
+/* ≥24px hit area via padding, pulled back with negative margin so the inline layout doesn't shift. */
+.metric-info-btn {
+ position: relative; /* anchors the ::after touch-target extension (pointer: coarse) */
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ margin: -6px -5px -6px 1px;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: var(--ink-soft);
+ cursor: help;
+ flex: none;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.metric-info-glyph {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 13px;
+ line-height: 1;
+}
+
+.metric-info-btn:hover .metric-info-glyph,
+.metric-info:focus-within .metric-info-btn .metric-info-glyph,
+.metric-info.is-open .metric-info-btn .metric-info-glyph {
+ color: var(--accent);
+}
+
+.metric-info-btn:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -3px;
+ border-radius: 50%;
+}
+
+.metric-info-pop {
+ position: absolute;
+ z-index: 40;
+ top: calc(100% + 8px);
+ left: 0;
+ width: 320px;
+ /* never wider than the viewport — the JS shift in MetricInfo.tsx handles the horizontal clamp */
+ max-width: min(320px, calc(100vw - 16px));
+ /* the popover often sits inside a `th` (white-space: nowrap); reset inherited wrapping so the
+ title/summary/readout always wrap inside the card instead of overflowing it */
+ white-space: normal;
+ overflow-wrap: anywhere;
+ padding: 12px 14px;
+ background: var(--ink);
+ color: var(--paper);
+ border-radius: 5px;
+ box-shadow: 0 14px 34px color-mix(in oklch, var(--ink) 38%, transparent);
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ text-align: left;
+ text-transform: none;
+ letter-spacing: normal;
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(-3px);
+ transition:
+ opacity 0.14s ease,
+ transform 0.14s ease,
+ visibility 0.14s;
+ pointer-events: none;
+}
+
+.metric-info-pop.is-end {
+ left: auto;
+ right: 0;
+}
+
+.metric-info:hover .metric-info-pop,
+.metric-info:focus-within .metric-info-pop,
+.metric-info.is-open .metric-info-pop {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+}
+
+/* Esc dismissal overrides hover/focus-within/is-open until the triggering hover or focus clears */
+.metric-info.is-dismissed .metric-info-pop {
+ opacity: 0 !important;
+ visibility: hidden !important;
+}
+
+.metric-info-title {
+ font:
+ 600 9.5px/1 'IBM Plex Mono',
+ var(--font-mono);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: color-mix(in oklch, var(--paper) 70%, var(--ink));
+}
+
+.metric-info-summary {
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--paper);
+}
+
+.metric-info-readout {
+ margin-top: 1px;
+ padding-top: 7px;
+ border-top: 1px solid color-mix(in oklch, var(--paper) 22%, var(--ink));
+ font:
+ 500 11px/1.45 'IBM Plex Mono',
+ var(--font-mono);
+ color: color-mix(in oklch, var(--accent) 70%, var(--paper));
+}
+
+@media (max-width: 600px) {
+ .metric-info-pop {
+ width: 264px;
+ }
+}
+
+@media (pointer: coarse) {
+ /* invisible hit-area extension: 24px ⓘ glyph → ≥44px touch target */
+ .metric-info-btn::after {
+ content: '';
+ position: absolute;
+ inset: -10px;
+ }
+}
+
+/* ===== end metric-info popover ===== */
+
+/* ===== list-search (in-page search for /authorities, /companies, /contracts) ===== */
+.list-search {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 14px;
+}
+
+.list-search-field {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ gap: 8px;
+ padding: 0 12px;
+ background: var(--paper-raised);
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+}
+
+.list-search-field:focus-within {
+ border-color: var(--accent);
+ outline: 2px solid color-mix(in oklch, var(--accent) 28%, transparent);
+}
+
+.list-search-icon {
+ color: var(--ink-soft);
+ font-size: 15px;
+}
+
+.list-search-input {
+ flex: 1;
+ min-width: 0;
+ border: none;
+ outline: none;
+ background: transparent;
+ padding: 10px 0;
+ font-size: 14px;
+ color: var(--ink);
+}
+
+.list-search-btn {
+ flex: none;
+ padding: 0 16px;
+ font: 500 12px/1 var(--font-mono, monospace);
+ letter-spacing: 0.04em;
+ background: var(--ink);
+ color: var(--paper);
+ border: 1px solid var(--ink);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.list-search-btn:hover {
+ background: var(--accent);
+ border-color: var(--accent);
+}
+
+/* ===== end list-search ===== */
+
/* EU-benchmark indicator block (authority page „Конкуренция"): identical shape for both
indicators - hero share, meter with the two EU thresholds as hairline ticks, verdict and
counts in text. The meter is decorative; the fill wears the accent only over the „high"
diff --git a/apps/web/app/styles/pages.css b/apps/web/app/styles/pages.css
index fd6341d32..ccf9c30b1 100644
--- a/apps/web/app/styles/pages.css
+++ b/apps/web/app/styles/pages.css
@@ -58,15 +58,14 @@
color: var(--text);
}
-/* ── Contract page ───────────────────────────────────────────────────────── */
-
-/* Risk Indicators */
+/* Risk Indicators — used by RiskIndicators.tsx on the contract page */
.risk-indicators {
margin: var(--s-6) 0;
padding: var(--s-5);
background: var(--warning-bg);
border-left: 3px solid var(--warning);
}
+
.risk-title {
margin: 0 0 var(--s-3);
font: 500 13px/1.2 var(--font-mono);
@@ -77,9 +76,11 @@
align-items: center;
gap: var(--s-2);
}
+
.risk-title svg {
flex: none;
}
+
.risk-list {
margin: 0;
padding: 0;
@@ -87,11 +88,13 @@
font: 400 14px/1.5 var(--font-sans);
color: var(--ink);
}
+
.risk-list li {
margin: 0 0 var(--s-2);
padding-left: 20px;
position: relative;
}
+
.risk-list li::before {
content: '•';
position: absolute;
@@ -99,6 +102,7 @@
color: var(--warning);
font-weight: bold;
}
+
.risk-list li:last-child {
margin-bottom: 0;
}
@@ -534,6 +538,162 @@
height: auto;
display: block;
}
+
+/* Hydrated force view: the canvas is a pan/zoom surface; nodes are draggable. */
+.net-canvas {
+ position: relative;
+}
+
+.network-svg.is-interactive {
+ cursor: grab;
+ touch-action: none; /* let d3-zoom own touch gestures instead of the page scrolling */
+}
+
+.network-svg.is-interactive:active {
+ cursor: grabbing;
+}
+
+.network-svg.is-interactive a[data-draggable='1'] {
+ cursor: grab;
+}
+
+/* Zoom controls — overlaid top-right of the canvas, client-only (rendered after hydration). */
+.net-zoom {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.net-zoom button {
+ width: 30px;
+ height: 30px;
+ font: 600 16px var(--font-mono, monospace);
+ line-height: 1;
+ color: var(--ink, #222);
+ background: var(--paper, #fff);
+ border: 1px solid var(--rule, #d8d6cf);
+ border-radius: 6px;
+ cursor: pointer;
+}
+
+.net-zoom button:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+/* Graph + side Information Card layout (mirrors /map). Card wraps under the graph on narrow screens. */
+.net-explore {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+ align-items: flex-start;
+}
+
+.net-explore .net-canvas {
+ flex: 1 1 460px;
+ min-width: 0;
+}
+
+/* Full-screen: the widget fills the viewport on a paper background; the graph grows to use the height,
+ the card sits beside it, and the controls/legend stay usable. */
+.net-graph:fullscreen {
+ background: var(--paper);
+ padding: 24px;
+ overflow: auto;
+}
+
+.net-graph:fullscreen .net-explore {
+ min-height: calc(100vh - 160px);
+}
+
+.net-graph:fullscreen .net-canvas {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.net-graph:fullscreen .network-svg {
+ max-height: calc(100vh - 180px);
+}
+
+.net-card {
+ flex: 0 0 240px;
+ align-self: stretch;
+ border: 1px solid var(--rule, #d8d6cf);
+ border-radius: 8px;
+ padding: 16px 18px;
+ background: color-mix(in oklch, var(--ink) 3%, var(--paper));
+}
+
+.net-card-title {
+ margin: 0;
+ font-size: 1.05rem;
+}
+
+.net-card-sub {
+ margin: 2px 0 12px;
+ font: 12px var(--font-mono, monospace);
+}
+
+.net-card-stats {
+ margin: 0 0 12px;
+ display: grid;
+ gap: 10px;
+}
+
+.net-card-stats div {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 12px;
+ border-bottom: 1px dotted var(--rule, #d8d6cf);
+ padding-bottom: 6px;
+}
+
+.net-card-stats dt {
+ font: 12px var(--font-mono, monospace);
+ color: var(--ink-soft, #555);
+}
+
+.net-card-stats dd {
+ margin: 0;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+.net-card-actions {
+ margin: 0;
+}
+
+.net-card-hint {
+ margin: 0;
+ font-size: 0.9rem;
+}
+
+/* Hover-to-explore emphasis: dim everything but the focused node + its neighbours; lift the focus. */
+.network-svg.is-hovering .is-dim {
+ opacity: 0.18;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .network-svg .node,
+ .network-svg .edge,
+ .network-svg .node-label,
+ .network-svg .edge-label,
+ .network-svg g[class],
+ .network-svg a {
+ transition: opacity 0.12s ease;
+ }
+}
+
+.network-svg .is-focus .node {
+ stroke: var(--accent);
+ stroke-width: 2.5;
+}
.network-svg .edge {
stroke: #d8d6cf;
}
@@ -545,6 +705,119 @@
fill: var(--ink, #111);
font: 11px var(--font-mono, monospace);
}
+
+/* Per-edge value label, rotated in the component to lie along the edge. A thick white halo
+ (paint-order: stroke) keeps the number readable where it crosses edges/nodes. Toggle hides it. */
+.network-svg .edge-label {
+ fill: var(--ink, #222);
+ font: 600 10px var(--font-mono, monospace);
+ paint-order: stroke;
+ stroke: #fff;
+ stroke-width: 3.25px;
+ stroke-linejoin: round;
+ pointer-events: none;
+}
+
+/* Non-centre nodes are anchors to a profile page → show the click affordance and an accent ring. */
+.network-svg a {
+ cursor: pointer;
+}
+
+/* With JS (issue #142) nodes are also draggable; show the grab affordance. Sighted-only — the
+ connections table stays the keyboard/AT path, so this is purely a pointer cue. */
+.network-svg a[data-draggable='1'] {
+ cursor: grab;
+}
+
+.network-svg a[data-draggable='1']:active {
+ cursor: grabbing;
+}
+
+.network-svg a:hover .node,
+.network-svg a:focus-visible .node {
+ stroke: var(--accent);
+ stroke-width: 2.5;
+}
+
+.network-svg a:focus-visible {
+ outline: none;
+}
+
+/* Edge-label toggle (pure CSS, no JS): unchecked hides every .edge-label. */
+/* Controls row above the graph: the edge-value toggle plus, once you've browsed to another node,
+ the Open / Reset actions. Wraps on narrow screens. */
+.net-controls {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 16px;
+ margin: 0 0 8px;
+}
+
+.net-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font: 12px var(--font-mono, monospace);
+ color: var(--ink-soft, #555);
+ cursor: pointer;
+ user-select: none;
+}
+
+.net-toggle input {
+ accent-color: var(--accent);
+}
+
+.net-graph:has(.net-toggle input:not(:checked)) .edge-label {
+ display: none;
+}
+
+/* Browse actions — appear only after a client-side re-centre (no JS = no recentre = hidden). */
+.net-actions {
+ display: inline-flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+
+.net-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 10px;
+ font: 600 12px var(--font-mono, monospace);
+ color: #fff;
+ background: var(--accent);
+ border: 1px solid var(--accent);
+ border-radius: 4px;
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.net-btn:hover {
+ filter: brightness(0.94);
+}
+
+.net-btn-ghost {
+ color: var(--ink, #222);
+ background: transparent;
+ border-color: var(--rule, #d8d6cf);
+}
+
+.net-loading {
+ font: 12px var(--font-mono, monospace);
+ color: var(--ink-soft, #555);
+}
+
+.net-hint {
+ font: 12px var(--font-mono, monospace);
+ color: var(--ink-soft, #888);
+}
+
+.net-error {
+ font: 12px var(--font-mono, monospace);
+ color: var(--accent);
+}
.net-legend {
display: flex;
flex-wrap: wrap;
@@ -571,6 +844,12 @@
.net-legend .key.authority {
border-radius: 50%;
}
+
+.net-caption {
+ margin: 10px 0 0;
+ text-align: center;
+ font-size: 12px;
+}
.net-legend .key.center {
background: var(--accent);
}
@@ -625,6 +904,19 @@
fill: var(--ink-soft, #555);
}
+/* Map + Information Card side by side; the card wraps under the map on narrow screens. */
+.map-layout {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 20px;
+ align-items: flex-start;
+}
+
+.map-layout .map-wrap {
+ flex: 1 1 460px;
+ min-width: 0;
+}
+
/* Regional choropleth (/map): static styling only; the per-region tier fill is computed inline. */
.map-wrap svg {
width: 100%;
@@ -637,6 +929,96 @@
stroke: #f7f7f4;
stroke-width: 1;
}
+
+/* Hover highlight for the focused region (mouse-driven; the table is the keyboard/AT path). */
+.map-wrap path.region {
+ transition: fill 0.1s ease;
+}
+
+.map-wrap path.is-active {
+ stroke: var(--accent);
+ stroke-width: 2;
+}
+
+/* The Information Card beside the map. */
+.map-card {
+ flex: 0 0 250px;
+ align-self: stretch;
+ border: 1px solid var(--rule, #d8d6cf);
+ border-radius: 8px;
+ padding: 16px 18px;
+ background: color-mix(in oklch, var(--ink) 3%, var(--paper));
+}
+
+/* Grouping toggle, kept inside the card so all map controls sit together. */
+.map-toggle {
+ display: flex;
+ gap: 0;
+ margin: 0 0 14px;
+ border: 1px solid var(--rule, #d8d6cf);
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.map-toggle button {
+ flex: 1;
+ padding: 6px 8px;
+ font: 12px var(--font-mono, monospace);
+ color: var(--ink-soft, #555);
+ background: var(--paper, #fff);
+ border: 0;
+ cursor: pointer;
+}
+
+.map-toggle button + button {
+ border-left: 1px solid var(--rule, #d8d6cf);
+}
+
+.map-toggle button.is-on {
+ color: #fff;
+ background: var(--accent);
+}
+
+.map-card-title {
+ margin: 0;
+ font-size: 1.05rem;
+}
+
+.map-card-sub {
+ margin: 2px 0 12px;
+ font: 12px var(--font-mono, monospace);
+}
+
+.map-card-stats {
+ margin: 0;
+ display: grid;
+ gap: 10px;
+}
+
+.map-card-stats div {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 12px;
+ border-bottom: 1px dotted var(--rule, #d8d6cf);
+ padding-bottom: 6px;
+}
+
+.map-card-stats dt {
+ font: 12px var(--font-mono, monospace);
+ color: var(--ink-soft, #555);
+}
+
+.map-card-stats dd {
+ margin: 0;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+.map-card-hint {
+ margin: 0;
+ font-size: 0.9rem;
+}
.map-legend {
display: flex;
align-items: center;
@@ -651,3 +1033,1990 @@
height: 12px;
border-radius: 2px;
}
+
+/* ===== analyze-landing =====
+ The /analytics hub: an editorial masthead + five EQUAL full-width hero cards (one per analysis),
+ each pairing two real KPI figures with a decorative, aria-hidden thumbnail. Cards are real anchors
+ (keyboard-focusable, visible focus ring); the 380px thumbnail pane collapses under ~720px. */
+.analyze-landing {
+ max-width: 1120px;
+ margin: 0 auto;
+}
+
+.az-masthead {
+ margin: 0 0 var(--s-7);
+}
+
+.az-kicker {
+ margin: 0 0 var(--s-4);
+ font: 10px/1.3 var(--font-mono);
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ color: var(--accent);
+}
+
+.az-title {
+ margin: 0 0 var(--s-4);
+ max-width: 18ch;
+ font: 600 40px/1.1 var(--font-serif);
+ letter-spacing: -0.01em;
+ color: var(--ink);
+}
+
+.az-title em {
+ font-style: italic;
+ color: var(--accent);
+}
+
+.az-lede {
+ margin: 0;
+ max-width: 600px;
+ font: 14px/1.55 var(--font-sans);
+ color: var(--ink-mid);
+}
+
+.az-cards {
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+
+/* One hero card — left editorial pane + right thumbnail pane. */
+.az-card {
+ position: relative;
+ display: grid;
+ grid-template-columns: 1fr 380px;
+ min-height: 208px;
+ background: var(--paper-warm);
+ border: 1px solid var(--rule);
+ border-radius: 6px;
+ box-shadow: 0 1px 2px oklch(18% 0.012 70 / 0.04);
+ color: inherit;
+ transition:
+ transform 0.15s ease,
+ box-shadow 0.15s ease,
+ border-color 0.15s ease;
+}
+
+.az-card:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 10px 24px oklch(18% 0.012 70 / 0.1);
+ border-color: var(--ink-soft);
+}
+
+/* stretched link: the whole card is clickable, but the stat ⓘ buttons sit above it (z-index) so they
+ stay independently operable. The focus ring renders on the card via the link's stretched ::after. */
+.az-card-stretch {
+ text-decoration: none;
+ color: inherit;
+}
+
+.az-card-stretch::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ border-radius: 6px;
+}
+
+.az-card-stretch:focus-visible::after {
+ outline: 2px solid var(--accent);
+ outline-offset: 3px;
+}
+
+.az-card .metric-info {
+ position: relative;
+ z-index: 1;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .az-card {
+ transition: none;
+ }
+
+ .az-card:hover {
+ transform: none;
+ }
+}
+
+.az-card-main {
+ display: flex;
+ flex-direction: column;
+ padding: 26px 30px;
+ min-width: 0;
+}
+
+.az-card-eyebrow {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: var(--s-2) var(--s-3);
+ margin: 0 0 var(--s-3);
+}
+
+.az-card-index {
+ font: 600 10px/1.3 var(--font-mono);
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--accent);
+}
+
+.az-card-cat {
+ font: 500 9px/1.3 var(--font-mono);
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+
+.az-card-title {
+ margin: 0 0 var(--s-2);
+ font: 600 25px/1.2 var(--font-serif);
+ color: var(--ink);
+}
+
+.az-card-title em {
+ font-style: italic;
+ color: var(--accent);
+}
+
+.az-card-title em.az-em-slate {
+ color: var(--slate);
+}
+
+.az-card-desc {
+ margin: 0;
+ max-width: 420px;
+ font: 13px/1.5 var(--font-sans);
+ color: var(--ink-mid);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.az-card-foot {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: var(--s-4);
+ margin-top: auto;
+ padding-top: var(--s-5);
+}
+
+.az-card-stats {
+ display: flex;
+ gap: var(--s-6);
+ margin: 0;
+}
+
+.az-card-stats div {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.az-stat-num {
+ margin: 0;
+ font: 600 19px/1.1 var(--font-mono);
+ color: var(--ink);
+}
+
+.az-stat-num--accent {
+ color: var(--accent);
+}
+
+.az-stat-label {
+ display: inline-flex;
+ align-items: center;
+ font: 500 8px/1.3 var(--font-mono);
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+
+.az-stat-hint {
+ max-width: 22ch;
+ margin: 1px 0 0;
+ font: 400 9px/1.3 var(--font-mono);
+ letter-spacing: 0;
+ text-transform: none;
+ color: var(--ink-soft);
+}
+
+.az-card-cta {
+ flex: none;
+ font: 600 11px/1.3 var(--font-mono);
+ letter-spacing: 0.04em;
+ color: var(--accent);
+ white-space: nowrap;
+}
+
+/* Right pane — a touch deeper than the card, with a decorative thumbnail. */
+.az-card-thumb {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ background: var(--paper-deep);
+ border-left: 1px solid var(--rule);
+ /* the card no longer clips overflow (so the ⓘ popover can escape), so round the thumb's own corners */
+ border-radius: 0 6px 6px 0;
+}
+
+.az-thumb {
+ display: block;
+ width: 100%;
+ max-width: 320px;
+ height: auto;
+}
+
+.az-fill-ink {
+ fill: var(--ink);
+}
+
+.az-fill-accent {
+ fill: var(--accent);
+}
+
+.az-fill-slate {
+ fill: var(--slate);
+}
+
+.az-fill-tan {
+ fill: var(--tan);
+}
+
+.az-fill-rule {
+ fill: var(--rule);
+}
+
+.az-thumb-soft {
+ opacity: 0.55;
+}
+
+.az-thumb-faint {
+ opacity: 0.14;
+}
+
+.az-stroke-ink,
+.az-stroke-accent {
+ stroke-width: 2.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.az-stroke-ink {
+ stroke: var(--ink);
+}
+
+.az-stroke-accent {
+ stroke: var(--accent);
+}
+
+.az-thumb-dash {
+ stroke-dasharray: 5 5;
+}
+
+@media (max-width: 720px) {
+ .az-title {
+ font-size: 32px;
+ }
+
+ .az-card {
+ grid-template-columns: 1fr;
+ }
+
+ .az-card-thumb {
+ border-left: 0;
+ border-top: 1px solid var(--rule);
+ padding: 16px 20px;
+ }
+
+ .az-thumb {
+ max-width: 240px;
+ }
+}
+
+@media (max-width: 460px) {
+ .az-card-foot {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--s-3);
+ }
+}
+
+/* ===== end analyze-landing ===== */
+
+/* ===== price-anomaly („Раздути спрямо сходни") =====
+ The CPV-cohort outlier dashboard: a masthead with 3 method KPIs, a 2-col top row (V2 cohort browse
+ + V3 distribution strips, both selecting a cohort via a real ?cohort= link) and a full-width grid of
+ flagged-contract scorecards faceted by the selection. Colours/typography mirror the Claude-Design
+ mock; every figure is real and the accent-red caveat never asserts wrongdoing. */
+.pa-page {
+ max-width: 1340px;
+ margin: 0 auto;
+}
+
+.pa-mast {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: var(--s-7);
+ flex-wrap: wrap;
+ margin: 0 0 var(--s-6);
+}
+
+.pa-mast-main {
+ min-width: 0;
+ flex: 1 1 460px;
+}
+
+.pa-mast-kicker {
+ margin: 0 0 var(--s-3);
+ font: 600 10px/1 var(--font-mono);
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ color: var(--accent);
+}
+
+.pa-mast-title {
+ margin: 0;
+ font: 600 36px/1.02 var(--font-serif);
+ letter-spacing: -0.018em;
+ color: var(--ink);
+}
+
+.pa-mast-title em {
+ font-style: italic;
+ color: var(--accent);
+}
+
+.pa-mast-lede {
+ margin: var(--s-3) 0 0;
+ max-width: 560px;
+ font: 13px/1.5 var(--font-sans);
+ color: var(--ink-mid);
+}
+
+.pa-mast-kpis {
+ display: flex;
+ flex: none;
+ margin: 0;
+}
+
+.pa-hk {
+ padding: 0 22px;
+ border-left: 1px solid var(--rule);
+}
+
+.pa-hk:first-child {
+ padding-left: 0;
+ border-left: 0;
+}
+
+.pa-hk-v {
+ margin: 0;
+ font: 600 24px/1 var(--font-mono);
+ color: var(--ink);
+}
+
+.pa-hk-v.accent {
+ color: var(--accent);
+}
+
+.pa-hk-l {
+ display: inline-flex;
+ align-items: center;
+ margin-top: var(--s-2);
+ font: 500 9px/1 var(--font-mono);
+ letter-spacing: 0.12em;
+ color: var(--ink-soft);
+}
+
+/* shared panel chrome */
+.pa-panel {
+ background: var(--paper-warm);
+ border: 1px solid var(--rule);
+ border-radius: 5px;
+ min-width: 0;
+}
+
+.pa-panel-head {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: var(--s-3);
+ padding: 15px 20px 13px;
+ border-bottom: 1px solid var(--rule);
+}
+
+.pa-panel-head--col {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--s-2);
+}
+
+.pa-panel-head--wrap {
+ flex-wrap: wrap;
+}
+
+.pa-kicker {
+ font: 600 9px/1 var(--font-mono);
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ color: var(--accent);
+}
+
+.pa-panel-title {
+ margin: var(--s-2) 0 0;
+ font: 600 18px/1 var(--font-serif);
+ color: var(--ink);
+}
+
+.pa-panel-title em {
+ font-style: italic;
+ color: var(--accent);
+}
+
+/* segmented sort tabs */
+.pa-seg {
+ display: flex;
+ flex: none;
+ border: 1px solid var(--rule);
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.pa-seg a {
+ padding: 7px 9px;
+ font: 500 9px/1 var(--font-mono);
+ letter-spacing: 0.03em;
+ text-decoration: none;
+ color: var(--ink-mid);
+ background: var(--paper-raised);
+ border-left: 1px solid var(--rule);
+}
+
+.pa-seg a:first-child {
+ border-left: 0;
+}
+
+.pa-seg a[aria-current='true'] {
+ background: var(--ink);
+ color: var(--paper);
+}
+
+/* cohort browse — ONE full-width table: stats + the inline distribution strip per row */
+.pa-browse {
+ margin-bottom: 14px;
+}
+
+.pa-browse-headrow,
+.pa-browse-row {
+ display: grid;
+ grid-template-columns: 52px minmax(110px, 1.3fr) 96px 60px 52px 124px minmax(190px, 1.7fr);
+ gap: 9px;
+ align-items: center;
+}
+
+.pa-browse-headrow {
+ padding: 9px 20px 7px;
+ border-bottom: 1px solid var(--ink);
+ font: 500 7.5px/1.2 var(--font-mono);
+ letter-spacing: 0.06em;
+ color: var(--ink-soft);
+}
+
+/* a header cell that carries an inline ⓘ — keep the glyph on the label's baseline, never wrap */
+.pa-th {
+ display: flex;
+ align-items: center;
+ gap: 1px;
+ min-width: 0;
+}
+
+.pa-th-r {
+ justify-content: flex-end;
+}
+
+.pa-r {
+ text-align: right;
+}
+
+.pa-browse-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.pa-browse-row {
+ padding: 9px 20px;
+ border-bottom: 1px solid var(--rule-soft);
+ border-left: 2px solid transparent;
+ text-decoration: none;
+ color: var(--ink);
+}
+
+.pa-browse-row:hover {
+ background: var(--accent-bg);
+}
+
+.pa-browse-row.is-on {
+ background: var(--accent-bg);
+ border-left-color: var(--accent);
+}
+
+.pa-browse-code {
+ font: 600 10px/1 var(--font-mono);
+ color: var(--ink-soft);
+}
+
+.pa-browse-row.is-on .pa-browse-code {
+ color: var(--accent);
+}
+
+.pa-browse-name {
+ font: 400 11.5px/1.3 var(--font-sans);
+}
+
+.pa-browse-row.is-on .pa-browse-name {
+ font-weight: 600;
+}
+
+.pa-browse-med {
+ font: 600 10.5px/1 var(--font-mono);
+ white-space: nowrap;
+}
+
+.pa-browse-n {
+ font: 400 10px/1 var(--font-mono);
+ color: var(--ink-mid);
+ white-space: nowrap;
+}
+
+.pa-browse-out {
+ font: 600 10px/1 var(--font-mono);
+ color: var(--accent);
+ white-space: nowrap;
+}
+
+.pa-browse-share {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.pa-share-track {
+ flex: 1;
+ height: 6px;
+ background: var(--rule-soft);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.pa-share-fill {
+ display: block;
+ height: 100%;
+ background: var(--accent);
+}
+
+.pa-share-pct {
+ width: 26px;
+ text-align: right;
+ font: 600 9px/1 var(--font-mono);
+ color: var(--ink);
+}
+
+/* the inline distribution strip, rightmost cell of each browse row */
+.pa-browse-strip {
+ min-width: 0;
+}
+
+.pa-strip {
+ display: block;
+ width: 100%;
+ height: auto;
+ overflow: visible;
+}
+
+.pa-strip-axis {
+ stroke: var(--rule-soft);
+ stroke-width: 1;
+}
+
+.pa-strip-ticktext {
+ font-family: var(--font-mono);
+ font-size: 8.5px;
+ fill: var(--ink-soft);
+}
+
+.pa-strip-med {
+ stroke: var(--accent);
+ stroke-width: 1.6;
+}
+
+.pa-strip-med.is-dashed {
+ stroke-width: 1.4;
+ stroke-dasharray: 3 2;
+}
+
+.pa-dot {
+ fill: var(--ink);
+ fill-opacity: 0.4;
+}
+
+.pa-dot.is-big {
+ fill: var(--accent);
+ fill-opacity: 0.95;
+}
+
+.pa-browse-legend {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 10px 20px 14px;
+ border-top: 1px solid var(--rule-soft);
+ font: 400 9.5px/1 var(--font-mono);
+ color: var(--ink-mid);
+}
+
+.pa-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.pa-legend-med {
+ width: 14px;
+ height: 2px;
+ background: var(--accent);
+}
+
+.pa-legend-big {
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ background: var(--accent);
+}
+
+.pa-browse-selcount {
+ margin-left: auto;
+}
+
+/* V4 — flagged-contract scorecards */
+.pa-scorecards {
+ overflow: hidden;
+}
+
+.pa-filter {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.pa-filter-label {
+ font: 500 8.5px/1 var(--font-mono);
+ letter-spacing: 0.1em;
+ color: var(--ink-soft);
+}
+
+.pa-filter-all {
+ font: 400 10px/1 var(--font-mono);
+ color: var(--ink-mid);
+}
+
+.pa-chip {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ max-width: 220px;
+ padding: 5px 8px;
+ font: 500 9.5px/1.2 var(--font-mono);
+ text-decoration: none;
+ border: 1px solid var(--accent);
+ border-radius: 3px;
+ background: var(--accent-bg);
+ color: var(--accent);
+}
+
+.pa-clear {
+ padding: 6px 10px;
+ font: 500 9px/1 var(--font-mono);
+ letter-spacing: 0.04em;
+ text-decoration: none;
+ border: 1px solid var(--rule);
+ border-radius: 3px;
+ background: var(--paper-raised);
+ color: var(--ink-mid);
+ white-space: nowrap;
+}
+
+.pa-clear:hover {
+ background: var(--ink);
+ color: var(--paper);
+}
+
+/* ── selected-CPV summary header (top of the scorecards, one block per selected cohort) ── */
+.pa-cohort-summary {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin: 0;
+ padding: 16px 20px 4px;
+}
+
+.pa-sumcard {
+ border: 1px solid var(--accent);
+ border-radius: 4px;
+ background: var(--accent-bg);
+ padding: 13px 16px;
+}
+
+.pa-sumcard-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.pa-sumcard-head .pa-card-cpv {
+ background: var(--paper);
+}
+
+.pa-sumcard-name {
+ font: 600 13px/1.3 var(--font-sans);
+ color: var(--ink);
+}
+
+.pa-sumcard-stats {
+ margin: 9px 0 0;
+ font: 400 12px/1.5 var(--font-sans);
+ color: var(--ink-mid);
+}
+
+.pa-sumcard-stats strong {
+ font-weight: 600;
+ color: var(--ink);
+}
+
+.pa-sumcard-link {
+ display: inline-block;
+ margin-top: 9px;
+ font: 600 11px/1 var(--font-mono);
+ letter-spacing: 0.02em;
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.pa-sumcard-link:hover,
+.pa-sumcard-link:focus-visible {
+ text-decoration: underline;
+}
+
+.pa-cards-grid {
+ list-style: none;
+ margin: 0;
+ padding: 18px 20px;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(384px, 1fr));
+ gap: 16px;
+}
+
+.pa-card {
+ background: var(--paper-raised);
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+ padding: 15px 16px 14px;
+}
+
+.pa-card-top {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+}
+
+.pa-card-id {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ min-width: 0;
+}
+
+.pa-card-rank {
+ font: 600 18px/1 var(--font-serif);
+ color: var(--accent);
+}
+
+.pa-card-cpv {
+ font: 600 9px/1 var(--font-mono);
+ letter-spacing: 0.06em;
+ color: var(--ink-soft);
+ border: 1px solid var(--rule);
+ border-radius: 2px;
+ padding: 3px 5px;
+}
+
+/* The card's CPV chip is a real link that toggles the ?cohort= facet (sibling of the title link). */
+a.pa-card-cpv {
+ text-decoration: none;
+ transition:
+ color 0.12s ease,
+ border-color 0.12s ease,
+ background 0.12s ease;
+}
+
+a.pa-card-cpv:hover,
+a.pa-card-cpv:focus-visible {
+ color: var(--accent);
+ border-color: var(--accent);
+ background: var(--accent-bg);
+}
+
+.pa-card-mult {
+ margin-left: auto;
+ text-align: right;
+ flex: none;
+}
+
+.pa-card-mult-v {
+ font: 600 20px/1 var(--font-mono);
+ color: var(--accent);
+}
+
+.pa-card-mult-l {
+ margin-top: 3px;
+ font: 500 8px/1 var(--font-mono);
+ letter-spacing: 0.08em;
+ color: var(--ink-soft);
+}
+
+.pa-card-title {
+ margin-top: 11px;
+ font: 600 12.5px/1.32 var(--font-sans);
+ color: var(--ink);
+ min-height: 33px;
+}
+
+.pa-card-title a {
+ color: inherit;
+ text-decoration: none;
+}
+
+.pa-card-title a:hover {
+ color: var(--accent);
+ text-decoration: underline;
+}
+
+.pa-card-buyer {
+ margin-top: 6px;
+ font: 400 9.5px/1.3 var(--font-mono);
+ color: var(--ink-soft);
+}
+
+.pa-card-buyer a {
+ color: var(--ink-mid);
+ text-decoration: none;
+}
+
+.pa-card-buyer a:hover {
+ color: var(--accent);
+}
+
+.pa-card-strip {
+ display: block;
+ width: 100%;
+ height: auto;
+ overflow: visible;
+ margin-top: 12px;
+}
+
+.pa-card-hi {
+ fill: var(--accent);
+ stroke: var(--paper-raised);
+ stroke-width: 1.5;
+}
+
+.pa-card-figs {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 8px;
+ margin: 10px 0 0;
+ padding-top: 11px;
+ border-top: 1px solid var(--rule-soft);
+}
+
+.pa-card-figs dt {
+ font: 500 7.5px/1 var(--font-mono);
+ letter-spacing: 0.08em;
+ color: var(--ink-soft);
+}
+
+.pa-card-figs dd {
+ margin: 4px 0 0;
+ font: 600 12px/1 var(--font-mono);
+}
+
+.pa-fig-val {
+ color: var(--ink);
+}
+
+.pa-fig-med {
+ color: var(--ink-mid);
+}
+
+.pa-fig-pct {
+ color: var(--accent);
+}
+
+.pa-cards-empty {
+ padding: 26px 20px;
+ text-align: center;
+ font: 400 11px/1.5 var(--font-mono);
+ color: var(--ink-soft);
+}
+
+.pa-caveat {
+ margin: 0;
+ padding: 11px 20px 14px;
+ background: var(--accent-bg);
+ border-top: 1px solid var(--accent);
+ font: 400 9.5px/1.45 var(--font-sans);
+ color: var(--ink-mid);
+}
+
+.pa-caveat-strong {
+ font-weight: 600;
+ color: var(--accent);
+}
+
+/* methodology block — the complete „как се смята" section */
+.pa-method {
+ margin-top: 22px;
+}
+
+.pa-method-body {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 18px 26px;
+ padding: 16px 20px 20px;
+}
+
+.pa-method-block {
+ min-width: 0;
+}
+
+.pa-method-block h3 {
+ margin: 0 0 6px;
+ font: 600 12px/1.3 var(--font-mono);
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--accent);
+}
+
+.pa-method-block p {
+ margin: 0 0 8px;
+ font: 400 12.5px/1.55 var(--font-sans);
+ color: var(--ink-mid);
+}
+
+.pa-method-block p:last-child {
+ margin-bottom: 0;
+}
+
+.pa-method-block strong {
+ font-weight: 600;
+ color: var(--ink);
+}
+
+.pa-method-block code {
+ font: 500 11.5px/1.4 var(--font-mono);
+ color: var(--ink);
+ background: var(--paper);
+ border: 1px solid var(--rule);
+ border-radius: 3px;
+ padding: 0 4px;
+}
+
+.pa-method-block ul {
+ margin: 0;
+ padding-left: 16px;
+ list-style: disc;
+}
+
+.pa-method-block li {
+ margin: 0 0 6px;
+ font: 400 12.5px/1.5 var(--font-sans);
+ color: var(--ink-mid);
+}
+
+.pa-method-block li:last-child {
+ margin-bottom: 0;
+}
+
+@media (max-width: 900px) {
+ .pa-method-body {
+ grid-template-columns: 1fr;
+ }
+
+ .pa-mast-title {
+ font-size: 30px;
+ }
+}
+
+@media (max-width: 820px) {
+ .pa-browse-headrow,
+ .pa-browse-row {
+ grid-template-columns: 48px minmax(90px, 1.3fr) 88px 54px 48px 100px;
+ }
+
+ .pa-th-strip,
+ .pa-browse-strip {
+ display: none;
+ }
+}
+
+@media (max-width: 560px) {
+ .pa-cards-grid {
+ grid-template-columns: 1fr;
+ }
+
+ /* Drop the РАЗДУТ ДЯЛ column too — 5 stat columns left, gap tightened. */
+ .pa-browse-headrow,
+ .pa-browse-row {
+ grid-template-columns: 44px 1fr 70px 42px 40px;
+ gap: 6px;
+ }
+
+ .pa-th-share,
+ .pa-browse-share {
+ display: none;
+ }
+}
+
+/* ===== end trends-dashboard ===== */
+
+/* ===== overruns-dashboard ===== */
+/* Static layout/typography for /overruns + the /analytics „Раздуване" hero. Ported from the route
+ files (no new inline style=, per docs/review-accessibility.md). Only data-driven values (bar/
+ scatter geometry, active accent) remain inline. All colours via app tokens. */
+
+/* shared primitives */
+.ov-panel {
+ background: var(--paper-warm);
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.ov-mono-label {
+ font: 500 9px/1 var(--font-mono);
+ letter-spacing: 0.12em;
+ color: var(--ink-soft);
+ text-transform: uppercase;
+}
+
+.ov-accent {
+ color: var(--accent);
+}
+
+/* page column: keep the dashboard within the 1200px editorial measure of the design mock */
+.ov-page {
+ max-width: 1200px;
+}
+
+/* masthead — kicker + title + lede on the left, the three headline KPIs inline on the right (design,
+ same composition as .trend-header). */
+.ov-mast {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 24px;
+ margin: 0 0 14px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid var(--ink);
+}
+
+.ov-mast-main {
+ min-width: 0;
+}
+
+.ov-mast-kicker {
+ margin: 0;
+ font: 600 10px/1 var(--font-mono);
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ color: var(--accent);
+}
+
+.ov-mast-title {
+ margin: 10px 0 0;
+ font-family: var(--font-serif);
+ font-size: 38px;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+ line-height: 1.02;
+ color: var(--ink);
+}
+
+.ov-mast-title em {
+ font-style: italic;
+ color: var(--accent);
+}
+
+.ov-mast-lede {
+ margin: 9px 0 0;
+ max-width: 540px;
+ font-size: 12.5px;
+ line-height: 1.45;
+ color: var(--ink-mid);
+}
+
+.ov-mast-kpis {
+ display: flex;
+ flex: none;
+ margin: 0;
+}
+
+.ov-hk {
+ padding: 0 22px;
+ border-left: 1px solid var(--rule);
+}
+
+.ov-hk:last-child {
+ padding-right: 0;
+}
+
+.ov-hk-v {
+ margin: 0;
+ font: 600 25px/1 var(--font-mono);
+ font-variant-numeric: tabular-nums;
+ color: var(--ink);
+}
+
+.ov-hk-v.accent {
+ color: var(--accent);
+}
+
+.ov-hk-l {
+ margin-top: 5px;
+ font: 500 9px/1 var(--font-mono);
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+
+@media (max-width: 760px) {
+ .ov-mast {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 14px;
+ }
+
+ .ov-mast-kpis {
+ flex-wrap: wrap;
+ }
+
+ .ov-hk:first-child {
+ padding-left: 0;
+ border-left: none;
+ }
+}
+
+/* sticky filter bar — „ПОДРЕДИ ПО" + segmented toggle (drives ?by=) + before→now legend */
+.ov-filterbar {
+ position: sticky;
+ top: 0;
+ z-index: 5;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 14px;
+ padding: 10px 14px;
+ margin-bottom: var(--s-4);
+ background: var(--paper-warm);
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+}
+
+.ov-filterbar-label {
+ font: 500 9px/1 var(--font-mono);
+ letter-spacing: 0.12em;
+ color: var(--ink-soft);
+ text-transform: uppercase;
+}
+
+.ov-seg {
+ display: inline-flex;
+ border: 1px solid var(--rule);
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.ov-seg a {
+ font: 500 10px/1 var(--font-mono);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ padding: 7px 12px;
+ color: var(--ink-mid);
+ background: var(--paper);
+ text-decoration: none;
+}
+
+.ov-seg a + a {
+ border-left: 1px solid var(--rule);
+}
+
+.ov-seg a[aria-current='true'] {
+ background: var(--ink);
+ color: var(--paper);
+}
+
+.ov-legend {
+ margin-left: auto;
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: 16px;
+ font: 400 10px/1 var(--font-mono);
+ color: var(--ink-mid);
+}
+
+.ov-legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.ov-swatch {
+ width: 10px;
+ height: 10px;
+ border-radius: 1px;
+}
+
+.ov-swatch.ink {
+ background: var(--ink);
+}
+
+.ov-swatch.accent {
+ background: var(--accent);
+}
+
+/* dashboard frame */
+/* single-line and two-line ellipsis truncation (ported from the design's .clamp1/.clamp2 — they were
+ referenced across overruns/trends but never defined, so long subjects wrapped and overflowed). */
+.clamp1 {
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.clamp2 {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+/* section rhythm: each of the four design sections is a serif heading + mono note, then its panel(s) */
+.ov-section {
+ margin-top: var(--s-6);
+}
+
+.ov-sec-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-bottom: 12px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--rule);
+}
+
+.ov-sec-title {
+ margin: 0;
+ font-family: var(--font-serif);
+ font-size: 21px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ color: var(--ink);
+}
+
+.ov-sec-title em {
+ font-style: italic;
+ color: var(--accent);
+}
+
+.ov-sec-note {
+ font: 400 10px/1.3 var(--font-mono);
+ color: var(--ink-soft);
+}
+
+/* two-up figure grid: a wide visual (scatter / treemap) beside a narrower data panel (inspector /
+ ranked list), per the design's minmax(0,1.3fr) minmax(360px,1fr). */
+.ov-figure-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.3fr) minmax(360px, 1fr);
+ gap: 14px;
+ align-items: start;
+}
+
+@media (max-width: 860px) {
+ .ov-figure-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* leaderboard board */
+.ov-board-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ padding: 13px 16px 8px;
+}
+
+.ov-board-title {
+ font: 600 16px/1.2 var(--font-serif);
+ color: var(--ink);
+}
+
+.ov-board-title em {
+ font-style: italic;
+ color: var(--accent);
+}
+
+.ov-board-scale {
+ font: 400 10px/1 var(--font-mono);
+ color: var(--ink-soft);
+}
+
+.ov-board-list {
+ list-style: none;
+ margin: 0;
+ padding: 0 8px 8px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ max-height: 560px;
+}
+
+.ov-row {
+ display: grid;
+ grid-template-columns: 32px 1fr;
+ gap: 12px;
+ align-items: center;
+ width: 100%;
+ text-align: left;
+ padding: 9px 8px;
+ border: none;
+ border-bottom: 1px solid var(--rule-soft);
+ border-left: 2px solid transparent;
+ background: transparent;
+ cursor: pointer;
+ font: inherit;
+}
+
+.ov-row[aria-pressed='true'] {
+ border-left-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
+}
+
+.ov-row-rank {
+ font: 600 22px/1 var(--font-serif);
+ color: var(--ink-mid);
+ text-align: center;
+}
+
+.ov-row[aria-pressed='true'] .ov-row-rank {
+ color: var(--accent);
+}
+
+/* per-row growth: neutral by default so the accent isn't diluted; reserved for the largest grower
+ and the selected row (see Fix: accent-red overload). */
+.ov-row-pct {
+ color: var(--ink-mid);
+}
+
+.ov-row-pct.is-top {
+ color: var(--accent);
+}
+
+.ov-row[aria-pressed='true'] .ov-row-pct {
+ color: var(--accent);
+}
+
+.ov-arrow {
+ color: var(--ink-mid);
+}
+
+.ov-row-body {
+ min-width: 0;
+}
+
+.ov-row-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.ov-row-subject {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--ink);
+}
+
+.ov-row-value {
+ white-space: nowrap;
+ font: 600 10.5px/1 var(--font-mono);
+ color: var(--ink);
+}
+
+.ov-row-meta {
+ display: block;
+ margin-top: 4px;
+ font: 400 9px/1.2 var(--font-mono);
+ color: var(--ink-soft);
+}
+
+/* before→now stacked bar */
+.ov-bar {
+ position: relative;
+ height: 13px;
+ margin-top: 5px;
+}
+
+.ov-bar-track {
+ position: absolute;
+ inset: 0;
+ border-radius: 2px;
+ background: repeating-linear-gradient(
+ 90deg,
+ transparent,
+ transparent 62px,
+ var(--rule-soft) 62px,
+ var(--rule-soft) 63px
+ );
+}
+
+.ov-bar-fill {
+ position: absolute;
+ left: 0;
+ top: 0;
+ display: flex;
+ height: 13px;
+ min-width: 3px;
+ border-radius: 0 2px 2px 0;
+ overflow: hidden;
+}
+
+.ov-bar-sign {
+ height: 100%;
+ background: var(--ink);
+}
+
+.ov-bar-inc {
+ height: 100%;
+ background: var(--accent);
+}
+
+/* scatter panel */
+.ov-scatter-panel {
+ padding: 13px 16px 8px;
+ min-height: 230px;
+}
+
+.ov-scatter-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+}
+
+.ov-panel-title {
+ font: 600 16px/1.2 var(--font-serif);
+ color: var(--ink);
+}
+
+.ov-panel-note {
+ font: 400 9.5px/1 var(--font-mono);
+ color: var(--ink-soft);
+ /* the note caption holds a label + the fullscreen button, laid out inline */
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.ov-scatter-body {
+ flex: 1;
+ min-height: 340px;
+ margin-top: 6px;
+}
+
+.ov-scatter-svg {
+ display: block;
+ overflow: visible;
+}
+
+/* progressive-enhancement hover cue so the clickable bubbles feel interactive (mouse only — the
+ keyboard path is the leaderboard buttons). */
+.ov-scatter-dot {
+ transition:
+ r 0.12s ease,
+ fill-opacity 0.12s ease,
+ stroke-width 0.12s ease;
+}
+
+.ov-scatter-dot:hover {
+ fill-opacity: 0.95 !important;
+ stroke-width: 1.75;
+}
+
+/* inspector */
+.ov-insp-head {
+ padding: 13px 16px 12px;
+ border-bottom: 1px solid var(--rule);
+}
+
+.ov-insp-title {
+ margin-top: 8px;
+ font-size: 12.5px;
+ font-weight: 600;
+ line-height: 1.32;
+ color: var(--ink);
+}
+
+.ov-insp-parties {
+ margin-top: 6px;
+ font: 400 9.5px/1.3 var(--font-mono);
+ color: var(--ink-soft);
+}
+
+.ov-insp-figures {
+ display: flex;
+ align-items: flex-end;
+ gap: 16px;
+ margin-top: 12px;
+ flex-wrap: wrap;
+}
+
+.ov-insp-fig-label {
+ font: 400 8.5px/1 var(--font-mono);
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+
+.ov-insp-fig-val {
+ margin-top: 3px;
+ font: 400 15px/1 var(--font-mono);
+ color: var(--ink-mid);
+}
+
+.ov-insp-fig-val.now {
+ font-weight: 600;
+ color: var(--ink);
+}
+
+.ov-insp-arrow {
+ color: var(--accent);
+ font-size: 14px;
+ padding-bottom: 1px;
+}
+
+.ov-insp-delta-wrap {
+ margin-left: auto;
+ text-align: right;
+}
+
+.ov-insp-delta {
+ font: 600 16px/1 var(--font-mono);
+ color: var(--accent);
+}
+
+.ov-insp-delta-meta {
+ margin-top: 2px;
+ font: 400 9px/1 var(--font-mono);
+ color: var(--ink-soft);
+}
+
+.ov-insp-grid-wrap {
+ padding: 12px 16px 14px;
+}
+
+.ov-insp-grid-heading {
+ margin-bottom: 6px;
+}
+
+.ov-insp-grid {
+ margin: 0;
+}
+
+.ov-insp-grid-row {
+ display: grid;
+ grid-template-columns: 118px 1fr;
+ gap: 10px;
+ padding: 6px 0;
+ border-bottom: 1px solid var(--rule-soft);
+}
+
+.ov-insp-grid-key {
+ font: 500 9px/1.35 var(--font-mono);
+ letter-spacing: 0.05em;
+ color: var(--ink-soft);
+}
+
+.ov-insp-grid-val {
+ margin: 0;
+ font-size: 11.5px;
+ line-height: 1.35;
+ color: var(--ink);
+}
+
+/* inspector head: kicker + status badge on one row */
+.ov-insp-head-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.ov-status-badge {
+ flex: none;
+ padding: 2px 8px;
+ border: 1px solid var(--rule);
+ border-radius: 999px;
+ font: 500 8.5px/1.4 var(--font-mono);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+.ov-status-badge.active {
+ border-color: color-mix(in oklch, var(--accent) 45%, var(--rule));
+ color: var(--accent);
+ background: color-mix(in oklch, var(--accent) 8%, transparent);
+}
+
+.ov-status-badge.closed {
+ color: var(--ink-soft);
+ background: var(--paper-warm);
+}
+
+/* annex history — REAL amendment rows for the selected contract */
+.ov-annex-wrap {
+ margin-top: 16px;
+ padding-top: 12px;
+ border-top: 1px solid var(--rule);
+}
+
+.ov-annex-heading {
+ margin-bottom: 8px;
+}
+
+.ov-annex-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.ov-annex-row {
+ padding: 7px 0;
+ border-bottom: 1px solid var(--rule-soft);
+}
+
+.ov-annex-main {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+}
+
+.ov-annex-seq {
+ flex: none;
+ font: 500 9px/1.3 var(--font-mono);
+ letter-spacing: 0.05em;
+ color: var(--ink-mid);
+}
+
+.ov-annex-date {
+ flex: none;
+ font: 400 9px/1.3 var(--font-mono);
+ color: var(--ink-soft);
+}
+
+.ov-annex-delta {
+ margin-left: auto;
+ flex: none;
+ font: 600 11px/1.3 var(--font-mono);
+ color: var(--accent);
+}
+
+.ov-annex-reason {
+ margin-top: 3px;
+ font-size: 10.5px;
+ line-height: 1.4;
+ color: var(--ink-mid);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.ov-annex-empty {
+ margin: 0;
+ font: 400 10px/1.5 var(--font-mono);
+ color: var(--ink-soft);
+}
+
+.ov-insp-source {
+ margin-top: 12px;
+ font: 400 9.5px/1.5 var(--font-mono);
+ color: var(--ink-soft);
+}
+
+/* leaderboard-as-table disclosure + methodology note */
+.ov-table-details {
+ margin-top: var(--s-4);
+}
+
+.ov-table-summary {
+ cursor: pointer;
+ font: 500 12px/1.4 var(--font-mono);
+ color: var(--ink-mid);
+}
+
+.ov-table-body {
+ margin-top: var(--s-3);
+}
+
+.ov-methodology {
+ margin-top: var(--s-3);
+}
+
+/* ── SECTION 3 — overrun-by-sector table (CPV division, aggregate growth, € at risk) ── */
+.ov-sector-list-panel {
+ padding: 12px 16px 14px;
+}
+
+/* bucket markers — works→accent, goods→slate, services→ochre, other→ink-soft. Each is paired with a
+ text label / legend so colour is never the sole carrier of the category (WCAG 1.4.1). */
+.ov-bucket-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 14px;
+ margin: 0 0 10px;
+ padding: 0;
+ list-style: none;
+ font: 400 9.5px/1 var(--font-mono);
+ color: var(--ink-mid);
+}
+
+.ov-bucket-legend-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.ov-sector-dot {
+ width: 9px;
+ height: 9px;
+ border-radius: 999px;
+ display: inline-block;
+ flex: none;
+ background: var(--ink-soft);
+}
+
+.ov-sector-dot.works {
+ background: var(--accent);
+}
+
+.ov-sector-dot.goods {
+ background: var(--slate);
+}
+
+.ov-sector-dot.services {
+ background: var(--ochre);
+}
+
+.ov-sector-dot.other {
+ background: var(--ink-soft);
+}
+
+/* horizontal scroll wrapper for the wide (6-column) tables so they don't crush on narrow screens */
+.ov-table-scroll {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.ov-sector-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.ov-sector-table thead tr {
+ font: 500 8.5px/1 var(--font-mono);
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+
+.ov-sector-table thead th {
+ padding: 6px 8px 7px;
+ text-align: right;
+ border-bottom: 1px solid var(--ink);
+}
+
+.ov-sector-table thead th:nth-child(1),
+.ov-sector-table thead th:nth-child(2) {
+ text-align: left;
+}
+
+.ov-sector-table tbody tr {
+ border-bottom: 1px solid var(--rule-soft);
+}
+
+.ov-sector-table td {
+ padding: 7px 8px;
+}
+
+.ov-sector-code {
+ font: 600 11px/1 var(--font-mono);
+ color: var(--ink-mid);
+}
+
+.ov-sector-name {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ min-width: 0;
+ font-size: 11.5px;
+ color: var(--ink);
+}
+
+.ov-sector-growth {
+ text-align: right;
+ font: 600 11px/1 var(--font-mono);
+ color: var(--ink-mid);
+}
+
+.ov-sector-growth.is-top {
+ color: var(--accent);
+}
+
+.ov-sector-risk {
+ text-align: right;
+ font: 500 11px/1 var(--font-mono);
+ color: var(--ink);
+}
+
+/* ── SECTION 4 — institutions table ── */
+.ov-auth-panel {
+ padding: 12px 16px 14px;
+}
+
+.ov-auth-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.ov-auth-table thead tr {
+ font: 500 8.5px/1 var(--font-mono);
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ink-soft);
+}
+
+.ov-auth-table thead th {
+ padding: 6px 8px 7px;
+ text-align: right;
+ border-bottom: 1px solid var(--ink);
+}
+
+.ov-auth-table thead th.c-rank,
+.ov-auth-table thead th.c-name,
+.ov-auth-table thead th.c-share {
+ text-align: left;
+}
+
+.ov-auth-table tbody tr {
+ border-bottom: 1px solid var(--rule-soft);
+}
+
+.ov-auth-table td {
+ padding: 8px;
+ font-size: 11.5px;
+ vertical-align: middle;
+}
+
+.ov-auth-table td.c-rank {
+ font: 600 12px/1 var(--font-mono);
+ color: var(--ink-soft);
+ width: 28px;
+}
+
+.ov-auth-table td.c-name {
+ color: var(--ink);
+}
+
+.ov-auth-table td.c-num {
+ text-align: right;
+ font: 500 11px/1 var(--font-mono);
+ white-space: nowrap;
+}
+
+.ov-auth-total {
+ color: var(--ink);
+ font-weight: 600 !important;
+}
+
+.ov-auth-growth {
+ color: var(--ink-mid) !important;
+}
+
+.ov-auth-growth.is-top {
+ color: var(--accent) !important;
+}
+
+.ov-auth-table td.c-share {
+ width: 150px;
+}
+
+.ov-auth-foot {
+ margin: 12px 0 0;
+ text-align: center;
+ font: 400 9.5px/1.4 var(--font-mono);
+ letter-spacing: 0.06em;
+ color: var(--ink-soft);
+}
+
+/* the scale caption holds a label + the button (the matching .ov-panel-note layout lives with its
+ base rule above) */
+.ov-board-scale {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+}
+
+/* native fullscreen — fill the viewport, let the chart grow to fill it */
+.trend-chart-panel:fullscreen,
+.ov-board:fullscreen,
+.ov-scatter-panel:fullscreen {
+ background: var(--paper);
+ padding: 20px 24px;
+ width: 100vw;
+ height: 100vh;
+ overflow: auto;
+}
+
+.trend-chart-panel:fullscreen .trend-chart-body,
+.ov-scatter-panel:fullscreen .ov-scatter-body {
+ flex: 1;
+ min-height: 0;
+}
+
+.ov-board:fullscreen .ov-board-list {
+ max-height: none;
+ flex: 1;
+}
diff --git a/apps/web/app/styles/tokens.css b/apps/web/app/styles/tokens.css
index ac6f4775c..ecbecfb71 100644
--- a/apps/web/app/styles/tokens.css
+++ b/apps/web/app/styles/tokens.css
@@ -1,8 +1,7 @@
-/* Design tokens — OKLch colour palette, type stack, 8-pt spacing scale.
- @theme exposes these to Tailwind (bg-paper, text-ink…) AND as CSS vars;
- :root aliases let component CSS keep using var(--ink), var(--accent), etc.
- The mock uses a system serif/mono stack — no webfont request. */
-
+/* Editorial design tokens — OKLch. @theme exposes them to Tailwind (bg-paper, text-ink…) AND as CSS
+ vars (--color-ink…); the ported component CSS below and the @sigma/config procedure colours read
+ the same vars, so the palette lives in exactly one place. The mock uses a system serif/mono stack —
+ no webfont request (Inter dropped). */
@theme {
--color-paper: oklch(98.5% 0.008 80);
--color-paper-warm: oklch(96% 0.012 78);
@@ -17,6 +16,16 @@
--color-accent: oklch(48% 0.18 28); /* red — links/warnings */
--color-accent-bg: oklch(94% 0.04 28);
--color-pos: oklch(45% 0.1 165); /* teal — positive deltas */
+ /* Decorative editorial accents (already the trend-dashboard chart palette: slate #5E7C8B count
+ series, tan #C4B79C € line). Promoted to shared tokens so the /analytics landing thumbnails and
+ the „парите" highlight read from the palette, not raw hexes. Never the sole carrier of meaning —
+ the slate word is also italic; the thumbnails are aria-hidden decoration. */
+ --color-slate: oklch(55% 0.035 233);
+ --color-tan: oklch(77% 0.03 90);
+ /* Warm ochre — the „услуги" (services) bucket marker on the /overruns sector treemap + ranked list.
+ Pairs with --accent (works) and --slate (goods); each bucket also carries a text label + legend,
+ so colour is never the sole differentiator (WCAG 1.4.1). */
+ --color-ochre: oklch(64% 0.12 70);
--font-sans:
system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
@@ -28,6 +37,9 @@
/* Short aliases → @theme tokens, so the ported component CSS keeps using var(--paper) etc. */
--paper: var(--color-paper);
--paper-warm: var(--color-paper-warm);
+ /* Pure-white raised surface — form chips/toggles that must read as "above" the warm panels
+ (the design renders the trend filter chips and the step toggle in #fff on the #FBF8F1 bar). */
+ --paper-raised: oklch(100% 0 0);
--paper-deep: var(--color-paper-deep);
--ink: var(--color-ink);
--ink-mid: var(--color-ink-mid);
@@ -37,6 +49,9 @@
--accent: var(--color-accent);
--accent-bg: var(--color-accent-bg);
--pos: var(--color-pos);
+ --slate: var(--color-slate);
+ --tan: var(--color-tan);
+ --ochre: var(--color-ochre);
/* Legacy token aliases — keep page-local inline styles working */
--bg: var(--paper);
diff --git a/apps/web/package.json b/apps/web/package.json
index 6328095f1..2f32fae94 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -29,6 +29,7 @@
"@cloudflare/vite-plugin": "^1.29.1",
"@react-router/dev": "7.18.0",
"@tailwindcss/vite": "^4.2.2",
+ "@testing-library/react": "^16.3.2",
"@types/node": "^22",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
diff --git a/apps/web/workers/cache-key.test.ts b/apps/web/workers/cache-key.test.ts
index e2fd5b4fd..740fcc0ec 100644
--- a/apps/web/workers/cache-key.test.ts
+++ b/apps/web/workers/cache-key.test.ts
@@ -119,24 +119,52 @@ describe('cacheKey', () => {
cacheUrl('http://local/contracts?cursor=c5&page=5').search,
);
});
+
+ it('keys repeated cohort values distinctly instead of collapsing to one (CWE-349, #56)', () => {
+ // cacheKey() iterates every [key, value] pair off url.searchParams via `for...of`, not
+ // `.get()`, so a repeated allow-listed param keeps every occurrence rather than only the first.
+ expect(cacheUrl('http://local/price-anomaly?cohort=a&cohort=b').search).not.toBe(
+ cacheUrl('http://local/price-anomaly?cohort=a').search,
+ );
+ });
});
+// Allow-list entries that intentionally sit ahead of their route on this stacked-PR base (see the
+// CANONICAL_QUERY_PARAMS comment for which route each is destined for). Excluded from the stale-entry
+// assertion below so stacked-later work doesn't fail unrelated PRs; any OTHER stale entry (a typo, a
+// param whose route was removed) still fails the build.
+const EXPECTED_STALE_PLANNED_PARAMS = new Set(['a', 'b', 'by', 'cohort', 'cpv', 'metric']);
+
describe('CANONICAL_QUERY_PARAMS drift guard', () => {
- it('covers every query param the app reads off the URL', () => {
+ it('covers every query param the app reads off the URL (CWE-349, #56)', () => {
const consumed = consumedQueryParams();
// Sanity: the scanner must actually find params, else a regex/glob change silently disarms it.
expect(consumed.size).toBeGreaterThan(10);
expect(consumed.has('bids')).toBe(true);
expect(consumed.has('page')).toBe(true);
+ // Security direction: every param a route loader / SSR render consumes must be keyed (in the
+ // allow-list) or explicitly declared response-neutral, or two distinct views collapse to one
+ // cache entry and the wrong data gets served.
const allowed = new Set([...CANONICAL_QUERY_PARAMS, ...INTENTIONALLY_UNKEYED]);
const undeclared = [...consumed].filter((p) => !allowed.has(p)).sort();
expect(undeclared).toEqual([]);
});
- it('does not retain allow-list entries that nothing reads', () => {
+ // Reverse direction: allow-list entries nothing currently reads. A dead/typo'd entry here is
+ // harmless for correctness (it can only over-key, never collapse two distinct responses into one
+ // cache entry) but silently degrades the cache hit rate forever if nothing catches it. Entries
+ // legitimately ahead of their not-yet-shipped route (EXPECTED_STALE_PLANNED_PARAMS) are excluded so
+ // this stays a real, failing assertion instead of either blocking unrelated stacked-PR work or
+ // being a cheater test that can never fail.
+ it('does not retain undocumented stale allow-list entries', () => {
const consumed = consumedQueryParams();
- const stale = [...CANONICAL_QUERY_PARAMS].filter((p) => !consumed.has(p)).sort();
+ const stale = [...CANONICAL_QUERY_PARAMS]
+ .filter((p) => !consumed.has(p) && !EXPECTED_STALE_PLANNED_PARAMS.has(p))
+ .sort();
+ if (stale.length > 0) {
+ console.info(`[cache-key] unexpected stale allow-list entries: ${stale.join(', ')}`);
+ }
expect(stale).toEqual([]);
});
});
diff --git a/osv-scanner.toml b/osv-scanner.toml
index dad96e316..5807b99a6 100644
--- a/osv-scanner.toml
+++ b/osv-scanner.toml
@@ -23,3 +23,18 @@
id = "GHSA-f88m-g3jw-g9cj"
ignoreUntil = 2026-10-01T00:00:00Z
reason = "sharp is a dev-only transitive of miniflare (local Workers simulator), pinned to 0.34.5 upstream and never bundled into the deployed Worker. Remove once miniflare/wrangler pins sharp >= 0.35.0 (pnpm why sharp)."
+
+# ── react-router 7.18.0 — GHSA-qwww-vcr4-c8h2 (High, CVSS 7.1), fixed in 8.3.0 ──────────────
+# WHY IGNORED: this CVE is a CSRF flaw in react-router's UNSTABLE RSC (React Server
+# Components) code paths only — "this only affects your application if you are using the
+# unstable RSC APIs" per the advisory. Verified via `git grep` across this repo for RSC
+# usage (unstable_.*RSC, react-server, unstable_RSCPayload, unstable_routeRSCServerRequest):
+# zero hits. This app does not use RSC. No fix exists in the 7.x line (introduced in 7.12.0,
+# only patched in 8.3.0) — upgrading to react-router 8.x is a major, breaking version bump
+# out of scope for a security patch to a code path this app never exercises.
+# REMOVE WHEN: this app adopts react-router's RSC APIs (re-evaluate applicability first), or
+# a deliberate, separately-planned major-version upgrade to react-router 8.x lands.
+[[IgnoredVulns]]
+id = "GHSA-qwww-vcr4-c8h2"
+ignoreUntil = 2026-10-01T00:00:00Z
+reason = "CSRF in react-router's unstable RSC code paths only (GHSA-qwww-vcr4-c8h2) - this app does not use RSC (verified via repo-wide grep for RSC APIs). No fix in the 7.x line; upgrading to 8.x is a major breaking change out of scope for a security patch to an unused code path."
diff --git a/packages/db/migrations/0003_contracts_overrun_index.sql b/packages/db/migrations/0003_contracts_overrun_index.sql
new file mode 100644
index 000000000..15341e1eb
--- /dev/null
+++ b/packages/db/migrations/0003_contracts_overrun_index.sql
@@ -0,0 +1,22 @@
+-- Partial index for the overrun predicate (annex_count > 0 AND current_value_eur > signing_value_eur
+-- AND signing_value_eur >= 1000), shared by /overruns + /analytics (OVERRUN_WHERE in
+-- packages/db/src/queries/overruns.ts). Those pages run several aggregates over that predicate; with no
+-- index each one full-scans ~190k contracts. The annex_count > 0 partial keeps the index to the small
+-- minority of contracts that carry annexes (the only rows that can ever be overruns), so every overrun
+-- aggregate starts from that narrow set instead of the whole table.
+--
+-- Composite on (signing_value_eur, current_value_eur) rather than annex_count alone: EXPLAIN QUERY
+-- PLAN against a ~190k-row fixture showed the annex_count-only index still needs a table lookup per
+-- matching row to evaluate `current_value_eur > signing_value_eur`, while this composite answers the
+-- predicate itself straight from the index (both value columns are covered) — ~4x fewer ms/run in
+-- that benchmark. NOT fully covering for every OVERRUN_WHERE consumer: the by-authority/by-sector
+-- breakdowns and the leaderboard also JOIN on c.tender_id and select further contracts columns
+-- (c.id, c.bidder_id, c.eu_funded, ...), so those still take one table lookup per matching row; only
+-- the single-pass corpus aggregate (SUM/AVG over signing_value_eur/current_value_eur alone, no join)
+-- is fully answered from the index.
+CREATE INDEX IF NOT EXISTS idx_contracts_overrun ON contracts(signing_value_eur, current_value_eur)
+ WHERE annex_count > 0;
+
+-- No down-migration: migrations in this repo are forward-only by convention (0000_init.sql,
+-- 0001_flow_pairs_bidder_index.sql have none either), matching wrangler d1's migration tooling,
+-- which has no built-in rollback.
diff --git a/packages/db/src/migrations.test.ts b/packages/db/src/migrations.test.ts
index 72e4e48b2..974700e8a 100644
--- a/packages/db/src/migrations.test.ts
+++ b/packages/db/src/migrations.test.ts
@@ -10,6 +10,7 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const migration0 = resolve(root, 'packages/db/migrations/0000_init.sql');
const migration1 = resolve(root, 'packages/db/migrations/0001_flow_pairs_bidder_index.sql');
const migration2 = resolve(root, 'packages/db/migrations/0002_current_value_currency.sql');
+const migration3 = resolve(root, 'packages/db/migrations/0003_contracts_overrun_index.sql');
const backfill = resolve(root, 'scripts/backfill-current-value-currency.sql');
const precompute = resolve(root, 'scripts/precompute.sql');
@@ -31,6 +32,7 @@ describe('served migrations', () => {
readScript(dbPath, migration0);
readScript(dbPath, migration1);
readScript(dbPath, migration2);
+ readScript(dbPath, migration3);
expect(
sqlite(
@@ -85,6 +87,24 @@ describe('served migrations', () => {
).trim(),
).toBe('1');
+ // 0002 adds the partial overrun index used by /overruns + /analytics (OVERRUN_WHERE).
+ expect(
+ sqlite(
+ dbPath,
+ "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_contracts_overrun' AND tbl_name='contracts';",
+ ).trim(),
+ ).toBe('1');
+
+ // Guard the index's actual definition, not just its presence: the partial predicate and the
+ // (signing_value_eur, current_value_eur) column order are both load-bearing for the covering
+ // scan OVERRUN_WHERE relies on — a silent regression in either must fail CI, not eyeballing.
+ const overrunIndexSql = sqlite(
+ dbPath,
+ "SELECT sql FROM sqlite_master WHERE type='index' AND name='idx_contracts_overrun';",
+ );
+ expect(overrunIndexSql).toContain('annex_count > 0');
+ expect(overrunIndexSql).toContain('contracts(signing_value_eur, current_value_eur)');
+
// The served schema must never carry raw_* staging tables.
expect(
sqlite(dbPath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'raw_%';").trim(),
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 150a51d18..6cb9f86fd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,9 @@ overrides:
vite@8: ^8.0.16
undici: ^7.28.0
'@babel/core': ^7.29.6
+ sharp: ^0.35.0
+ postcss: ^8.5.18
+ valibot: ^1.4.2
importers:
@@ -36,7 +39,7 @@ importers:
version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.7.0))
wrangler:
specifier: ^4.93.1
- version: 4.93.1(@cloudflare/workers-types@4.20260521.1)
+ version: 4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@25.9.1)
apps/etl:
dependencies:
@@ -82,13 +85,16 @@ importers:
devDependencies:
'@cloudflare/vite-plugin':
specifier: ^1.29.1
- version: 1.37.3(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(workerd@1.20260520.1)(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1))
+ version: 1.37.3(@types/node@22.19.19)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(workerd@1.20260520.1)(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19))
'@react-router/dev':
specifier: 7.18.0
- version: 7.18.0(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1))
+ version: 7.18.0(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19))
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.3.0(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))
+ '@testing-library/react':
+ specifier: ^16.3.2
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@types/node':
specifier: ^22
version: 22.19.19
@@ -112,7 +118,7 @@ importers:
version: 8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0)
wrangler:
specifier: ^4.75.0
- version: 4.93.1(@cloudflare/workers-types@4.20260521.1)
+ version: 4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19)
packages/api-contract:
dependencies:
@@ -298,6 +304,10 @@ packages:
peerDependencies:
'@babel/core': ^7.29.6
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
@@ -412,6 +422,9 @@ packages:
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
+ '@emnapi/runtime@1.11.3':
+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
+
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
@@ -584,152 +597,161 @@ packages:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
- '@img/sharp-darwin-arm64@0.34.5':
- resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-darwin-arm64@0.35.3':
+ resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
- '@img/sharp-darwin-x64@0.34.5':
- resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-darwin-x64@0.35.3':
+ resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-darwin-arm64@1.2.4':
- resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+ '@img/sharp-freebsd-wasm32@0.35.3':
+ resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==}
+ engines: {node: '>=20.9.0'}
+ os: [freebsd]
+
+ '@img/sharp-libvips-darwin-arm64@1.3.2':
+ resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==}
cpu: [arm64]
os: [darwin]
- '@img/sharp-libvips-darwin-x64@1.2.4':
- resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+ '@img/sharp-libvips-darwin-x64@1.3.2':
+ resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==}
cpu: [x64]
os: [darwin]
- '@img/sharp-libvips-linux-arm64@1.2.4':
- resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+ '@img/sharp-libvips-linux-arm64@1.3.2':
+ resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-arm@1.2.4':
- resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ '@img/sharp-libvips-linux-arm@1.3.2':
+ resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-ppc64@1.2.4':
- resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ '@img/sharp-libvips-linux-ppc64@1.3.2':
+ resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-riscv64@1.2.4':
- resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ '@img/sharp-libvips-linux-riscv64@1.3.2':
+ resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-s390x@1.2.4':
- resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ '@img/sharp-libvips-linux-s390x@1.3.2':
+ resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linux-x64@1.2.4':
- resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ '@img/sharp-libvips-linux-x64@1.3.2':
+ resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
- resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.2':
+ resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@img/sharp-libvips-linuxmusl-x64@1.2.4':
- resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ '@img/sharp-libvips-linuxmusl-x64@1.3.2':
+ resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@img/sharp-linux-arm64@0.34.5':
- resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-arm64@0.35.3':
+ resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-arm@0.34.5':
- resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-arm@0.35.3':
+ resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==}
+ engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-ppc64@0.34.5':
- resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-ppc64@0.35.3':
+ resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==}
+ engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-riscv64@0.34.5':
- resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-riscv64@0.35.3':
+ resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==}
+ engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-s390x@0.34.5':
- resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-s390x@0.35.3':
+ resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==}
+ engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
- '@img/sharp-linux-x64@0.34.5':
- resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linux-x64@0.35.3':
+ resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@img/sharp-linuxmusl-arm64@0.34.5':
- resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linuxmusl-arm64@0.35.3':
+ resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@img/sharp-linuxmusl-x64@0.34.5':
- resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-linuxmusl-x64@0.35.3':
+ resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@img/sharp-wasm32@0.34.5':
- resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-wasm32@0.35.3':
+ resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==}
+ engines: {node: '>=20.9.0'}
+
+ '@img/sharp-webcontainers-wasm32@0.35.3':
+ resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==}
+ engines: {node: '>=20.9.0'}
cpu: [wasm32]
- '@img/sharp-win32-arm64@0.34.5':
- resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-arm64@0.35.3':
+ resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==}
+ engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
- '@img/sharp-win32-ia32@0.34.5':
- resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-ia32@0.35.3':
+ resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==}
+ engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
- '@img/sharp-win32-x64@0.34.5':
- resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ '@img/sharp-win32-x64@0.35.3':
+ resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==}
+ engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
@@ -1154,6 +1176,25 @@ packages:
peerDependencies:
vite: ^7.3.5
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/react@16.3.2':
+ resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@testing-library/dom': ^10.0.0
+ '@types/react': ^18.0.0 || ^19.0.0
+ '@types/react-dom': ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@turbo/darwin-64@2.9.14':
resolution: {integrity: sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A==}
cpu: [x64]
@@ -1187,6 +1228,9 @@ packages:
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -1255,9 +1299,20 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
+ ansi-styles@5.2.0:
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+ engines: {node: '>=10'}
+
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -1341,10 +1396,17 @@ packages:
babel-plugin-macros:
optional: true
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
electron-to-chromium@1.5.360:
resolution: {integrity: sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA==}
@@ -1541,6 +1603,10 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1555,8 +1621,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
- nanoid@3.3.12:
- resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
+ nanoid@3.3.16:
+ resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -1597,8 +1663,8 @@ packages:
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
- postcss@8.5.15:
- resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
+ postcss@8.5.23:
+ resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
prettier@3.8.3:
@@ -1606,6 +1672,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -1615,6 +1685,9 @@ packages:
peerDependencies:
react: ^19.2.6
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
react-refresh@0.14.2:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'}
@@ -1667,12 +1740,22 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
- sharp@0.34.5:
- resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
- engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ sharp@0.35.3:
+ resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==}
+ engines: {node: '>=20.9.0'}
+ peerDependencies:
+ '@types/node': '*'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -1771,8 +1854,8 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
- valibot@1.4.0:
- resolution: {integrity: sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==}
+ valibot@1.4.2:
+ resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
peerDependencies:
typescript: '>=5'
peerDependenciesMeta:
@@ -2184,6 +2267,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/runtime@7.29.7': {}
+
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
@@ -2219,15 +2304,16 @@ snapshots:
optionalDependencies:
workerd: 1.20260520.1
- '@cloudflare/vite-plugin@1.37.3(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(workerd@1.20260520.1)(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1))':
+ '@cloudflare/vite-plugin@1.37.3(@types/node@22.19.19)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(workerd@1.20260520.1)(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19))':
dependencies:
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260520.1)
- miniflare: 4.20260520.0
+ miniflare: 4.20260520.0(@types/node@22.19.19)
unenv: 2.0.0-rc.24
vite: 8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0)
- wrangler: 4.93.1(@cloudflare/workers-types@4.20260521.1)
+ wrangler: 4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19)
ws: 8.21.0
transitivePeerDependencies:
+ - '@types/node'
- bufferutil
- utf-8-validate
- workerd
@@ -2288,6 +2374,11 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@emnapi/runtime@1.11.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
'@emnapi/wasi-threads@1.2.1':
dependencies:
tslib: 2.8.1
@@ -2375,98 +2466,108 @@ snapshots:
'@img/colour@1.1.0': {}
- '@img/sharp-darwin-arm64@0.34.5':
+ '@img/sharp-darwin-arm64@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-arm64': 1.3.2
optional: true
- '@img/sharp-darwin-x64@0.34.5':
+ '@img/sharp-darwin-x64@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.3.2
optional: true
- '@img/sharp-libvips-darwin-arm64@1.2.4':
+ '@img/sharp-freebsd-wasm32@0.35.3':
+ dependencies:
+ '@img/sharp-wasm32': 0.35.3
+ optional: true
+
+ '@img/sharp-libvips-darwin-arm64@1.3.2':
optional: true
- '@img/sharp-libvips-darwin-x64@1.2.4':
+ '@img/sharp-libvips-darwin-x64@1.3.2':
optional: true
- '@img/sharp-libvips-linux-arm64@1.2.4':
+ '@img/sharp-libvips-linux-arm64@1.3.2':
optional: true
- '@img/sharp-libvips-linux-arm@1.2.4':
+ '@img/sharp-libvips-linux-arm@1.3.2':
optional: true
- '@img/sharp-libvips-linux-ppc64@1.2.4':
+ '@img/sharp-libvips-linux-ppc64@1.3.2':
optional: true
- '@img/sharp-libvips-linux-riscv64@1.2.4':
+ '@img/sharp-libvips-linux-riscv64@1.3.2':
optional: true
- '@img/sharp-libvips-linux-s390x@1.2.4':
+ '@img/sharp-libvips-linux-s390x@1.3.2':
optional: true
- '@img/sharp-libvips-linux-x64@1.2.4':
+ '@img/sharp-libvips-linux-x64@1.3.2':
optional: true
- '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ '@img/sharp-libvips-linuxmusl-arm64@1.3.2':
optional: true
- '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ '@img/sharp-libvips-linuxmusl-x64@1.3.2':
optional: true
- '@img/sharp-linux-arm64@0.34.5':
+ '@img/sharp-linux-arm64@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-linux-arm64': 1.2.4
+ '@img/sharp-libvips-linux-arm64': 1.3.2
optional: true
- '@img/sharp-linux-arm@0.34.5':
+ '@img/sharp-linux-arm@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-linux-arm': 1.2.4
+ '@img/sharp-libvips-linux-arm': 1.3.2
optional: true
- '@img/sharp-linux-ppc64@0.34.5':
+ '@img/sharp-linux-ppc64@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-linux-ppc64': 1.2.4
+ '@img/sharp-libvips-linux-ppc64': 1.3.2
optional: true
- '@img/sharp-linux-riscv64@0.34.5':
+ '@img/sharp-linux-riscv64@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-linux-riscv64': 1.2.4
+ '@img/sharp-libvips-linux-riscv64': 1.3.2
optional: true
- '@img/sharp-linux-s390x@0.34.5':
+ '@img/sharp-linux-s390x@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-linux-s390x': 1.2.4
+ '@img/sharp-libvips-linux-s390x': 1.3.2
optional: true
- '@img/sharp-linux-x64@0.34.5':
+ '@img/sharp-linux-x64@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-linux-x64': 1.2.4
+ '@img/sharp-libvips-linux-x64': 1.3.2
optional: true
- '@img/sharp-linuxmusl-arm64@0.34.5':
+ '@img/sharp-linuxmusl-arm64@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.2
optional: true
- '@img/sharp-linuxmusl-x64@0.34.5':
+ '@img/sharp-linuxmusl-x64@0.35.3':
optionalDependencies:
- '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.2
optional: true
- '@img/sharp-wasm32@0.34.5':
+ '@img/sharp-wasm32@0.35.3':
dependencies:
- '@emnapi/runtime': 1.10.0
+ '@emnapi/runtime': 1.11.3
+ optional: true
+
+ '@img/sharp-webcontainers-wasm32@0.35.3':
+ dependencies:
+ '@img/sharp-wasm32': 0.35.3
optional: true
- '@img/sharp-win32-arm64@0.34.5':
+ '@img/sharp-win32-arm64@0.35.3':
optional: true
- '@img/sharp-win32-ia32@0.34.5':
+ '@img/sharp-win32-ia32@0.35.3':
optional: true
- '@img/sharp-win32-x64@0.34.5':
+ '@img/sharp-win32-x64@0.35.3':
optional: true
'@jridgewell/gen-mapping@0.3.13':
@@ -2518,7 +2619,7 @@ snapshots:
'@poppinss/exception@1.2.3': {}
- '@react-router/dev@7.18.0(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1))':
+ '@react-router/dev@7.18.0(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(react-router@7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(typescript@5.9.3)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0))(wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19))':
dependencies:
'@babel/core': 7.29.7
'@babel/generator': 7.29.7
@@ -2547,12 +2648,12 @@ snapshots:
react-router: 7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
semver: 7.8.0
tinyglobby: 0.2.17
- valibot: 1.4.0(typescript@5.9.3)
+ valibot: 1.4.2(typescript@5.9.3)
vite: 8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0)
vite-node: 3.2.4(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)
optionalDependencies:
typescript: 5.9.3
- wrangler: 4.93.1(@cloudflare/workers-types@4.20260521.1)
+ wrangler: 4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19)
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -2777,6 +2878,27 @@ snapshots:
tailwindcss: 4.3.0
vite: 8.0.16(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.7.0)
+ '@testing-library/dom@10.4.1':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/runtime': 7.29.7
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@testing-library/dom': 10.4.1
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+ optionalDependencies:
+ '@types/react': 19.2.15
+ '@types/react-dom': 19.2.3(@types/react@19.2.15)
+
'@turbo/darwin-64@2.9.14':
optional: true
@@ -2800,6 +2922,8 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@types/aria-query@5.0.4': {}
+
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -2880,8 +3004,16 @@ snapshots:
'@opentelemetry/api': 1.9.1
zod: 4.4.3
+ ansi-regex@5.0.1: {}
+
+ ansi-styles@5.2.0: {}
+
arg@5.0.2: {}
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
assertion-error@2.0.1: {}
babel-dead-code-elimination@1.0.12:
@@ -2949,8 +3081,12 @@ snapshots:
dedent@1.7.2: {}
+ dequal@2.0.3: {}
+
detect-libc@2.1.2: {}
+ dom-accessibility-api@0.5.16: {}
+
electron-to-chromium@1.5.360: {}
enhanced-resolve@5.21.6:
@@ -3125,27 +3261,43 @@ snapshots:
dependencies:
yallist: 3.1.1
+ lz-string@1.5.0: {}
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
mdn-data@2.27.1: {}
- miniflare@4.20260520.0:
+ miniflare@4.20260520.0(@types/node@22.19.19):
dependencies:
'@cspotcode/source-map-support': 0.8.1
- sharp: 0.34.5
+ sharp: 0.35.3(@types/node@22.19.19)
undici: 7.28.0
workerd: 1.20260520.1
ws: 8.21.0
youch: 4.1.0-beta.10
transitivePeerDependencies:
+ - '@types/node'
+ - bufferutil
+ - utf-8-validate
+
+ miniflare@4.20260520.0(@types/node@25.9.1):
+ dependencies:
+ '@cspotcode/source-map-support': 0.8.1
+ sharp: 0.35.3(@types/node@25.9.1)
+ undici: 7.28.0
+ workerd: 1.20260520.1
+ ws: 8.21.0
+ youch: 4.1.0-beta.10
+ transitivePeerDependencies:
+ - '@types/node'
- bufferutil
- utf-8-validate
ms@2.1.3: {}
- nanoid@3.3.12: {}
+ nanoid@3.3.16: {}
node-releases@2.0.45: {}
@@ -3178,14 +3330,20 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
- postcss@8.5.15:
+ postcss@8.5.23:
dependencies:
- nanoid: 3.3.12
+ nanoid: 3.3.16
picocolors: 1.1.1
source-map-js: 1.2.1
prettier@3.8.3: {}
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
punycode@2.3.1: {}
react-dom@19.2.6(react@19.2.6):
@@ -3193,6 +3351,8 @@ snapshots:
react: 19.2.6
scheduler: 0.27.0
+ react-is@17.0.2: {}
+
react-refresh@0.14.2: {}
react-router@7.18.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
@@ -3271,38 +3431,75 @@ snapshots:
semver@7.8.0: {}
+ semver@7.8.5: {}
+
set-cookie-parser@2.7.2: {}
- sharp@0.34.5:
+ sharp@0.35.3(@types/node@22.19.19):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
- semver: 7.8.0
+ semver: 7.8.5
+ optionalDependencies:
+ '@img/sharp-darwin-arm64': 0.35.3
+ '@img/sharp-darwin-x64': 0.35.3
+ '@img/sharp-freebsd-wasm32': 0.35.3
+ '@img/sharp-libvips-darwin-arm64': 1.3.2
+ '@img/sharp-libvips-darwin-x64': 1.3.2
+ '@img/sharp-libvips-linux-arm': 1.3.2
+ '@img/sharp-libvips-linux-arm64': 1.3.2
+ '@img/sharp-libvips-linux-ppc64': 1.3.2
+ '@img/sharp-libvips-linux-riscv64': 1.3.2
+ '@img/sharp-libvips-linux-s390x': 1.3.2
+ '@img/sharp-libvips-linux-x64': 1.3.2
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.2
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.2
+ '@img/sharp-linux-arm': 0.35.3
+ '@img/sharp-linux-arm64': 0.35.3
+ '@img/sharp-linux-ppc64': 0.35.3
+ '@img/sharp-linux-riscv64': 0.35.3
+ '@img/sharp-linux-s390x': 0.35.3
+ '@img/sharp-linux-x64': 0.35.3
+ '@img/sharp-linuxmusl-arm64': 0.35.3
+ '@img/sharp-linuxmusl-x64': 0.35.3
+ '@img/sharp-webcontainers-wasm32': 0.35.3
+ '@img/sharp-win32-arm64': 0.35.3
+ '@img/sharp-win32-ia32': 0.35.3
+ '@img/sharp-win32-x64': 0.35.3
+ '@types/node': 22.19.19
+
+ sharp@0.35.3(@types/node@25.9.1):
+ dependencies:
+ '@img/colour': 1.1.0
+ detect-libc: 2.1.2
+ semver: 7.8.5
optionalDependencies:
- '@img/sharp-darwin-arm64': 0.34.5
- '@img/sharp-darwin-x64': 0.34.5
- '@img/sharp-libvips-darwin-arm64': 1.2.4
- '@img/sharp-libvips-darwin-x64': 1.2.4
- '@img/sharp-libvips-linux-arm': 1.2.4
- '@img/sharp-libvips-linux-arm64': 1.2.4
- '@img/sharp-libvips-linux-ppc64': 1.2.4
- '@img/sharp-libvips-linux-riscv64': 1.2.4
- '@img/sharp-libvips-linux-s390x': 1.2.4
- '@img/sharp-libvips-linux-x64': 1.2.4
- '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
- '@img/sharp-libvips-linuxmusl-x64': 1.2.4
- '@img/sharp-linux-arm': 0.34.5
- '@img/sharp-linux-arm64': 0.34.5
- '@img/sharp-linux-ppc64': 0.34.5
- '@img/sharp-linux-riscv64': 0.34.5
- '@img/sharp-linux-s390x': 0.34.5
- '@img/sharp-linux-x64': 0.34.5
- '@img/sharp-linuxmusl-arm64': 0.34.5
- '@img/sharp-linuxmusl-x64': 0.34.5
- '@img/sharp-wasm32': 0.34.5
- '@img/sharp-win32-arm64': 0.34.5
- '@img/sharp-win32-ia32': 0.34.5
- '@img/sharp-win32-x64': 0.34.5
+ '@img/sharp-darwin-arm64': 0.35.3
+ '@img/sharp-darwin-x64': 0.35.3
+ '@img/sharp-freebsd-wasm32': 0.35.3
+ '@img/sharp-libvips-darwin-arm64': 1.3.2
+ '@img/sharp-libvips-darwin-x64': 1.3.2
+ '@img/sharp-libvips-linux-arm': 1.3.2
+ '@img/sharp-libvips-linux-arm64': 1.3.2
+ '@img/sharp-libvips-linux-ppc64': 1.3.2
+ '@img/sharp-libvips-linux-riscv64': 1.3.2
+ '@img/sharp-libvips-linux-s390x': 1.3.2
+ '@img/sharp-libvips-linux-x64': 1.3.2
+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.2
+ '@img/sharp-libvips-linuxmusl-x64': 1.3.2
+ '@img/sharp-linux-arm': 0.35.3
+ '@img/sharp-linux-arm64': 0.35.3
+ '@img/sharp-linux-ppc64': 0.35.3
+ '@img/sharp-linux-riscv64': 0.35.3
+ '@img/sharp-linux-s390x': 0.35.3
+ '@img/sharp-linux-x64': 0.35.3
+ '@img/sharp-linuxmusl-arm64': 0.35.3
+ '@img/sharp-linuxmusl-x64': 0.35.3
+ '@img/sharp-webcontainers-wasm32': 0.35.3
+ '@img/sharp-win32-arm64': 0.35.3
+ '@img/sharp-win32-ia32': 0.35.3
+ '@img/sharp-win32-x64': 0.35.3
+ '@types/node': 25.9.1
siginfo@2.0.0: {}
@@ -3382,7 +3579,7 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
- valibot@1.4.0(typescript@5.9.3):
+ valibot@1.4.2(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
@@ -3412,7 +3609,7 @@ snapshots:
esbuild: 0.28.1
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
- postcss: 8.5.15
+ postcss: 8.5.23
rollup: 4.60.4
tinyglobby: 0.2.17
optionalDependencies:
@@ -3425,7 +3622,7 @@ snapshots:
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
- postcss: 8.5.15
+ postcss: 8.5.23
rolldown: 1.0.3
tinyglobby: 0.2.17
optionalDependencies:
@@ -3438,7 +3635,7 @@ snapshots:
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
- postcss: 8.5.15
+ postcss: 8.5.23
rolldown: 1.0.3
tinyglobby: 0.2.17
optionalDependencies:
@@ -3505,13 +3702,13 @@ snapshots:
'@cloudflare/workerd-linux-arm64': 1.20260520.1
'@cloudflare/workerd-windows-64': 1.20260520.1
- wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1):
+ wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@22.19.19):
dependencies:
'@cloudflare/kv-asset-handler': 0.5.0
'@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260520.1)
blake3-wasm: 2.1.5
esbuild: 0.28.1
- miniflare: 4.20260520.0
+ miniflare: 4.20260520.0(@types/node@22.19.19)
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
workerd: 1.20260520.1
@@ -3519,6 +3716,25 @@ snapshots:
'@cloudflare/workers-types': 4.20260521.1
fsevents: 2.3.3
transitivePeerDependencies:
+ - '@types/node'
+ - bufferutil
+ - utf-8-validate
+
+ wrangler@4.93.1(@cloudflare/workers-types@4.20260521.1)(@types/node@25.9.1):
+ dependencies:
+ '@cloudflare/kv-asset-handler': 0.5.0
+ '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260520.1)
+ blake3-wasm: 2.1.5
+ esbuild: 0.28.1
+ miniflare: 4.20260520.0(@types/node@25.9.1)
+ path-to-regexp: 6.3.0
+ unenv: 2.0.0-rc.24
+ workerd: 1.20260520.1
+ optionalDependencies:
+ '@cloudflare/workers-types': 4.20260521.1
+ fsevents: 2.3.3
+ transitivePeerDependencies:
+ - '@types/node'
- bufferutil
- utf-8-validate
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 96815ceab..bba29a738 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -24,6 +24,15 @@ overrides:
# @babel/core <7.29.6 — arbitrary file read via sourceMappingURL (GHSA-4x5r-pxfx-6jf8);
# dev/build-time only (via @react-router/dev), never ships to the Worker.
'@babel/core': '^7.29.6'
+ # sharp <0.35.0 — HIGH severity (CVSS 7.0) advisory GHSA-f88m-g3jw-g9cj, via
+ # wrangler→miniflare. Dev/build-time only; never ships to the Worker.
+ sharp: '^0.35.0'
+ # postcss <8.5.18 — path traversal via sourceMappingURL auto-load
+ # (GHSA-r28c-9q8g-f849); patch-level fix.
+ # valibot <1.4.2 — flatten() crashes on inherited-property keys
+ # (GHSA-5qjj-4xww-7phc); patch-level fix.
+ postcss: '^8.5.18'
+ valibot: '^1.4.2'
onlyBuiltDependencies:
- esbuild