Peer · Changelog
All notable user-facing or infrastructure changes to Peer. Newest at the top.
v0.45.0 — 2026-10-06
Paper screening with your own Jev key.
Jev is a second pass over your day's papers, and it is yours to switch on: you apply for a Jev key, paste it into your Profile (or the Welcome wizard's AI step), and Peer asks Jev, for each of the 50 best candidates, up to four fixed questions about the paper: is it the meaning of your word, is it core to your project or only background, does it match your method, would it help your project. Peer moves papers up or down on the answers. A paper Jev cannot judge counts as neutral: Jev neither lifts nor lowers it, though papers Jev rates well can move ahead of it. A paper Jev says is a mismatch is moved back, never removed. Jev reads English best.
It is optional, and nothing changes without it: with no key the briefing is screened exactly as before, with your topics, your project text, how new a paper is and where it was published. It works whether or not you have a model key, and with both, Jev sets the order while your model still writes the reasons.
We have not measured how much this improves your list, so we state no number. Jev bills your own account for what it reads. Your key stays in this browser: it is never synced to your account, never saved into a backup file, and Peer's server passes it to Jev only while it screens your papers, without storing or logging it. Peer keeps Jev's answers for each paper against your account (the question, the answer and how sure Jev was), with no paper text and no key. A key you add today applies to your next briefing; the page names what Jev did the last time. The privacy page now has a section for it.
A profile backup file no longer carries any key: the Jev key and the model key (and the older optional data keys) are left out when a backup is written, as a restore already refused to install them.
For anyone running Peer: Jev no longer runs on a key of the site's own. The build refuses JEV_API_KEY and PEER_JEV_BROKER_SECRET on Vercel; remove them (and the other PEER_JEV_* settings, which nothing reads now) from the project's environment variables before deploying. The jev-broker Supabase function is gone from the repository; run supabase functions list and delete it, with its secrets, if it was ever deployed. The migration 20260924000400_private_decisions.sql keeps each reader's Jev answers; apply it (select to_regclass('public.private_decisions') tells you whether it is there). The live smoke test now reads JEV_SMOKE_API_KEY instead of JEV_API_KEY.
v0.44.0 — 2026-10-06
Peer's AI runs on your own key, and only on it.
Peer no longer has a model of its own. The Gemini key the site ran for every signed-in reader is gone, so ranking by a model, relevance reasons, the digest bullets and Deep reports now run only if you add your own provider key (Profile or the Welcome wizard; Google Gemini, OpenAI, Anthropic, Qwen and DeepSeek). Without a key the briefing still works: it is the reading without a model, the same one a signed-out visitor gets, and it names what a key would add. Signing in no longer changes what a model does for you; it still syncs your profile, your saves and your delivery history, and it is still required to use a key (a signed-out visitor with a key reads without a model, and the page now says so instead of showing "AI on").
What went with it: the free, trial and paid plans and their prices, the deep-report allowances and the daily house ceiling, the model's choice of figure for a section (a figure is picked by its caption and number now) and the model-written title for an uploaded PDF (its title is the file's own, or the file name). The hourly request limit on the AI routes stays.
The privacy page now says what the code does: Peer has no model of its own, and your key goes from your browser through Peer's server to the provider you chose, and is not stored. It also no longer has a section about a record of model use, because Peer keeps none: it used to write one row per model call (provider, model, token counts, timing), and that table, the dollar budget that guarded Peer's own model spend and the daily limit on forced pool rebuilds are gone. Peer funds no web search either, so the only search a briefing can run is on a Tavily key you paste in yourself.
For anyone running Peer: the build refuses GOOGLE_API_KEY on Vercel (it used to require it); remove it from the project's environment variables before deploying. The migration 20261007000000_drop_plan_and_restore_signup.sql drops the four plan columns from profiles and puts the sign-up trigger back; take select plan, count(*) from public.profiles group by 1; first if you want a record of who was on what. A second migration, 20261007000100_drop_ledger_and_budget.sql, drops the usage table (usage_events) and the two budget tables and clears the counter rows for the old allowances; export usage_events first (the file's header says how). PEER_COMPANY_SPEND_CAP, BRAVE_SEARCH_API_KEY and the GOOGLE_VERTEX_SEARCH_* names are read by nothing now (the build still refuses them on Vercel).
v0.43.5 — 2026-09-25
A figure the paper printed stretched is shown in its own proportions.
Some PDFs place a picture wider or taller than the picture is — an image pulled to the column's width in a word processor, its circles drawn as ovals and its lettering widened. Peer copied the page as printed, so the card copied the stretch. When a figure is one embedded picture printed more than 6% off its own shape, it is now rendered back to that shape, by lengthening the squeezed side so no detail is lost. The card mats it at its true ratio and leaves the rest of the window empty rather than fill it. Figures printed as drawn — every figure in the arXiv papers checked — come out unchanged.
v0.43.4 — 2026-09-25
The tab's mark follows the browser's light and dark, as the masthead's does.
On a dark tab strip the black sheet sank into the bar and only its corner showed. The sheet now takes the heading ink of whichever scheme the browser is in — near-black on light, near-white on dark — and the corner the accent's matching shade, exactly as the masthead's mark does on the page.
v0.43.3 — 2026-09-25
The tab shows the mark the masthead shows.
The browser tab still carried the black pear: two lines of work each redrew the site icon, and when they were merged the older one won. The tab icon is now the masthead's mark, drawn from the same geometry — the black sheet with its registered corner in the accent — and favicon.ico, for the browsers that ask for one by name, is rasterised from it at 16, 32 and 48 pixels.
v0.43.2 — 2026-09-23
A caption's tail is told by its face.
v0.43.1 read the lines under a caption by their size, and on a real PDF the caption and the body were both 12pt — so the fix passed its test and did nothing on the page. A caption is set in its own face; the lines under it in that same face are the caption, and the first line back in the body's face is the paper. “Medical image analysis pipeline showing preprocessing,” now ends “feature extraction, and classification stages”, as printed.
Its topics leaves out a concept OpenAlex filed into the wrong field — a protein paper under “Cleavage (geology)” — by the bracket that marks it, the same list the briefing's plates already use.
v0.43.1 — 2026-09-23
Where the column ended in air, and a caption cut off.
In your library. A paper page that Peer could not read the full text of ended with the notes block, the record, and half a column of nothing. The column now ends with the reader's own context for the paper: what they have read or kept under the same topics, nearest first, each a link back into the reading, with the topics they share — and the paper's topics themselves, each a search. From this browser's library and shelf; no request is made. On a paper Peer could read, the same block is the way onward.
A PDF caption is as long as it was printed. The extractor took the first line of a caption and stopped, so a figure arrived under “Medical image analysis pipeline showing preprocessing,” — a clause with its sentence cut off. It now reads the lines under the first at the line pitch, in the caption's own size, until the gap opens or the body's size returns, and hands what follows back to the prose.
v0.43.0 — 2026-09-22
The paper's mathematics, drawn.
A formula came through the extractor as the jumble its MathML flattens to — "Q K T d k" in the middle of a sentence, and a display equation as a line of the same, run into the paragraph before it. The paper's argument is often in those lines.
Display equations stand on their own. Each one is lifted out of the prose and set apart on a line: the mathematics centred, the paper's own number for it at the right, a hairline at the left the way the reader marks any block set apart, and — on hover — TeX, which copies the source for a note or a draft. It goes exactly where the paper put it, after the paragraph it followed.
Inline mathematics is drawn in the text — in the paper's body, in the abstract, in every quoted sentence — so “where dₖ is the key size” reads as the paper wrote it.
Where the source was HTML (arXiv and the rest), the TeX comes from the page and KaTeX draws it, loaded once on the first formula and never on a page without one. Where it was a PDF, there is no TeX: a printed equation is still recognised — a line made of symbols, or one ending in the paper's “(3)” — and set apart in the mono as it was printed, with its number. Until the renderer arrives, and wherever it cannot parse a formula, the TeX itself is shown: readable, never blank.
The reading document is version 5.
v0.42.0 — 2026-09-22
The paper's figures, where the paper put them.
Read it here rendered the paper's words and none of its pictures — the extractor had the captions all along and threw the figures away with them. Each figure now sits in the body after the paragraph that first names it ("as Figure 3 shows" is the paper telling you to look now); a figure the prose never names lands at the end of the section where the paper printed it. Tables are not placed — a table's caption without its table is a sentence about nothing.
Where the source was HTML (arXiv, ar5iv, PMC, bioRxiv, publishers), the figure's own picture comes with it, on the plate's mat, with the caption under it in the record's mono. Nothing is fetched to build the reading; the page asks for each picture as it comes into view.
Where the source was a PDF, Peer knows the page each caption was read from, and a new route serves that page's embedded picture as a PNG — encoded by hand, because the deployed runtime has no image library. It is offered only when the page holds one figure and no other: a PDF's rasters come back without positions, and a wrong picture under a caption is worse than none. A figure drawn as vector art has no raster to serve; then the caption stands alone with its page number, which is the truth.
The reading document is version 4; a reader's day-old cached version 3 is discarded and fetched fresh.
v0.41.0 — 2026-09-21
The subject mark on a card is a badge now.
v0.38.0 gave each card a mark for its subject and drew it as a 13px stroke in the meta line's faint grey — correct, and invisible: at arm's length the board still read as ten grey rectangles. The mark now sits in a 28px tinted square at the top right of the card, the way the profile frames a signal's glyph, set a size larger and a shade darker, and it takes the accent when the card is hovered. Hover it and it still names the family.
Two families join the set, because a machine-learning briefing meets them right after the sciences: robotics and control (a body on a base, and the arm it reaches with) and hardware and systems (a die and its pins). Fifteen marks in all.
The paper is wider. The reading column set its prose at the book measure — 66 characters, 28em — which at 16.5px is 462px of text, a third of the column it sat in, with the rest of the page empty beside it. The abstract, the extracts and the paper's own body now share a long measure of 80 characters (34em), and the body steps up a size, from 16.5px to 17.5px, with a little more air between paragraphs. On the two-column spread the column itself is the limit and nothing moves; below it, the paper fills the page it is on.
v0.40.1 — 2026-09-21
Search: the starts are plates, and the chips carry marks.
v0.40.0 set the page's starts as bare words under eyebrows, and it read as a page nobody had designed — three columns of grey type with nothing to hold them. They are now the product's own components:
Each group is a plate: a card in Peer's frame — the surface, the grain, the registration corners the notes and the briefing use — with a glyph in its header naming what kind of thing it holds (a hash for your topics, an open book for your reading, a person for your shelf, a clock for what you searched) and its count at the right.
Each start is a chip with its own mark at the left: a term takes the subject mark a card would take for it, so the chips here and the marks on the briefing are one vocabulary; an author takes the person; a past search takes the clock. The mark goes accent on hover. A count sits in its own small square, a fact about the chip rather than part of its words.
The field is framed with the same registration corners, and its key hints are now a pair: / “to search from anywhere” while it is empty, ↵ “search now” once something is typed.
v0.40.0 — 2026-09-21
Search: the field is the page, and the page knows you.
The field. The one thing a reader comes to this page to touch was a body-size box at 820px under a headline twice its height — the page read as a manifesto with a form field. It is now the largest object on the page: the full width of it, display-size type, a search glyph to match, and an accent rule under it while it has focus. The / key that brings the pointer here from anywhere is printed at its right end until something is typed.
Before a word is typed. The page used to end at the field. It now offers where a search can start from, drawn from what this browser already knows and fetched from nowhere: your topics (what you told Peer you work on), from your reading (the terms two or more of your papers shared, most-shared first, with the count), people on your shelf (the first authors of what you kept), and you searched (this browser's own recent queries, kept only when a search found something, cleared with one word). Each is a query waiting to be run; a tap runs it. A group with nothing in it is not drawn, so a fresh browser sees the field and one line about what it takes.
When the index does not answer. OpenAlex refused the first request for a query with a 429 often enough to notice — four of the first five searches on one afternoon, each answered in 150ms and each fine a moment later — and the page said "Nothing turned up", which blames the query for the server's afternoon. The search route now asks once more, after the pause the server names or a short one of its own; and if the answer is still no, the page says Search didn't answer, with Try again, and remembers nothing. The route also now sends the configured OPENALEX_EMAIL like every other OpenAlex call in the codebase, instead of the placeholder it alone had written in.
The results grid takes a fourth card from 2xl rather than xl, where the page is 1180px and a fourth card was 270px wide.
v0.39.0 — 2026-09-21
The shelf and the profile: a narrow rail, a long column.
Both pages were one 820px column down the middle of the window. On a wide screen that is a third of the page holding everything and two thirds holding nothing — and the page's own name, "Saved" or "Your signals", scrolled away with the first card.
From xl they are two columns, and the proportion is the point: the rail is narrow (240px) and the column takes every pixel the window adds.
Saved — the rail holds the name, the counts, and a way between the two halves of the shelf ("Notes 7", "Papers 4"), and it is sticky, so it is still there at the bottom of a long shelf. The column holds the cards, and takes a third card per row from 2xl.
Your signals — the rail holds the name, the signals-set count and Edit. The column holds the account, the profile, the theme and the reading rhythm, each of them wider than it was.
Below xl nothing moves: one column, the rail's contents first, exactly as before. The reading spread is untouched — it is sized by the measure, not by the window, and both of its columns hold reading.
v0.38.0 — 2026-09-21
A wider board, and a mark for what each paper is about.
The board fills the screen. It was capped at 1280px and dealt three cards at every width above that, so a 1920 monitor spent a third of itself on empty margin and still read three abreast. The card is what should stay constant — about 400px, which is what a title and three lines of reason want — so the count is what grows: four cards from 1700px, five from 2200px, with the board widening to match. Below 1700 nothing moves at all.
Each card carries a small mark for its subject. Ten cards in a briefing are ten grey rectangles that differ only in their words, and the words are all in the same face at the same size — there is nothing to sort by until you start reading, which is the opposite of what a board is for. The mark sits at the end of the filing line, in the meta line's own weight, and names a family: earth and agriculture, life and health, physical sciences, economy, security, images, language, geometry, networks — or, where the paper is not about a field but is a kind of reading, a course or survey, a toolkit, data, a model. Hover it and it says which.
It is read off the paper's own title and the terms already cleaned for its plate — never the venue, because half of one real briefing came back "physical sciences" for being published on Zenodo, which CERN runs; and never the raw concept tags, which put "Computational Physics and Python Applications" on a textbook. It is a reading, not a record: it is drawn faint, nothing is filed by it, and nothing on the card depends on it.
v0.37.0 — 2026-09-21
Drag a paper into the draft.
The rail could already put a paper in a note two ways: Cite dropped [@key] where the caret was, Card placed the card after whatever you wrote in last. Both of them decide *where* for you, which is fine until you know exactly where you want it — between these two paragraphs, under that heading, at the end of the section you just finished.
So the shelf can now be picked up. Drag a paper out of the rail's Papers tab and the draft draws the same line it draws when you move a block of your own; let go and the paper lands in that gap as a card. It brings its record with it, so the citation key is registered on the way in and the paper is in the note's References and its BibTeX export immediately — nothing to look up afterwards.
A card dropped at the end of the note, or above another card, gets an empty paragraph after it, and the caret goes there: the gesture ends where writing resumes.
Clicking a row still opens its record, and Cite and Card are still in it — dragging is the third way, not the only one. On a phone, where the rail is a panel over the draft rather than a column beside it, the two buttons remain the way in.
v0.36.3 — 2026-09-21
The file rail, tidied.
Six notes made a mess of the editor's rail. Three faults, all of them the rail's own:
The two-line clamp on a title never clamped — a block sat beside it and won the display, so a paper title ran to five lines and the column lost its rhythm. It clamps now, on both tabs.
The line under a title said the kind, and the kind was almost always the same: four reading notes in a row read "reading notes · 22h ago" four times. A reading note is *about a paper*, so it now says which paper — "Harris et al., 2009 · 23h" — and any other note says how much of other people's work is in it. A record with no authors names its venue rather than repeating the title back at itself, and the timestamp drops the word "ago", which never varied.
Rows were separated by a gap, which is not enough when a row is two lines tall. They are separated by a hairline now, and sit a little deeper.
v0.36.2 — 2026-09-21
The paper is open, and the panel holds its contents.
Yesterday's Read it here still asked for a click before the paper existed on the page. That is the wrong shape for a reader who has already chosen to read: the text Peer extracted is now simply there, under everything Peer has to say, with no button between it and the eye. The command stays — it is the way *down* to the text now, not the way *into* it — and the paper's sections are real anchors, so a link can point at §3.2 of a paper.
Beside it, the left column of a wide screen no longer ends in air. Under the decision it now carries the paper's contents: where the text came from, how many sections and words Peer reached, and every heading in the paper's own order, indented as the paper numbers them, each one a jump. The panel is sticky, so the contents stay in view while the paper scrolls past them — the shape of a reading room rather than a landing page.
On a phone nothing moves: one column, the contents printed as a strip at the head of the paper, exactly as before. Where Peer holds no full text there is no contents and no command, because there is nothing to point at.
v0.36.1 — 2026-09-20
Read it here, said loudly.
Peer read the paper and then whispered it: the loudest button on a paper's page was Open the PDF, which leaves Peer, while the text Peer had already extracted sat behind a grey line of type at the bottom of a block in the other column. That was a fair hierarchy while a deployed Peer could not read a PDF. It has not been one since yesterday.
Read it here is now the first command in the decision block, filled, with the paper's key t; Open the PDF keeps its place as the second. Pressing it unrolls the paper's text and — only where the text is not already on the screen — scrolls to it, so on a phone you land on the first section and on a wide screen the page does not jump for a block you can already see.
Where Peer holds no full text, nothing changes: the source is the first command again, because it is the only one.
v0.36.0 — 2026-09-20
Peer reads PDFs — here, not only on a laptop.
A paper whose full text is a PDF used to read as "abstract only" on peer.homes, with the page admitting why: "the PDF is there, but only a self-hosted Peer reads PDFs". The reading ran in a Python helper that needs PyMuPDF, a compiled extension; a developer's machine has it and a deployed one cannot. So the same paper read in full locally and not at all for anyone else — the worst kind of difference, because nothing was broken where anyone was looking.
It is plain TypeScript now, over the text layer pdf.js hands back, and the same code runs in both places. A PDF gives words with a size, a face and a position; the structure is inferred from what the page shows and nothing else:
- a heading is a line set larger than the body, or in its other face, or numbered ("3.1 Encoder and Decoder Stacks") — never a display equation, a caption, or prose that happens to open with a figure; - a paragraph is a break in the page's vertical rhythm, or an indent, and a word the column broke is mended ("archi-/tecture"); - a caption ("Figure 1: …") goes to the figure pool, not into the prose it interrupts; - the cover — names, affiliations, the preprint stamp down the margin — is not the paper, and the references are not its argument.
On the paper this feature was tested against, the new reading finds ten sections where the old helper found two. A PDF that carries no text at all is a scan, and the page now says that instead of blaming the deployment.
v0.35.3 — 2026-09-20
The masthead's tools carry their glyphs.
Write takes a pencil, Search a glass, Saved the bookmark the shelf has always been marked with, Profile a figure — and the shortcuts sheet, a question mark drawn on the same grid as the rest instead of typed as a character.
They are the phone bar's own icons, which existed already: the two bars now draw from one family (components/shell/icons), the phone at 22px on a 1.6px stroke, the masthead at 13px on 1.3px. Signed in, your face stays the glyph on Profile.
A glyph in this product names a place or an action in the shell, and nothing else — the content is still words, because there the words are the paper's.
v0.35.2 — 2026-09-20
A mark of its own.
The tab still carried a drawn, cream-and-orange creature from the app this one grew out of — an illustration, in a product that draws nothing it cannot justify. Peer's mark is now the geometry the interface is made of: a sheet, with one registered corner in the accent. The same corner the reading graph puts on the node you point at, a card grows under the pointer, and the masthead puts around the section you are in.
- The mark sits beside the nameplate in the masthead — the chrome's one spot of colour, and the same object the tab shows. - The tab icon is an SVG, so it stays crisp at any size; a 16/32/48 .ico is there for the browsers that still ask for one.
v0.35.1 — 2026-09-20
The masthead, drawn.
It was four words and a button on an empty rule. It is now the instrument's own bezel, in marks this product already owns:
- The nameplate is registered — four 1px corners around "Peer", the mark the reading graph puts on the node you point at and a card grows under the pointer. They brighten when you point at it. - The section you are in wears the same corners, so where you are is said with a mark instead of a slightly brighter grey. - Hairlines instead of middots between the words, and in the paper rail's row: the machine's own line, the one the bands, the graph's key and every plate edge already use. - Scrolled, the glass takes the product's tooth — the grain every card surface carries. On its own layer, because glass-bar sets background as a shorthand and would have wiped an image off the bar.
Write keeps the one closed box in the row: a place is registered, an action is boxed.
v0.35.0 — 2026-09-20
Write, in the masthead. Your files, beside the draft.
Write is now the one action in the top bar, set apart from the four words that are places: outlined at rest, filled while you are in a note. It opens a note to write in — and reuses an untouched blank one, so pressing it twice never leaves an empty note behind.
The editor's rail became a cabinet with two drawers:
- Notes — every note in this browser. Each row carries its own menu: rename (in place), duplicate, export .md, delete. - Papers — your shelf, plus anything this note cites that has since left it. Opening one shows the record: authors, venue and year, its own abstract where the record has one (and it says so where it does not), and four actions — Cite puts [@key] where the caret last was, Card places the paper as a card, Open goes to the paper, Source to the publisher. A paper you have taken notes on also offers those.
And a citation in the draft now opens that paper's record in the rail instead of navigating away from what you were writing.
Fixed: a block could keep showing its Markdown after losing the caret to a re-render — with nothing focused, the caret comes back to it.
v0.34.1 — 2026-09-19
A rail of your notes, beside the one you are writing.
Every note in this browser is listed down the left of the editor, newest first, with the open one marked — Obsidian's file list, Notion's sidebar. Switching notes is a click instead of a trip back to the shelf, and "+ New" starts one from where you are. Past seven notes the rail carries a filter, which reads titles and text. Where the window is too narrow for a column, the bar's "Notes" button opens the same list as a panel.
It is the editor's rail, not the shell's: Peer's chrome is still the masthead.
v0.34.0 — 2026-09-18
Notes: what you saved, turned into writing.
Saved is now where reading becomes writing. It holds two things: Notes, your own, newest first, and Papers, the shelf as before. From the shelf:
- Take notes on any paper opens its reading notes — made the first time from the paper's record: its card, the opening of its abstract in its own words, and four headings to answer under (what it claims, how, what you think, questions). The prompts are placeholders, never text. - Draft from papers lets you pick several and start a related-work section (one paragraph per paper, each opening on its citation) or a paper outline (abstract to conclusion, the papers already cited under related work). - + New note starts blank.
The editor works in blocks, the way Notion and Logseq do, over Markdown, the way Obsidian keeps a note. The block you are typing in shows its source; the rest read rendered.
- / opens the block menu: headings, lists, to-do, quote, code, divider, paper card. Markdown typed at a block's start converts it: # , - , 1. , [] , > , three backticks, ---. - @ (or [[) cites a paper you saved, read or have in today's briefing. It reads as "(Rose et al., 2024)" and links to the paper; [[ also links your other notes by title. - Enter splits a block, Backspace at its start joins it back, the arrows move between blocks, Tab nests, ⌘⇧↑↓ moves a block, ⌘B ⌘I ⌘E format. Drag a block by its grip; click the grip to turn it into another kind, duplicate or delete it (with undo). Pasted Markdown arrives as blocks, and text copied out of a PDF has its broken lines mended. - A References list builds itself from what you cite, in the order you first cite it. - Export as Markdown (front matter, [@key] citations Pandoc reads, a references list) or as a .bib with the same keys — pandoc draft.md --citeproc --bibliography draft.bib turns a draft into a formatted paper.
A paper's own page now says which of your notes cite it, with the way into its reading notes.
Notes are kept in this browser, the way Obsidian keeps a vault on your disk: Peer never uploads them, and signing out leaves them where they are. /privacy says so. Export a note to keep a copy elsewhere.
Two fixes the editor turned up: a class merge was dropping the display type sizes whenever a colour sat beside one — a heading set at body size — and a phone-only rule pinned every text field to 16px, which shrank the note's title below its own sections. Both now hold.
v0.33.1 — 2026-09-18
The bar chart above today's papers is gone.
"Today's papers" opens on the cards now, the way "Your reading" opens on the graph. The strip of ten bars — each paper's match against the day's best — was meant to show the shape of the day: two strong papers, a flat middle, a tail. On most days it showed ten bars of nearly the same height, and its key ("The dim ones are read") repeated what the deck already says in words. Each bar was also a link to its card, which nothing on the page ever said.
v0.33.0 — 2026-09-18
Steer the briefing from your reading graph.
Click a term in the graph and it stays picked up; the readout in the plate's corner carries its full name, where it came from, and three buttons:
- + More / − Less. A deliberate lean on the term, written to the same preference ledger your saves and dismissals feed. Today's papers re-rank at once, right below: the day's pool is already built and your preferences are applied when it is read, so the board simply re-reads it — no new search, no model call. Press the same button again and the lean comes off. The chip says what is set: ALGORITHMS +. - Follow. The term joins the explore topics the briefing searches alongside your own. Topics move into the search once a day, so a follow shows in tomorrow's briefing. A followed chip is filled, a step dimmer than your own topics, and the key gains a "followed" swatch.
Before you press anything, the readout says which lever works when; after, it says what the press did.
A lean on a term inside one of your own topics counts. "Machine Learning and Data Classification" sits under "machine learning", and dislikes of anything carrying your topic's words are normally ignored, so one bad paper cannot bury your field — but a lean set on purpose is not a stray dislike, and it applies.
A click on a paper now picks it up too, instead of opening it; "Open →" in the readout opens it, and Enter still opens it straight from the keyboard. Tab reaches every term; Enter or Space picks it up, Escape puts it down.
The graph makes room for what you hold. If the term is under the readout, the plate slides down just far enough to show it — or, with no room below, fits the whole library into the space left — and the key steps aside until you put the term down. Only a click moves it; pointing never does.
And it keeps its shape when the day changes under it. A lean that moves a paper into today's ten or out of it rebuilds the graph, and that used to re-deal the whole layout: the term in your hand jumped across the plate. Now everything already on the plate holds still, and only the paper that arrived finds its place.
v0.32.5 — 2026-09-18
The reading calendar is off the briefing.
"Your reading" is the library graph now, and nothing under it — the eight-week grid of reading days and its "1 day read in the last 8 weeks" line are gone. It answered "which days", which the graph does not need to be read and the briefing did not need either, and it was one more small chart between the library and today's papers. /profile keeps its own labelled calendar, and the briefing no longer asks the server for the reading history it no longer draws.
v0.32.4 — 2026-09-18
Pointing at a card, its corners close in.
The corners used to grow along the card's edge and take the accent. Now they also come off the edge — 6px inside it, legs 16px — like a viewfinder locking on, in the same 180ms the frame takes to brighten, and ease back out when the pointer leaves. Off the edge they no longer have to fight the brightened frame to be seen.
v0.32.3 — 2026-09-18
The space between your reading and today's papers, tidied.
Three things used to sit between the reading graph and the cards, each lined up with nothing: the graph's key along the bottom of the reading calendar, the calendar floating right of centre with its sentence at the far edge, and the day's bar chart on a line of its own below, belonging to neither side.
Now the page is two sections, each under its band:
- Your reading. The graph carries its own key, in the plate's bottom-left corner — the instrument's key sits inside the instrument, the way its readout sits in the top-left. Under the plate, the reading calendar, left-aligned, with its sentence beside it. - Today's papers. The day's bar chart, then the cards it describes. Same arrangement as the calendar — chart, then its sentence — so the two charts on the page read as one system.
The bar chart's key said "grey is read", and every bar is grey; the read ones are the dimmer grey. It says that now, on its own line.
v0.32.2 — 2026-09-18
Pointing at the reading graph no longer makes it twitch.
Moving between two nodes flashed the whole graph. Pointing lifts one neighbourhood and lets the rest recede, and it was set on every enter and cleared on every leave — so going from a node to its neighbour passed through "pointing at nothing", and every node on the plate flashed back to full and down again in the space of one move. A sweep across the plate strobed it. Now the pointer has to rest on a node for a moment before anything recedes, moves straight from one node to the next with no flash between, and waits a beat on leaving so it can reach the next one.
An invisible title was catching the pointer. A paper's title that was dropped for overprinting was transparent, not gone, and papers are drawn over the chips — so pointing at a chip under an invisible title kept handing the pointer to the paper and back. Titles are labels now, never targets.
And the marker no longer flashes in the corner. The four accent corners were positioned a frame after they appeared, so they were painted once at the plate's top-left and then jumped to the node. They are placed on the frame that draws them.
v0.32.1 — 2026-09-18
Your saves and your reading now survive a reload when you are not signed in.
What was happening. Every page load for a visitor who was not signed in ran the sign-out reset. The sign-in bridge asked "is anyone signed in?" on arrival, got "no", and treated that as "someone just signed out" — so a signed-out reader's saved papers and read marks were wiped on every reload. The briefing said "ten unread" for papers you had already read that morning, and /profile said "Read 0" beside a calendar showing the days you read, because the reading dates were the one thing the reset forgot. It forgot them on a real sign-out too, which left the last account's reading on a shared machine.
What happens now. Peer tells apart the two kinds of data a browser can hold. What you made while signed out is yours: it stays until you clear site data, which is what /privacy has always said. What came down from an account is that account's copy: it goes when that session ends — on sign-out, or when the session is found to have ended while the tab was closed — and it is never pushed up into a different account that signs in on the same browser. A reset now clears the whole reading record: the marks, the days and the library behind the reading graph.
And a network failure is not a sign-out. Finding out who is signed in is a request to the sign-in service. Only a definite "there is no session" counts as signed out; if the request fails, Peer does nothing, so being offline when the page loads can never wipe anything.
/privacy now says what the reading graph keeps in your browser, and that signing out clears it — it lives only in this browser, so it does not come back when you sign in again.
v0.32.0 — 2026-09-18
Your library opens the briefing, and the cards arrive.
"Your reading" is at the top. It used to close the page, below ten cards, so the one view of everything you have read was the last thing on the screen anyone reached. It now sits directly under the date: the library first, with today's papers already placed against it, then the day's cards.
The cards arrive as you reach them. The reading page's approach is on the board now. The mount fade it replaces played all ten on load, so the cards below the fold had finished arriving before anyone scrolled to them. The first screenful is still dealt in order, 40ms apart; everything after that comes up as it approaches, about a sixth of a screen early, so a card is never still moving while you read it.
Pointing at a card registers it. Its corners reach further in, from 12px to 18px, and take your accent, in the time the frame takes to brighten. They had to change: a hovered frame steps up to the corners' own resting value, so corners that stayed put dissolved into the edge at the exact moment the card was being looked at. The action row's buttons now arrive in order — like, dislike, save — 40ms apart.
Every press now animates, as it was always written to. Tailwind compiles active:scale-* to the scale property, not transform, and the transition lists named transform — so every button press in the product snapped straight to its pressed size. Twenty-six press sites were affected; the button bases every pill and icon button derives from were two of them.
Fixed: a figure plate's corner marks were invisible. The marks' utility declared its defaults on the mark itself, which overrides whatever the host sets, so the plate's own ink and its inset never reached its corners: they were drawn 40% white on a near-white mat. The defaults are fallbacks now, and a figure plate is registered in its own ink, 6px in.
v0.31.0 — 2026-09-18
Your reading, drawn as a library: every paper you have read or kept, and the terms that join them.
What it is. At the foot of the briefing, the "Your reading" band is now a graph. A square is a paper — filled once read, outlined while it is one of today's and still waiting. A filled chip is one of your own topics; an outlined chip is a term two or more of your papers share. Today's unread papers are in it on purpose, and only when they connect to something: the library is most useful the morning a new paper turns out to sit next to three you have already read. Point at anything and its neighbourhood lifts while the rest recedes, a readout in the plate's corner says what it is, and four corners in your accent register it. Click a paper to open it; drag anything and the graph re-settles around it.
Every line is a fact from the paper's own record. No similarity score, no embedding, no "related" guess — two papers are near each other only because their records say the same thing. A term comes from one of two places, and the readout names which: OpenAlex filed the work under it, from a curated taxonomy of about 4,500 topics assigned to the whole work — which is what joins the books that ship no abstract — or it is in the paper's own title or abstract, under the same rules the card's plate uses, which is what keeps the older tagger's "Identity (music)" on a crowdsourcing paper out. A label that contains one of your topics — "Machine Learning and Data Classification" under "machine learning" — hangs beneath it on a solid line instead of being merged into it, so two corners of one field stay two corners, and its chip names only what differs: DATA CLASSIFICATION.
How it moves. Most of the layout is found before the first paint, so it opens on the library's shape rather than a tangle unknotting; then about a second and a half of the terms drawing their papers in, topics first, papers after, the lines last, and the titles only once it is still — placed like town names on a map, most-linked first, and dropped wherever they would overprint. The camera follows the settle so nothing leaves the plate. It is a simulation that cools and stops, not a loop. Reduced motion draws it already still, and so does a briefing opened in a background tab, which has no one to play it to.
The library needed somewhere to live. A read paper used to leave the store the day it left the briefing — Peer kept its id and the date, nothing you could draw. It now keeps the title, the venue, the day and the paper's cleaned terms, a few hundred bytes each, in this browser. Papers read before today are not in it; everything from here on is.
v0.30.1 — 2026-09-18
The reading calendar, drawn at the size of what it says.
It was sized by its container. The columns were fractions of the width and every cell was a square, so on the briefing's 1232px board eight weeks made cells 150px on a side and seven rows of them a thousand pixels tall. The fifty-five days a new reader had not read were drawn in a grey two percent off the page, so the grid itself vanished, and the one day read was a white slab in the corner of an empty field. The day-strip above it had already been through exactly this and wrote down the lesson in its own header; the calendar had not.
It now uses the day-strip's unit — 12px marks, 3px apart — so the two charts on one board read as one system, and the grid is exactly as wide as its weeks. A day with nothing read is an empty slot, outlined and unfilled, rather than the palest step of the ramp, because a day with nothing read is not a small amount of reading; the slots are what give a filled day somewhere to be. On the briefing the sentence sits beside the chart instead of above a field: "1 day read in the last 8 weeks".
The weekdays were wrong. The grid ended on today in the bottom row whatever day today was, so on a Thursday the row /profile labels "Mon" was a Saturday — on the one chart whose own comment says the weekday pattern is the whole point. Columns are now calendar weeks, Sunday at the top, and the days of this week that have not happened are not drawn, so the ragged last column is where "now" is. A week that is still in progress no longer breaks a streak before you have had the chance to read in it.
v0.30.0 — 2026-09-17
Corner marks, an approach, and the sentence three cards were sharing.
The corner detail. Latent registers a plate with L-brackets at its corners — a printer's mark that says "this is the whole plate, not a crop". Peer's card already had a 1px frame, and a second ring inside it would be two frames saying one thing, so the marks ARE the frame: the edge stays at --nm-frame and the last 12px into each corner steps up to --nm-frame-hi. Nothing is added.
The paper's extracted figure gets the same marks in its own ink, because a figure is genuinely a reproduction — and there the marks replace the rectangle rather than joining it. The typographic term plate gets none: a crop mark says "cropped from a larger original", and those words are Peer's own composition, so nothing was cropped. Where a figure's light mat covers the card's top edge the card drops that pair, because white marks on a near-white mat are not marks.
Peer's blocks arrive as you reach them; the paper's own words are already there. That division is the reason this page has motion at all. The mechanism is Latent's and it is the good part: the animation lives on the child and is paused by a custom property it inherits from the section, so one observer adding one class runs a whole group with no per-element JavaScript. Three things are not theirs — no blur, because a sentence you are about to read must not arrive out of focus; 360ms rather than 500 or 720; and it fires 15% of a screen *early*, so a block is never still moving while its first line is being read.
What this replaces had never once been seen. Five blocks carried a mount fade that fired when the model's report landed — roughly 900px below the fold, while the reader is still on the abstract — so by the time anyone scrolled down it was long over, and its 40ms stagger ran between blocks that are never on screen together. Reduced motion lifts the pause rather than keeping it; so does printing, since a paused animation prints blank.
The card was pale, and the largest cause was one string. The sentence under a card's title fell through to "Matches your interest in <your topic>", which for a reader with one topic is the same sentence on every paper of the day — three cards carrying it carry no bits each. It is out of the chain. The line is now decided for the whole board at once, the way the plate's words already were, because a card cannot see that two other cards are saying what it says: any sentence more than half the day is carrying is suppressed everywhere it appears. With no abstract and no model, the card says the lead author's institution, or nothing. "Open this paper for details." is deleted — it described the link the reader was already looking at.
And the ground it sat on. The card surface was 1.12:1 against the page and the two radial washes over the grain were viewport-fixed, so a card's separation from the ground *changed as it scrolled* and almost vanished in one corner. The washes are gone, the surface goes to #232323 (1.24:1) and the frame to 30%, and the faint tone to #8d8d8d, which was already under AA and would have fallen further. Light is untouched. The eight slab tokens that survived the TUI pass with zero consumers — highlight, shade, rim, bevel, key, ambient, underside — are deleted, 25 declarations, nothing rendered differently.
Instrument marks, where the numbers are real. The day strip draws its zero, at the chart's own width and no wider, so the bars are measured against something rather than floating; its caption now says they are drawn against today's best match, because the tallest bar is full height every single day and the old wording implied today's top paper was a perfect one. Each card takes its position — 01, 02 — in the mono row it already had. Not a percentage: the match score is largely a within-day percentile, is overwritten by the rerank and reordered past by diversification, so no percentage of it is a fact about one paper. The position is a fact, and the masonry is column-major, so nothing else on screen said the reading order runs down column one.
Smaller. A focus ring, on every control the keys reach — the product had one :focus-visible rule in the whole of src/, a Tailwind variant, on a page whose own comment calls it keyboard-first. A selection you can see: it was the badge ground at 9%, which over a grained page is inside the noise. reading-prose and measure-mono, because measure is in em and on a wrapper that declared no size it was resolving against the body's 17px — fourteen pixels of disagreement down a column that promises one right edge twice in its own comments. And the key legend and the panel that reserves room for it now read the same token; they were 28px and 24px, so the DOI line sat 4px behind a blurred bar.
--nm-frame was declared for the explicit dark theme and never for the dark palette that follows the OS — and following the OS is the default, because the boot script throws on a first visit before it can write anything else. So every card frame on the first briefing Peer ever showed a dark-OS reader resolved from the light value and came out 28% fainter than the drawn one. Two lines.
One repeating animation, as the file claimed twice and broke five times. Eighty skeleton blocks swept at once on a cold briefing; the loading header stacked animate-pulse and animate-ping on the same 6px disc that the claim's dot owns; the refresh icon spun. Skeletons are now still, the header is an ordinary band, and the refresh state is a word — syncing… — beside the other three. The only thing that loops anywhere is the accent dot.
Reduced motion now stops loops properly: animation-duration: 0.01ms alone does not stop an infinite animation, it *strobes* it. And it stopped killing one-shots — flattening every transition to 0.01ms teleported the swipe card while its removal timeout still waited 180ms.
One curve, from the one place it is set. 56 transitions declared no duration and silently took Tailwind's 150ms on a third curve nobody chose, so the largest motion decision in the product was made by a default. Two theme lines move all of them to Peer's own 180ms on the expo-out. ease-snap is now a press curve only, and says so.
One label. eyebrow gained the weight it was missing, sectionLabel and its four tracking tiers are deleted, and the 57 hand-rolled uppercase tracking-[…] strings — six tiers, none of them the token, 44 of them bold — are one form. The last two font-bold in the product were on two field labels at 10.5px; they are gone.
One gap, one page rhythm. Band owns the pause above a section (48 on a phone, 64 above it) instead of nine call sites typing 32, 40, 48, 56 and 64 for the same figure. PageContainer owns the page's top and bottom; 96px, Latent's actual heartbeat, appeared five times before this and every one was on a page nobody designs.
One display voice. display-line for Peer's own sentences, the new paper-line for the paper's. /profile narrated the reader's own behaviour in Newsreader italic; the changelog set its title in grotesk and every entry in serif; text-text-heading is not a token and had been silently doing nothing on four of the changelog's selectors. The claim keeps its weight, and the file now says why.
One empty state, shared by the briefing, /saved, /search, /error and /not-found, which were three different products. /search printed its result count twice, 60px apart, in two treatments.
Things that were claims Peer could not support. The profile drew "EVENTS 0 / JOBS 0" under "What you save" for every reader, though neither can ever be saved. The briefing had a "no topics" branch that could not fire. Two gradient washes had painted nothing since they shipped — color-mix(in_srgb,…) inside a real style attribute never gets Tailwind's underscore conversion. The card stagger had never run: the delay was on the wrapper and the animation two levels down, and animation-delay does not inherit.
Smaller, and visible. The record is keyed in words, not pictures — the venue had no key at all, only a 13px building glyph. The day-strip's caption moved beside its chart, where 1,085px of that row was empty. Figure captions went back to the paper's serif; they are the paper's own words. The reading heatmap stopped spending 126 accent cells on data, which is the rule the day-strip twenty pixels away states in its own comment. The keyboard-focused card stopped jumping 2px on every j. The crash screen is no longer the last cream page in a greyscale product. The radius variant, whose three options all compiled to 0, is gone.
And a guard, so this is the last sweep. ESLint now warns on off-scale spacing and errors on any rounded-[…] or inline borderRadius — the two mechanisms that let a round corner survive a squaring. Every previous systematization here was a one-time pass with no enforcement, and the files record the decay in their own comments.
v0.28.0 — 2026-09-17
MIT, a page counter, and a page that says what Peer keeps.
MIT. The repository was public with no licence, which means nobody could legally use or fork it — the opposite of what a public repository is for.
A page counter. Vercel Analytics, one component. It records which page was opened, not who opened it, and it is the only third-party script on the site.
And the page that says so. Written from the code rather than from a template: every claim on it points at a table in schema.sql, a fetch in lib/sources/, or a line in profile-sync.tsx. What it says, in short —
- Signed out, nothing about you reaches Peer's servers; your browser keeps your topics and today's papers, and clearing site data ends it. - Signed in, the account, the profile you type, what you save, what you open and what you like are stored, because that is what makes tomorrow's briefing different from today's. - Your own model key never leaves your browser. The sync deliberately drops it, and the line that does so is there for a future edit to trip over. - The usage rows that record what a model call cost hold no paper text, no prompt, no answer and no credential — the table has no column that could. - Deleting a signed-in account is a request today, not a button. The page says that plainly instead of implying otherwise.
It is linked from the one place a reader is asked for an account, and it is in the sitemap.
v0.27.0 — 2026-09-17
Launch preparation: a ceiling, a card, and a warm pool.
A house ceiling on spend. The daily breaker capped one reader at 200 deep reports; it said nothing about what a hundred readers cost together, and the model key is the operator's. With sign-up open that is the whole of the financial risk at launch, so there is now a second counter — every reader, one UTC day, 1,000 deep reports — and either one can refuse. It is a round number on purpose: a launch that goes unexpectedly well should cost a known amount. Raising it is one constant.
A card for every shared link. Peer had no Open Graph image, so a link posted anywhere arrived as a bare URL. The card is the briefing's own front at 1200×630: the eyebrow, the headline, and the day-shape bars. Drawn in the runtime's own face rather than Host Grotesk deliberately — an image route that fetches a font at request time fails on a cold edge, and no card is worse than a card in the wrong face.
The day's first reader no longer builds the day. Peer caches one paper pool per UTC day; the request that finds it empty waits for the sources — 20–30 seconds measured cold, against 0.4s warm. That bill landed on whoever arrived first. A scheduled job now pays it three times a day, asking for the starter sample, which is what a first-time visitor loads.
Indexing, on purpose. robots.txt and a sitemap: the briefing and the changelog are worth finding; /api, /auth, /profile, /saved and the wizard are not. The canonical host reads from one place, so pointing a domain at Peer is a Vercel variable rather than an edit.
v0.26.0 — 2026-09-17
A grotesk for Peer's own voice, and two curves.
After latenthealth.com — read from its own stylesheet rather than from a screenshot, which is where the tokens below come from.
The face. Latent sets everything in FK Grotesk, which is licensed; the fallback they name in their own token is Host Grotesk, which is open, so that is what Peer takes. It replaces Inter for everything that is *Peer* talking: the dateline, the deck, page titles, labels, controls. Inter stays loaded behind it, so a failed font fetch degrades to yesterday's page rather than to Helvetica.
The paper still speaks serif. Newsreader keeps the paper's title, its claim, its abstract and its body — the rule that has held since the reading page was built. What changed is only the other half of the conversation.
A display line at weight 400. Peer's page titles were semibold with -0.02em tracking; they are now regular at -0.03em, which is the whole of the look being borrowed. Inter needs weight to hold that line, which is the real reason the face changed.
Labels became eyebrows. Mono, uppercase, opened out to +0.06em, with a 6px mark in front: ● THE CLAIM, ● THE RECORD, ● A SAMPLE, UNTIL YOU SAY OTHERWISE. The masthead's nav and the reading page's commands are the same object — [O] OPEN ON ARXIV. This overrides the "nothing uppercase" rule set with the masthead: that rule was about shouty small-caps buttons, and this is its opposite, a label so quiet it needs the tracking to be read at all.
Two curves, three durations, one animation. cubic-bezier(0.16, 1, 0.3, 1) for anything that enters or changes colour, cubic-bezier(0.65, 0, 0.35, 1) for anything that returns; 180 / 360 / 720ms. Latent's whole site has two keyframe animations and Peer now has one: the accent dot on the claim breathes at 2.4s and nothing else repeats. A second pulsing thing would make both of them ornament. It stops under reduced motion.
v0.25.1 — 2026-09-16
A missing model key no longer holds the site back.
The build refused to deploy without GOOGLE_API_KEY, on the reasoning that a deployment with no model ships "a product whose AI silently does nothing". Half of that is right and the conclusion was wrong: Peer without a model is not broken — it is the same briefing every signed-out reader gets, and it is what this deployment served for months. Refusing to ship it kept the live site several versions behind over one unset variable.
The key is now warned, not required: the build names it, says the AI half is off, and ships. The two Supabase settings stay required — without them the server cannot tell who a request is for at all, which is a real break.
v0.25.0 — 2026-09-16
Open on a paper, not a form.
A first visit used to be a seven-step wizard. Peer asked for your research topics, your project, your methods, your sources and a model key before it would show you a single paper — the one screen that says what the product *is* came last, and only to the people who finished the form.
The first visit is now a briefing. No profile, no sign-in, nothing typed: ten real papers from today, sampled across six broad fields, with the day's shape and the reading surface exactly as a set-up reader sees them.
And the sample says it is a sample. A strip above the cards names what it is — "these are real papers, across a few broad fields" — because ten papers that look chosen for you and were chosen for nobody is the dishonest version of this idea. The deck does not claim the sample's fields as your interests, the plates do not headline them (that is the "your own query, read back at you" failure the plate rules exist to prevent), and a card with no abstract says "Open this paper for details" instead of "Matches your interest in molecular biology".
Setup happens against the papers, one choice at a time. Tap the field you work in — or type your own — and the sample becomes your briefing on that tap: the topic is promoted immediately rather than waiting for the next day's day-lock, the strip goes away, and the deck starts naming your topic. Project, methods, sources and your own model key are all still there, in the long form, now linked rather than imposed.
The wizard no longer intercepts anyone. /welcome is a page you can visit, not a gate you must pass. The only thing the first-run code still does is backfill the local flag for a returning reader whose synced profile already has topics.
One line had to go with it: the feed store returned an empty list, before the request, whenever the profile had no topics — which is exactly the reader this release is for.
v0.24.0 — 2026-09-14
One plan, on Peer's model.
The freemium branch is merged, and then cut down to what Peer needs with no users yet: one tier. Every signed-in reader gets the whole product on Peer's own Gemini key — AI ranking, relevance reasons, the model report and deep reports with no monthly cap — behind the daily circuit breaker that protects the bill. A reader who adds their own key uses it instead. Signed out, Peer still works without a model, as before. The free / trial / paid machinery stays in the code and the database, resolved to a single plan in one line of lib/entitlement/resolve.ts, so three plans are one edit away.
The build now requires `GOOGLE_API_KEY` on Vercel. It used to ban it: the guard kept deployments BYOK-only. A deployment without the key is refused at build time rather than shipping a product whose AI silently does nothing.
Every AI route is behind one entitlement check, meters its spend into usage_events, and counts deep reports and rate limits in usage_counters — tables that exist in production as of today.
The profile grant is the one that works. The branch's migration revoked UPDATE on the four plan columns, which on Supabase does nothing while the table-level grant stands — a signed-in user could have set their own plan. The migration file now says what production actually ran: table-level write revoked, every other column granted back to authenticated.
The merge kept main's side wherever the two had diverged: the papers-only briefing and the reading page stay as they are, and the branch's work on the jobs and events surfaces — deleted from main — went with them. Typed routes are on, so a dead internal link is a compile error. The profile page no longer says "Tier 0".
v0.23.0 — 2026-09-09
The day has a shape, and now you can see it.
The brief's whole summary was one sentence — "Ten papers on protein structure prediction and diffusion models — ten unread." True, and silent about the one thing that decides how long you spend here. A real day looks like this:
0.89 0.89 0.68 0.67 0.66 0.65 0.64 0.64 0.58 0.53
Two papers that genuinely match, a flat middle of six that only roughly do, and a tail. That shape is where you stop reading, and it is invisible in a list where every row is the same height. It is now ten bars under the deck, in the order the page lists them, each as tall as its paper matches — Peer's own number, the one that already chose the order, so the strip explains the order instead of adding a second opinion to it. A bar is a link to its card; hovering one names the paper and its age.
Not a chart of topics. That was the first idea and the data killed it: across a day's ten papers the concepts the sources hand over are arXiv filing codes ("cs.LG", "quant-ph") and OpenAlex categories that land off-domain often enough to matter — "Thermodynamics" on a diffusion-models paper, "Training (meteorology)" on an RNA one. The terms recurring on two or more papers were two filing codes, "computer science", and the reader's own declared topics read back at them. A chart of that would have been confident nonsense. This one is built only from numbers Peer computed itself.
And it is a chart, not a band. Stretched across the board the bars were 124px wide and 34px tall, ten of them in the accent: a solid orange rule under the deck, which is decoration, which is the one thing this interface does not do. At 12px on a 52px baseline the same ten numbers are a shape you read in one look. The bars are neutral — in this palette the hue is a signal and how well a paper matches is data — and a read paper drops to the hairline tone: spent, still counted.
Zero-based against the day's best, because a bar chart that starts anywhere else is the oldest lie in the genre.
v0.22.0 — 2026-09-09
A byline, and the chart moves to the page you actually open.
The byline said "A. Kalisz, J. Simons +5". Initials are what you set when the column is 40mm wide and paper costs money; on screen they cost the one thing a byline is for, which is recognising the people. The names are now written as they are written, three before it asks to be opened, and the "+5" — which read as a typo rather than a control — is a control that says what it does.
And it says where the first author works. OpenAlex returns an institution with every authorship, inside the authorships field Peer has always fetched whole and read exactly one key out of. "University of Bayreuth" under the names was in the payload the entire time. Same shape of miss as the full text in v0.20.0, found the same way — by reading what was already on the wire.
Peer's chart was on the profile, which is the wrong page. The reading calendar is a habit, and a habit belongs on the page you open every morning, not on the page you visit to change a setting. It is now one component in two sizes: the profile's labelled eighteen weeks, and a bare eight-week strip at the foot of the briefing — after the day's papers, because the brief opens on what there is to read and closes on what has been read. Two copies of a chart became one.
And it works signed out. readItems was a set of booleans, so the only reading history Peer had was /api/read, which answers 401 without a session — on a self-hosted, local-first product, its own chart needed an account. The store now keeps the day each paper was first read, and the chart prefers the server's history and falls back to this browser's. Re-opening a paper next week does not move the day it was read.
Nothing is drawn from nothing: with no reading history the strip renders nothing at all, and the streak is still never counted off invented weeks.
canonicalizeHeading reads one heading at a time, so a paper whose results live in "5.1 Model Comparison Across Budgets", "5.2 O3", "5.3 FK-steering" and "5.4 DPO" had exactly one section bucketed results — "5 Results" itself, a 315-character paragraph saying which subsection discusses what. "5.1" was called *methods*, on the strength of the word "model" in its title. The numbering is the document telling us its own structure and it was being stripped and thrown away: numbered subsections now inherit their parent's bucket, unless their own heading is structural — "5.3 Limitations" under "5 Results" is still limitations.
The results are often in the figures. For a great many papers the running text says "Section 5.1 discusses the comparison" and the finding itself is in a caption Peer had already extracted and was only using to label a picture. A caption can now be quoted as a finding — last, after the paper's own prose, and cited as "Figure 6" rather than "§Results", because it is not a section and should not be dressed as one. Captions are also out of the running text now: they were being read twice, and a caption spliced into a Results section is a sentence the splitter cannot tell from prose.
`body` is a pool. A paper whose headings never say "results" — a maths paper with numbered sections and nothing else — had these blocks empty however good its numbers were. The unclassified sections are the last pool for findings and for method; never introduction or related_work, where a number is somebody else's result or a promise about this one.
Rendered mathematics is not prose. LaTeXML writes every formula twice — the MathML and the TeX it came from — so "d = 3" arrived as "d = 3 d=3", "K" as "K K", and every quote, every figure caption and every line of the paper's body carried it. The TeX half is dropped. What is left of an equation still survives a sentence splitter, so a sentence that is under 62% letters and spaces is no longer quotable, and neither is an algorithm listing's step number.
Together, on the three papers checked: findings 0 → 3, 0 → 1, 3 → 3; method 2 → 2 (both sentences now from the method, not an appendix pointer), 0 → 2. No sentence is quoted under two headings any more.
The reading is version 3. It carries a day-long cache in the reader's browser, so a better summary with the same shape is exactly as invisible as a missing field. The version now moves when the derivation changes, not only when a field does. The figure module's cache key moved with it.
v0.20.0 — 2026-09-07
The paper itself, and a title bar that is drawn.
Peer had the whole paper the entire time. getFullText returns an extracted document — every section, capped at 18k characters each and 90k in total — and buildReading read eight sentences out of it and dropped the rest. A reader who opened a paper Peer could reach saw one figure, an abstract, and a few quoted lines, and then had to leave for the publisher to read the thing. The reading now carries body: every section, in the paper's order, under a new block. It opens with the contents — which is also the honest statement of how much of the paper Peer reached — and where it came from. Long papers open on a click; short ones are simply there. The paper's own words, so it is set in the reading serif at the measure; the mono on that page is Peer's voice and none of this is Peer's.
Paragraphs survive extraction now. stripTags turned every tag into a space and every run of whitespace into one space, so a section arrived as a single four-thousand-character line. That was invisible while the only readers were a sentence splitter and a model. Block closers are a blank line and <br> a single one; headings ask for one line explicitly. One paper went from 14 unbroken sections to 65 paragraphs. A line that is only a step number — LaTeXML renders an algorithm listing one cell per line — is dropped.
The reading document is version 2. It has a version gate for exactly this reason, read by both the localStorage cache and the response check, and a document written by an older build is now discarded rather than half-read.
The masthead was the last surface without an edge. Transparent at rest, three sans words and one boxed ? floating on the ground with nothing holding them, while everything under it had become hairlines. It has the hairline at rest now; the glass still arrives on scroll, background only, so the height never changes. The nav is mono with the separators the rest of the machine's lines use — one row rather than three words with air between them — and the ? lost its chip and joined it. The wordmark stays in the display serif: the product's own name is the one thing in that bar that is not the machine talking. The nav at the top and the key legend at the foot of the reading page are now the same object at the two edges of the window.
v0.19.0 — 2026-09-07
TUI.
Peer was a soft-UI product: rounded slabs floating on cast shadows, bevelled top edges, a lift under the pointer, Inter for everything the machine says. The direction now is a terminal — drawn, square, keyboard-first, dense — and most of it turned out to be four values.
Nothing is round. Tailwind compiles its whole radius scale from eight variables; they are all zero now, and the one radius it does not take from a variable — rounded-full — is squared by a single rule beside them. 170 rounded-* classes across the product changed without one of them being touched. Put the eight values back and the shape returns exactly.
A card is drawn, not floated. --shadow-card was a rim, a contact shadow, a cast shadow, an ambient shadow and two bevels. It is one hairline now, and the only thing that happens under a pointer is that the hairline gets brighter — no lift, no press-below. The input well, carved with two inset shadows, is a box you type inside. Held, a card's frame takes the hue: a terminal has no depth to press into, what it has is a selected row.
The machine speaks mono; the paper speaks serif. The reading page's own labels — the section names, the record, the footers, the commands — are in Roboto Mono. The paper's title, its claim and its sentences stay in Newsreader. The rule is not new; it is the one the stylesheet already stated for prose, applied in the other direction.
Sections are bands. A name in mono on the rule it starts, running the width of the column: The claim ────, The abstract ────, How it was done ────. The 22px serif headings are gone — a landmark down the column is now the rule, which is the width of the column, rather than a bigger line of the same colour. The claim's band label carries the one accent on the page.
Commands are named by their key. [o] Open on arXiv ↗, [s] Save, [x] Skip, [c] Copy — the key first, in mono, because on this page the key is the interface and the button is its shadow. Three of the icons added yesterday are gone with the change: a bookmark beside "Save" said nothing the word did not. The one that stayed marks a destination.
The legend. Every key the reading page answers to, on one line along the foot of the screen: j next · k prev · s save · x skip · l like · u undo · o open · c copy · esc briefing. Peer had nine keys here and was telling the reader about the four that also have buttons. It is generated from PAPER_KEYS — the table the keyboard layer and the help sheet already read — so it cannot describe a key that does not exist. Nothing on it is clickable; it is a legend, not a toolbar. Desktop only: a phone has no keyboard and already has the thumb bar.
v0.18.0 — 2026-09-07
The reading page gets a shape.
Without a key the page was an abstract and nothing else: eleven lines of body text at one size, two of them in ink, and under them half a screen of nothing. Everything on it was true and none of it was *arranged* — no second place for the eye to land after the title, no edges between one thing and the next, and on a wide screen a 1032px strip of page with 450px of black on either side.
The claim is lifted. When a model has read the paper, Peer's skim already sits above the abstract as a deck, and the ink below moves to the sentences that are its evidence. Tier 0 now does the same with the paper's own words: the claim sentence — the one the ink was already choosing — is set at display size above the abstract, and its ink is dropped from the abstract below, so exactly one thing on the page is loud and nothing is emphasised twice. It carries the one accent mark on the page: a short rule, because this is the only line on a reading page that Peer chose rather than the paper.
The column has bands. Every block heading now carries the hairline above it, so "How it was done", "Where it is thin" and the rest read as sections with edges instead of as one column of text with bigger lines in it.
The record. A new block, last and always present: published date in full (the page had only "3w ago"), the venue, and every other door the paper has — the publisher's page, Scholar, and the authors' code. A repository was the worst of the omissions, because it is the part of a paper a reader can actually run, and the decision block opens exactly one link.
The end of the paper is a place. The next paper was a 12.5px label over a 19.5px title, the quietest thing on a page that ends early. It is now a band: a rule above it, the title at heading size, and an arrow that travels a couple of pixels under a pointer.
Marks on the actions. Open, save, skip, copy and the DOI line each carry one stroke, drawn at the line's own size so the icon reads as punctuation rather than as an object.
The measure stops growing at 2xl. A line does not get wider because the window did. Above 1536px the reading column is fixed at 560px and every extra pixel goes to the panel, so a wide screen buys a bigger figure and a title with more room. The spread itself is 1200px there rather than 1032.
Also: a figure caption is prose, so it breaks at a word — "…seed samples (top row), sh…" was cut at the character it happened to fall on.
Thirteen sizes between 22px and 52px, typed as literals at 2px intervals, so no two pages agreed on how big a page title is — the briefing said 40, saved said 38, the profile said 44, the changelog said 44 with no small step at all. A continuum is not a scale. There are now five display steps on a ~1.19 ratio, with fixed roles: a section heading inside a page, a panel heading, a page title on a phone, a page title on a desktop, and a cover statement. Every heading in the product is one of them, every page title is now the same size, and the six stray sizes below 20px (9px, 11px, 12px, 18px, 19px) are back on the nine steps that were already there. Nothing types a font size any more.
The plate stops shouting the filing label. When a paper has no figure and no term of its own, the plate used to set the venue and the year in display italic — "Zenodo / 2026", "arXiv / 2026", "Journal of Artificial Intellig…" — which is the meta row directly below it, repeated at three times the size, and truncated mid-word when it did not fit. Display type is the loudest thing on a card; it should not be the one line the reader has already read. Those papers now have no plate at all: the card opens on its own title, and the reading page with it. A quieter paper gets a quieter card. Terms longer than the plate's first line are dropped rather than truncated, so display type never breaks mid-word again.
v0.16.3 — 2026-09-07
The last two hues.
Auditing the deployed stylesheet rather than the source turned up two more colours the greyscale pass had not reached, and they were hiding in different ways than the literals in v0.16.2. The DeepSeek caution note on the AI-key panel was bordered in amber-500 — Tailwind's own palette, which is always there to be reached for and owes nothing to Peer's tokens; a caution is the accent's job now. And --done-light / --done-dark, the blue completion seeds, were still declared on every accent theme after --color-done went grey — dead weight that a grep for the palette still found, because it was still in the CSS.
An audit of the build now reports no colour outside the six accent seeds.
v0.16.2 — 2026-09-07
Eleven colours the palette could not reach.
The greyscale pass changed the tokens, and eleven rings and shadows on the profile and the AI-key panel were not written in tokens — they were typed as raw rgba(185,28,28…), rgba(194,99,14…), rgba(217,122,48…): the old red, the old burnt amber, the old peach. They kept their hue while everything around them lost it, which is exactly the failure a token system exists to prevent, and it survived a release because the values are literals no palette grep would find — they turned up in the deployed stylesheet, not the source. Each is now the ring of the token its element already uses. Nothing hued is hard-coded in a component any more.
v0.16.1 — 2026-09-07
A little material.
Greyscale removed the colour that used to carry the surfaces, and left flat fields behind. What replaces it is light: the interface now has a grain, an edge and a direction.
Grain. A fine tileable noise on the ground and on every card. The ground's is fixed to the viewport, so it stays put under the scroll the way paper does rather than sliding with the content. It is deliberately faint — the tile is mid-grey with a noisy alpha, so it lightens a dark surface and darkens a light one, and at any strength where the texture is obvious on a #1d1d1d card the same tile is a grey veil over a white one. Tooth, not tint. (The ground had a grain already; it was near-black noise, which on #111 was a texture nobody could see.)
An edge. A card catches the light along its top edge and loses it along the bottom, so it reads as a slab with thickness instead of a lighter rectangle.
A cut mat. The figure sits *in* its mat now, not on it: the inset catches on the window's top and left, where a light from the upper left would leave it.
A press. Pressing a card puts it a pixel below where it started, with the cast shadow collapsed to contact and a shallow well under the top edge. A thing under a finger does not merely stop rising.
v0.16.0 — 2026-09-07
Greyscale, and one hue.
Peer was a warm ivory system: every neutral carried a little of the accent and a little more of a warm brown, and the interface answered in six or seven colours — burnt amber tags, peach chips, a green relevance ramp, a blue completion state, a red for failure. Now every neutral is a true grey and the accent is the only coloured thing on any screen, which is what lets it read as a signal instead of as decoration.
The greys are the old values. Each one is the exact lightness of the warm colour it replaces, converted through OKLab with the chroma set to zero, so every contrast the layouts were built on is unchanged and only the hue is gone. Light is a #fafafa ground with white cards — sheets on a table, where before the cards sat darker than the page; dark is #111 with #1d1d1d cards.
What used to arrive in colour. Tags and the profile's signal ramp step down through the greys instead of through amber and peach. Failure states, links and relevance take the accent — a monochrome interface that keeps a second alarm colour has two accents, and the louder one is the one the eye learns to ignore. The one place two states must stay apart is the swipe: keeping is the accent, letting go is ink.
The card. A mat exists to hold a picture. Six cards in ten have no figure, and on those the mat was the brightest object on the page with two words in it — a placeholder wearing a composition's clothes, and worse once the warmth was gone. A figure is still matted, because scientific figures are drawn on white and matting is what stops a chart losing its axis labels to a crop. Type is not: it sits on the card's own surface, at its own proportion rather than a photograph's 16:9, and the numbering that reads "01" beside a lone term is gone — numbering says "several, in an order", and one term is neither. The venue and age line was the last small-caps label in the product; it is sentence case now, in the mono the reading page uses for the same fact.
v0.15.0 — 2026-09-07
The briefing ranks by relevance.
Today's ten opened with two protein-structure papers and then, from the fifth card on, an art installation about diffusion models, a paper on the C/O ratios of Uranus, 3D duet singing animation, and pitch-class steering for music generation. Every one of them had earned its place: they say "diffusion models", and the ranking could not tell them apart from the reader's own field. Measured against a labelled copy of the day's forty candidates — kept in scoring/__fixtures__ so this stays measurable — twenty-four were strangers.
Relevance was outweighed by age. The score was 0.35 keyword + 0.30 TF-IDF + 0.20 recency + 0.15 source, which reads as relevance-first and behaved as recency-first. The keyword term was the same 0.667 for twenty-eight of thirty papers — it counts how many of your topics a paper mentions, not how much it is about them. TF-IDF was the only term that discriminated, and the cosine of two short documents lives around 0.01–0.35, so weighted at 0.30 it could move a score by 0.1 while recency moved it by 0.17. Relevance is now scored against the rest of the day's pool, where the number means something, and leads: 0.55 topicality, 0.30 keyword, 0.10 recency, 0.05 source.
A mention is not a subject. Two papers matched "protein structure prediction" on one sentence apiece — "generative modeling, which has transformed prediction in fields as diverse as weather forecasting and protein structure prediction, holds the potential to forecast earthquake aftershocks" — and that name-drop, counting as a full second topic match, ranked an earthquake paper with the day's real protein work. A match is now weighed by where it falls: title, tags, said repeatedly, or said once in passing. It still counts; it no longer counts the same.
A daily briefing carries news. The reader's freshness window reaches the source queries, but Semantic Scholar's search takes no date parameter and answers with the field's classics: the 2000 PSIPRED paper and the 2020 AlphaFold paper were both in a pool built for today. Nothing caught them afterwards — they stayed out of the briefing only because age outweighed relevance, so the moment relevance was allowed to lead, they led. There is a stated ceiling now, generous about the window (60 days for a reader on "week") and applied both where the pool is built and where it is read, since a pool outlives the request that built it.
Today's briefing, after: six straight papers on protein structure prediction, then diffusion-model work, and the first stranger at card seven instead of card five. Nothing in it is older than three weeks.
v0.14.1 — 2026-09-07
The briefing opens with a dateline.
The page used to start with two lines of small print — the masthead's "Monday, September 7 · 10 papers · 9 unread" and a mono "matching diffusion models, protein structure prediction · synced 4m ago": a status bar where a front page should be. Now the date is the headline, in serif at display size, and everything those lines said is one sentence of reading type under it — "Ten papers on diffusion models and protein structure prediction — nine unread." — with the unread phrase in ink, the one part that changes as the day is read. When everything is read it says so: "all read, back tomorrow." While the day loads it names what it is looking for. The sync state and the refresh button are the only small item, at the right of the headline, because they are status; a failed load is still red there.
The masthead's centre is empty on the briefing, so the day is stated once. On a phone the nameplate sits above the dateline; the mono day line is gone.
v0.14.0 — 2026-09-07
The masthead.
One line of chrome. The 208px sidebar, its floating toggle, the two top-right pills and the phone's row of tabs are replaced by a 48px masthead across the top of every page but the welcome wizard: the Peer wordmark at the left, the day's state on the window's centre line — "Sunday, September 7 · 10 papers · 9 unread" — and Search · Saved · Profile and a ? keycap at the right. It is transparent at the top of the page and takes its glass once the page has scrolled; its height never changes, so nothing on the page moves. Content uses the whole window: the board and the reading spread centre in it, and no page slides sideways any more.
The rail is the masthead. On a paper the centre reads "← Briefing · 3 of 10 · k j", and the two keycaps are the previous and next buttons. The in-content rail is gone, so there is one position counter, and it is always on screen. A deep link shows the way back alone.
The day is stated once. The briefing page's header — date, count, unread, rendered again 208px to the left in the sidebar — is now the masthead's. The page keeps one line under it: the topics it matched and when it synced, with the refresh button at the right; a failed load still says so in red. Before the day's fetch lands the masthead states only the date — the store does not know a count yet, and "0 papers" would have been a claim.
A thumb bar on the phone. Below 768px the shell is a 56px bar at the bottom, in reach: Today, Search, Saved, You, each a 22px icon over a label and well over 44px tall; Saved carries its count when there is one. On a paper the bar is the rail — "← Briefing · 3 of 10 · Next →" — the first way to move on from a paper without judging it. The briefing's top is the wordmark and the day line, which scroll away; the reading page has no top chrome at all, so the plate is the first thing on screen. The page keeps a bottom padding the height of the bar, so the bar never covers the decision.
`/` is honest. From any page it goes to Search and focuses the box; on Search it focuses in place (on a phone the box waits to be tapped, so the keyboard does not rise over the page). It used to look for a box that exists on one route. The dead "g /" the sidebar advertised, the \ sidebar toggle and the help sheet's "View" group are gone; the help sheet's headings are sentence case — Anywhere, Navigate, Briefing, Reading — and its foot reads "Peer 0.14.0 · What's new", the first link to the changelog the app has had.
Account on the You page. Sign in with GitHub, and Sign out, live in an Account section at the top of /profile, shown only when Supabase is configured; signed in, the masthead's "Profile" is the avatar. The GitHub star counter and its hourly fetch are deleted, with the floating pills, the sidebar's persisted open/closed state, the uppercase "SUN · SEP 7" and "V0.13.2", and the undo toast's uppercase label. The undo toast and the copy toast keep fixed corners and sit above the thumb bar on a phone.
v0.13.2 — 2026-09-06
The reading spread.
A paper's page uses the width. From 1280px the page is two columns: the plate, the title and the decision on the left, under the eye; the paper's words and Peer's additions on the right, in a column no wider than a line should be. The left panel stays put while the reader scrolls — when a reading or a model lands, it lands in the right column, and the decision does not move. The spread stops growing at 1352px; on a wider display it sits on the desk with ground either side. Read still means decided: on the spread the decision is on screen at open, so a paper is read when the end of its words has been on screen for a second, not the decision. Phones and tablets are unchanged.
v0.13.1 — 2026-09-06
Keys, swipe, and read-means-decided.
Keys on a paper. j ] → and k [ ← move through today's briefing in its order; s saves, x skips to the next paper (the Undo toast and u restore it), l likes, o and Enter open the source, c copies the page as Markdown, Esc and Backspace return to the briefing. One table (PAPER_KEYS) drives the handler and the help sheet's new "Reading" group. The briefing's own keys are untouched. Arrows never take a live text selection — Shift+Arrow extends one, and a plain arrow on a selection is the reader adjusting it.
Swipe on the plate. On a phone the plate is the card's swipe object: right saves, left marks not interested and advances. Touch and pen only, the same thresholds as the card, pan-y so the page still scrolls. A skip keeps the reader mounted through the fly-out — the dismissed paper is read from the pending dismissal until the route changes, so no loading mat flashes and no request fires for a paper just dismissed.
Read means decided. Opening a paper no longer marks it read. It is read when the Decision block has been on screen for a second, or on any decision — save, skip, like, open, copy, next. A record-only page is therefore read on open; u un-reads.
Assistive technology. The reading article takes focus after a client navigation, so j/k announce the new paper. The authors line keeps the names as its accessible name ("A. Jumper, B. Evans +9, Show 9 more") instead of replacing them. Keycap chips inside buttons are hidden from screen readers ("Save", not "Saves"). The DOI line says it copies and has a 44px touch target; the copy toast's live region is always in the DOM so it is announced. The plate and its caption are a real figure/figcaption.
v0.13.0 — 2026-09-06
The reading surface.
/papers/[id] is rewritten as a reading sheet in a fixed order the reader learns once: rail · plate · title and meta · the paper's own words · the decision · Peer's additions · next in briefing. Nothing on it is invented.
The paper's words. The abstract as written, in its two paragraphs, with the claim and the numbers set in ink; the rest muted. No heading is put over an abstract sentence. A paper with no abstract shows the record and, when Semantic Scholar has one, its TLDR labelled as machine-written.
One sentence about what Peer read. "Abstract only. Method, caveats and what it means for your project need the full text and a key." — or the full text's source and page count, the paywall host, or the plain statement that the PDF is there and only a self-hosted Peer reads it. Every string comes from one table (describeAvailability); none is typed in JSX. The recommendation line under the authors is the briefing's own reason, never the deep-link placeholder a paper resolved by id carries, and never the reranker's generic fill-in.
Model blocks with receipts. With a key, each block is prose whose every claim carries the paper's own sentence: an ink mark when the sentence is in the abstract, a quiet quote with its section heading when it is not. When the reader turned deep reports on and the model still read the abstract alone, the sentence names the wall (paywalled, unreadable here, unfound, or the deep step not finishing) instead of telling them to turn on a setting they already turned on. A model that was asked and could not finish says so.
Copy as Markdown. Frontmatter with the basis the text was read from, the abstract with the marks in bold, every quote with its heading, a "Not on this page" list generated from the typed omissions, and a BibTeX block. A reader who ran the model is never told a block "needs a key".
A PDF's section names arrive in capitals ("MATERIALS AND METHODS"); the attribution sets them in title case for display and export — the shouting is typesetting, not meaning, and the page has no other capitals.
Deleted. The Surface route and its model, the six-pill action row and the Cite modal, the scroll progress bar, ScrambleText, the icon section titles, "At a glance", "Explore further", "More like this", keyword chips, the pull-quote of the scoring string, "Why it fits you", "Related from your feed", the skeletons and shimmer bars, the tier-upgrade block, and every fabricated-report path behind them. The plate figure is resolved once per paper for the card, the page and the caption line together.
v0.12.4 — 2026-09-06
A reading per paper, and evidence-verified reports.
`GET /api/papers/[id]/reading`. The deterministic reading, built server- side from the record and the full text with no profile and no key, so it is one document per paper for every reader and is cached for a day. A full-text attempt that times out answers no-store and is not kept.
Every claim carries a verbatim sentence. The report prompts ask for an evidence sentence copied character-for-character from the abstract (Tier 1) or the full text (Tier 2), and verifyReportEvidence drops — never flags — any claim whose sentence is not in the text the model was given. Drops are counted and shown. The relation to the reader's project is asked for only when the profile has one. At the abstract tier no figure is kept: nothing bound it, so a URL the model emitted is not the paper's; the sanitizer keeps only https and PDF-rendered data images.
Vercel reads no PDFs, and says so. The PDF text extractor is traced into the reading and report functions. Where no interpreter can be spawned, or the helper is missing, the extractor reports a machine reason (no-python, no-extractor) and the reading maps either to "the PDF is there, but only a self-hosted Peer reads PDFs" — never "no full text". The extractor also reports the document's real page count rather than the number of pages it read: a 50-page paper was being called "a 40-page PDF" because 40 is the reading cap.
v0.12.3 — 2026-09-06
The deterministic reading.
reading.ts builds the sheet the reading page shows before, and without, a model: the abstract split into sentences with pickSkimMarks choosing the claim and the numbers; up to three verbatim findings from the results section carrying a number or a comparison; two method sentences; the authors' own limitations, else the hedges in the discussion. Each quote carries the heading it came from. Absence is typed (omitted), never faked.
Provenance. ExtractedDocument.pageCount reaches the reading; a Semantic Scholar TLDR is carried as tldr, labelled, and no longer becomes the abstract; reviewPaperLabel reads the title only. The old fallback report — "Main result", "Key result 2", "Overview / Section N", invented fit reasons — is deleted; the one report without a model is emptyReport.
v0.12.2 — 2026-09-06
Full text reaches Peer again.
Three of today's ten papers could not be read past their abstract for reasons that had nothing to do with access. Fixed on the way to the reading-surface work:
arXiv's own HTML. arXiv has rendered LaTeX submissions at arxiv.org/html/<id> since late 2023, but Peer only knew ar5iv, which now answers recent ids with a redirect to the abstract stub. The native render is tried first (263 ms for a 38,000-character paper). The LaTeXML parser also stopped truncating any section that has subsections: it matched <section>…</section> non-greedily, so "4 Evaluation" ended at the first nested close and 4.4 Results and 4.5 Limitations were lost. It slices between consecutive headings of any level instead, and captures the abstract.
A `limitations` bucket. Heading canonicalisation stripped one leading number, so "4.4 Results" became "body", and no bucket existed for the one section a reader most wants quoted. Numbering of every shape is stripped now, and Limitations / Threats to Validity / caveats have their own bucket. The Python PDF extractor mirrors it.
Zenodo. A Zenodo DOI resolves to a landing page whose "Description" is long enough to pass the full-text check, so the record page was being read as the paper. The records API lists the deposited PDF; it is a source now, ranked above every HTML fallback, and the full-text check requires a recognisable body section or several long ones rather than one long block.
Macs. The PDF extractor shelled out to python, then py -3. macOS ships neither — only python3 — so every PDF on a Mac quietly yielded nothing. python3 is tried first, after PYTHON_BIN when it is set.
Captions. Labels read "Figure Figure1"; tables were labelled as figures; LaTeXML subfigure fragments — "(a) Original image", three of every four captions on a typical page — were emitted as captions of their own. One parser serves all four extractors.
Verified live against real papers: arXiv 2609.02697 (HTML: 16 sections including Limitations, labelled captions), arXiv 2609.02113 (no HTML render; PDF via python3), and today's Zenodo preprint (deposited PDF: Abstract, Introduction, Related Work, Methodology, Results, Discussion, Conclusion). 30 new tests.
v0.12.1 — 2026-09-06
An empty briefing says why.
The empty screen was one block of caption-size text — "Your briefing is still waking up… Set up profile" — shown for every reason the grid could be empty. The screenshot that prompted this was a dead dev server: the fetch failed, fetchRealFeed swallowed the failure into an empty list, and a reader with two topics configured was told to set up their profile under a header that read "synced just now".
Three reasons, three screens. No topics: "What are you working on?" with a real Set up profile button. Sources unreachable: "Couldn't reach the paper sources." with Try again (the underlying error sits on the button's tooltip) and Edit topics, and the header reads "sync failed" in red instead of a sync time. Nothing new: "Nothing new for these topics today." with Refresh and Widen topics. Display serif at 28px, one line of body copy, buttons from the shared button primitive; no caption-size instructions. The block sits in the header's column rather than floating in the middle of the page.
The store records failure. feedError is set when the papers lane throws and cleared at the start of the next load. A failed load no longer stamps lastRefresh, so the header cannot read "synced just now" over an error. The flag is transient — not persisted — so a reload starts clean. Which screen to show is a pure function (emptyReason) with tests.
Also: before the first load the header read "synced not synced yet". It reads "not synced yet".
Verified in the browser: no topics (cleared in storage), nothing new (ten x presses dismissed the whole briefing, no reload fired), sources unreachable (/api/feed rejected at the fetch layer, then r), and recovery — Try again with the network restored brought ten papers back and cleared the red header.
v0.12.0 — 2026-09-06
The touchscreen drives the briefing.
The other half of the principle from v0.11.0: on a phone the interface is the finger, not a row of small buttons. Peer's only touch provision was a media query that forced the three icon buttons permanently visible — the phone got the small-button interface the whole series has been removing.
Swipe a card. Right saves (or unsaves — the reveal says which); left marks it not interested and the card flies out, with the usual Undo toast. The reveal under the card fades in with progress, the card follows the finger and rubber-bands past the threshold, a short drag snaps back, and a completed drag swallows the click that follows it so a swipe never also opens the paper. Commit is 96px or a fast flick in the same direction; a flick back toward the origin cancels.
Only touch and pen. A mouse has hover and the keyboard layer, and a mouse-drag on a link fights text selection. touch-action: pan-y leaves vertical scrolling to the browser; the card only takes a drag once it has locked to the horizontal axis, so a diagonal scroll never moves it.
The buttons are for assistive technology now. On touch devices they stay in the DOM — VoiceOver and switch users navigate by control, not by gesture — but leave the visual.
The gesture arithmetic (axis lock, resistance, commit) is a pure module with tests. The pointer-capture call is guarded: an inactive pointer id throws, and the drag works without capture.
Also fixed on the way: the card's Save button called an idempotent save on every click, so its "Unsave" state re-saved. It toggles now, the same branch the keyboard's s and the swipe use.
Verified with synthesised touch pointer events against the live handlers: right 160px saves and the header counts it, the click after a swipe does not navigate, 40px snaps back, left 160px removes the card and Undo restores it. A physical-finger pass is still owed — the preview pane was not compositing when the real drag was attempted.
v0.11.0 — 2026-09-06
The keyboard drives the briefing.
The designer's principle: on a PC the interface is the keyboard, on a phone it is the touchscreen; a product should not depend on small on-screen buttons. Peer did. The keyboard layer carried only global shortcuts — go-to chords, search, refresh, undo — and not one card-level action, so the only way to save, dismiss or like a paper on a desktop was the three hover-revealed icons.
Card-level keys, on the briefing: j / k (or the arrow keys) move a focus ring between papers in reading order; Enter or o opens the focused paper; s saves or unsaves it; x marks it not interested (the existing u undoes); l asks for more like it. Esc clears the ring. The ? help sheet lists them under a new Paper group.
The ring is painted as a DOM attribute and the index kept in a ref, so moving between ten cards re-renders nothing; the card list is read from the DOM on each press, in document order — which in a CSS-columns masonry is the column-major reading order j/k should follow. The focused card shows its actions as if hovered. After x the ring stays in place and the next paper slides under it.
Also: the g x chord still routed to the persona quiz, which left the nav in v0.8.0. Removed.
Touch is the other half of the principle and is next: swipe right to save, swipe left to dismiss, with the buttons reduced to an assistive-technology fallback on touch devices.
v0.10.1 — 2026-09-06
The loading skeleton catches up with the card.
It still drew the card from three releases ago: a single 820px column, a kind badge and score chip up top, a 3px accent stripe, three body lines, and a hairline footer with three action buttons. Every one of those has since left the real card, so the load-in was a jump cut from one layout to another.
It now mirrors what renders: a three-column masonry the width of the feed, each placeholder a cover card — a 16:9 plate on top (every real card carries one), then meta, a serif-height title of one to three lines, two or three skim lines, an author line. No buttons; the real ones are hidden at rest. Line counts vary per card so the masonry is a masonry before the data arrives, and the cards arrive with the same reading-order stagger the real ones use.
The component is shared with search, which already prints its own "searching…" status; the "Brewing your daily briefing" header is a prop now and search passes none, so the two messages no longer stack.
v0.10.0 — 2026-09-05
Peer is a paper briefing. Events and jobs are gone from the product.
The founders' verdict, verbatim: "先拿掉 event 和 job 我们 focus 在 paper". This release removes every user-facing surface for the two once-a-year verticals. The library code underneath (lib/events, lib/jobs, lib/opportunities) stays for one more release — see the last paragraph.
Removed. The /events and /jobs routes and their detail pages; the /api/events/*, /api/jobs/feed and /api/jobs/report routes; the Events and Jobs entries in the sidebar and mobile bar; the Events and Jobs topic lists on the profile page and in onboarding; the Locations and Work rights profile fields (both existed to filter jobs); the onboarding "Work rights" step — the wizard is seven steps, not eight, and no longer asks for visa status before research topics; the Adzuna and USAJobs connectors, which existed only to widen job coverage. Tavily stays, described as what it now is: optional web discovery for papers.
Saved is a shelf. It carried Papers / Events / Jobs segments and a To-do / Done rail for applications and registrations; its own comment admitted papers "have no completion action" and sat in To-do forever. It is a list of saved papers now.
The daily surface never asks for the other lanes. loadFeed defaults to ["papers"]. The feed tile and the report page's "Related from your feed" list are paper-only, which severs the last paper-side import of lib/opportunities/facets from a rendered surface.
Dead code swept. Deleting the consumers orphaned the event/job report kit (ReportSection, WhyPeerSentThis, timeline, fact tiles, badges), five UI primitives that only the event and job cards used, the dashboard and deadlines board, the visa country picker, the surface-topics composer, the feed "more" tile, the onboarding tour, and the decorative logo halftone. Seventeen test files went with their subjects, including the 1,725-line typography test that asserted Georgia point sizes for the job and event reports. 66 files, −16,801 lines.
Kept on purpose. /api/jobs/dispatch-digests — "jobs" as in cron jobs. It is the daily email entry point, its URL is pinned in .github/workflows/digest-cron.yml, and renaming a production cron endpoint has no product benefit.
Still to do — the library. lib/events, lib/jobs and lib/opportunities are now unreferenced by any product surface but cannot be deleted wholesale: the paper pipeline imports five files from lib/opportunities (shared, pool-cache, pool-cache-runtime, page-fetch, facets), and the event/job fields remain on UserProfile, in the feed store, in /api/profile's column map and in the Supabase schema. That is a separate, deliberate extraction.
v0.9.4 — 2026-09-05
The feed card is a cover card.
The left stripe is gone. The 3px accent rail on every card's left edge encoded paper / event / job on a mixed feed. On a papers-only feed it was the same mark on every card — decoration, and the same kind of chrome the rest of this series has been removing.
The plate bleeds. It used to sit inset inside the card with padding around it and a radius of its own, which reads as "a box with an image in it". It now runs to the top edge and takes the card's own corners, and the text block below it has real air.
The title is set in the display serif. It is the specimen's name, and it now shares a typeface with the terms on the plate above it — plate and title read as one typographic system. Sans is reserved for labels: the venue line and the authors. The footer hairline is gone; spacing separates.
The mat is under both fills, with its own ink. The off-white mat from v0.9.2 had never actually reached the browser (see below), and once it did the typographic plate's near-white type vanished on it. The plate now has its own ink tokens, relative to the mat rather than the card: on dark, dark warm ink on off-white; on light, the ordinary text colours on a 4% tint. Figure and type sit on the same ground, which is what makes the two fills read as one slot.
Infra note: Turbopack's persistent dev cache in .next/dev was serving a compile from before the latest globals.css edit, and a restart re-read the cache rather than the file — each restart was one edit behind. Clearing .next/dev fixed it.
v0.9.3 — 2026-09-05
The sidebar is the spine of a daily ritual, not a list of pages.
Today is the primary object. The one thing that changes every day — the date, how many papers came, how many are unread — now sits at the top of the sidebar as a block, and that block is the Feed link. "SAT · SEP 5 / Today 10 / 10 unread", and when everything is read, "all read · back tomorrow".
Frequency is visible. Search and Saved are reading tools and sit as plain rows. Events and Jobs sit under their own quiet OCCASIONAL label at a lighter weight; a once-a-year need should not look like a daily one. This is the same hierarchy the home page encodes, carried into the nav. Profile is anchored to the bottom.
The active state is a rail that travels. The active nav item used to be a card — bg-surface shadow-card, the same elevation as a content card — which put chrome and content on one level. It is now a 2px accent rail that slides to wherever you went, positioned from the DOM in a layout effect and written back as CSS variables, so moving it never re-renders anything.
Chrome is quieter. Six keyboard-shortcut hints were permanently on screen; they now appear only for the row under the pointer or keyboard focus. The footer — three unrelated things in a bordered strip — is one line: a sync dot, a single ? key, the version.
Also: the Events and Jobs page headers read "0 jobsin today's pool" — JSX swallowed the space after an expression. Template literals now.
v0.9.2 — 2026-09-05
Every card has a plate.
The window is universal. Roughly four papers in ten yield an extractable figure, and "some have an image and some don't" was the loudest thing wrong with the feed. There is now one 16:9 window on every card, at the same offset, always filled. When the extractor finds the paper's own figure, the figure fills it. When it does not, the window holds the paper's own terms set in the display serif over a numbered grid, closed by a rule. Two fills, one slot.
The terms are the paper's, and they are checked. The source field is matchedKeywords ∪ tags, which is hostile in two directions: matchedKeywords ARE the reader's required topics, so unfiltered they would put the reader's own query on all ten plates; tags are OpenAlex concepts, which mis-disambiguate often enough to matter — a live protein-structure briefing carried "Generative grammar" and "Representation (politics)". Allocation now runs once across the whole briefing and a term must survive four gates: it is not the reader's own topic (or a substring of one), it is not an arXiv filing code, it is long enough to set at display size, and it appears somewhere in the paper's own title or abstract. That last gate is what removes mis-disambiguations: a concept the paper never mentions is not a concept the paper is about. No term may headline more than two cards.
Figures are matted, not bled. object-contain on a mat rather than object-cover, because cropping a scientific figure destroys its axis labels. On dark themes the mat is off-white, so a white-ground chart reads as paper instead of glaring like a lightbox, and a colour figure keeps its colours.
v0.9.1 — 2026-09-05
Elevation and motion.
The shadows were soft-UI, and soft-UI cannot work here. Neumorphism reads as extrusion only when the object and its ground are the same material and the ground is a mid-tone that can travel both ways. The dark families have no such ground — bg and surface sit about four points of L* apart — so the highlight had been cut to 4% opacity and the pair had degenerated into one 16px black smudge with no contact edge. Hover made it worse: blur went 16px to 26px and black 45% to 60%, so a card went *softer* under the pointer instead of lifting.
Replaced with three layers doing three jobs: a 1px rim for separation, whose contrast is anchored to the surface so it stops depending on how dark the ground is; a short-offset contact layer; and a wide ambient layer with heavy negative spread, which is what keeps a 26px blur from becoming fog.
Motion. Cards now arrive staggered in reading order — the plumbing had been in globals.css all along and the feed had never used it, so all ten faded up on one frame, which reads as the page reflowing rather than as a delivery. Figures cross-fade in instead of being inserted. The 500ms hover zoom on figures is gone; a Ken Burns on a scientific plot reads as an advertisement and scales the axis labels.
Thirty icon buttons at rest. Ten cards each showed three. The action row keeps its footprint but appears for the card under the pointer, and stays permanently visible on touch, on keyboard focus, and on a card whose state is already on.
A read paper stays readable. The read state was a blanket 70% opacity over the whole card, which faded the title and the summary and, on dark, dragged the card toward the background. Only the cover recedes now.
Figures that cannot be shown no longer leave a hole. Finding a figure URL is not the same as being able to display it — biorxiv answered 401 for every one. Sending no referrer fixes those, and anything still failing drops its cover rather than reserving an empty 16:9 box.
v0.9.0 — 2026-09-05
The briefing has pictures, and ten different papers in it.
Papers show their own figures. Peer has always had a rule-based figure extractor and a route built for exactly this — /api/figure's own header reads "hit per-card after feed loads" — and no card had ever called it. The feed was ten identical blocks of text.
It was also looking in the wrong place: the extractor asked ar5iv alone, and ar5iv now serves a stub for recent preprints. Measured across seven papers from one briefing, ar5iv returned "no figures" for every one while arxiv.org/html carried 26 <figure> elements for the same ids. arXiv's own rendering is tried first now, with ar5iv kept as the fallback for older papers it still covers. Extraction is pure scraping with no model call, so figures appear on a deployment with no credentials configured.
The grid is masonry. Roughly four papers in ten carry an extractable figure, so card heights genuinely differ. A fixed grid either ragged-edged every row or reserved dead space on the six cards without an image.
One researcher can no longer take the whole briefing. A live feed showed six of ten slots held by the same author, all from the same repository. diversify caps per topic, and its key is the first three title tokens — "Graph Neural Networks for Protein Structure Prediction" and "Quantum Machine Learning Protein Structure Prediction" hash differently, so the cap never fired. There is now a per-author cap of two.
Worse, that pass had never run at all for most users: it sat behind requestedTier >= 1 while the client only ever sends 0 or 2. It is deterministic local computation — no model, no network — so it now runs at every tier, which is where PRODUCT_DIRECTION's Tier 0 floor puts it.
Card text says what the paper found. The skim line was summaryIntro, the first one or two sentences of the abstract, which in a paper is the motivation and reads identically across a field: three cards in one briefing opened with three ways of saying protein structure prediction is hard. Everything that distinguished them sat unread in summaryResultDiscussion. The line is now chosen from the whole abstract, preferring sentences that carry a claim or a quantity and avoiding both boilerplate openers and sentences whose reference dangles once lifted out of context.
v0.8.2 — 2026-09-04
Feed card: the hierarchy now encodes something.
The three loudest slots on a card all held information that was identical across the whole briefing, while the one field that separated two papers was in the smallest, faintest, most truncated line.
- The "PAPER" badge is gone — on a papers-only feed it said nothing, and it said it twice: paper.source repeated it at the bottom of every card as the literal string "other", which is what that six-value enum ("arxiv" | four ML conferences | "other") returns for everything outside those venues. - The match percentage is gone. It spanned 59–64% across the ten cards and occupied the top-right corner. Papers have no relevance floor, so it could not be used to choose between them either. - "Why you · <topics>" is gone from the card. Its first entry was the reader's own required topic echoed back, on every card; the remaining entries were OpenAlex concept tags that frequently landed in the wrong domain ("Representation (politics)" on a protein-structure paper). Its deduplication was case-sensitive, so one card read "protein structure prediction, Protein structure prediction, Protein structure". The statement it was trying to make is true of the whole briefing, so it now appears once, in the page header. - Venue and age lead instead, which is what actually differs: "Scientific Reports · 8d ago" against "Zenodo · 6d ago". The published date was already on every paper and had never been shown. - Venue drops the host institution. OpenAlex returns "Zenodo (CERN European Organization for Nuclear Research)"; at card width that truncated mid-word and pushed the date off the line entirely. - The kind badge still appears for a Discussion, so a forum thread is never mistaken for peer-reviewed work. That was the badge's real job.
v0.8.1 — 2026-09-04
Removing things that were not true.
The contribution calendar was inventing data. When the real per-day reading API returned nothing — which is every signed-out visitor, and any fetch error — the grid fell back to synthesizeActivity(), a seeded pseudo-random fill derived from the total activity count, and gave each invented cell a tooltip reading "N interactions". The streak badge counted those invented weeks. For a demo shown to signed-out visitors this presented fabricated numbers as the user's own history. The function is deleted; with no real data the panel now says so.
The persona quiz no longer claims to shape your feed. Onboarding said "It helps shape your feed". Its stored result has exactly two readers — the quiz's own hydration and an onboarding checkmark — and reaches neither the feed, scoring, nor reports. The copy now says what is true.
The onboarding tour no longer has a step that cannot fire. Step 3 spotlighted [data-tour="highlights"], an attribute no element carries, so it was silently skipped and the counter jumped 2/4 to 4/4.
`/api/test-digest` is development-only. It runs the full feed pipeline and sends an email while bypassing digest_enabled, frequency and time-of-day, and was reachable by any signed-in caller on a deployed instance.
Paper titles are legible immediately. The feed card wrapped every title and summary in a ~600ms character-scramble reveal — a delay applied to the exact text the reader came for.
Dead code. The unused DailyDigest panel and its loading progress bar are gone; only the headless PaperDigestLoader was ever mounted.
v0.8.0 — 2026-09-04
The daily surface is papers, and only papers.
The home page. The default landing tab was dashboard — a metrics page containing zero items of content: four count tiles (Papers today / Events today / Jobs today / You saved), a 14-day arrival-volume chart, a "what you're holding" tracker, a topic-coverage panel and a deadlines board. All removed. The home page is now the briefing: how many papers arrived, how many are unread, when it last synced, and the papers.
Papers, events and jobs are no longer peers. They were concatenated into one list, sorted by relevance score alone, and written into one grid — so a job posting scoring 0.81 outranked a paper scoring 0.79 and occupied an identical cell. Events moved to /events, jobs to /jobs. They also no longer run on every home-page tick: loadFeed takes a lanes option and the daily surface asks for ["papers"]. The progress bar labelled "Finding today's papers" was 30% driven by conference scrapers and job boards.
Manual controls left the automatic feed. The feed route carried eleven composers exposing fourteen typeable fields. Typing two characters into the search box deleted the briefing outright. Search now lives at /search; the briefing is untouched by anything done there.
Credentials have a home. The AI-provider key, deep-report toggle and Data-API keys had no section on /profile at all — they lived only in the one-time /welcome wizard and pinned permanently to the feed, which is why the feed page ended up doubling as the settings page. They are on /profile now.
Also. Persona left the primary nav (its quiz result is read by nothing). The sidebar version string is read from one constant instead of being hardcoded in JSX, where it had drifted to v0.1.0 while this file reached v0.7.22.
Versioning is 0.x.y until v1; y for fixes/chore, x for features.
---
Two passes on profile UX.
(1) Visual cleanup of `/profile`. Removed the "READER" eyebrow label above the user's name in the identity band (no info, pure decoration); deleted the entire dashboard-card footer (Tuning any time kicker + Adjust signals → button) which was a third redundant entry point to edit mode alongside the header Edit pill and inline adjust link; shortened the long Either — surprise me industry label to Either so the career caption no longer overflows the row with PhD Year 3 · Either · School / Lab; hid the Reading card's Shareable reader card footer (two disabled Copy card / PNG buttons next to a "Coming soon" badge — features not built yet, so dead UI removed); fixed the header signal counter from 0 of 5 → 0 of 4 to match the four visible SignalRow entries (the previous count included preferredMethods which has no editor anywhere).
(2) AppearanceCard rewrite — instant-apply + 4 new themes. Killed the draft-state + Select color / Cancel two-step confirm pattern — clicking a theme card now applies immediately via the existing updateColorTheme store action. Header dropped the redundant right-side System pill and the "Switch palettes directly here. No edit mode required." filler subtitle. Cards dropped the redundant Cream palette / Black palette body lines and the gray "almost-checked" icon on unselected cards (now: only the selected card shows the orange check). Picker restructured: themes are tagged with a mode: "auto" | "light" | "dark" in colorThemeOptions and rendered as three labeled groups — Auto (System), Light (Cream / White / Pink / Blue + new Sage / Lavender), Dark (Black + new Slate / Plum). Grid is grid-cols-3 lg:grid-cols-6 per group so light themes fit one row on desktop.
Four new themes ship with full token sets (bg / bg-secondary / surface / surface-hover / border × 2 / heading / text × 3 / accent / link / tag / peach / yellow × 2 / red / skeleton × 2) in both globals.css (declarative [data-color-theme="X"] blocks) and lib/theme.ts (runtime inline-style overrides). Sage: pale green paper #eff4ea with deep-teal #0f766e accent and olive #65a30d tag. Lavender: soft purple #f5f3fc with violet #7c3aed accent and fuchsia #c026d3 tag. Slate: GitHub-dark inspired #0d1117 with sky-blue #58a6ff accent and mint #56d364 tag. Plum: deep #1a0e1f with light-violet #c084fc accent and pink #f0abfc tag.
(3) Venue badges follow the theme. search-result-card.tsx previously hardcoded four hex colors for source-type badges (#b32f2f arXiv red, #2d6a8a journal blue, #7a4ec1 conference purple, #1f7a4d open-access green). Replaced with theme tokens — arXiv → accent, Journal → link, Conference → tag, Open Access → peach — so the four badges remain mutually distinguishable but the palette now shifts with the active theme. Cleanup also removed three orphan helpers in profile/page.tsx (previewColorTheme, IconPalette, IconShare / IconDownload) and the now-unused applyColorTheme import. File shrunk 2484 → ~2360 lines.
New mode on the paper detail page: a Surface button in the ActionRow (alongside Save / Cite / Like) links to /papers/[id]/surface, which renders the paper as a structured working canvas instead of a long-form report. The route is backed by lib/papers/surface-model.ts (498 lines) which derives a set of SurfaceCell entries from Paper + UserProfile — title, key facts, format detection via FORMAT_TERMS, method detection via 21 METHOD_PATTERNS regexes, reading-time at 220 wpm, tag extraction capped at 8 — and components/papers/paper-thinking-surface.tsx (627 lines) which lays the cells out as a focused workspace. The surface renders in an intentionally isolated dark palette (hardcoded CSS vars on the component, does not follow the app color theme) for distraction-light reading; this is by design — switching the app theme should not change the working canvas.
Codex pattern step 2 — pulling the briefing status into the search-input cluster so the box reads as a stateful command surface, not a blank prompt. BriefingStatus moved out of the page header into the search section, where it now sits as a rounded-full pill directly above the rounded-3xl input box (mb-2 gap, no longer the previous 24px header margin) so the two surfaces feel like one connected unit. Restyled the pill content: small accent dot at the left (the "● live" indicator pattern Codex uses for ongoing context), inline counts (87 items · 12 unread), synced-age suffix, and a new refresh icon button on the right that calls loadFeed and spins while in flight — same handler the FeedMoreTile already uses, so the affordance is now reachable from the top of the page too. Closed state ("Briefing closed · all N reviewed · back tomorrow") uses the same pill chrome with an accent-tinted fill instead of the previous heavier bg-accent-dim block. Secondary text (synced 2m ago, the reviewed-count tail) hides below the sm breakpoint so the pill stays single-line on phone. Pure UI; no change to feed loading, refresh semantics, or briefingItems shape.
The three loading cards on Discovery were rendering as flat dark rectangles with no internal structure on the black theme (and system in dark mode). Root cause: .skeleton-shimmer was a hardcoded rgba(24, 24, 27, 0.04 → 0.10) overlay — dark ink on dark surface, so the four inner placeholder bars per card were technically present but visually black-on-black. The user just saw three empty boxes while the feed loaded for 2-3s.
Fix in two passes. (1) Theme-aware shimmer — introduced --color-skeleton-base / --color-skeleton-highlight tokens, defined per theme: dark-ink overlays on cream / white / pink / blue / light-system (0.05 → 0.12 opacity), light-ink overlays on black and dark-system. .skeleton-shimmer now interpolates these via CSS vars, so the same component reads correctly on every palette. Added a prefers-reduced-motion branch that drops the animation and renders a static tint. (2) Skeleton structure — rewrote LoadingSkeleton in components/ui.tsx to mirror the real PaperTile: top-left accent stripe (matches KindStripe), two-pill header row (badge + score chip), two-line title with varied widths, one-line author/venue, three-line relevance reason, and a footer with source label + three icon buttons separated by a top border. Each of the three cards gets slightly different line widths (86/72/92% etc) so the stack reads as distinct papers rather than three identical bars. A small "Brewing your daily briefing" status with a pulse + ping dot sits above the cards as a one-line activity signal. Staggered animate-fade-in-up (0/90/180ms) replaces the previous bare drop-in.
Codex-inspired follow-up to v0.7.13. Three changes. (1) Surface weight — corner radius bumped from rounded-2xl to rounded-3xl, added a 1px border on the cream theme token plus a layered close/far shadow that thickens on focus-within, so the bar reads as one elevated object rather than a flat card. (2) Send action — new accent-orange circular send button on the right of the tools row; click or Enter skips the 400ms auto-search debounce and fires the request immediately, with a spinning glyph during flight and a disabled cream state below 2 chars. (3) Two-zone tools row — the four existing pill controls (AI search / AI key / Deep report / Tavily) now sit in a left "modes" zone with the send button on the right, giving the bar a clear left-to-right rhythm (what to use → go) instead of a single wrapping pill row. Bonus: the clear text button shrank to a small × icon inside the input. No behavior change to any pill or expanded panel — pure UI pass.
Daily-briefing first paint felt slow because /api/feed was averaging 4.4s end-to-end with constant [openalex] 400, [semantic-scholar] 429, [pubmed] 429 spam in the server logs. Root causes were stacked: (1) every source fanned out 4–6 generated queries sequentially with an 8s per-call timeout, so one slow query stalled the whole source; (2) no 429 backoff anywhere — semantic-scholar and pubmed just gave up and moved on, but only after eating their full quota of attempts; (3) OpenAlex 400s came from punctuation in the generated phrases ({}[]:; from phrasesFromText leaking through to the search= param); (4) Tavily discovery was awaited *before* source fetch even started, adding a serial 1–2s; (5) Promise.allSettled over sources had no outer wall, so one hung source could drag the response past 10s.
Five-front fix in one pass. (A) OpenAlex search terms are now sanitized (strip "!?{}()[]\\^~*:; and unbalanced quotes), with the response body logged on non-ok so a future 400 is debuggable in one read. (B) New shared sourceFetch helper (lib/sources/_fetch.ts) wraps every academic-source HTTP call with one 429-aware retry that honors Retry-After (capped at 1.5s), plus a tighter per-call timeout (6s instead of 7–8s). (C) Pipeline now kicks off runTavilyDiscovery in parallel with source fetch via Promise.all instead of awaiting it first — Tavily still feeds connectorStats but its boost queries no longer block the critical path. (D) Per-source query fan-out capped (openalex/arxiv/s2: 6 → 3, pubmed: 3 → 2, dblp: 4 → 2) and queries run in parallel inside each source via Promise.allSettled, so a source's wall time is now max(query) instead of sum(queries). (E) New withSourceTimeout race in feed/pipeline.ts enforces an 8s hard wall per source so a hung adapter can't drag the whole response. Plus a defensive type-coerce in cleanDisplayText so a non-string field (e.g. dblp returning year as number) no longer throws text.replace is not a function mid-map.
Measured end-to-end via three repeated curl POSTs to /api/feed with {topics:["large language models","retrieval augmented generation","graph neural networks"]}: cold 2.7s, warm 0.65–0.69s, all five sources returning items (openalex:30, semantic_scholar:10, arxiv:30, dblp:30, pubmed:27), zero errors entries in the response meta.
filtersToApiQuery was emitting OpenAlex-native params directly (sort=cited_by_count:desc, filter=open_access.is_oa:true,cited_by_count:>10), but /api/papers/search reads app-level keys (sort ∈ relevance|cited|newest, plus oa, cites, from, to, src, venue) and composes the OpenAlex filter= clause server-side. The mismatch meant every chip in the FilterBar updated state and the URL fine, but the request that left the browser carried params the server didn't recognize — results came back identical to an unfiltered search. Rewired the helper to emit the app-level keys the route actually reads. End-to-end verified: sort=cited&cites=100&oa=1 now returns highly-cited open-access works (75k+ citations vs. the relevance baseline's 29k).
Some paper-detail summaries contained long unbreakable tokens (concatenated DOIs, sequence IDs, undbroken acronyms) that pushed the right edge of the body card past the viewport on phones, clipping the last word and giving the impression that the page itself was scrolling sideways. Two-layer fix: overflow-x: hidden on body so no descendant ever drags the page wider than the viewport, and break-words on the report-row body paragraph so within the card the text wraps even at unbreakable strings instead of being clipped at a flush right edge.
MetaRow was sitting in its own beige-ish card (bg-bg-secondary/35 rounded-xl) and rendering up to seven topic / method / venue chips, which on most profiles wrapped onto two lines and added a third visual block under the greeting. Dropped the card surface entirely so the row reads as inline text — Tuned for label + chips + a smaller pencil edit affordance — and capped visible chips at five with a quiet +N overflow indicator. Chip dimensions trimmed (h-6 → h-5, 11.5px → 11px, tighter padding) so five fit on one row at typical viewport widths. Tone alphas eased from /100 to /70 so chips read as soft signals rather than hard tags. The row's role unchanged — clicking it still goes to /profile and the empty-state CTA still surfaces when no topics are set.
Reworked the top of the Discovery page from a heavy stack — big editorial greeting + a search input + three side-by-side AUTO SEARCH / AI KEY HOOKUP / TAVILY HOOK cards — into one cohesive command bar that mirrors how a chat-style input organizes its modal tools. The search input and three controls now share a single rounded surface: input on top, a row of compact tool pills below (Auto/AI search, AI key, Tavily), and an inline expanded settings panel that reveals only when a pill is clicked. Single-tool-open semantics (one shared openTool state) replaced the two independent aiProviderOpen / tavilyOpen booleans, so toggling between AI key and Tavily settings no longer leaves both expanded at once. Active state on each pill keeps a subtle accent tint when a non-default value is set so users can see at a glance which tools are customized. Greeting headline drops from 36/44px to 26/32px and the date row from 21/24px to 14/15px — the page now opens with a confident statement of state rather than a banner that dominates the first viewport. Same applies to the Search-mode title (34/38px → 24/28px). All wiring (loadFeed gating, profile updates, Tier display, helper copy, gating-by-aiPaperSearchEnabled) preserved.
Paper detail (/papers/[id]) tightened up on phone-sized viewports across four fronts. (1) Action row — Read paper plus Save / Cite / Like / Not interested previously stacked into 2–3 wrapped rows because every pill was h-11 with pl-3.5 pr-4 text-[13.5px]; now they collapse to h-9 / px-3 / 12.5px on mobile and the primary CTA drops a half-step too (h-10 / px-4 / 13.5px), so the whole action set sits two rows max on a 375px screen. Desktop sizing unchanged. (2) Stats strip — PropertyStrip keeps its 2-col mobile grid but the gap shrinks (gap-x-3 gap-y-3 vs 5 / 4) so the six properties feel less marooned. (3) Method/result figures — compact and hero PaperFigureFrame variants now cap height on mobile (max-h-[300px] / max-h-[360px]) and lower the min-h floor a notch, so a square pie chart no longer eats half the viewport. object-contain keeps proportions; sm:max-h-none drops the cap above mobile. (4) Article container — px-6 py-14 becomes px-4 sm:px-6 py-10 sm:py-14, reclaiming 16px of horizontal space and trimming an extra 32px of empty top padding on phones. Reading flow gets noticeably calmer below the fold; nothing changes on tablet/desktop.
The fixed top-3 right-3 z-[55] UserMenu / GithubStars wrapper in app/layout.tsx lived above (z-wise) the mobile top bar (z-50), so on narrow viewports the right-side Saved / Persona / Profile tabs sat directly underneath the "Aspen Labs" pill + GitHub-star pill and were unreachable — only Feed was visible. The previous mobile-polish pass (v0.7.10) tightened the nav padding which reduced the unintended margin and made the collision suddenly very obvious. Fix: scope the floating wrapper to hidden lg:flex so it only renders on desktop, and inline <UserMenu compact /> inside the mobile nav at the right edge so signed-in users still get the avatar + sign-out, signed-out users still get the GitHub sign-in CTA. Layout on mobile now: [Logo] [Tabs justified to the right of the logo] [UserMenu]. Tab font dropped one notch (13 → 12.5px) and active-underline geometry tightened to keep all four tabs comfortable next to the avatar on a 375px viewport.
Mobile top bar tightened — height drops from 56px to 48px, horizontal padding from 24px to 16px, the brand wordmark hides on the narrowest phones (logo alone serves as home), and the active tab now gets a 2px accent underline so the current section is obvious at a glance instead of relying purely on weight/color shifts. Tab gap shrunk so all four tabs fit comfortably alongside the brand on a 375px viewport with breathing room left over. <main> top padding adjusted to match the new bar height. Daily-digest header buttons collapse to icon-only on mobile (Regenerate / Listen labels reappear at sm+) and the section's horizontal padding eases from 28px → 20px on small screens, reclaiming text width without crowding content. Feed tile summaries (paper / event / job) bumped from 12.5px → 13.5px on mobile with slightly looser line-height for single-column readability; desktop sizing unchanged so dense grid stays dense.
/persona already advertised "Saved locally only — not uploaded" in the result footer, but the quiz result lived in component useState only — close the tab or refresh and it was gone, so the footer's promise was a lie. Persist now: on completion the scores are written to localStorage under peer:persona:v1, on next mount the page hydrates from there and re-derives the persona via pickPersona (rather than caching the persona blob, so future tweaks to persona names / blurbs / portraits surface automatically). Retake clears the storage. SSR-safe (typeof window guards), tolerant of quota/private-mode failures (silent fall-through to a fresh quiz). Sync to the server profile is a separate follow-up tied to the auth track.
The contribution-style calendar on /profile was always rendering blank cells even when read_items had data. /api/read?aggregate=daily groups reads by toISOString().slice(0,10) (UTC YYYY-MM-DD), but useDailyActivityCells was building its lookup keys from a Date set to *local* midnight then formatted via toISOString() — which yields the UTC string of local-midnight, off by a day for any non-UTC user. Reads timestamped late local-evening (= early next-UTC-day) bucketed under tomorrow's key on the server but were looked up under today's key on the client — every cell missed, calendar stayed empty. Switched the client to setUTCHours(0,0,0,0) and millisecond-arithmetic day stepping so both sides agree on day boundaries.
Two unrelated bugs were taking the production deploy down. (1) applyColorTheme was calling Object.entries(themeVars[theme]) without guarding for the case where theme is undefined — which it was for every existing user, because the profiles.color_theme column declared in schema.sql had never actually been applied to the live DB. The throw bubbled up through React render and Chrome surfaced "This page couldn't load". Added a defensive if (!vars) return so unknown / missing themes silently fall back to the CSS defaults instead of crashing the tree. (2) /api/read was upserting into read_items to refresh read_at on repeat reads, but the table had no UPDATE RLS policy — so the conflict-resolution path always failed with "new row violates row-level security policy". Added the missing users update own reads policy (USING + WITH CHECK on auth.uid() = user_id), matching the pattern already used by saved_items. Schema source-of-truth in web/supabase/schema.sql updated to keep parity. The profiles.color_theme schema drift remains — that's a separate auth/Supabase-track follow-up; the defensive code makes it non-blocking.
Result page goes from a stacked column to a two-column editorial layout — portrait (with cream gradient frame, soft shadow, italic "Profile sketch" caption) sticks to the left on desktop, the title / tagline / blurb / look / axes flow on the right at a comfortable reading width. Title bumps to 52px Instrument Serif, tagline gets italic Source Serif, "Spotted at the conference like" header now sits between hairline rules — a touch more magazine, less form-result. Mobile collapses cleanly to a single column. Two additional portraits seeded: lab-lead.png and synthesist.png. Five remaining slots open.
Quiz results now always land on one of the 10 curated PAIR personas (or the flat-profile Polymath fallback). The 10 SOLO names — "The Bench Operator", "The Theorist", "The Polymath" (specialist-pole), "The Builder", "The Provocateur", etc. — were a mid-state fallback for users whose top-two combo wasn't in the curated set, but they had no portraits and felt like a "you're not really any of these" cul-de-sac. Removed. New pickPersona scores all 10 PAIRs by alignment with the user's axis values (sign-aware sum of the two pole projections) and picks the highest-fit one — so a Generalist+Solo profile, which previously fell to "The Polymath" SOLO, now lands on whichever curated PAIR best matches that direction (typically Synthesist or Field Crosser). 11 possible outcomes total (10 PAIRs + flat Polymath), every named result has a slot for a portrait. Renamed the misseeded bench-operator.png → bench-builder.png since the visual fits Bench Builder perfectly and Bench Operator no longer exists as a result.
The /persona result now renders a portrait above the persona title. Convention is purely file-system based — drop a PNG at web/public/persona/<slug>.png (slug = persona name, lowercased, leading "The" stripped, non-alphanumerics → dashes — so bench-operator.png, theoretical-provocateur.png, group-builder.png, etc.) and it appears next deploy. No code change needed per persona; missing images render nothing (the <img> errors silently). Seeded the first two from existing references — Bench Operator and Group Builder.
Two things in one. (1) The /persona quiz that v0.7.0 announced was never actually committed — web/src/app/persona/, web/src/components/persona/, and web/src/lib/persona/ only existed locally and Vercel had no idea about them. Tracked all of them now (same fix shape as v0.7.1 — other agent's WIP wasn't git added). (2) Added a look field to every Persona — concrete MBTI-style appearance for all 10 curated PAIR_NAMES, all 10 SOLO_NAMES (with poles having distinct visuals: e.g. The Bench Operator gets "olive flannel, top-knot, dead-project sticker laptop, coffee, anti-glamour"), and the Polymath flat fallback. New "Spotted at the conference like" section on the result page renders the look as an italicized accent-bordered pull-quote between the blurb and the axes bars. Same data, more recognizable.
The Affiliation row in the profile editor splits into two fields. School / org is now an autocomplete-backed input — type to filter ~150 curated entries (top global research universities, CMU/MIT/Oxford/Tsinghua/etc. + industry research labs like Anthropic, DeepMind, FAIR), with substring highlighting on the matched portion, ↑/↓/Enter/Tab keyboard nav, and free-text fallback for anything not in the list. Lab / group is a separate plain-text field below for the unit within the org (CSAIL, HCI Group, Vision Lab, your advisor's group). Both are persisted on the profiles row and surface in the read view as MIT / CSAIL. New lab column on profiles table; schema.sql + the /api/profile mapping updated to round-trip.
Production deploys had been failing since v0.6.8 — the search filter feature committed page.tsx imports for @/components/search/filter-bar, @/components/search/filter-chip, @/components/search/more-filters-panel, and @/lib/search/filters, but the directories themselves were never git added. They existed locally and worked in dev but Vercel's clean checkout couldn't resolve the imports → every commit since v0.6.8 returned the old deploy. Added the four missing files (752 lines) so the production build succeeds and v0.6.9–v0.6.14 actually reach users.
A short forced-choice quiz that maps a researcher across five continuous axes — Empirical ↔ Theoretical, Specialist ↔ Generalist, Solo ↔ Collaborative, Builder ↔ Critic, Formal ↔ Narrative — and names a persona from the two strongest signals (e.g. "The Theoretical Provocateur", "The Bench Specialist", "The Synthesist"). Jung-style multi-dimensional rather than MBTI's 16 boxes: every axis is a value in [-1, +1], not a category, and the persona name reflects a combination, not a verdict. 15 questions, 3 per axis, no Likert middle ground. Result card shows axis bars with score markers, a tagline, and a prose blurb in the editorial serif. Saved locally only — no upload. Accessible from the sidebar (or g x). First step toward the broader "input your paper, get an academic style profile" feature; URL/PDF analysis comes next, this seeds the framework.
PaperFigure was forcing every figure into a fixed 16:8 (hero) or 16:9 (compact) frame with object-cover, which silently cropped portrait charts, square diagrams, and any landscape figure with a different ratio. The container now starts at the variant's default aspect (skeleton state only), then on onLoad reads naturalWidth / naturalHeight from the image and sets the container's aspect-ratio to match. Image switches to object-contain so it always fits, and a transition: aspect-ratio 300ms smooths the resize when the natural aspect arrives. A max-h-[80vh] cap prevents extreme portrait images from dominating the viewport. Net effect: every figure renders at its true proportions, nothing gets cropped.
Adds an Affiliation field (school / lab / company) to the profile editor, persisted as a new school column on profiles. Improves first-run by stopping the editor from pre-seeding battery-research demo topics — defaultProfile.researchTopics and preferredVenues are now empty arrays so the profile-setup nudge in the header surfaces immediately. The Topics, Methods, and Venues ChipInputs gained quick-add suggestion rows ("Try: + transformers + RAG + diffusion models …") shown only when the field is empty, and Topics carries an inline hint reminding people specificity matters (a single word like "whatever" matches nothing useful — we just shipped a fix for that exact case in v0.6.12). Schema.sql + the /api/profile mapping updated together so the new column round-trips cleanly.
Three weak spots on the paper detail page got a pass. (1) "At a glance" Signal chips used a binary ✓/× toggle that read awkwardly — Older paper × was double-negation, and On arXiv × for a non-arXiv paper just wasted a slot. Replaced with a new FactChip that only surfaces facts when they're present: Preprint on arXiv (accent), Code available (tag), a relative-time chip (23 days ago / 2 years ago), and a smarter team-size label (Solo author / 3 authors · small team / 12 authors · large team). (2) "Explore further" link chips now carry a leading icon per destination (document for publisher, mortarboard for Google Scholar, code-brackets for source/search code) so the row is scannable at a glance instead of three near-identical pills. (3) The lonely "Train my feed on this" button at the bottom got a clearer label (More like this) and a co-located one-line explainer so it reads as an action, not a stranded chip.
The feed store had a realPapers.length > 0 ? realPapers : mockPapers fallback that silently surfaced battery-research demo fixtures (with hardcoded "your PhD"-style relevance reasons) whenever the real API returned 0 results — for example when the user typed a topic with a typo like transfomer. Events and Jobs were even worse: always wired to mockEvents / mockJobs because no real adapters exist yet. Removed both fallbacks. Empty source = empty feed; the FeedMoreTile already shows a context-aware "Tune your signals" prompt when topics under-deliver, so a real empty state is more honest and more actionable than fake content.
Feed grid cards now carry a category-specific line icon next to the kind label (Paper / Discussion / Event / Job) and a thin colored stripe on the left edge for at-a-glance scanning. Inline mini-icons attach to metadata: author for papers, calendar + pin/globe for events, building + pin/globe for jobs. The redesign keeps the same density and serif/sans typography rhythm — purely a scannability pass, no content moved.
Search cards now read more like Airbnb listings than a database dump. Each card gets a colored type badge (arXiv, Journal, Conference) and an Open Access badge with icons; venue and date sit on a single meta row with calendar/book glyphs; the citation count moves to a quiet footer separator with its own icon and is humanized (12.4k cited instead of 12,432 cited). Title shifted to line-clamp-3 so longer paper titles breathe; abstract switches to the editorial serif at 13.5px for scannable reading. Result counter at the top no longer surfaces the raw OpenAlex universe size — it just shows how many results are visible (e.g. 12 results for "transformer"), which fits the triage use case far better than 12 of 4,567,890. Search API now returns sourceType and isOpenAccess to drive the badges.
Two fixes for the "many paper links are wrong" complaint. (1) The OpenAlex adapter now requests best_oa_location and open_access and prefers, in order: an OA PDF → an OA landing page → the DOI URL → the OpenAlex page itself. Result: papers with a free PDF on arXiv / institutional repository / publisher OA channel surface that link instead of a paywalled DOI redirect. (2) When OpenAlex returns an arXiv preprint via the 10.48550/arxiv.* DOI prefix, the mapper detects it, swaps the URL to a clean https://arxiv.org/abs/<id>, populates linkArxiv, and renames the venue from "arXiv (Cornell University)" to plain "arXiv". The detail page CTA correctly says "Read on arXiv" again for these.
OpenAlex search now exposes the dimensions that actually matter for relevance triage. A chip row appears under the search bar in search mode (year preset or custom range, sort by relevance/citations/date, open-access toggle, min-citations threshold), with source-type and venue search behind a "More filters" inline drawer. State syncs to the URL so a filtered search is shareable and back-button safe; opening a shared link rehydrates every filter. Reset link clears all non-default filters in one click. Daily feed unchanged — filters only render in search mode.
Added a context-aware action tile to the last cell of the homepage grid. Detects three states and adapts copy:
- Profile under-tuned (placeholder topics like Whatever, idk, or single short word) → "Tune your signals" + link to /profile. Short-circuits the most common reason for a sparse feed. - Sparse but tuned (< 4 items) → "Light today" + Refresh button. - Plenty → "More?" + Refresh button.
Replaces the loose "Refresh recommendations" link below the grid; collapses two affordances into one tile that lives where the eye lands. Visual differentiation: dashed border, no shadow — reads as an action, not content.
HN posts share the Paper data model (and "Papers" tab) with arXiv/OpenAlex items, which made a Show HN thread visually identical to a real publication. The FeedTile now uses an allowlist of academic id prefixes (arxiv:*, openalex:*) to decide which items get the orange "Paper" badge — anything else renders as a muted "Discussion" badge. Allowlist over blocklist on purpose: future non-academic adapters (Twitter, Substack, etc.) default to "Discussion" until explicitly opted in. (A separate Discussions tab is the obvious next step if this surface keeps growing.)
HN posts link to arbitrary external URLs (GitHub repos, personal blogs, corporate sites). The og:image on those is rarely a meaningful representation of the discussion — usually a generic social preview, repo card, or logo banner. The detail page was loading these as the hero figure, which felt wrong. Now extractFigure returns null for any hn:* item id; the figure component's hideOnMiss collapses gracefully. Cache-bust param bumped v=2 → v=3 so existing CDN entries refresh.
Three bugs surfaced from a Show HN paper detail view: (1) abstracts arrived with HTML entities (/ ') and <p> tags un-decoded — added decodeHtmlEntities + stripHtml in the mapper before splitting; (2) HN system tags (story, front_page, show_hn, author_*, story_*) were polluting "Methods & techniques" — now filtered out via isUsefulKeyword; (3) primary CTA was hardcoded "Read on arXiv" — now source-aware (Read on Hacker News / Read on arXiv / fallback Search arXiv).
Replaced the 3-tier briefing layout (hero + "Worth your time" + "Quick hits") with a single dense card grid that scales 1 → 2 → 3 → 4 columns from mobile through xl. New <FeedTile /> compact card — tighter padding, two-line title, kind badge + relevance %, single-tap save action. Container widens from 820px to 1280px so wide screens actually use the space; header (greeting, search, type tabs) stays narrow-centered for reading rhythm. Search results adopt the same grid.
Added a server-rendered page that reads public/CHANGELOG.md, parses the version/date/title/body structure, and renders entries in the editorial brand style (Instrument Serif headlines, Source Serif body, accent-orange version tags). 1-min revalidate so a markdown push lands on the live site without a redeploy. Raw markdown still served verbatim at /CHANGELOG.md for the curious.
Backfilled past releases from git log and seeded an ongoing release log. From here on, every user-facing/infra commit adds an entry.
Replaced the removed Vercel cron with .github/workflows/digest-cron.yml. Triggers /api/jobs/dispatch-digests every hour at :05 with bearer auth via the CRON_SECRET repo secret. Free, preserves per-user timezone hour preference (Vercel Hobby plan rejected hourly cron).
Removed web/vercel.json. Hobby plan rejected the 0 * * * * schedule, blocking every deploy after v0.5.0. Cron re-added externally in v0.6.0.
.mcp.json excluded from repo. Local-only tooling config, never product code.
Resend wrapper with lazy init + branded HTML/plaintext/subject templates (Gmail-friendly table layout, escapes user-derived fields) + /api/test-digest self-send endpoint. Gracefully degrades when RESEND_API_KEY is missing — in-app inbox keeps working.
Server-side persistence for saved items, read items, feedback signals, and digest deliveries. New RLS-scoped tables: saved_items, read_items, feedback_events, briefing_deliveries. Profile gets 5 digest preference columns (timezone, hour, channel, frequency, enabled). API routes: /api/{saved,read,feedback,briefings} + cron /api/jobs/dispatch-digests. <FeedSync /> bridges local Zustand ↔ cloud on auth state change. Profile page adds digest preferences UI, Past Briefings inbox, and a reading calendar driven by real per-day read counts.
Profile data persists via Supabase profiles table (was localStorage-only). User menu floats top-right.
Sign-in with GitHub via Supabase Auth. Top-right pill shows the project's live GitHub star count.
Search results route to internal briefing detail page; encoded-colon route param decoded correctly; "Loading briefing…" replaced with a shimmer skeleton.
/api/feed returns scored items from the Tier 0 pipeline; replaces sample data fixture.
Briefing surface treats items as a persistent reading list rather than an ephemeral feed.
Combined keyword / TF-IDF / recency / source-weight scoring. Source adapters for OpenAlex, arXiv, Hacker News. Wired into /api/feed. Profile redesigned as editorial dashboard with reading stats. Briefing feed redesigned with hero + quick-hit layout. Switched to HTTPS for arXiv to avoid Vercel-side 301 timeout.
v0.1.0 — pre-versioning Earlier work (project scaffolding, initial UI, prototype data layer). See git log for full history.