diff --git a/bun.lock b/bun.lock index 1a9f868..fff0144 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "@simplewebauthn/browser": "^14.0.0", "@tanstack/react-query": "^5.104.1", "@tanstack/react-router": "^1.170.42", + "@tanstack/redact": "0.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -329,6 +330,8 @@ "@tanstack/react-store": ["@tanstack/react-store@0.11.2", "", { "dependencies": { "@tanstack/store": "0.11.2", "use-sync-external-store": "1.7.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ=="], + "@tanstack/redact": ["@tanstack/redact@0.3.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.19.0", "magic-string": "^0.30.21" }, "peerDependencies": { "@cloudflare/vite-plugin": "1.65.0", "next": "~15.5.0 || ~16.0.0 || ~16.1.0 || ~16.2.0 || ~16.3.0 || ~16.4.0", "vinext": "~1.3.0", "vite": ">=5" }, "optionalPeers": ["@cloudflare/vite-plugin", "next", "vinext", "vite"] }, "sha512-8A4SWi2RluvHj5j3/mifSPkUrhvmBLvvVXzM2DU7eTafvcWRXFLA/8cMe7ivZAbL7W1FMKZjitnIoP0sYIAWJA=="], + "@tanstack/router-core": ["@tanstack/router-core@1.171.39", "", { "dependencies": { "@tanstack/history": "1.162.5", "cookie-es": "3.1.1", "seroval": "1.7.6", "seroval-plugins": "1.7.6" } }, "sha512-qUhoJ0meuSUqGYQbHbcJYdASr06YtK+bulbj6vtrhKYFkLgkSsZ6tXmMyym7sFxiSJSPYgasgozrL01LyyR/6A=="], "@tanstack/router-generator": ["@tanstack/router-generator@1.167.46", "", { "dependencies": { "@babel/types": "7.29.11", "@tanstack/router-core": "1.171.39", "@tanstack/router-utils": "1.162.4", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "2.7.0", "magic-string": "0.30.21", "prettier": "3.9.10", "zod": "4.6.7" } }, "sha512-JSailAThcqPvqBzE3+MhNh6zQyZM2IdmIsHOxZgAc061Lh++SZqamNm/Whe7QNXWK5sLP4E0J1F8AI0hP8wTSw=="], diff --git a/docs/architecture.md b/docs/architecture.md index 495ee77..8ad20ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,6 +56,11 @@ v4 with shadcn/ui components, [Paraglide JS](https://inlang.com/m/gerre34r/libra for German and English, [MapLibre GL JS](https://maplibre.org/) for the map and own WebGL2 code for the wind. +The code is written against React 19, but the build runs it on +[TanStack Redact](https://github.com/TanStack/redact), a smaller runtime with the same API: its +Vite plugin points the `react` and `react-dom` imports at it. Redact renders synchronously, without +React's concurrent rendering; removing `redact()` from `vite.config.ts` goes back to React. + | Folder | | |---|---| | `src/routes/` | pages: start, station, point, map, gear, account, sign-in, sources, admin | diff --git a/web/package.json b/web/package.json index 3845c64..d282dad 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "@simplewebauthn/browser": "^14.0.0", "@tanstack/react-query": "^5.104.1", "@tanstack/react-router": "^1.170.42", + "@tanstack/redact": "0.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/web/src/components/favorite.tsx b/web/src/components/favorite.tsx index 2532e17..d1dc691 100644 --- a/web/src/components/favorite.tsx +++ b/web/src/components/favorite.tsx @@ -15,6 +15,7 @@ import { describeError } from '@/lib/i18n'; import { m } from '@/paraglide/messages'; import { Button } from '@/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/ui/dialog'; +import { TEXT_CENTRED } from '@/ui/svg-style'; import { AlarmButton } from './alarm'; interface Props { @@ -240,7 +241,7 @@ function SectorRose({ sector }: { sector: Sector }) { diff --git a/web/src/components/live/stats-card.tsx b/web/src/components/live/stats-card.tsx index 830c6cc..c13d81f 100644 --- a/web/src/components/live/stats-card.tsx +++ b/web/src/components/live/stats-card.tsx @@ -11,6 +11,7 @@ import { cn } from '@/lib/utils'; import { petal, rings, rose, STATS_BANDS_KN, sectorDegrees, wholeYear, windyShare } from '@/lib/wind-stats'; import { m } from '@/paraglide/messages'; import { Card } from '@/ui/card'; +import { TEXT_CENTRED } from '@/ui/svg-style'; import { WindArrow } from '@/ui/wind-arrow'; import { windBand } from '@/ui/wind-scale'; @@ -75,7 +76,7 @@ function RoseChart({ month }: { month: MonthStats }) { {[0, 4, 8, 12].map((s) => { const [x, y] = at(1.13, sectorDegrees(s)); return ( - + {points[s]} ); @@ -93,7 +94,7 @@ function RoseChart({ month }: { month: MonthStats }) { )), )} - + {percent(r.calm)} diff --git a/web/src/components/map/measured-spark.tsx b/web/src/components/map/measured-spark.tsx index fc9d7ad..3f9e26b 100644 --- a/web/src/components/map/measured-spark.tsx +++ b/web/src/components/map/measured-spark.tsx @@ -5,6 +5,7 @@ import { spark } from '@/lib/spark'; import { formatWind, UNIT_LABEL } from '@/lib/units'; import { useNow } from '@/lib/use-now'; import { m } from '@/paraglide/messages'; +import { NON_SCALING_STROKE } from '@/ui/svg-style'; const HOURS = 6; const W = 240; @@ -34,7 +35,7 @@ export function MeasuredSpark({ slug, unit }: { slug: string; unit: WindUnit }) d={s.line} className="fill-none stroke-chart-wind" strokeWidth={1.5} - vectorEffect="non-scaling-stroke" + style={NON_SCALING_STROKE} strokeLinejoin="round" /> {s.gusts.map((g) => ( diff --git a/web/src/components/water-tile.tsx b/web/src/components/water-tile.tsx index 27003a3..5ead450 100644 --- a/web/src/components/water-tile.tsx +++ b/web/src/components/water-tile.tsx @@ -10,6 +10,7 @@ import { cn } from '@/lib/utils'; import { levelTrend, type Trend } from '@/lib/water'; import { m } from '@/paraglide/messages'; import { Card, CardHeader, CardMeta, CardTitle } from '@/ui/card'; +import { NON_SCALING_STROKE } from '@/ui/svg-style'; /** The gauge near a place (within 15 km): level and its week, flood state, flow, current. */ export function WaterTile({ lat, lon, timeZone }: { lat: number; lon: number; timeZone: string }) { @@ -190,7 +191,7 @@ function History({ points }: { points: LevelPoint[] }) { strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" - vectorEffect="non-scaling-stroke" + style={NON_SCALING_STROKE} />
diff --git a/web/src/ui/svg-style.ts b/web/src/ui/svg-style.ts new file mode 100644 index 0000000..b38af03 --- /dev/null +++ b/web/src/ui/svg-style.ts @@ -0,0 +1,11 @@ +import type { CSSProperties } from 'react'; + +// SVG presentation attributes as CSS, which React and TanStack Redact both write as `style`. +// Redact 0.3.1 hyphenates only clip*, fill* and stroke* attributes (attributeName() in +// src/core/attributes.ts); `textAnchor` and `vectorEffect` reach the DOM in camelCase and do nothing. + +/** Text centred on its x. */ +export const TEXT_CENTRED: CSSProperties = { textAnchor: 'middle' }; + +/** A stroke as wide on screen however the viewBox is stretched. */ +export const NON_SCALING_STROKE: CSSProperties = { vectorEffect: 'non-scaling-stroke' }; diff --git a/web/src/ui/wind-compass.tsx b/web/src/ui/wind-compass.tsx index 2293bff..bd2c3a5 100644 --- a/web/src/ui/wind-compass.tsx +++ b/web/src/ui/wind-compass.tsx @@ -4,6 +4,7 @@ import { inSector } from '@/lib/favorites'; import { compass } from '@/lib/format'; import { cn } from '@/lib/utils'; import { m } from '@/paraglide/messages'; +import { TEXT_CENTRED } from '@/ui/svg-style'; const C = 60; /** Every 10°, longer every 90° */ @@ -82,7 +83,7 @@ export function WindCompass({ dir, calm, range, sector, className }: Props) { x={x} y={y} dy="0.35em" - textAnchor="middle" + style={TEXT_CENTRED} className={cn('font-semibold text-[11px]', d === 0 ? 'fill-foreground' : 'fill-muted-foreground')} > {points[i * 4]} @@ -114,7 +115,7 @@ export function WindCompass({ dir, calm, range, sector, className }: Props) { )} {dir === null && ( - + {calm ? '0' : 'VRB'} )} diff --git a/web/vite.config.ts b/web/vite.config.ts index 9eec5a4..ffd81cd 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url'; import { paraglideVitePlugin } from '@inlang/paraglide-js'; import tailwindcss from '@tailwindcss/vite'; +import { redact } from '@tanstack/redact/vite'; import { tanstackRouter } from '@tanstack/router-plugin/vite'; import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; @@ -20,6 +21,10 @@ export default defineConfig({ // src/routes/* → src/routeTree.gen.ts; has to run before the React plugin. tanstackRouter({ target: 'react', autoCodeSplitting: true }), react(), + // TanStack Redact in place of React (https://github.com/TanStack/redact): the code keeps + // importing `react` and `react-dom`, the plugin points those imports at the smaller runtime. + // It renders synchronously; nothing here relies on concurrent rendering. + redact(), tailwindcss(), ], resolve: {