Skip to content

Dev - #37

Merged
zackrylangford merged 39 commits into
exoplanethub:devfrom
zackrylangford:dev
Sep 2, 2026
Merged

Dev#37
zackrylangford merged 39 commits into
exoplanethub:devfrom
zackrylangford:dev

Conversation

@zackrylangford

@zackrylangford zackrylangford commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator

No description provided.

exoplanethub-builder[bot] and others added 30 commits August 30, 2026 18:24
The sync Lambda only upserted, so retracted and renamed planets accumulated
in the table forever -- 13 stale records over ten months, inflating every
count the UI derives from /api/planets.

Adds a sweep pass that runs after a successful ingest, diffing the archive's
pl_name set against a projection-only paginated scan and deleting the
difference. It refuses to delete more than 5% of the table in one run, so a
truncated fetch aborts loudly rather than emptying the table, and it never
raises -- the upserts have already committed and must still report success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…14)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
table.batch_writer() flushes every 25 items, so a throttle partway
through can commit several batches before raising. The failure path
returned deleted=() regardless, publishing total_removed: 0 for a run
that really did delete records — the same silent-no-op signal this PR
exists to remove.

Rename SweepResult.deleted to submitted and accumulate names as they
are handed to the writer, so the count bounds what may have committed
instead of understating it. Names are recorded before delete_item
because a buffered item can commit in the very flush that raises.

The fake table hid this: batch_writer() returned self and delete_item
wrote through immediately, so committed and reported were equal by
construction. Replace it with a fake that mirrors boto3's BatchWriter
— buffer, flush at 25, flush the remainder on exit — tracks handed and
committed names separately, and can model a batch that commits and
still raises, since BatchWriteItem is not atomic.

Also scope the INFO level to the sweep logger rather than the root
logger, and stop the README claiming the table cannot drift above the
archive's count when a tripped guard deliberately leaves drift.
…mantics

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Table view is the default, so a keyboard or screen-reader user could not
open a planet at all: rows opened on <tr onClick> and sort headers on
<th onClick>, neither reachable.

Rows now carry a real button on the planet name rather than a focusable
<tr>: role="button" on a row strips the row/cell semantics screen readers
use to navigate a table, and a named button also announces which planet it
opens. The row keeps its pointer affordance and no longer double-reports.

Sort headers collapse into a SortableHeader that renders the button and
aria-sort together, so direction is no longer carried only by a glyph, and
the unsortable Star column stops claiming to be clickable.

ESIModal already met the dialog standard while PlanetModal had none of it.
Rather than copy the focus trap, extract it into ModalDialog, which owns
the overlay, portal, escape, scroll lock, focus trap and focus restore, and
cannot be rendered without role/aria-modal/aria-labelledby. Its focus trap
also recognises form controls, which the ESIModal original did not.

Also: decorative glyphs are aria-hidden, the nav hamburger reports
aria-expanded/aria-controls, view toggles report aria-pressed, and the nav
links use next/link instead of forcing a full reload.

Closes #6

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d-records

fix: sweep records the NASA archive no longer lists
…estions closed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pense answer

- Band edges moved to actual Pecaut & Mamajek 2013 values (33000/10000/7300/
  6000/5300/3900/2300); Harvard set noted as rejected alternative (K/M edge
  mislabels ~146 M-dwarf hosts on the flagship red-dwarf filter)
- ?sort=key.dir added to URL schema, Goals, AC, task 1; out-of-scope
  alternative noted and rejected
- Suspense punt replaced with the build-verified answer (no boundary needed
  on Next 16.3.3; /explore stays static)
- Period span corrected to ~8 orders; slider bounds derived from data
- Results total computed from fetched list, never a literal
- Memoisation AC added; planetFilters.ts ~200-line split threshold noted

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed to table as props

