Windows-GPU-UNLOCK
RTX3090 UEFI 포트, 한국어 무리셋 윈도우 부팅 가이드, 검증된 소스와 벤치마크 제공
SkyRadar는 개인용 실시간 항공 레이더로 주변 항공기를 확인하고 식별해 기기로 하늘에서 찾을 수 있게 해준다
SkyRadar is a personal live aviation radar. See aircraft around you, identify them, and point your device to find them in the sky.
기타TypeScript · CSSGitHub @Matheus-Bigi온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
See what's flying around you.
SkyRadar is a personal, real-time aviation radar. It combines a real
geographic map, a radar-style overlay, live airborne aircraft, aircraft
identification, a compass, "Look Here" directional guidance, and an AR
"Sky View" mode — all on one screen, centered on your current location.
This is a private/non-commercial project: no ads, accounts, subscriptions,
or analytics.README 전문 보기 ↗See what's flying around you.
SkyRadar is a personal, real-time aviation radar. It combines a real geographic map, a radar-style overlay, live airborne aircraft, aircraft identification, a compass, "Look Here" directional guidance, and an AR "Sky View" mode — all on one screen, centered on your current location.
This is a private/non-commercial project: no ads, accounts, subscriptions, or analytics.
npm install
npm run dev
Open the app on https, or on localhost (browsers exempt localhost from
the secure-context requirement) — geolocation, device orientation, and the
camera (for Sky View) all require a secure context on a real device.
No API keys are required to get the full experience: with no Flightradar24 credentials configured, SkyRadar automatically uses a built-in live traffic simulator (see Aircraft data below) so every feature — radar, silhouettes, categories, trails, Look Here, Sky View — works immediately.
On first load, allow location access (and camera, if you open Sky View). If you don't want to grant real location access while exploring, the location screen offers a "Use demo location instead" fallback centered on Portland, OR.
SkyRadar is deliberately not a smaller Flightradar24. Flightradar24 (or whatever provider sits behind the data layer) is just a data source. SkyRadar answers two questions Flightradar24 doesn't:
"What is flying around me right now?" "Where do I look to see it?"
Everything in the design — the radar sweep, the compass, Look Here, Sky View — serves that. See the in-app Settings panel for the full set of controls; there is intentionally no second screen.
LocationManager → useGeolocation (src/hooks)
HeadingManager → useDeviceHeading (compass, iOS permission handling)
MotionManager → useDeviceMotion / useDeviceAttitude (pitch + roll)
AircraftDataProvider → src/lib/aircraft/providers (FR24 | simulator)
AircraftClassifier → src/lib/aircraft/classify.ts
AircraftPositionEngine→ src/lib/geo.ts (distance/bearing/relative bearing/elevation)
RadarRenderer /
AircraftRenderer → src/components/RadarCanvas.tsx (single canvas: map
overlay, rings, sweep, trails, silhouettes, hit-testing)
MapRenderer → src/components/MapView.tsx (MapLibre GL, dark style)
AircraftDetailCard → src/components/AircraftCard.tsx
LookHereEngine → src/components/LookHereOverlay.tsx
SkyViewEngine → src/components/SkyView.tsx (camera + guidance)
src/components/SkyViewCanvas.tsx (the HUD)
src/lib/ar/projection.ts (sky → screen)
src/lib/ar/attitude.ts (sensor angles → pitch/roll)
SettingsManager → src/store/usePreferencesStore.ts (persisted)
src/components/SettingsPanel.tsx (one panel, no
second “Layers” list — see The control rail)
Range rings. The scope draws three rings, at a third, two thirds and all of the selected range. Thirds rather than quarters because every range SkyRadar offers divides cleanly by three — 1/2/3, 3/6/9, 5/10/15, 10/20/30 miles — so each ring carries a distance you can read at a glance instead of "6.75 MI". The labels sit just inside their own ring at a fixed screen angle, tilted off vertical to clear the "N" marker, so they stay upright and in place whether the plot is north-up or turning with the device.
Rendering model. The geographic map (MapLibre GL, a free CARTO Dark
Matter vector style — no API key needed) is the base layer. A single
<canvas> sits on top of it and draws everything else — radar rings, the
rotating sweep, aircraft silhouettes, trails, and labels — every animation
frame, using map.project() to convert each aircraft's real lat/lon into
screen pixels. This keeps the radar sweep and aircraft animation smooth at
60fps independent of how often new data arrives (aircraft are carried forward
between fixes; see Carrying an aircraft between
fixes), and keeps
radar rings dimensionally accurate to the selected range in miles regardless
of zoom.
The canvas itself is pointer-events: none; aircraft selection is handled
via the map's own click event plus a small hit-test against each frame's
drawn marker positions, so panning/gestures on the base map are never
blocked.
Data flow. The browser never talks to Flightradar24 directly. It polls
/api/aircraft?lat=&lon=&rangeMiles= (a Next.js route handler), which asks
the configured AircraftDataProvider for aircraft within that radius,
normalizes the result into the app's internal Aircraft model, and returns
only aircraft currently airborne. Two further details — the photo and the
route — are looked up per aircraft when a card opens (see Identifying one
aircraft).
AircraftDataProvider (src/lib/aircraft/types.ts)
├── Flightradar24Provider — real FR24 API (paid), server-side only
├── AdsbAggregatorProvider — adsb.lol / airplanes.live / adsb.fi (free, keyless)
├── OpenSkyProvider — real OpenSky Network API (free, last resort)
└── MockAircraftProvider — local fake-traffic simulator (dev-only opt-in)
SkyRadar's rule: it never shows an aircraft, number, or value that isn't real and at your real location. The simulator exists purely for local development and is never selected automatically — see below.
With no configuration at all, /api/aircraft runs a failover chain of
real sources (failover.ts) and returns the first that answers, sharing one
request deadline so several attempts still fit inside a serverless budget.
The keyless community ADS-B aggregators come first: they need no account,
answer a radius query directly, and report the most real detail —
registration, ICAO type, model, owner/operator, and a military flag, all
straight off the aircraft's own transmissions, already in feet and knots.
OpenSky trails them because its anonymous tier quotas by source IP, which a
shared serverless address tends to exhaust (setting
OPENSKY_USERNAME/OPENSKY_PASSWORD raises that quota). Its free tier also
reports only position/callsign/speed/altitude/heading — no registration,
type, model, or operator — so those fields are hidden rather than guessed.
Aircraft whose position is over a minute stale, that are on the ground, or that report no position at all are dropped: a real aircraft plotted where it no longer is would still be a lie.
The lookup fails silently by design — a card showing nothing is the right
outcome when no photo exists — which makes "nothing on file" and "upstream
refused us" look identical from a device. The card itself distinguishes them
(no photo on file vs. a retry button), and
/api/aircraft/details-diagnostics?hex=®istration= shows every
source's HTTP status and a raw response excerpt, so an empty photo list can
be told apart from a rate-limit page or a response shape we failed to read.
It bypasses every cache.
Open /api/aircraft/diagnostics?lat=<lat>&lon=<lon>&rangeMiles=15 in a
browser to see what every source is doing right now — which answered, how
fast, what each failed with, how many aircraft came back, and the five
nearest with their real details. When the radar says "LIVE DATA
UNAVAILABLE", the banner also names the provider and its error directly.
Set FR24_API_KEY (and optionally FR24_API_BASE_URL) in .env.local to
use the real Flightradar24 API instead (richer metadata, but a paid plan)
— see .env.example. The key is read only inside the /api/aircraft route
handler and is never sent to the client. Flightradar24's API schema can
vary by plan/version; field parsing in flightradar24.ts is defensive
(every field optional) but double-check field names against your
account's docs if you wire up real credentials.
MockAircraftProvider (a small live simulation: aircraft spawn with a
plausible callsign/type/operator/altitude/speed for their category, then
move continuously and eventually "land") only ever runs if you explicitly
set AIRCRAFT_PROVIDER=mock — intended for developing/testing without any
network calls. The UI shows a permanent "SIMULATED DATA" watermark whenever
it's active, and this should never be set in a real deployment.
If a real provider's request fails (rate-limited, network error, etc.),
/api/aircraft returns an error and the app shows a "LIVE DATA UNAVAILABLE"
status — it never silently substitutes fake aircraft.
Everything on screen, and what supplies it.
| What you see | Source | Kind | Key? | Path |
|---|---|---|---|---|
| Background map | Esri "World Dark Gray" (base + labels) → OpenStreetMap | Third-party raster tiles | No | Browser → tile CDN |
| Aircraft positions & telemetry | adsb.lol → airplanes.live → adsb.fi → OpenSky | Community ADS-B, live | No | Browser → /api/aircraft → aggregator |
| Aircraft photos | Planespotters → airport-data.com | Third-party photo API | No | Browser → /api/aircraft/photo → upstream |
| Your neighbourhood name | Nominatim (OpenStreetMap) | Third-party geocoder | No | Browser → /api/geocode/reverse → Nominatim |
| Airports on the scope | src/lib/airports.ts | Bundled reference data (~60) | — | Ships with the app, never fetched |
| Airline names | src/lib/aircraft/airlines.ts | Bundled ICAO designators | — | Ships with the app |
| Category & silhouette | src/lib/aircraft/classify.ts | Derived in-app from ADS-B hints | — | Pure logic |
| Your location | Device GPS (watchPosition) | Device sensor | — | Leaves the device only as a rounded lat/lon in a query |
| Compass, pitch, roll | deviceorientation(absolute), devicemotion | Device sensors | — | Never leaves the device |
| Camera (Sky View) | getUserMedia, rear camera | Device sensor | — | Never leaves the device |
| Range, filters, units, N-UP | localStorage (skyradar:preferences) | Your own browser | — | Never leaves the device |
| Radar beep | AudioContext oscillator | Synthesised in-app | — | No audio file |
/api/aircraft tries these in order and returns the first that answers,
sharing one request deadline so several attempts still fit inside a
serverless budget.
| # | Provider | Endpoint | Key | Why this rank |
|---|---|---|---|---|
| 0 | Flightradar24 | FR24_API_BASE_URL | Paid, only if FR24_API_KEY is set | Richest metadata; the only source of origin/destination |
| 1 | adsb.lol | api.adsb.lol/v2/lat/…/lon/…/dist/… | None | Keyless, answers a radius query, reports the most real detail |
| 2 | airplanes.live | api.airplanes.live/v2/point/… | None | Same readsb/tar1090 JSON shape |
| 3 | adsb.fi | opendata.adsb.fi/api/v2/… | None | Same shape again |
| 4 | OpenSky | OPENSKY_API_BASE_URL | Optional user/pass | Last: its anonymous tier quotas by source IP, which a shared serverless address exhausts |
| — | Local simulator | in-process | — | Never automatic. Only when AIRCRAFT_PROVIDER=mock |
If every source fails the app shows LIVE DATA UNAVAILABLE and clears the scope rather than inventing traffic.
| Field on the card | ADS-B key | Origin |
|---|---|---|
| Position, altitude | lat/lon, alt_geom ?? alt_baro | Broadcast by the aircraft |
| Ground speed, track | gs, track ?? true_heading ?? mag_heading | Broadcast by the aircraft |
| Vertical speed | baro_rate ?? geom_rate | Broadcast by the aircraft |
Position age (lastUpdated) | seen_pos | The aggregator's receiver |
| Registration, type, model, owner/operator | r, t, desc, ownOp | The aggregators' shared airframe database |
| Military flag | dbFlags bit 0 | The aggregators' shared airframe database |
| Callsign | flight | Broadcast by the aircraft |
| Airline | — | Derived in-app from the callsign's published ICAO designator |
| Category, silhouette | — | Derived in-app from type, model, operator and emitter category |
| Distance, bearing, elevation | — | Computed in-app from your GPS and its position |
| Outside air temperature | oat | Flightradar24 only — never from ADS-B |
| Origin / destination | — | Flightradar24 only; the keyless chain carries no route at all |
A field a provider does not report is left undefined and the UI hides it — no placeholder, no estimate.
| Layer | Lifetime | Where |
|---|---|---|
| Aircraft snapshot | 5s per location | Server, in memory (adsb.ts) |
| Browser poll | every 4s | useAircraftData |
| Position age filter | drops anything over 60s | adsb.ts |
| Photo lookup | per hex/registration | Server |
| Preferences | indefinite | localStorage |
The five-second cache against a four-second poll means roughly every other poll returns a replay. That is harmless now that positions are dated by the provider's clock rather than by arrival (see Carrying an aircraft between fixes), and tightening it would double the request rate against a free keyless aggregator, so it stands.
/api/aircraft/diagnostics?lat=&lon=&rangeMiles= — which source
answered, how fast, what each failed with, and the nearest few aircraft./api/aircraft/details-diagnostics?hex=®istration= — every photo
source's HTTP status and a raw response excerpt. Bypasses every cache.Tapping an aircraft asks two more questions of the outside world. Both fire when the card opens rather than when it's expanded, so the answers are already there by the time anyone taps MORE, and both are keyed to that aircraft's own identity and cleared the moment the selection changes — leaving one aircraft's photo or route on another's card would be showing something that isn't real.
The photo (/api/aircraft/photo) is a photo of that exact airframe,
never a stock image of the type. Getting one to appear — and appear
immediately — comes down to four things:
thumbnail_large on every photo, and reading only that field turned
perfectly good photos into "none on file".Sources are grouped into tiers, and every source in a tier is asked at once, so a tier costs one round trip rather than one per identifier. The second tier is only consulted when the first has nothing, which keeps the common case to two parallel requests.
A non-JSON response — a rate-limit page, say — is treated as an error to retry, never as "this airframe has no photo". A confirmed absence is cached briefly; a failure is never cached at all.
The slowest possible design is to start looking for a photo when someone taps an aircraft. SkyRadar doesn't: every aircraft on the scope is queued the moment it appears, and by the time you tap one its picture is usually already there.
Order matters more than volume. On a typical scope most contacts are general aviation, but the ones people actually tap are the airliners and the military traffic — so those are fetched first, rotorcraft next, everything else last, nearest first within each band. Nothing is skipped; a GA contact still gets its photo, it just waits behind the ones more likely to be wanted. Tapping an aircraft promotes it straight to the front of the queue, so even one the queue hadn't reached is fetched immediately.
The browser asks first. Planespotters' photo API is built to be called from a page — tar1090 does exactly that — so SkyRadar tries it directly from the device before falling back to its own server. Going direct uses the viewer's own address and their browser's own user agent, rather than a serverless function's shared address and a custom agent string, which can be the difference between being served and being throttled. It is also simply faster: no hop through our server at all. If that route turns out to be blocked by CORS, the first failure latches it off for the session and everything goes through the server batch instead — which is also where the second photo service lives.
A miss is not final. An empty answer can mean "this airframe has no photo" or "that service was having a moment", and those are indistinguishable at the time. Each aircraft gets three rounds, spaced seconds apart, with the caches bypassed on later rounds so a retry genuinely retries. Until those are exhausted the card shows a framed placeholder with a sweeping mark, so a photo still on its way looks like a photo on its way rather than a bug. Only afterwards does the card say no photo available, with a retry button.
One thing the server deliberately does not do is let the CDN cache an empty answer. A successful lookup is worth holding for an hour; an answer with nothing in it might just be one bad moment upstream, and caching that would freeze it into an hour of blank cards for everyone.
There is deliberately no route. ADS-B carries none — an aircraft
broadcasts its identity, position and movement, and nothing about its
schedule — so a route has to be looked up by callsign against a flight
database. The free ones (adsb.lol's routeset, adsbdb) are community
maintained callsign→route tables rather than live schedule data, and they
are wrong often enough to matter: one flight on final approach to Portland
came back as Seattle→Tucson, another over Portland as Seattle→Denver when it
was really flying Portland→Newark.
SkyRadar briefly tried to rescue that with geometry — rejecting any route that didn't fit the aircraft's position, altitude and vertical speed. It worked, but it was an elaborate defence against a source that shouldn't be trusted in the first place, and it left the card showing a route sometimes and "no confirmed route" other times. Showing nothing is better than showing a filtered guess, so the lookup was removed entirely.
Reinstating it needs an authoritative source, not a better filter. The
Flightradar24 provider already parses orig_iata/dest_iata, so setting
FR24_API_KEY would bring real routes in with the position data itself, no
separate lookup and no verification — at the cost of a paid plan.
The airline name comes from the callsign. An airliner's ADS-B callsign
is its operator's registered ICAO designator plus a flight number — DAL2411
is "Delta 2411" — so airlines.ts resolves the designator to the operator's
name. That's reading a published identifier, not inferring one; an
unrecognised designator shows nothing rather than a guess. This is
deliberately separate from the Owner/Operator field, which is the
airframe's registered owner and is often a leasing company or a regional
partner flying under a mainline callsign.
Some failures are permanent for the life of a page: a photo lookup that gave up, a basemap that exhausted its fallback chain, a feed that stopped answering. Reloading clears all of them — which is why reloading appeared to fix missing photos — but a reload is a blank screen, a re-acquired GPS fix, a re-initialised map, and the loss of every photo already cached.
So SkyRadar re-runs just the parts that failed. About once a minute it retries the photos it had given up on, brings the next aircraft poll forward, and restarts the basemap chain if no tile ever arrived. Nothing that already works is touched, and nothing flashes.
The cycle never fires while the app is in use. An open card, a tracked aircraft, an open panel, or a touch in the last few seconds all count as busy, and a refresh that came due during any of that simply waits for a quiet moment. Returning to a backgrounded tab counts as a good moment to refresh, since a sleeping tab has not really been idle.
An outage clears the scope. When the feed stops answering, the aircraft are removed rather than left on screen. Positions are interpolated forward between polls, so a stale snapshot didn't just sit there — it kept moving, drawing aircraft on dead reckoning from a position nobody had confirmed in minutes. An empty scope under a "LIVE DATA UNAVAILABLE" banner is the honest picture.
Fixes arrive every four seconds. The screen redraws sixty times a second. What happens in between decides whether traffic appears to fly or to twitch.
What it does now. Each aircraft is carried forward from its own last measured position, along its own reported ground track, at its own reported ground speed, for the time that has actually passed since that position was measured. Every number in that sentence is one the aircraft itself reported over ADS-B. Nothing is inferred from one aircraft to another, and nothing is inferred from the clock on the wall.
Dating the position is the part that needs care. The API returns fetchedAt
alongside each snapshot, and every aircraft carries lastUpdated; both are
the server's clock, so the difference between them is that aircraft's
position age — a real reported figure, ADS-B's seen_pos — and subtracting
one from the other cancels any skew between the server's clock and the
phone's. The store pairs that with the device-clock moment the snapshot was
first seen. First, not last: the provider caches for five seconds while the
browser polls every four, so roughly every other response replays a snapshot
already held. A replay arrives later but still describes the instant it was
built, so the anchor must not move with it.
An aircraft that reports no usable ground speed or track is not carried at all. It is drawn exactly where it was reported, because an unknown velocity is not a reason to guess. Carrying is also capped at twenty seconds, which clears the worst healthy case — a position a few seconds old, served from a five-second cache, held for a four-second poll — without letting a stalled feed fly an aeroplane across the county.
What it did before, and why that had to change. The old engine took the last two snapshots, treated the gap between their arrival times as the time between them, and slid each aircraft along the line from one to the other, running past the newer one by a fixed factor to keep things moving between polls.
That assumed the feed advances in step with our polling. It does not, and the five-second cache against a four-second poll makes it fail in the most regular way possible: roughly every other response is byte-identical to the last. Two identical snapshots imply an aircraft that has not moved, so it froze. The next fresh snapshot then carried two intervals of travel, which the engine read as one, so it raced away at double speed and overshot. The next fix corrected the whole overshoot in a single frame.
On screen that is every aircraft edging forward, stalling, then twitching back together on an eight-second cycle, however steadily they are all really flying. The lockstep was the tell: the error came from a clock every aircraft shared, not from any of their positions.
Two smaller things fell out of the rewrite. The old code appeared to blend
heading and altitude between fixes, using Math.min(t, 1) — but t reaches
1 at the exact moment a fix lands and only grows after that, so the blend
never once ran and both values always came straight from the newest report.
Dropping it changed nothing. And the engine never interpolated in the sense
its name suggested: it only ever extrapolated.
How it is tested. scratchpad/test-wobble.cjs flies one aircraft dead
straight at a constant speed past a mocked feed that reproduces the real
one — the five-second cache, the replayed fetchedAt, the position age — and
measures the icon on the canvas in both the radar and Sky View. Any reversal
it sees is the app's, because the aeroplane never turns. An earlier mock
served a freshly measured position on every single request; it was clean
enough to hide this fault entirely, which is the more useful lesson.
The background map is deliberately raster, not vector, and its style is
defined inline in MapView.tsx rather than fetched. A vector style has to
pull a style document, then glyphs, then sprites, then render dozens of
layers — every one of those a way to end up staring at a black screen.
Raster tiles are just images: they either arrive and you see a map, or they
don't and the app says so.
Sources are tried in order — Esri Dark Gray (a base coat plus a transparent label coat), then OpenStreetMap, dimmed since it's a light basemap — advancing when a basemap produces no tile within a few seconds or errors repeatedly. A tile actually arriving is the only thing treated as proof the map works. CARTO is deliberately absent: its basemaps now stamp "API KEY REQUIRED" across every tile.
Place names, roads and landmarks come baked into the tiles, so SkyRadar carries no city/neighbourhood/road layers of its own — and no overlays at all. Airports are drawn by the radar canvas instead (see below), which can label them; a raster style ships no glyphs to render text with.
Anything with a real position — aircraft, airports, the range rings — is
placed through map.project() whenever a map exists, so it cannot drift
away from the ground feature it sits over. The polar bearing/distance plot
remains only for when there is no map at all; map.project() is camera
arithmetic and needs no tiles, so this stays independent of tile servers.
Drawing a polar plot alongside a Mercator map means trusting two independent scale calculations to agree, and they didn't: the camera zoom was derived with the 256px-tile constant (156543.0) while MapLibre lays the world out on 512px tiles (78271.5). That put the map a full zoom level too close, so every overlay landed at half its true distance from centre — the error growing with distance, which is why the centred "you are here" marker looked perfect while the airports were miles out.
Heading-up turns the whole plot so the direction you're facing is at the top of the screen. Two things have to be true for that to be honest.
Aircraft must stay pointing where they're actually flying. The
silhouettes are drawn nose-up at 0°, so in the default north-up plot an
aircraft's true track can be drawn directly. The instant the plot turns that
stops being true — screen-up is no longer north — and drawing the raw track
leaves every aircraft pointing somewhere it isn't going, swinging round as
you turn the device instead of staying put over the ground. Every silhouette
is drawn at track − whatever bearing is currently up (screenHeadingDeg).
That bearing is read back from the map rather than taken from the value we asked it for: the map eases into a new bearing and ignores sub-degree changes, so reading it keeps aircraft glued to the ground even mid-turn.
Place names can't rotate with the map. The basemap is raster, so its text is pixels — rotate the map and the words rotate too, upside down along the bottom of the screen. Nothing can straighten them. What can be done is take them off: Esri's labels arrive as a separate transparent coat, so heading-up hides that layer and shows them again when the plot settles north-up. Toggling layer visibility rather than rebuilding the style keeps every loaded tile exactly where it is, so the map never blanks.
Airports keep their labels either way — those are drawn by the radar canvas, upright, in both modes. The one gap is the OpenStreetMap fallback, whose labels are baked into the same tiles as the map itself and so can't be separated; that only applies if Esri is unreachable. Names while rotating would need a vector basemap, where labels are text the renderer can keep upright.
Phone and tablet magnetometers drift, and cases, speakers and car dashboards throw them off further. Two things address that:
webkitCompassHeading and the
alpha fallback report where the device's natural top edge points. Hold
an iPad in landscape and that's no longer the top of what you're looking
at, so the reading is a clean 90° out until the page's rotation is
subtracted.Sky View can say the compass looks off. Getting that judgement right matters more than it sounds, because a notice that is wrong is worse than no notice — it tells someone their working instrument is broken.
webkitCompassAccuracy of -1 when it cannot say, and plenty of iPads
report that for a whole session while pointing perfectly straight. Reading
it as a fault pinned the notice on permanently. Only an actual figure,
above 35°, counts as a poor fix.The screen is held awake for as long as the app is on screen, entry screen included. Watching for aircraft is mostly standing still and looking up, which is precisely the shape of "idle" a phone dims and locks for; losing the scope mid-approach because nobody had touched the glass is the one thing this app cannot afford to do. No switch, because there is no case where you would want the opposite while you are using it.
Two details it would be easy to get wrong:
A browser without the API, or one that refuses, is taken at its word: the screen simply behaves the way it normally would and nothing is said about it.
Full screen is offered where the browser will honour it — a control in the top-left column on the radar page, and a compact one beside the heading in Sky View, which is the view the browser's own toolbars cost the most. It is feature-detected rather than assumed: Safari on an iPad will put an element full screen, Safari on an iPhone will do it for video and nothing else, and a button that silently fails is worse than no button at all.
It used to sit at the foot of the control rail, and that was the wrong place for the same reason the screensaver button was: the rail scrolls when it runs out of room, and on a tablet it does, so the button was real but below the fold and you had to know to scroll for it. It now sits third in the left-hand column, under the LIVE badge and the refresh button, where nothing can push it off screen.
The whole document goes full screen rather than one view, so the choice survives opening and leaving Sky View. The control follows the document rather than remembering what it last asked for, because leaving is not always the app's doing — Escape, a swipe, or the browser simply changing its mind.
On an iPhone, where the Fullscreen API is not available, adding SkyRadar to the Home Screen gives the same result: it then opens without Safari's chrome at all.
Traffic within three miles raises an alarm on the radar page. Which kinds of traffic is up to you — Settings ▸ ALERTS is a list of the same five categories the filter rail uses, and ticking none of them is how you turn the alarm off. It is built for a tablet left running on a windowsill: the point is to be noticed from across the room, and then to get out of the way.
It warns without hiding what it is warning about. The alarm is a vignette — red at the edges of the screen, fully transparent through the middle, where the scope, the aircraft and their labels are. A flat wash over everything would have been easier to write and would also be the one thing an alarm must never do. It takes no pointer events either, so the map still pans and aircraft are still selectable while it is going.
The cycle is 1.8 seconds, about half a hertz. Anything at or above three flashes a second is a photosensitivity risk, and this is meant to run unattended for hours. Readers who have asked for reduced motion get the same warning held steady instead of pulsing.
A banner names the category, the contact and its distance, because a screen
that changes colour without saying why is just alarming. The category is named
the way it reads best rather than the way it is spelled internally: OTHER
becomes UNIDENTIFIED, because "OTHER WITHIN 3 MI" is not English and what
that category actually means is an aircraft the classifier could not place.
One table in src/lib/aircraft/categories.ts holds all three names — short
for the rail, full for Settings, and the alarm's own — because a category
ticked as "Military aircraft" and announced as something else would look like
a different aeroplane.
Two colours, and the difference is the point. Red is kept for military and unidentified traffic. Everything else you asked to be told about arrives in green. If every alarm were red the colour would only mean "an alarm", and the one that matters would look like all the others. A mixed set is red: the urgent contact decides. The tone is read off the contacts actually raising the alarm, so acknowledging a military contact and then having a helicopter arrive gives a green alarm naming the helicopter, not a red one naming something already dealt with.
Three miles, not the selected range. The alarm is about something approaching visual range, so it does not move when the scope is switched between 3 and 30 miles.
It ignores the category filter, deliberately. Ticking AIRLINE in the rail is a statement about what you want drawn, not about what is worth being warned of, and an alarm silently disabled by an unrelated control is worse than no alarm. What is worth waking up for is its own list in Settings. The banner names the contact, so a warning is never mysterious even when that aircraft is filtered off the plot.
There is no separate on/off switch beside that list, because "warn me about nothing" and "do not warn me" are the same instruction, and two controls for one idea is one too many. A caption under the list says as much rather than leaving an empty list looking broken.
Four ways it ends, and all of them are deliberate:
Positions are the last ones actually reported, not the carried-forward ones the canvas draws. The difference is a few seconds of travel, and raising an alarm off a measurement rather than off a prediction is the right trade.
In full screen, iPadOS keeps drawing over the page: the clock and date at the top left, the battery and wifi at the top right, and a floating button to leave full screen that sits lower still, right on top of the wordmark. None of it is inside the safe-area insets the browser reports, so there is nothing to read and the room has to be made deliberately.
--chrome-top is that room. Every top-anchored control — the wordmark and its
buttons, the status banner, the alarm banner, the top of the control rail —
starts from it, so full screen moves the page furniture together rather than
one piece at a time. It is env(safe-area-inset-top) + 0.75rem normally and
+ 5rem while full screen is on, which puts the wordmark clear of the exit
button rather than under it.
The status readout and the refresh button moved to a column under the wordmark at the same time. On the right they kept ending up beneath the battery and wifi; on the left, under a wordmark that has already been pushed clear, they are out of everything's way.
The gear stayed in the opposite corner, directly above the control rail's HIDE
handle and sharing its right edge. Deliberately above the rail rather than
inside it: the rail folds away, and a settings button that folded away with it
would leave no way back to Settings without first bringing the rail out —
which is the wrong order for a control that can turn the rail's own contents
on and off. It hangs off --chrome-top like everything else up there, so full
screen drops it clear of the battery and wifi too.
A third view, alongside MAP and RADAR and Sky View, for a tablet left running on a windowsill. It is autonomous by design: once started, nothing about it waits on a tap.
SCREENSAVER sits in the view group with the other three. It began as a button at the foot of the control rail, below FULLSCREEN — which was where it was asked for, and the wrong place: the rail scrolls when it runs out of room, and on a tablet it does, so the button was present but below the fold. That is indistinguishable from missing. Up with the other views it is always in reach, and it is where a view belonged anyway. Starting it from the ordinary page takes full screen on the way in, so the screensaver is only ever seen full screen.
It is the one button in that group set a size down, at 10px rather than 11px.
"SCREENSAVER" is eleven characters against "SKY VIEW"'s eight and at the
rail's phone width the label ran past its own padding; shrinking the type
keeps the word whole, which is the point — an abbreviation nobody recognises
is not a saving. Worth knowing if you touch it: two Tailwind utilities for
the same property are settled by their order in the generated stylesheet, not
the order they are written, so a text-[10px] appended after a shared
text-[11px] silently loses. The shared class string here carries shape
only; size, padding and tracking are set per button.
It sweeps the rings, but only the ones worth sweeping. Every fourteen seconds it moves to the next range that has something in it. The rings are cumulative, so a single aircraft two miles out is inside all four and the display breathes in and out around it; an aircraft at twenty-two miles is inside only the widest, so it sits on 30 and stays there rather than showing three empty circles in turn. With nothing anywhere it settles on 30, which is the most useful thing an empty scope can show.
It shows a card for whoever has a picture. Every nine seconds the next aircraft in the current ring takes the card, fully open — no MORE to press — with its photograph, the four figures that matter, and what the airframe is. Aircraft with no photograph are skipped entirely rather than shown with an empty frame, and with nothing photographable in range there is simply no card. The card cross-fades: the outgoing one is gone before the next arrives, so two aircraft are never on screen at once.
The card's aircraft is ringed on the scope. Two circles, breathing
slowly, around whichever aircraft is holding the card, plus the larger white
silhouette a selected aircraft gets. Without it the card named an aeroplane
and left you to work out which of six blips it meant. The ring follows what
is displayed rather than what the director has chosen — the card lags by
one fade, and a ring running ahead of it would point at the wrong aircraft
for half a second. It rides a focusAircraftId prop of its own rather than
reusing selection, so the radar page's selected look is untouched.
The metrics stay up. Current range, how many aircraft are inside it, and the breakdown by category — with a four-segment bar showing which rings are in the rotation and which one is showing.
The clock and the place are the size they should be for something read from the other side of a room: the time at 72px, the place name beneath it in wide-tracked capitals.
Everything else keeps working. The map, the radar sweep, the trails, the airports, and the proximity alarm. Every category is on the plot regardless of what the rail is filtered to, because a screensaver showing a filtered sky would be showing the wrong sky.
The alarm here is colour only. The full-screen wash still arrives, red for
military and unidentified traffic and green for everything else you have asked
about, but the banner that names the contact and offers GOT IT does not. A
screensaver is watched, not operated: nobody is standing at the tablet to
acknowledge anything, so the prompt would sit there unanswered until the
aircraft flew away — and an alarm you cannot dismiss is one you learn to
ignore. On the radar page, where someone is holding the device, the banner is
unchanged. It is one showBanner prop on ProximityAlert; the vignette is
never behind it.
The floating ✕ in the corner of full screen is Safari's own control for leaving it, drawn by iPadOS over the page. It is not something SkyRadar renders and there is no way for a web page to remove it. Adding SkyRadar to the Home Screen and opening it from there does away with Safari's chrome entirely, ✕ included.
Getting out. A tap anywhere asks EXIT SCREENSAVER? rather than acting, because a screensaver that exits on a brush against the table is no use. An unanswered prompt gives up after twelve seconds and lets it carry on.
The screensaver drives the range and the filter through props, not through the store. The range you chose is yours: coming back from a screensaver that has been sweeping 3 to 30 all night leaves the scope exactly where you left it, and the same goes for the category filter. There is a test that checks the persisted store directly, because that is the only thing that can prove nothing was written.
While it runs, the poll asks for the widest range rather than the one being displayed, so the rings can narrow and widen instantly instead of waiting out a fresh fetch at every step.
It asks for full screen when it opens, and gives it back on the way out — but only if it was the one that took it. Someone already in full screen who then starts the screensaver should not be dumped out of it afterwards.
The aircraft card gained a presentation flag for this, defaulting off. It
strips the card of everything you could press, since every tap in the
screensaver belongs to the exit prompt and a Close button that cannot be
reached is worse than no button. It also drops the POSITION and
IDENTIFICATION blocks, which restate the figures already across the top and
add reference detail nobody reads from across a room — and without them the
card fits the screen unaided, which matters when there is no one there to
scroll it.
Every control on the radar page lives in one rail down the right-hand side. They used to sit across the bottom of the screen, where they covered the lower part of the scope — exactly where aircraft to the south appear.
What the rail holds, and what it no longer does. Top to bottom: the compass, the heading-up and lock toggles, the view (MAP / RADAR / SKY VIEW / SCREENSAVER), the range, and the category filter. Full screen has left the rail for the top-left column, so nothing in the rail can be scrolled out of reach.
The category filter takes more than one. "Military and gliders" is an
ordinary thing to want and the old control could not express it: one value,
"ALL" | AircraftCategory, so picking a second category dropped the first.
It is now a list, and the rules around it are chosen so a tap can never leave
you somewhere confusing:
Every row says how many. Each category carries the number of aircraft in it, so the list answers "what do I get if I tick this?" before you tick it, and a category with nothing in it reads as empty rather than as a button that does nothing.
The numbers are of what is in range, not of the whole feed. A promise of twenty airliners that turns into six the moment you tick it would be worse than no number at all, so they are counted inside the selected range using the same 5% headroom the scope draws with — and they change when the range does. They also do not depend on what is currently ticked, because the question they answer does not.
ALL is exactly the five added up, and the count in the top bar beside LIVE is exactly the ticked ones added up. Both come from one computation rather than three, which is what keeps them from ever disagreeing. The top bar is the right home for the on-screen total because the rail folds away, and that is precisely when the per-category numbers are not there to add up yourself. It is withdrawn entirely during an outage: a count left standing would describe a scope that has already been cleared.
This is why the rail is w-28 from sm up. A busy 30 mile scope near a
hub runs to three digits, and at w-24 MILITARY 142 overflowed its row and
clipped the number.
It stays w-24 on a phone, and the rows tighten there instead. Widening it
everywhere was the first attempt, and on a 390px screen those 16px reached
into the bottom-right of the scope — measured as a 7px intrusion at the outer
edge of the south-southeast wedge, which is precisely the part of the plot the
rail was moved off the bottom of the screen to protect. A tablet has the room
to spare. A phone does not, so --rail-open carries the width and everything
anchored beside the rail follows it.
The tick boxes are what make it a multi-select rather than a set of pills
that happen to allow more than one. Highlighted pills alone read as a
segmented control — pick another and the first turns off — which is exactly
what this used to be, so nothing on screen would have said the behaviour had
changed. A SHOW 3/5 count beside the heading says how much is hidden
without having to read down the list.
The check inside each box is a drawn path, not the character ✓. As text it
lands in the button's own text content, so every row reads ✓ALL, ✓HELI
and so on — invisible when unticked, but there for anything that reads,
matches or copies the label.
The filter is persisted, so the shape change needed a migration rather than a
type change: a v1 "MILITARY" becomes ["MILITARY"] and "ALL" becomes
[]. The matcher also refuses to trust the shape, treating a non-array as
"show everything" — because a stale string would take neither branch cleanly
("ALL".includes("MILITARY") is false) and silently hide every aircraft on
screen, which looks like an outage rather than a bug.
Sky View sits in the view group rather than beside it because it is a third way of looking at the same traffic, not a feature to switch on. That it opens as an overlay instead of changing a mode is an implementation detail, and the old styling — a filled green button off on its own — announced it as something else entirely.
There is no Layers panel. It listed five toggles and Settings already carried four of them under clearer names: radar sweep, radar sound, aircraft trails and airports. Two panels for one set of preferences meant two places to look and two controls for the same switch. Only the Sky View master toggle was unique to Layers, and that has moved into Settings' own SKY VIEW section alongside the AR options it governs.
On a phone that rail is about a quarter of the screen's width, and the map underneath it is the point of the app, so it folds away: HIDE › at the top of the rail tucks it off the right edge, and a ‹ tab at the screen's edge brings it back. The choice persists, because someone who wants the map uncovered wants it uncovered next time too.
Two details make it behave:
A folded rail is also hidden rather than merely slid off-screen. A control parked out of sight is still in the tab order and still read aloud, and a keyboard or VoiceOver user would otherwise land on buttons they cannot see.
Hold the device up, follow the guidance, find the aeroplane. The whole mode rests on one decision: it points at an area, never at a dot.
A tablet compass is several degrees out on a good day, and an airliner has moved a few hundred metres since the position now on screen was measured. A tight reticle would claim a precision nobody has — and it would be wrong in the most annoying way, confidently. So Sky View marks a patch of sky, says how far to turn and tilt to bring it into view, and leaves the last step to the eyes, which are far better at it than any marker.
How big the patch is. SEARCH_RADIUS_DEG in src/lib/ar/projection.ts
is one number — 14° of sky — the same for every aircraft at every moment.
It did not start that way. The radius used to be computed per aircraft from the compass error and from how far the aircraft had flown since its last report, which was defensible and unusable: fixes arrive every four seconds, so the circle swelled for four seconds, snapped back when one landed, and swelled again. A ring that breathes cannot be measured against. The moment the mode actually earns its keep is the moment you have the aircraft in sight and want to know how close the app really is — and that is exactly when a changing ring stops answering the question.
Fixed is also the more honest shape. The error that dominates out here is the compass, and compass error is angular: it does not care how far away the aircraft is or how fast it is going. The old formula's second term mostly added motion, not information. 14° is sized generously, once, for the days when the fix is stale and the heading is a few degrees out.
Where the sky maps onto the screen. The aircraft's bearing and elevation
become a unit vector in east/north/up; the device's heading and pitch give a
camera basis; a pinhole projection with focal = (width/2) / tan(hfov/2)
turns that into a pixel, and roll rotates the result into the screen's frame.
A target behind you is reported as such rather than projected to a nonsense
point. The camera's field of view is an estimate (63°) because cameras don't
report it — the search area is wide enough that a few degrees either way
changes nothing.
Why the silhouettes glide. Positions arrive every four seconds. Drawn
raw, an aircraft crossing overhead jumps a finger's width at a time — barely
noticeable on the radar, glaring through a camera, where the sky behind it is
moving smoothly and the icon is not. So Sky View reads the same motion engine
the radar uses (src/lib/render/interpolate.ts), once per animation frame and
outside React, and draws each aircraft where it is now rather than where its
last measured position put it. How that engine works, and the fault that made
it necessary to rewrite, is under Carrying an aircraft between
fixes.
Which way to turn. The guidance bar gives one instruction per axis:
TURN LEFT 40° · LOOK UP 25°. When an axis rounds to zero it says ON BEARING / ON ELEVATION instead — "TURN LEFT 0°" is a direction that isn't
one. More than 150° off and it simply says the aircraft is behind you, which
is more use than a number.
When the target is off screen a solid arrow slides out from the aim point in the target's true direction, labelled with the callsign and how far off aim it is. Aircraft are usually above you, so this is the ordinary case, not the exotic one. The arrow is confined to the band of screen that is actually visible: the HUD is painted underneath the top bar and the target card, and an arrow drawn in those bands — as the "it's below you" arrow once was — is an arrow nobody sees. That band is measured from the live card rather than assumed, because the card grows with the aircraft it describes.
Which way up the picture is. Roll comes from gravity — beta/gamma
give world-up in the device's own frame, and its angle from the screen's up
direction is the tilt of the horizon. Turning that into the page's frame
needs to know how far the page is rotated, and screen.orientation.angle is
treated as a hint rather than an answer: it measures the page against the
device's natural orientation, and on some tablets that is landscape, while
beta/gamma arrive in a portrait-fixed frame regardless. On such a device
the two disagree by a quarter turn, and taking the browser at its word drew
the horizon vertical and inverted every movement — which is exactly what an
iPad did, held in landscape reporting beta 0, gamma -90 with an angle of 0.
Gravity settles it. The browser rotates the page to whichever quarter turn leaves it most upright, and that decision is itself made from gravity, so gravity can be asked directly. The browser's number is still used wherever it agrees with a plausibly-held device, and the quarter turn in use is sticky, so leaning the tablet reports the lean rather than snapping the horizon round.
Silhouettes point the way the aircraft is seen to be going — down the view as it recedes, up as it comes on, sideways as it crosses. They were drawn nose-up at first, on the grounds that a plan-view icon says nothing about attitude. True, but it left an aircraft passing overhead drawn flying backwards, which reads as a bug however defensible the reasoning.
What is drawn is the apparent direction, not the compass track, and the difference matters. Take an aircraft climbing away to the north: its altitude is rising, but its elevation in your sky is falling, so on screen it slides towards the horizon. Point the icon along its track and it would be drawn climbing while visibly sinking — the same complaint in reverse. To gain height in your sky while flying away, an aircraft has to climb steeper than the line of sight to it, which is rarer than it sounds.
apparentTrackDeg() finds it by asking where the aircraft will appear a
moment from now — stepping its position along its own velocity, then putting
both points through the same projection as the marker itself, so perspective,
roll and the camera's aim are all accounted for without repeating any of that
arithmetic. The step is scaled to a fixed fraction of the range, which keeps
the chord close to the tangent whether the aircraft is a mile away or forty.
It declines to answer rather than guess. An aircraft coming straight at you barely moves across the view, so there is no direction to read; one with no track reported gives nothing to work from. In both cases the icon holds the last direction it was seen travelling, or sits nose-up if it never had one — so it stays still instead of spinning on noise.
The accuracy this needs is mild. The marker's position depends on the compass being right; its rotation is a difference between two nearby points, so a compass several degrees out moves both by nearly the same amount and the angle between them barely changes.
(On the radar plot, where you are looking at a plan view, the silhouette is rotated by the track itself — see Heading-up. That path is untouched by this.)
Choosing an aircraft, and letting one go. Sky View opens on something useful — whatever was chosen on the radar, or the nearest aircraft if nothing was. That opening pick is made once, on the way in. Making it every time the selection emptied was what stopped anyone letting go of an aircraft at all: the tap cleared it and the effect immediately chose another.
Tapping empty sky lets the current aircraft go, and so does tapping the one already being followed — the same gesture the radar uses. What is left is a sky with nothing singled out: no search ring, no guidance, and the card's whole band handed back to the view, with one line saying how to start following something again. Every aircraft is then drawn on the same terms, so the distance cue below does all the talking.
Leaving Sky View and coming back makes a fresh opening pick, because that is a new visit rather than a continuation of the old one.
Distance is drawn, not just written. An aircraft thirty miles off used to
be drawn exactly as loudly as one overhead. Both are real and both belong on
screen, but they are not equally interesting, and on a busy afternoon the
distant ones crowd out the aircraft you could actually walk outside and see.
Three things now vary with range (src/lib/ar/depth.ts):
The scale is the range the scope is set to, the same one the radar's rings use, so the cue always spans the world being shown: on 30 miles the far half recedes, and on 3 miles everything left is close and stays bright. Three miles is treated as close on any range, because it is — squeezing the whole fade into a three-mile world would call a nearby airliner faint.
The aircraft being hunted is exempt at every distance, in all three respects. Choosing one and then watching it fade would be the app arguing with the user.
Contacts are also drawn furthest-first, so a nearer aircraft always sits on top of a distant one. Drawing them in the order the list happened to arrive in put the far ones on top, which is exactly backwards for reading depth.
Labels get out of each other's way. On a busy afternoon half a dozen aircraft sit within a few degrees of each other. Labels are placed after every silhouette is down: the chosen target first, which always keeps its label, then the rest nearest-first, each taking the first of four placements (below, above, right, left) that collides with nothing already drawn. A label with nowhere to go is dropped — the silhouette still speaks for itself, and one readable box beats three stacked on the same patch of sky. Only the target gets a search ring, for the same reason — and only the target's label stands off far enough to clear one. Standing every label off by a ring nobody can see left a distant aircraft's name floating a hundred pixels away from it, with nothing to say which belonged to which.
The pitch ladder turns with the world; its numbers stay upright. The ladder is an artificial horizon and belongs to the sky, but text that rolls over with the device is text nobody can read at a glance.
Everything on the HUD carries a dark halo (shadowBlur on a near-black
shadow) because in daylight the camera image is close to white, and a white
HUD on a white sky is no HUD at all.
Before any permission is granted there is nothing real to show, so the screen shows the instrument instead of a splash. Aircraft cross the dark trailing fading tracks, each carrying a callsign, an altitude and a speed, drawn with the radar's own silhouette sprites, its own trail falloff, its own label stack and its own green. A fighter crosses in seconds; a helicopter takes most of a minute. That spread is the point — it shows, before you have granted anything, that this app knows the difference.
Nothing there is real. Those are invented aircraft (AmbientTraffic) on
a decorative canvas, shown only on the entry screen, before any location is
known and where there is no scope to confuse them with. The callsigns are
deliberately fictional — SKR is not an assigned ICAO designator — and none of
it can reach the radar, which draws only from live ADS-B.
Keeping the mark, the buttons and the byline crisp is done by fading each contact as it nears them, not by laying a dark pool over the middle. The pool was the obvious approach and it was wrong twice: it swallowed the traffic, and at these near-black values the gradient banded into visible rings. Fading per contact leaves the background perfectly flat.
Keeping three or four in view is a budget, not a count. Plenty of contacts are invisible at any moment — fading in at an edge, or crossing the clear zone behind the logo — so capping the number aloft was the wrong control: on a phone, where the content covers most of the width, the pool filled with contacts stuck in the dimmed middle, the cap was reached, and nothing new could spawn. The screen sat empty with nine aircraft in the air. The budget now counts what can actually be seen, at the opacity a contact actually reads at, and tops up when it runs short. A phone gets a lower floor on purpose: forcing four into the narrow bands above and below its content would crowd the screen rather than improve it.
Three things decide whether that budget is ever met:
Nothing is ever drawn over the mark, the button or the byline: inside the content's box the opacity is exactly zero. Each trail segment is faded by whichever of its two ends is least clear — judging a segment by one end let a fast contact stroke a line right across the wordmark between two samples — and each label is faded where the text lands rather than where its aircraft is.
With prefers-reduced-motion, the traffic holds still as a composed tableau
rather than disappearing — the screen stays populated, nothing moves.
Dark, desaturated map; restrained radar green; clean vector aircraft
silhouettes (no emoji, no generic single icon — see
src/lib/render/silhouettes.ts) that rotate with heading and differ by
category (airliner, regional jet, turboprop, GA, helicopter, military
helicopter, fighter, military transport); a subtle, distinct treatment for
military/government aircraft (different silhouette + small diamond
indicator + faint outline, never a giant red marker). Category is always
also conveyed by shape and label text, never by color alone.
Location is used only to compute what's around you. SkyRadar does not store location history or send precise location to third parties; it requests only the accuracy each feature needs (standard accuracy for the radar, high accuracy only while Sky View is open). Camera access is requested only when Sky View is opened, and camera frames never leave the device — they're used purely as an AR background.
npm audit currently flags advisories against Next.js/eslint-config-next
and a nested glob. Nearly all of them concern features this app doesn't
use (Server Actions, i18n Middleware, custom servers, the Image
Optimization API, WebSocket-upgrade proxying) or dev-only tooling
(eslint-config-next's transitive glob, not shipped at runtime).
Clearing them fully means moving to Next.js 16 (a breaking major-version
change, including a React 19 requirement) — worth doing as deliberate
follow-up, not folded into this build.