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)} + > + + + + ); +} 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