From 4de9d3ecf9265ddaa01221f30365e11a5f6012f8 Mon Sep 17 00:00:00 2001 From: nstarman Date: Tue, 6 Oct 2026 16:35:30 -0400 Subject: [PATCH 1/2] Let the Card Builder add the stars button for a package under 40 stars Under STARS_MIN the stars link is optional: a card's own buttons (all) leave it out, but a name that lists it shows it, and the Card Builder offers it as an unticked pill. The embed page renders every link a card could show (stars included, hidden under all), as it does for the rest, so the name chooses. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 2 +- src/components/Card.astro | 2 +- src/lib/cardbuilder/builder.js | 4 ++-- src/lib/cardbuilder/facts.js | 7 ++++--- src/lib/cardbuilder/model.js | 17 +++++++++++------ src/lib/cardname.js | 3 ++- src/lib/cards.js | 19 ++++++++++++------- src/pages/embed/index.json.js | 2 +- tests/cardbuilder.test.js | 33 +++++++++++++++++++++++++++++++-- tests/cards.test.js | 24 ++++++++++++++++++++++++ 10 files changed, 89 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6e6a0a99..2b2ade3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -396,7 +396,7 @@ PR, or it renders as nothing. `repo` is the *paper's* repository; `code` is the software itself. -A software card's last button is its stars (for a package with at least `STARS_MIN`, 40) — `stars` among the `buttons` keys — from `config/stars.json`, +A software card's last button is its stars (for a package with at least `STARS_MIN`, 40; under it the link is `optional`: left out of `buttons:all`, shown where a name lists it — `buttons:code,docs,stars` — and a pill in the Card Builder, unticked) — `stars` among the `buttons` keys — from `config/stars.json`, a snapshot of each `repo`'s GitHub stars that `scripts/collect-stars.mjs` rewrites and `.github/workflows/refresh-stars.yml` proposes monthly as a pull request. Do not edit it by hand. diff --git a/src/components/Card.astro b/src/components/Card.astro index ff189686..e0314225 100644 --- a/src/components/Card.astro +++ b/src/components/Card.astro @@ -88,7 +88,7 @@ const { summary: sum, details: det } = cardText(item); // The paper button's links, each, for the embed page to choose from. const paperTo = { auto: paperHref(item), journal: paperHref(item, 'journal'), arxiv: paperHref(item, 'arxiv'), ads: paperHref(item, 'ads'), site: paperHref(item, 'site') }; const paperWord = face.paper && face.paper.label !== 'icon' ? face.paper.label : 'paper'; -const linkList = cardLinks(item); +const linkList = cardLinks(item, allCounts); const contextLabel = topic && `${topic[1]} research highlights`; ---
new FormData(form); - const state = () => ({ it: items[form.elements.card.value], figureOn: el('eb-figure-on').checked, space, areasOn, btnOrder, groups, allLinks: [...form.querySelectorAll('[name=link]')].map((b) => b.value), limits }); + const state = () => ({ it: items[form.elements.card.value], figureOn: el('eb-figure-on').checked, space, areasOn, btnOrder, groups, allLinks: [...form.querySelectorAll('[name=link]:not([data-optional])')].map((b) => b.value), limits }); const spec = () => readSpec(snap(), state()); const stackedIn = (a) => stackedInModel(snap(), state(), a); const canTuneRow = (r) => canTune(r.dataset.tune, snap(), state()); @@ -97,7 +97,7 @@ export function mountCardBuilder({ form, data }) { el('eb-extras-drop').hidden = !extras.innerHTML; // The paper button first, as it is in the box, where it has somewhere // to link; then each link's. - el('eb-links').innerHTML = (Object.values(it.paperTo).some(Boolean) ? '' : '') + it.links.map(([k, name, title]) => ``).join(''); + el('eb-links').innerHTML = (Object.values(it.paperTo).some(Boolean) ? '' : '') + it.links.map(([k, name, title, optional]) => ``).join(''); } // A part among the buttons shows where its extra is ticked; the paper diff --git a/src/lib/cardbuilder/facts.js b/src/lib/cardbuilder/facts.js index addedcbd..693e9da3 100644 --- a/src/lib/cardbuilder/facts.js +++ b/src/lib/cardbuilder/facts.js @@ -28,14 +28,15 @@ export const linkPlace = (l) => { const toPublisher = (i) => i.type === 'publication' && ['paper', 'doi'].includes(ownLink(i)?.rel) && !/adsabs|arxiv/.test(ownLink(i).url); export const linkChoices = (i) => { const byKey = new Map(); - for (const l of cardLinks(i)) { + for (const l of cardLinks(i, true)) { const k = relKey(l); - const o = byKey.get(k) ?? { names: new Set(), titles: new Set() }; + const o = byKey.get(k) ?? { names: new Set(), titles: new Set(), optional: !!l.optional }; o.names.add(linkPlace(l)); o.titles.add(l.label ?? l.rel); byKey.set(k, o); } - return [...byKey].map(([k, o]) => [k, [...o.names].join(' · '), [...o.titles].join(', ')]); + // Last, whether the card leaves it out of its own accord (a package's few stars): offered, not ticked. + return [...byKey].map(([k, o]) => [k, [...o.names].join(' · '), [...o.titles].join(', '), o.optional]); }; /** The facts the builder keeps of one item. */ diff --git a/src/lib/cardbuilder/model.js b/src/lib/cardbuilder/model.js index c31bdb7a..3c93917e 100644 --- a/src/lib/cardbuilder/model.js +++ b/src/lib/cardbuilder/model.js @@ -193,6 +193,10 @@ export function readSpec(f, st) { return { id: f.get('card'), it, slug, width, height, format: f.get('format'), theme: f.get('theme') }; } +/** The link keys an item's card shows of its own accord: all but the optional ones (a package's + * stars, under STARS_MIN), which are in `it.links` last-flagged, to be named. */ +export const ownKeys = (it) => it.links.filter((l) => !l[3]).map(([k]) => k); + /** The first group's keys, less any another group has: a key is in one. The * builder has controls for the first group; another is carried as the name * has it, until it has controls of its own. */ @@ -304,9 +308,10 @@ export function controlOps(c, { it, stepPx, framePx }) { set('background', c.background); set('text', c.text); ops.push(['checks', 'extra', c.extras]); - ops.push(['checks', 'link', c.links === 'all' ? null : c.links]); // null: every one - const keys = it.links.map(([k]) => k); - const listed = c.links === 'all' ? keys : c.links.filter((k) => ['empty', 'paperbutton', ...Object.values(CELL)].includes(k) || keys.includes(k)); + // `all` is the keys a card shows of its own accord; the optional ones are ticked only where named. + const keys = it.links.map(([k]) => k), own = ownKeys(it); + ops.push(['checks', 'link', c.links === 'all' ? own : c.links]); + const listed = c.links === 'all' ? own : c.links.filter((k) => ['empty', 'paperbutton', ...Object.values(CELL)].includes(k) || keys.includes(k)); const btnOrder = [...(listed.includes('paperbutton') ? [] : ['paperbutton']), ...listed, ...keys.filter((k) => !listed.includes(k))]; // A part the name lists among the buttons has its area there. for (const [k] of SMALL) if (listed.includes(CELL[k])) radio(`${k}at`, 'list'); @@ -319,14 +324,14 @@ export function controlOps(c, { it, stepPx, framePx }) { export function fitName(name, it) { const c = parseName(name); // A list of buttons to keep, read against the links this item has. - const keys = it.links.map(([k]) => k); + const keys = it.links.map(([k]) => k), own = ownKeys(it); // The paper button where this item can have one; a part among the // buttons where it shows; and the paper button first, as left out. const paperButton = it.byline && Object.values(it.paperTo).some(Boolean) ? c.paperButton : undefined; - const listed = c.links === 'all' ? keys : c.links.filter((k) => k === 'empty' || keys.includes(k) || (k === 'paperbutton' && paperButton) || (Object.values(CELL).includes(k) && c.extras.includes(k) && has(it, k))); + const listed = c.links === 'all' ? own : c.links.filter((k) => k === 'empty' || keys.includes(k) || (k === 'paperbutton' && paperButton) || (Object.values(CELL).includes(k) && c.extras.includes(k) && has(it, k))); const kept = listed[0] === 'paperbutton' ? listed.slice(1) : listed; // Another group of buttons keeps the keys the item has, and goes if it has none. const groups = (c.groups ?? []).map((g) => (g.links === 'all' ? g : { ...g, links: g.links.filter((k) => k === 'empty' || keys.includes(k)) })).filter((g) => g.links === 'all' || g.links.some((k) => k !== 'empty')); - return formatName({ ...c, groups, paperButton, links: sameOrder(kept, keys) ? 'all' : kept, extras: c.extras.filter((x) => has(it, x)), authors: authorsFor(it, c.authors), titleLink: linkFor(it, c.titleLink), titleStatus: c.titleStatus && !!it.tstatus ? true : undefined, figure: it.figure ? c.figure : 'none', + return formatName({ ...c, groups, paperButton, links: sameOrder(kept, own) ? 'all' : kept, extras: c.extras.filter((x) => has(it, x)), authors: authorsFor(it, c.authors), titleLink: linkFor(it, c.titleLink), titleStatus: c.titleStatus && !!it.tstatus ? true : undefined, figure: it.figure ? c.figure : 'none', rest: c.rest ?? 'split', text: it.text ? c.text : 'none' }); } diff --git a/src/lib/cardname.js b/src/lib/cardname.js index 9548ab79..1a7ce193 100644 --- a/src/lib/cardname.js +++ b/src/lib/cardname.js @@ -677,7 +677,8 @@ export function cardFace(spec, f) { var seqOf = function (g, n) { var seq = []; if (n === 0 && paperLi && !inList('paperbutton')) seq.push({ paper: true, kept: !!pb }); - if (g.links === 'all') f.keys.forEach(function (k, i) { if (owner(k) === n) seq.push({ link: i, kept: true }); }); + // An optional link is in an all, hidden: a name that lists it shows it. + if (g.links === 'all') f.keys.forEach(function (k, i) { if (owner(k) === n) seq.push({ link: i, kept: (f.optional || []).indexOf(k) < 0 }); }); else { g.links.forEach(function (k) { if (k === 'empty') seq.push({ empty: true, kept: true }); diff --git a/src/lib/cards.js b/src/lib/cards.js index f702cfdd..fa89f6cc 100644 --- a/src/lib/cards.js +++ b/src/lib/cards.js @@ -86,21 +86,24 @@ export const starLabel = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : Strin export const STARS_MIN = 40; /** A package's stars, as a link to who gave them — none where the count is - * unknown, rather than a button that says 0, or is under STARS_MIN. + * unknown, rather than a button that says 0. Under STARS_MIN it is `optional`: + * left out of a card's own buttons (`all`), and offered — named, or ticked in the + * Card Builder — where `every` asks for every link a card could show. * config/stars.json is refreshed monthly, so the number is a month old at worst. */ -function starLink(item) { +function starLink(item, every) { const n = item.repo && starCounts.stars[item.repo]; - return n == null || n < STARS_MIN ? [] : [{ rel: 'stars', label: `${starLabel(n)} stars`, count: starLabel(n), url: `https://github.com/${item.repo}/stargazers` }]; + if (n == null || (n < STARS_MIN && !every)) return []; + return [{ rel: 'stars', label: `${starLabel(n)} stars`, count: starLabel(n), url: `https://github.com/${item.repo}/stargazers`, ...(n < STARS_MIN && { optional: true }) }]; } /** The links a card carries, in order: for software its papers lead, each * with its year. */ -export function cardLinks(item) { - return item.type === 'software' ? [...softwarePapers(item), ...links(item), ...starLink(item)] : links(item); +export function cardLinks(item, every = false) { + return item.type === 'software' ? [...softwarePapers(item), ...links(item), ...starLink(item, every)] : links(item); } /** The link keys a card can be cut down to — `ads`, `preprint`, `code` … */ -export const linkKeys = (item) => [...new Set(cardLinks(item).map(relKey))]; +export const linkKeys = (item, every = false) => [...new Set(cardLinks(item, every).map(relKey))]; /** What an embed shows when its URL asks for nothing in particular: the card * the website draws for it, in a fixed box at the standard look, so an @@ -163,7 +166,9 @@ export function cardFacts(item, every = false) { role: hasRole(item), students: hasStudents(item), venue: hasVenue(item), status: hasStatus(item), tstatus: !!titleStatus(item), split: !!splitTitle(item), byline: item.type === 'publication', lead: hasPosition(item) && authorPosition(item) === 1, varxiv: item.type === 'publication' && !venueLine(item), refs: item.type === 'highlight' ? item.refs.length : 0, - keys: cardLinks(item).map(relKey), + keys: cardLinks(item, every).map(relKey), + // Links a card offers but does not show of its own accord: named, they show. + optional: cardLinks(item, every).filter((l) => l.optional).map(relKey), paper: Object.fromEntries(['auto', 'journal', 'arxiv', 'ads', 'site'].map((k) => [k, !!paperHref(item, k === 'auto' ? undefined : k)])), }; } diff --git a/src/pages/embed/index.json.js b/src/pages/embed/index.json.js index a8a8d59c..78577705 100644 --- a/src/pages/embed/index.json.js +++ b/src/pages/embed/index.json.js @@ -34,7 +34,7 @@ export function GET({ site }) { page: abs(`/embed/${i.id}/`), default: defaultSlug(i), figure: hasFigure(i), - links: linkKeys(i), + links: linkKeys(i, true), })), }; return new Response(`${JSON.stringify(body, null, 2)}\n`, { headers: { 'Content-Type': 'application/json' } }); diff --git a/tests/cardbuilder.test.js b/tests/cardbuilder.test.js index 62848f8a..83c20e0a 100644 --- a/tests/cardbuilder.test.js +++ b/tests/cardbuilder.test.js @@ -8,7 +8,7 @@ import { items } from '../src/lib/data.js'; import { itemFacts, linkPlace } from '../src/lib/cardbuilder/facts.js'; import { between, cellGrid, named, partZones, sum, venueDrops } from '../src/lib/cardbuilder/geometry.js'; import { - boxHeight, canTune, clampInt, controlOps, defaultsOf, figWidth, fitName, hiddenKeys, inBox, linksOf, namesN, presetWidths, readSpec, shapeHeight, shownKeys, sideHas, smallBox, stackedIn, stretch, typedPx, venueArea, + ownKeys, boxHeight, canTune, clampInt, controlOps, defaultsOf, figWidth, fitName, hiddenKeys, inBox, linksOf, namesN, presetWidths, readSpec, shapeHeight, shownKeys, sideHas, smallBox, stackedIn, stretch, typedPx, venueArea, } from '../src/lib/cardbuilder/model.js'; import { parseSettings, serializeSettings } from '../src/lib/cardbuilder/settings.js'; @@ -35,7 +35,7 @@ function roundTrip(slug, i) { const it = itemFacts(i); const { ops, state } = controlOps(parseName(fitName(slug, it)), { it, stepPx: STEP_PX, framePx: FRAME_PX }); const { f, figureOn } = formOf(i.id, it, ops); - return readSpec(f, { it, figureOn, ...state, allLinks: it.links.map(([k]) => k), limits }).slug; + return readSpec(f, { it, figureOn, ...state, allLinks: ownKeys(it), limits }).slug; } describe('controls ⇄ name', () => { @@ -233,3 +233,32 @@ describe('what the page knows of each item', () => { expect(linkPlace({ rel: 'docs', url: 'https://example.org/' })).toBe('docs'); }); }); + +describe('the stars of a package with few', () => { + const few = items.find((i) => i.id === 'coordinax'); + const it0 = itemFacts(few); + const slug = (links) => `size:fill:fit-figure:none-title:full:whole:link-authors:none-text:details-extras:none-buttons:${links}`; + + it('are a pill of their own, last, flagged to be ticked — and not among the card’s own buttons', () => { + expect(it0.links.at(-1)[0]).toBe('stars'); + expect(it0.links.at(-1)[3]).toBe(true); + expect(it0.links.slice(0, -1).every((l) => !l[3])).toBe(true); + expect(ownKeys(it0)).toEqual(it0.links.slice(0, -1).map(([k]) => k)); + }); + + it('read back from the controls as the card’s own: all, and not the stars', () => { + expect(roundTrip(slug('all'), few)).toBe(slug('all')); + }); + + it('read back as named where the name lists them', () => { + expect(roundTrip(slug('code,docs,stars'), few)).toBe(slug('code,docs,stars')); + expect(roundTrip(slug('stars,code'), few)).toBe(slug('stars,code')); + }); + + it('tick as the controls have them: the card’s own, and the stars where named', () => { + const ticked = (name) => controlOps(parseName(slug(name)), { it: it0, stepPx: STEP_PX, framePx: FRAME_PX }).ops.find((o) => o[0] === 'checks' && o[1] === 'link')[2]; + expect(ticked('all')).toEqual(ownKeys(it0)); + expect(ticked('code,stars')).toEqual(['code', 'stars']); + }); +}); + diff --git a/tests/cards.test.js b/tests/cards.test.js index 8dea58df..7974c8d4 100644 --- a/tests/cards.test.js +++ b/tests/cards.test.js @@ -711,4 +711,28 @@ describe('the stars button', () => { // Every package that has a button has at least that many. for (const i of software.filter((x) => linkKeys(x).includes('stars'))) expect(counts[i.repo], i.id).toBeGreaterThanOrEqual(STARS_MIN); }); + + it('is offered, flagged optional, where every link a card could show is asked for — and left out of `all`, shown where named', () => { + const few = software.filter((x) => counts[x.repo] < STARS_MIN); + const base = 'size:fill:fit-figure:none-title:full:whole:link-authors:none-text:details-extras:none'; + for (const i of few) { + expect(linkKeys(i, true).at(-1), i.id).toBe('stars'); + expect(cardLinks(i, true).at(-1)).toMatchObject({ rel: 'stars', optional: true, count: starLabel(counts[i.repo]) }); + expect(cardFacts(i, true).optional, i.id).toEqual(['stars']); + expect(cardFacts(i).optional, i.id).toEqual([]); + // The card's own buttons: the stars are there to be shown, hidden. + const all = cardFace(parseName(`${base}-buttons:all`), cardFacts(i, true)); + const star = all.seq.find((x) => cardFacts(i, true).keys[x.link] === 'stars'); + expect(star, i.id).toMatchObject({ kept: false }); + expect(all.seq.filter((x) => x.kept)).toHaveLength(all.seq.length - 1); + // Named, they show, in the place they are named. + const named = cardFace(parseName(`${base}-buttons:code,stars`), cardFacts(i, true)); + expect(named.seq.filter((x) => x.kept).map((x) => cardFacts(i, true).keys[x.link])).toEqual(['code', 'stars']); + } + // A package with enough has it in its own, nothing optional. + for (const i of software.filter((x) => counts[x.repo] >= STARS_MIN)) { + expect(cardFacts(i, true).optional, i.id).toEqual([]); + expect(cardFace(parseName(`${base}-buttons:all`), cardFacts(i, true)).seq.every((x) => x.kept)).toBe(true); + } + }); }); From 93048e840563c6e7af3a8fb38e61d7491cc6b3fe Mon Sep 17 00:00:00 2001 From: nstarman Date: Tue, 6 Oct 2026 16:51:37 -0400 Subject: [PATCH 2/2] Record Chrome's layout of the cards again (Card.astro changed) Co-Authored-By: Claude Sonnet 5.5 --- tests/fixtures/software-cards.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/software-cards.json b/tests/fixtures/software-cards.json index ff1baa66..9c6a58ad 100644 --- a/tests/fixtures/software-cards.json +++ b/tests/fixtures/software-cards.json @@ -1,4 +1,4 @@ -{"syncKey":"44310272a0d1","svgs":["","","","","","","","","",""], +{"syncKey":"5c442430e4c1","svgs":["","","","","","","","","",""], "cards":[ {"id":"astropy","input":{"id":"astropy","tier":"lead","title":"Astropy","href":"https://github.com/astropy/astropy","role":"Core developer · Coordination Committee · Strategic Planning","text":"The community core package for astronomy in Python. The collaboration received the 2025 AAS Lancelot M. Berkeley Prize, a 2023 IOP Publishing Top Cited Paper Award and the 2022 ADASS software prize.","links":[{"key":"paper","url":"https://doi.org/10.3847/1538-4357/ac7c74","label":"paper 2022","year":"2022","count":null},{"key":"code","url":"https://github.com/astropy/astropy","label":null,"year":null,"count":null},{"key":"docs","url":"https://www.astropy.org","label":null,"year":null,"count":null},{"key":"stars","url":"https://github.com/astropy/astropy/stargazers","label":"5.3k stars","year":null,"count":"5.3k"}]},"cases":[ {"width":200,"theme":"light","slug":"size:200:fit-figure:none-title:full:whole:link:top-authors:none-text:details-extras:role-buttons:all:fit-look:feature","face":{"data":{"size":"","w":"px","h":"fit","textsize":"feature","padding":"feature","corners":"feature","buttons":"feature","titleat":"top","text":"details","role":"","foot":"center"},"style":{"--wpx":"200px"}},"model":{"title":"Astropy","w":200,"h":509.34375,"r":[18,18,18,18],"ops":[{"k":"box","x":0,"y":0,"w":200,"h":509.34375,"r":[18,18,18,18],"fill":"#ffffffff","stroke":null,"sw":0,"href":null},{"k":"box","x":0,"y":0,"w":200,"h":509.34375,"r":[18,18,18,18],"fill":"#12161c06","stroke":null,"sw":0,"href":null},{"k":"text","x":21.59375,"y":19.1875,"w":72.578125,"h":23,"s":"Astropy","font":"IBM Plex Mono","weight":500,"size":17.28,"color":"#12161cff","ls":0,"href":"https://github.com/astropy/astropy"},{"k":"text","x":21.59375,"y":49.109375,"w":137.78125,"h":16,"s":"CORE DEVELOPER ·","font":"IBM Plex Mono","weight":400,"size":12.48,"color":"#2a5da8ff","ls":1.1232,"href":null},{"k":"text","x":21.59375,"y":67.828125,"w":103.34375,"h":16,"s":"COORDINATION","font":"IBM Plex Mono","weight":400,"size":12.48,"color":"#2a5da8ff","ls":1.1232,"href":null},{"k":"text","x":21.59375,"y":86.546875,"w":94.734375,"h":16,"s":"COMMITTEE ·","font":"IBM Plex Mono","weight":400,"size":12.48,"color":"#2a5da8ff","ls":1.1232,"href":null},{"k":"text","x":21.59375,"y":105.265625,"w":155.015625,"h":16,"s":"STRATEGIC PLANNING","font":"IBM Plex Mono","weight":400,"size":12.48,"color":"#2a5da8ff","ls":1.1232,"href":null},{"k":"text","x":21.59375,"y":127.375,"w":146.703125,"h":20,"s":"The community core","font":"IBM Plex Sans","weight":400,"size":16,"color":"#5a6472ff","ls":0,"href":null},{"k":"text","x":21.59375,"y":151.375,"w":83.640625,"h":20,"s":"package for","font":"IBM Plex Sans","weight":400,"size":16,"color":"#5a6472ff","ls":0,"href":null},{"k":"text","x":21.59375,"y":175.375,"w":151.890625,"h":20,"s":"astronomy in Python.","font":"IBM Plex Sans","weight":400,"size":16,"color":"#5a6472ff","ls":0,"href":null},{"k":"text","x":21.59375,"y":199.375,"w":125.140625,"h":20,"s":"The collaboration","font":"IBM Plex Sans","weight":400,"size":16,"color":"#5a6472ff","ls":0,"href":null},{"k":"text","x":21.59375,"y":223.375,"w":130.5625,"h":20,"s":"received the 2025","font":"IBM Plex Sans","weight":400,"size":16,"color":"#5a6472ff","ls":0,"href":null},{"k":"text","x":21.59375,"y":247.375,"w":116.25,"h":20,"s":"AAS Lancelot M.","font":"IBM Plex Sans","weight":400,"size":16,"color":"#5a6472ff","ls":0,"href":null},{"k":"text","x":21.59375,"y":271.375,"w":118.671875,"h":20,"s":"Berkeley Prize, a","font":"IBM Plex Sans","weight":400,"size":16,"color":"#5a6472ff","ls":0,"href":null},{"k":"text","x":21.59375,"y":295.375,"w":148.21875,"h":20,"s":"2023 IOP Publishing","font":"IBM Plex Sans","weight":400,"size":16,"color":"#5a6472ff","ls":0,"href":null},{"k":"text","x":21.59375,"y":319.375,"w":113.484375,"h":20,"s":"Top Cited Paper","font":"IBM Plex Sans","weight":400,"size":16,"color":"#5a6472ff","ls":0,"href":null},{"k":"text","x":21.59375,"y":343.375,"w":145.96875,"h":20,"s":"Award and the 2022","font":"IBM Plex Sans","weight":400,"size":16,"color":"#5a6472ff","ls":0,"href":null},{"k":"text","x":21.59375,"y":367.375,"w":115.984375,"h":20,"s":"ADASS software","font":"IBM Plex Sans","weight":400,"size":16,"color":"#5a6472ff","ls":0,"href":null},{"k":"text","x":21.59375,"y":391.375,"w":39.234375,"h":20,"s":"prize.","font":"IBM Plex Sans","weight":400,"size":16,"color":"#5a6472ff","ls":0,"href":null},{"k":"box","x":22.09375,"y":424.265625,"w":60.46875,"h":29.390625,"r":[7.6,7.6,7.6,7.6],"fill":"#f5f7faff","stroke":"#cfd6e0ff","sw":1,"href":"https://doi.org/10.3847/1538-4357/ac7c74"},{"k":"svg","x":28.984375,"y":431.4375,"w":15.03125,"h":15.03125,"svg":0,"href":"https://doi.org/10.3847/1538-4357/ac7c74"},{"k":"text","x":48.015625,"y":431.203125,"w":27.65625,"h":15,"s":"2022","font":"IBM Plex Sans","weight":400,"size":11.52,"color":"#5a6472ff","ls":0,"href":"https://doi.org/10.3847/1538-4357/ac7c74"},{"k":"box","x":88.359375,"y":424.265625,"w":29.390625,"h":29.390625,"r":[7.6,7.6,7.6,7.6],"fill":"#f5f7faff","stroke":"#cfd6e0ff","sw":1,"href":"https://github.com/astropy/astropy"},{"k":"svg","x":95.53125,"y":431.4375,"w":15.03125,"h":15.03125,"svg":1,"href":"https://github.com/astropy/astropy"},{"k":"box","x":123.546875,"y":424.265625,"w":29.390625,"h":29.390625,"r":[7.6,7.6,7.6,7.6],"fill":"#f5f7faff","stroke":"#cfd6e0ff","sw":1,"href":"https://www.astropy.org/"},{"k":"svg","x":130.71875,"y":431.4375,"w":15.03125,"h":15.03125,"svg":2,"href":"https://www.astropy.org/"},{"k":"box","x":22.09375,"y":459.453125,"w":55.84375,"h":29.390625,"r":[7.6,7.6,7.6,7.6],"fill":"#f5f7faff","stroke":"#cfd6e0ff","sw":1,"href":"https://github.com/astropy/astropy/stargazers"},{"k":"svg","x":28.984375,"y":466.625,"w":15.03125,"h":15.03125,"svg":3,"href":"https://github.com/astropy/astropy/stargazers"},{"k":"text","x":48.015625,"y":466.390625,"w":23.03125,"h":15,"s":"5.3k","font":"IBM Plex Sans","weight":400,"size":11.52,"color":"#5a6472ff","ls":0,"href":"https://github.com/astropy/astropy/stargazers"}]}},