Addresses round-2 review on PR #39: single URL writer (no second debounced
router.replace racing on the query string), Back/Forward can't desync the
sort indicators. Also: view toggle explicitly out of scope, K/M mislabel
count corrected to ~150 (157 as of review re-run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The trap returned early when the dialog had no focusable stops, so
neither wrap branch could match and native Tab walked out of a dialog
claiming aria-modal="true". Swallow the key instead.

No current caller hits this — both dialogs carry a Close button — but
the primitive exists so the next author does not have to remember.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…= and ?sort= (#43)

* feat(explore): add planetFilters, the one owner of filter semantics

Holds the FilterState type, the URL codec and the predicate engine, so
the query-string schema exists in exactly one place. The default sort is
omitted from the URL and anything malformed parses to an inactive filter
rather than an error state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(explore): bind filter state to the URL with useFilterParams

The URL is the source of truth; local state holds only the edit that has
not been written yet, and the URL reclaims it whenever it moves on its
own. Writes are debounced and use replace, so Back does not step through
keystrokes, and a queued write is dropped once the URL it was queued from
has changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(explore): filter both views from one source of truth

Search, discovery method and sort leave PlanetTable's local state for a
single useFilterParams in ExploreClient, which applies the filter once
and hands the table a sorted-in-place-free list plus sort props. The grid
was never filtered before; it is now, from the same set.

Pagination gains two independent guards: an explicit reset to page 1 when
the visitor changes a filter, and a clamp to the last real page that also
covers Back/Forward, where no reset runs. "Page 1 of 0" is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(explore): keep typing that lands mid-write, clamp the page in state

Two bugs the review caught, both reproduced as failing tests first.

useFilterParams could not tell its own URL write from an external one, so a
keystroke arriving while its write was in flight was discarded along with the
write it had queued — the input reverted under the visitor mid-typing. The hook
now remembers the queries it dispatched and only surrenders the edit to a query
it never asked for. Debouncing moved into an effect so its cleanup cancels the
superseded write instead of a manual clearTimeout keyed on the URL.

The page clamp was derived and never written back, so every reader had to
remember to use the clamped copy and the grid's Previous button did not — it
dead-clicked whenever the clamp was active. usePagination now hides the raw
request entirely, which makes that mistake unrepresentable rather than fixed.

Also: an unknown ?method= renders as its own option instead of the select
falsely reading "All Types" over zero planets, and serializeFilters writes the
trimmed search it actually applies.

The router mock in both test files returned a fresh object per render, which
re-ran effects that depend on it and hid missing dependencies; it is stable now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(explore): give usePagination sole ownership of the paging rules

PlanetTable took page + itemsPerPage + onPageChange and recomputed the
page count itself, duplicating the rule usePagination was added to own.
It now takes the hook's return value as one Pagination prop.

pageItems joins the contract because totalPages alone is not enough: a
table that reads the page count from the hook but slices with its own
itemsPerPage can show "Page 1 of 3" over every row it was handed. The
offset arithmetic was duplicated in both pagination UIs anyway, so the
hook absorbs it and neither caller keeps an unclamped page or a second
copy of itemsPerPage in scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(explore): pin that both views page through one page size

Grid and table now share a page size only because they share the hook.
Nothing failed if the grid grew its own again, so this asserts the page
label and the item count survive a view switch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: aronnaxp <aronnaxp@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(explore): add radius, mass and orbital period range filters

Ranges live in the URL as `min..max` with either end omissible, in real
units, alongside the search/method/sort params task 1 established. The
codec and predicate stay in planetFilters.ts; a planet with no
measurement for a quantity drops out only while that range is active,
and the control says so in plain language.

Paired number inputs are the primary mechanism, with a log-scale
dual-thumb track over two native range inputs as the enhancement. Track
bounds come from the fetched data's own min/max, so the ~8 decades of
orbital period stay reachable instead of collapsing against the tail.

Also adds pl_orbper and st_teff to the API projection; st_teff is
task 4's input, added now to avoid a second one-line change to the
same list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(explore): announce thumbs in real units and stop them collapsing

Review follow-ups on the range filters.

Sliders announced a log-track coordinate (a 10 Earth-radii bound read as
"333"). Both thumbs now carry aria-valuetext in the measured unit, matching
what the neighbouring number input shows.

The upper thumb could end up with min === max, which Chromium paints at the
LEFT end of the track whatever its value: "radius at least 176" drew thumbs at
both ends, reading as the whole range selected when it is the narrowest
filter. Both inputs now span the full track and crossing is prevented when the
bound is committed, so no input is ever degenerate. With the thumbs no longer
constrained by each other they can stack at the top of the track, where only
the lower one can move, so it is raised to take the pointer.

A crossed pair from the URL or the number inputs drew the range it swaps into
while matching nothing; the track is now dropped instead.

st_teff left the /api/planets projection. Nothing reads it yet, so it was a
per-planet payload cost for every visitor, and its test named a filter that
does not exist. It returns with the star type filter that uses it.

* fix(explore): keep the range rail mounted while the bounds cross

Only the thumbs come off now. Unmounting the whole track container made the
filter card lose 32px on the first keystroke of most typed maximums, jerking
itself and the results below it, and putting it back on the next keystroke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: aronnax-builder[bot] <aronnax-builder[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: aronnaxp <aronnaxp@users.noreply.github.com>
…48)

* feat(explore): multi-select discovery method and one shared ordering

Widen ?method= to a comma-separated contract and hoist the sort
comparator out of PlanetTable so both views order before paginating.

Method becomes a list in FilterState, so "no filter" is the empty array
rather than a null/empty-string pair. The predicate takes the union, so
naming a method the data lacks alongside a real one no longer collapses
the result set to nothing. withMethod sorts the selection, giving one
set of methods exactly one URL however it was clicked.

The comparator moves to lib/planetFilters.ts as sortPlanets, called once
in ExploreClient. Grid and table now render the same ordered array, so
grid page N and table page N hold the same planets for a shared URL --
they did not before, which defeated the point of putting sort in the URL.

The single-select dropdown becomes a checkbox fieldset rather than a
<select multiple>, which is hostile on touch and to screen readers.

* fix(explore): stop a method checkbox unmounting under the visitor mid-click

The offered boxes were derived from filters.methods, which those same boxes
mutate. Unticking a method no planet carries — one only the URL named — dropped
it from the selection, so its own <label> unmounted mid-interaction: keyboard
focus fell to <body>, and the box was gone for good with no way to re-tick it.

The offered set now only grows within a mount, adjusted during render like
useFilterParams does rather than in an effect, so no pass ever paints without
the box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(explore): canonicalise ?method= order on parse as well as serialize

withMethod sorted the selection but parseMethods kept URL order, so untick and
re-tick from ?method=Transit,Astrometry produced a second URL for the same set.
Sorting on the way in too makes "one set of methods, one URL" actually hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: aronnax-builder[bot] <aronnax-builder[bot]@users.noreply.github.com>
Co-authored-by: aronnaxp <aronnaxp@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ard-accessibility

fix(explore): make the table, rows and modals keyboard accessible
…pty state (#50)

* feat(explore): star type filter, clear all, live results count and empty state

Adds st_teff to PLANET_SUMMARY_FIELDS so the projection carries the host
temperature the new filter bands, then derives O/B/A/F/G/K/M from it.

The band table lives in lib/starBands.ts rather than planetFilters.ts, which
the spec calls for once that file passes ~200 lines. Bands are half-open with
the lower bound inclusive, on the Pecaut & Mamajek edges the spec pinned. A
host cooler than 2,300 K or with no measured temperature is unclassified, not
M, and drops out only while the star filter is active.

Canonical ordering of a multi-select moves into serializeFilters, so it now
lives in exactly one place instead of being repeated across the parser and
each mutator. Methods order alphabetically and star classes by temperature;
either way one set of selections has exactly one URL.

Clear all resets to DEFAULT_FILTERS and so strips the query string. Both it
and the empty state's button return focus to the search box, since each stops
being focusable the moment it works.

The results count spans both views, which the grid's inline markup could not,
so the grid becomes PlanetGrid owning its own pagination like PlanetTable
does. That left two copies of the pagination footer, now one
PaginationControls; the "(N planets)" it used to carry is what the live count
states, better, for both views.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(explore): keep sort through Clear all, own the failed-load state, correct the O-type label

Addresses the three blockers on PR #50.

Clear all no longer discards the sort. `cleared(state)` now lives beside
`isFiltered` in planetFilters.ts, so the one module that decides what counts
as a filter also decides what clearing throws away; the two can no longer
disagree about sort the way DEFAULT_FILTERS did.

The explore page distinguishes a failed load from an empty archive. It fetched
into `planets: []` on any failure, so a dropped connection rendered NoResults
telling the visitor the archive was empty — the opposite of what happened. The
page now holds a loading/unavailable/loaded union, shows its own error panel
with a working retry, and never hands an empty list to the explorer on failure.
That makes NoResults' empty-archive branch true rather than merely vague.

O is labelled "O — blue": the table bands on temperature alone, off the
Pecaut & Mamajek dwarf calibration, so it cannot name a luminosity class.

Also closes both non-blocking notes. PaginationControls moves focus to the page
indicator when Previous/Next disables itself, matching the clear buttons and
leaving focus on the button while it stays usable. The results count moves into
ResultsCount, which shows the number instantly but announces it only once the
count settles, so a screen reader is not read a fresh total per keystroke.

---------

Co-authored-by: aronnaxp <aronnaxp@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ters

Merge rather than rebase, per the epic's ground rules — the epic branch is
shared and has had PRs against it.

Two conflicts, both where #6 and the filters epic changed the same code:

- PlanetTable.tsx — #6's SortableHeader wins over the epic's inline
  `<th onClick>` headers, but keeps the epic's hoisted props (pagination,
  sortKey/sortOrder/onSort). The table stays a controlled component; the
  header just becomes a real button carrying aria-sort. Drops the now
  redundant esiAriaSort and the useState/useMemo #6 still needed.

- ExploreClient.test.tsx — add/add. Kept the epic's file and folded #6's
  two view-toggle tests into it using its existing helpers; a separate file
  would have duplicated the whole next/navigation mock harness.

The epic's two `it.fails` in PlanetTable.test.tsx were the accessibility
gaps #6 fixes, so they become real passing tests. The keyboard-activation
one is rewritten for the controlled table: it now asserts every sortable
column reports its key through onSort, and that aria-sort follows the props.
Chaining clicks through one header after another cannot work now that the
table no longer owns the sort state.

Verified: 422 pass, tsc 0, eslint 0, next build clean with /explore still
Static, no orphaned CSS in PlanetTable.module.css.
merge: bring dev (keyboard accessibility #6) into epic/41-explore-filters
)

* docs(roadmap): record archive-first direction and the filters build

Item 3 described "improve advanced filters" for filters that did not exist.
Reword it to what the epic built: search, radius/mass/period ranges, discovery
method, star type, and all filter and sort state carried in the URL.

Add a Direction section recording the lane settled in #36 — archive first, with
anything presented as new derived from the sync diff rather than hand-written.

"Current Focus" becomes "Shipped": all three items under it are complete, so
marking only item 3 would have implied the other two were still pending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(roadmap): separate shipped diff surfaces from intended ones

The Direction paragraph named recent confirmations, retractions and
records broken as things the site already derives from the sync diff.
Only the latest-confirmations feed exists; retractions cannot exist yet
because the sweep hard-deletes removed planets (#40).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: aronnaxp <aronnaxp@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
feat: explore filters — search, ranges, method, star type, all in the URL
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The file had reached 871 lines across ten describe blocks sharing a
~120-line preamble. Extract that preamble into exploreTestUtils.tsx and
split the suites by concern: filters, URL state, and view.

Tests directly assigned the module-level `query` binding to set the URL
before rendering. That binding cannot cross a module boundary, so the
helper exposes startAtUrl() in its place; every other line of every test
moved unchanged.

Closes #56

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… cost, next/link, st_logg (#51)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…k-look button in name <td>

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lib/planetUrl.ts owns the whole /planet/<name> mapping in both directions so no
other file encodes or decodes a planet URL. Names round-trip exactly per spec
decision 1 — spaces, plus signs, parentheses and dots survive; an unusable
segment decodes to null so the route can 404 instead of throwing.

lib/planetDetail.ts is a server-only GetItem by pl_name returning the full
stored item or null, wrapped in React cache() so generateMetadata and the page
share one read. Read failures propagate rather than resolving null, keeping an
outage out of the ISR-cached 404 path.

Co-authored-by: aronnaxp <aronnaxp@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ound (#63)

* Add /planet/[name] route with grouped stat sections

Server-renders a planet profile from the archive item: Planet, Star,
System and Discovery sections as label/value pairs under one h1, plus a
provenance line citing the archive and the record's sync date.

planetStatSections owns field selection, units and null handling so the
route renders sections without knowing what a field means. Every unknown
degrades to an em dash with screen-reader copy rather than a dropped row.

A malformed segment and an unstocked name resolve to the same notFound(),
whose boundary recovers the attempted name from the URL to prefill the
explore search.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Add planet page error boundary and cap the reflected URL name

An outage propagates by design from #61, but this route was its first
consumer and the throw landed on Next's default "Application error"
screen — worse degradation than the home page, which says "Latest
discoveries are unavailable right now". error.tsx follows the explore
Unavailable precedent: role="alert", branded copy, reset() retry and an
/explore way out.

The not-found page reflected the URL segment as displayed text, so an
arbitrary sentence could be styled onto our domain. planetNameFromParam
now rejects a segment longer than any archive designation, which fixes
both consumers at once: the lookup 404s without a read, and the
not-found page falls back to "Browse the archive".

Also folds in the reviewer's starBandOf note, so planetStats no longer
re-looks-up a class to recover its label, and renames a page test that
claimed to observe a cache() dedupe it cannot see through the mock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: exoplanet-builder[bot] <exoplanet-builder[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: aronnaxp <aronnaxp@users.noreply.github.com>
* Add the ESI badge and Earth comparison to the planet page

The page carried the archive's numbers but nothing that told a
non-astronomer what they meant. earthComparisons() turns the five
Earth-relative fields into sentences: proportions read as percentages
below Earth and multiples above it, kelvin becomes °C and °F against
Earth's own airless figure, and an orbit is counted in the unit that
keeps it legible — hours, Earth days or Earth years.

The badge gains a page variant rather than a fork. Placement and scale
were the only card-specific rules in its stylesheet, so they move out of
.badge into .card, leaving .badge as the shared pill and .page as the
larger standalone presentation. PlanetCard is untouched.

The Kepler-452 b fixture stored esi 0.83; the sync writes round(100 *
geometric_mean), so it is 83.

* Keep Earth-comparison numbers consistent with the Planet section

Two significant digits rounded whole-number counts to values the archive
never held: a 267-day orbit printed as 270 beside "267.3 days" in the
Planet section, 365 days printed as 370 (longer than the Earth year the
branch had just ruled out), and 3,247x starlight as 3,200x. Format whole
above 10 and keep 2 significant digits below, where they buy legibility.

Also name compute_esi in the 255 K comment so the baseline is not later
aligned with the sync, and give the badge the focus ring its sibling
controls define, now that it is the page header's only control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: exoplanet-builder[bot] <exoplanet-builder[bot]@users.noreply.github.com>
Co-authored-by: aronnaxp <aronnaxp@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
aronnax-builder Bot and others added 9 commits September 1, 2026 22:20
…ags (#67)

* Add planet-specific metadata to /planet/[name]

Shared links previewed as a bare domain because the route inherited the
site-wide title and description. generateMetadata now returns a title,
stat-bearing description, and OG/Twitter tags per planet, reusing the
cache()d getPlanetDetail through the existing loadPlanet seam so titling
and rendering still cost one GetItem.

planetHighlights lives in planetStats beside the stat table it must agree
with, so a preview cannot quote a figure the page contradicts. A planet
the archive only names degrades to a plain sentence rather than dangling
commas, and an unknown name gets noindex 404 metadata instead of throwing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Point metadataBase at the serving host, via one shared origin

The apex 307s to www, so every canonical and og:url this epic emits
resolved to a redirecting host — the exact www/apex ambiguity a canonical
exists to settle, and a temporary redirect is the weakest signal for it.
Task 5's sitemap would have inherited the same URLs, which Search Console
reports as "Page with redirect" and declines to index.

SITE_ORIGIN lives in lib/site.ts so the sitemap and OG image routes import
the origin instead of retyping the literal, and site.test.ts pins both the
host and the no-trailing-slash contract those callers concatenate against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: aronnaxp <aronnaxp@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…lanet/[name] (#69)

* Link planet names to /planet/[name] across card, table, modal and discoveries

Every internal link is built with planetUrl(), so no component encodes a
planet name itself.

PlanetCard and LatestDiscoveries: the <h3> name becomes a next/link. Their
"Learn More" / plain-text behaviour is otherwise unchanged.

PlanetModal: adds a "View full profile" link as the next step out of the
quick look. The dialog had one focus stop before this, so the trap now
cycles two.

PlanetTable (spec decision 5): the name button becomes a next/link that
stops propagation, and each row gains a quick-look button labelled
"Quick view: <name>" inside the existing name cell. Keyboard users keep a
modal entry point and gain a navigation one; mouse row-click still opens
the modal.

Also lifts a shared PageSection that owns the aria-labelledby wiring, and
moves both planet-page section components onto it.

Closes #68

* Lift PlanetNameLink; make the quick-look icon presentation-explicit

Review on #69 flagged the planet-name link hand-rolled at three call
sites with two byte-identical CSS blocks and a third near-copy.

components/planet/PlanetNameLink owns planetUrl and the treatment;
card, discoveries and table call it. planetUrl now has one caller in
the component tree instead of three. The modal's "View full profile"
stays out of it: its label is a CTA, not the planet name.

Settles the underline divergence on always-underlined, matching the
planet page's .provenanceLink. Hover-only underline gave touch users
no affordance at all, since there is no hover to reveal it.

The module carries no 'use client', so it still server-renders inside
LatestDiscoveries and takes the stopPropagation the clickable table
row needs from PlanetTable.

U+1F441 is Emoji_Presentation=No, so a bare glyph asked for a text
presentation most system fonts lack. Replaced with an inline svg that
inherits currentColor and sizes to the 28px circle, plus title="Quick
view" so a sighted mouse user can tell it from watch/favourite.

Tightened the row-name assertion: it was a substring match that passed
on "Alpha b<glyph>" and could not fail as named.

---------

Co-authored-by: aronnaxp <aronnaxp@users.noreply.github.com>
* Planet pages 5/7: sitemap covering every archived planet URL

Adds app/sitemap.ts so all ~6k /planet/<name> URLs are indexable directly
rather than only by crawling the explore table.

Extracts the paginated LastEvaluatedKey walk out of app/api/planets/route.ts
into scanAllPlanets() in lib/dynamo.ts, parameterised by projected field and
typed as Pick<Planet, Field>[] so both call sites keep exact row types.

The route's revalidate export would have prerendered the sitemap during
next build, which fails wherever Dynamo is unreachable — CI has no AWS
credentials. connection() defers the Scan to request time and the hour
moves onto the data cache, which also bounds a public endpoint that would
otherwise Scan the table once per crawler request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Advertise the sitemap in robots.txt and drop the false lastmod

Nothing pointed crawlers at /sitemap.xml, so the ~6k planet URLs it lists
were no more discoverable than before. app/robots.ts allows crawling and
names the sitemap at its absolute canonical URL; it is static, so it adds
no build-time Dynamo dependency.

last_updated is a sync-run stamp, not a modification time: the sync
computes one timestamp per invocation and writes it to every row on a
6-hour schedule, so every <lastmod> was identical and advanced four times
a day whether or not a planet changed. Google distrusts lastmod site-wide
when it is inaccurate, so omit it — which also drops parseTimestamp and
narrows the Scan projection to pl_name alone.

scanAllPlanets now takes a non-empty tuple, making the empty field list
that would build an invalid ProjectionExpression unrepresentable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: aronnaxp <aronnaxp@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Add per-planet OG image so shared planet links unfurl as a card

Every /planet/<name> page advertised summary_large_image with no image, so a
shared link rendered as a blank panel. app/planet/[name]/opengraph-image.tsx now
generates a 1200x630 PNG from the planet's own record.

shareCard() holds what the card says and how it is coloured, so the route file is
layout only and the copy is testable without rendering a PNG. Distance and
discovery-year phrasing moves into planetStats alongside planetHighlights, which
now shares the light-year phrase rather than rebuilding it.

ESI bands name their theme colour slot instead of deriving it from array
position, because satori cannot resolve a CSS custom property and needs the hex.
SITE_THEME gives the layout and the card one source for the theme the site
renders, so the image cannot silently drift from the page.

A name the archive does not stock renders a branded fallback rather than a 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Render the OG card through real satori and drop weights it cannot apply

next/og ships a single Geist face and the route passes no `fonts`, so the
600/700 declarations on the wordmark, hero and badge selected nothing. The
card's hierarchy is size, colour and shape; the declarations only misled.

The mocked suite asserts copy but never reaches satori, which rejects a
multi-child <div> with no explicit display — a 500 on the one route whose
job is to never break an unfurl, invisible to tsc, eslint and every test.
A sibling file mocking only planetDetail renders the planet and fallback
paths for real and checks the PNG header, in ~180ms with no network.

Also from review: pin each ESI band's tier to its position so colour and
range cannot drift apart, default getTheme to SITE_THEME, tie SITE_THEME
to theme.json's defaultTheme, resolve the palette once in shareCard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: aronnaxp <aronnaxp@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The quick look, card and table each rounded planet fields by hand, so the
same planet read "551.71 parsecs" in the modal and "551.7 parsecs" on the
page it links to. Each also used a falsy check, reporting a measured zero
as unknown and leaving the unit stranded beside it ("N/A parsecs").

planetKeyStats() gives the modal its six stats through the same formatters
the page sections use, and measurement() is now shared with the card and
table so a unit abbreviation is the only thing they choose for themselves.

Co-authored-by: aronnaxp <aronnaxp@users.noreply.github.com>
Stale lockfile resolutions were pinning vulnerable transitives that
already had patched releases satisfying their parents' ranges. Audit
goes 28 -> 0, and 3 -> 0 in the production tree.

package.json is unchanged: `pnpm update` rewrites the caret floors, so
the manifest was restored and `pnpm install` re-run, leaving the fresh
resolutions bound to the original declared ranges.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Names the planet in its Open Graph image alt so the share card is described accurately to screen readers and link previews, and adds the epic #59 entry to ROADMAP.md.

The route-level alt export stays constant because adding generateImageMetadata would move the route to /opengraph-image/[__metadata_id__]; per-planet alt is supplied from page metadata instead.

Closes #74
…resh

chore(deps): refresh pnpm lockfile to clear all advisories
feat: shareable planet pages — /planet/[name]
@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

@zackrylangford is attempting to deploy a commit to the CloudZack Team on Vercel.

A member of the Team first needs to authorize it.

@vercel

vercel Bot commented Sep 2, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
exoplanethub-com Ready Ready Preview Sep 2, 2026 8:20pm UTC

Request Review

@zackrylangford
zackrylangford merged commit 9c7879f into exoplanethub:dev Sep 2, 2026
4 checks passed

This branch was successfully deployed

1 active deployment
Preview — 847ec860 Deployed Sep 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants