Skip to content

[UI] Redesign the homepage around search, stats and a lighter hero - #1823

Open
EdouardCourty wants to merge 2 commits into
composer:mainfrom
EdouardCourty:homepage-redesign
Open

[UI] Redesign the homepage around search, stats and a lighter hero#1823
EdouardCourty wants to merge 2 commits into
composer:mainfrom
EdouardCourty:homepage-redesign

Conversation

@EdouardCourty

@EdouardCourty EdouardCourty commented Sep 1, 2026

Copy link
Copy Markdown

Closes #1822

What changed

The homepage was structured like a documentation page: two dense text columns
("Getting Started" / "Publishing Packages") above the fold, no usage stats, and the
search bar with no particular emphasis. This PR turns it into a search-first hero,
closer to what other package registries (PyPI, RubyGems, crates.io) do:

  • Hero: short tagline → big search bar → live stats (packages published / versions /
    installs) → two action buttons ("Install Composer" externally, "Discover Packagist"
    to the about page)
  • The "Getting Started" and "Publishing Packages" content moved to /about, merged
    into its existing sections instead of duplicating the composer.json example
  • Added an "About Packagist.org" link to the header nav (was footer-only before)
  • "Latest News" now takes the full width instead of sitting in a half-empty row
  • Updated the logo for a sharper and optimized (20kb, WEBP) version (current one is blurry)

Screenshots

New homepage
Capture d’écran 2026-09-03 à 15 02 32

Updated about page
screencapture-127-0-0-1-8000-about-2026-09-01-18_21_13 (1)

@EdouardCourty
EdouardCourty force-pushed the homepage-redesign branch 4 times, most recently from 70ba8d8 to ac6858a Compare September 3, 2026 13:20
@EdouardCourty EdouardCourty changed the title Redesign the homepage around search, stats and a lighter hero [UI] Redesign the homepage around search, stats and a lighter hero Sep 3, 2026
@Seldaek

Seldaek commented Sep 4, 2026

Copy link
Copy Markdown
Member

Going to post Claude review, but I haven't checked all findings yet so maybe don't act on it yet, I can also take over maybe because I think a bunch of stuff need "business" decisions and not just following blindly what it is blabbering :)

@Seldaek Seldaek left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep review of ac6858a1

I read the complete post-PR state of every changed file plus layout.html.twig, js/search.js,
BlogRssFetcher, the Doctrine/cache/Twig/CSP configs, translations/messages.en.yml and the
PHPStan config. No crash-level bugs. The direction is good — the hero reads far better than
the old two-column wall of docs. What's left is robustness, layout correctness and a couple of
content decisions.

Most of the small stuff is left as inline suggestions you can commit directly. The items below
either span several files or need a decision.


Should fix before merge

1. / should degrade, not 500, when the DB or Redis is unavailable

This is the site's entry point and the most CDN-cached page we have, and the PR gives it two
new hard backend dependencies:

  • The Redis catch only handles Predis\Connection\ConnectionException. Predis raises
    Predis\Response\ServerException for -LOADING, -MISCONF, -OOM and -READONLY, and
    the two are siblings, not parent/child — ConnectionExceptionCommunicationException
    PredisException, while ServerException extends PredisException directly. So a Redis
    restart or failover (LOADING Redis is loading the dataset in memory) escapes the catch.
    \Predis\PredisException covers both.
  • The two getTotal() calls have no error handling at all. They go through
    Connection::executeCacheQuery() with a 24h profile, so the steady state is cheap — but on a
    result-cache miss or eviction they hit MySQL, and any DB hiccup then 500s the homepage.
    \Doctrine\DBAL\Exception is an interface in DBAL 4.x implemented by DriverException
    ConnectionException / ServerException / ConnectionLost, so one catch clause covers
    connect failures, lost connections and server errors.

Worth doing because the fallback genuinely works for anonymous visitors: checkForQueryMatch()
returns early without touching the DB when there's no q, Killswitch::isEnabled() is pure
constants, BlogRssFetcher goes through cache.app whose adapter swallows backend errors, and
the layout only touches the DB via app.user. So an anonymous / can still render during an
outage. (It is not a full DB-independence claim: /?q=<name> still does a findOneBy and
logged-in requests still load the user, so both will 500 either way.)

Important: the controller change needs the template change with it. Twig's number_format
does number_format((float) $number, ...), so an unguarded 'N/A' renders as 0 — "0
packages published" during an outage is worse than "N/A". search_section.html.twig already
guards downloads that way; packages and versions need the same. Both are inline
suggestions.

Two related things that pre-date this PR, so entirely your call whether to fold them in:
statsAction has the same narrow ConnectionException catch, and statsTotalsAction
(/statistics.json) does all three lookups with no error handling whatsoever.

2. clearfix does not break a Bootstrap 5 grid row

From the vendored 5.3.8 build:

.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;...}
.clearfix::after{display:block;clear:both;content:""}

clear: both is inert inside a flex container. The BS5 idiom for a grid line break is
<div class="w-100"></div>.

Concrete consequence in the section this PR edits: "How to submit packages?" now has five
col-lg-6 children (naming, creating-composer.json, validate-and-publish,
managing-package-versions, update-schedule). With the intended break, "Validate and publish"
ends line 2. Without it the flow is [naming | creating], [validate | managing-versions],
[update-schedule alone], leaving "Update Schedule" as an orphaned half-width column. Before
this PR the section had four children and laid out as two clean rows.

The second new one (just above "How to submit packages?") sits outside any .row — the
enclosing <section class="row"> closes on the line above it — so it can never do anything
either way. Both are inline suggestions.

Heads up that four pre-existing clearfix uses in the same file are silently broken for the
same reason. Not yours to fix, but if you're touching the file anyway it's a cheap sweep.

3. tests/Controller/PackageControllerTest.php looks like it wandered in from another branch

It has nothing to do with a homepage redesign — I'd revert it out of this PR. Separately, the
new comment is wrong: it says the payload is encoded with
JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE, but the assertion passes only
DEFAULT_ENCODING_OPTIONS | JSON_UNESCAPED_SLASHES (79) where production uses 335. It passes
today only because the fixture is pure ASCII, so the previous comment was vague and the new one
is actively incorrect. Fixing it properly means matching the flags and adding a non-ASCII
value so the difference can actually fail — better as its own PR.


Decisions for @Seldaek rather than changes to make here

The homepage can now render a completely empty content area. The content block is only the
news list, wrapped in {% if newsItems is defined and newsItems|length > 0 %}.
BlogRssFetcher::getNewsItems() returns [] on any exception, and additionally filters to
items categorised composer or packagist.org — so "zero items" is a realistic steady state,
not just a hard-failure state. In that case the visitor gets the hero followed by an empty white
.container.content. That failure mode didn't exist when the page had two columns of static
copy. Either a small fallback panel (popular/new packages) or skipping the .content wrapper
entirely would cover it.

SEO / content. The most-linked page on the site loses ~60 lines of keyword-rich copy
("Define Your Dependencies", "Install Composer", "Autoload Dependencies", "Publishing
Packages") and it lands on /about, which has far fewer inbound links. Legitimate product call,
just worth making deliberately rather than as a side-effect of a UI PR.

One content regression inside that move: the homepage's minimal publishing example (name /
description / require with "php": ">=8.2", explicitly labelled "the strictly minimal
information you have to give") is deleted with no replacement. The example that survives on
/about is the full Monolog one, which recommends psr-0 autoloading and
"php": ">=8.0.0". So "don't duplicate the composer.json example" ends up keeping the verbose,
stale one and dropping the concise, current one. Either keep the minimal snippet or refresh the
Monolog one to psr-4.

Homepage and /statistics will disagree. statsAction renders
'packages' => max($chart['packages']) from getCountByYearMonth() (3600s TTL) while the hero
uses getTotal() (86400s). Different queries, different caches, so the two pages show different
numbers. A single getTotals() helper would fix the drift and also de-triplicate the three
lookups now duplicated across index, statsAction and statsTotalsAction.

Dev-environment cost of the counts. result_cache_driver is configured only under
when@prod, so in dev/test doctrine-bundle falls back to an in-memory ArrayAdapter and
every dev homepage load runs SELECT COUNT(*) FROM package_version GROUP BY 1=1 against the
full table. Not a production problem, but noticeable while working on the page.


Smaller UX / a11y notes

  • Duplicated brand identity. The hero renders brandname (52px) + navclaim (20px italic),
    and the sticky header directly above already shows
    <h1 class="navbar-brand">Packagist <em class="d-none d-lg-inline">The PHP Package Repository</em></h1>.
    At lg+ both strings are visible about 60px apart. Also, the visually dominant text on the
    page is a div while the real h1 is the 24px nav brand — either promote the hero title to
    h1 and hide the nav duplicate on /, or mark the hero copy aria-hidden="true".
  • The hero isn't collapsed on an active search. js/search.js only toggles #search-container's
    d-none, so on /?q=foo the full hero (~300px) stays above the results and pushes the first
    hit below the fold — with a 720px-wide search field sitting above ~1140px-wide results. Adding
    a class to .wrapper-search-hero that hides .hero-search-intro / .hero-search-footer
    while a query is active would handle it.
  • Header nav will probably overflow at md. menu.about_packagist is "About Packagist.org"
    (19 chars) and each nav link carries padding: 18px 25px 19px — 50px per item. Logged out at
    768px: brand (~130px) + Browse / About Packagist.org / Submit / Create account / Sign in
    ≈ 620px ≈ 750px against ~744px of usable container width, and logged-in is worse since
    .username-link is allowed up to 200px below 992px. Cheapest fix is a short menu.about
    ("About") key for the header, keeping the long form in the footer — or bump to
    navbar-expand-lg.
  • Images. Measured: the new logo-packagist.webp is 400×455 / 20,654 B, while the reused
    logo-composer.png is 290×356 / 104,378 B — 5× heavier than the logo this PR set out to
    optimise, rendered at ~52×64, and now unconditional in the hero (it previously carried
    d-none d-lg-inline-block). Neither <img> declares width/height, so the browser can't
    reserve space before decode → CLS on our most-visited page (inline suggestions add the
    intrinsic dimensions). Also, two different Packagist logos are now live: the sharp webp on /
    and the 100×100 logo-small.png on every other page. The "current one is blurry" motivation
    applies site-wide, so this leaves the inconsistency half-fixed. And there's no <picture>/PNG
    fallback for the webp.
  • Hardcoded English. "packages published", "versions", "installs", "Install Composer",
    "Discover Packagist" are literals, while the rest of the file and the layout consistently use
    |trans. translations/messages.en.yml already ships statistics.registered ("Packages
    registered"), statistics.versions_avail ("Versions available") and statistics.installed
    ("Packages installed") — the wording differs from yours, so pick whichever you prefer, but the
    strings should be translatable. Related: /statistics labels installs with (since 2012-04-13); the bare hero total labelled "installs" reads like a current rate.
  • Hardcoded hover colour. color: #d97c10CLAUDE.md asks for customisation via :root
    overrides, and the palette already pairs --color-X / --color-X-dark
    (--color-danger/--color-danger-dark, etc.). Adding --color-brand-orange-dark would fit
    the convention. Not left as a suggestion since it needs the :root block too.
  • js- prefix carrying presentation. js-search-field-wrapper has exactly one occurrence in
    the codebase (the template itself) — no JS reads it. Styling off a js- hook is a bit of a
    trap for whoever greps for it later; a dedicated .hero-search-field class would be clearer.

Verified clean, so you know what's already been checked

  • CSP allows the new .webpimg-src 'self' https: data:, and assets use a plain ?v=
    version strategy with no manifest lookup that could fail at runtime.
  • |raw on the stat numbers is safe — the values are int from COUNT(*), and the pattern
    is copied verbatim from the existing templates/web/stats.html.twig.
  • The ?? 0 additions are correct and necessaryGROUP BY 1=1 returns zero rows on an
    empty table, so the old $result[0]['count'] raised Undefined array key 0, which is exactly
    why the homepage test needed it. One thing to confirm: we run PHPStan level 8 with
    reportUnmatchedIgnoredErrors: true and there's no baseline entry for either getTotal(), so
    the ?? may trip nullCoalesce.offset ("always exists and is not nullable"). CI will tell
    you.
  • menu.about_packagist and search.claim_html both exist, and the updated test's
    'Packagist is the main' matches the translation.
  • {% set showSearchDesc %} after {% extends %} matches the established pattern in 14 other
    templates.
  • All existing #how-to-* anchors referenced from version_list.html.twig /
    view_package.html.twig survive the about-page move, #getting-started was correctly added to
    the CSS, all about-page ids are unique, .publishing-packages is correctly deleted now that
    it's dead, and web/build/ is gitignored so no compiled CSS is expected in the diff.

Nothing here is a blocker beyond the three items in the first section — thanks for taking this
on, the search-first framing is the right call.

Comment thread src/Controller/WebController.php
Comment thread templates/web/search_section.html.twig Outdated
Comment thread templates/about/about.html.twig Outdated
Comment thread templates/about/about.html.twig Outdated
Comment thread templates/web/search_section.html.twig Outdated
Comment thread css/app.scss
Comment thread templates/web/index.html.twig Outdated
Comment thread css/app.scss Outdated
Comment thread tests/Controller/PackageControllerTest.php Outdated
Comment thread templates/layout.html.twig
@EdouardCourty

EdouardCourty commented Sep 4, 2026

Copy link
Copy Markdown
Author

Apologies for the weird changes that were in the original PR, I guess agentic coding is not always so great 😢

Thanks for the deep review — replied inline on each item. Rebased onto current main (was 4 commits behind, no overlap) and pushed fed3fe19.

Fixed

All "should fix" and inline-suggestion items are in: the Redis/DB error handling, the two broken clearfixes, image dimensions, rel/target on the external link, the CSS nits (text-align, dead float: none, :focus-visible, the hardcoded hex → --color-brand-orange-dark), the empty {% if %} leftover, the header nav overflow at md (new menu.about key for just that link), and PackageControllerTest.php is fully reverted — it no longer shows up in the diff at all.

Two more, not flagged as committable suggestions but raised in the writeup:

  • Duplicated brand identity / wrong <h1>. Promoted the hero title to the real <h1> and demoted the header wordmark to a <div>, but only on the home route (app.current_route == 'home' in layout.html.twig) — every other page keeps its <h1> exactly as before. This does touch layout.html.twig, which is otherwise out of scope for this PR; figured a single conditional tag swap with no content/link change was worth the exception rather than leaving two <h1>s on the homepage.
  • Hero not collapsing during an active search. Reused the hasSearch you'd already have seen computed in js/search.js's onStateChange to toggle a .search-active class on .wrapper-search-hero, with CSS hiding the intro/footer while it's set. Couldn't exercise this end-to-end locally since dev has no Algolia credentials (ALGOLIA_APP_ID etc. are empty in .env), so onStateChange never fires in that environment — verified the CSS/class side manually by toggling the class by hand in the console, screenshot confirms the hero collapses to just the search bar.
  • Minimal composer.json example. Rather than re-adding a second example, updated the surviving Monolog one on /about to psr-4 (Monolog\\src/Monolog) and a current PHP constraint (>=8.2), so at least the one that remains isn't stale.

Left to you, as flagged

  • Empty .content when there's no news (fallback panel vs. skip the wrapper vs. leave it)
  • Homepage vs. /statistics numbers drift (shared getTotals() helper would fix it, but that's a bigger refactor than this PR's scope)

@Seldaek Seldaek left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2 — reviewed fed3fe19

Almost everything from the first pass landed, and CI is green across the board including PHPStan
— so the ?? 0 concern I flagged there is closed. Two of the fixes went further than suggested:
the h1 duplication is solved properly (hero title becomes the h1, nav brand switches to a
div on home), and the Monolog example got refreshed to psr-4 / "php": ">=8.2", which
wasn't asked for. Thanks for the thorough pass.

Two new findings, both fallout from changes I suggested — see the inline comments:

  1. .hero-brand-title picks up margin-top: 20px / margin-bottom: 10px from the h1 element
    selector now that it is an h1. My suggestion, my miss — one-click fix inline.
  2. The hero-collapse fix doesn't cover the initial page load, because onStateChange only fires
    on state changes. That's the reason the separate init block at js/search.js:68-77 exists.

One layout call worth a conscious look rather than a fix: the w-100 works now, and since the
"How to submit packages?" section has five col-lg-6 children one of them is always alone. The
break lands between "Validate and publish" and "Managing package versions", giving
[naming | creating], [validate alone], [managing | schedule] — a half-empty row mid-section
rather than at the end. Reads as deliberate grouping (validate closes the "create your package"
flow), so probably what you wanted, just flagging it's a choice.


Open points — @Seldaek to triage, not blocking this PR

Recording these so they don't get lost. None of them are asks of @EdouardCourty — they're
either pre-existing, or product calls, or follow-up work.

Product / content decisions

  • / can render a completely empty content area. The content block is only the news
    list, and BlogRssFetcher::getNewsItems() returns [] on any exception and filters to
    items categorised composer / packagist.org — so "zero items" is a realistic steady state,
    not just a hard-failure state. The visitor then gets the hero followed by an empty white
    .container.content. That failure mode didn't exist when the page had two columns of static
    copy. A small fallback panel (popular/new packages) or skipping the .content wrapper would
    cover it. This is the one I'd care about most.
  • SEO. The most-linked page on the site loses its indexable copy to /about. Deliberate
    call to confirm, now that the content itself is in good shape there.
  • "installs" has no qualifier. /statistics labels the same number
    statistics.installed + (since 2012-04-13); a bare lifetime total labelled "installs" reads
    like a current rate.

Consistency / correctness follow-ups

  • / and /statistics compute totals differentlygetTotal() (86400s cache) vs
    max($chart['packages']) from getCountByYearMonth() (3600s). Different queries, different
    TTLs, so the two pages will show different numbers. A shared getTotals() helper would fix the
    drift and de-triplicate the lookups now duplicated across index, statsAction and
    statsTotalsAction.
  • statsTotalsAction (/statistics.json) has no error handling at all, and
    statsAction still uses the narrow Predis\Connection\ConnectionException — so both still
    500 on a -LOADING / -OOM / -READONLY reply, which is what this PR fixed for /. Same
    widening applies. (Broader sweep of that pattern is #1829.)
  • Five inert clearfix divs remain in about.html.twig (lines 7, 143, 172, 183, 196).
    Same root cause as the one fixed here — clear: both does nothing in a flex .row. Worth
    noting the "How to update packages?" row has seven col-lg-6 children with three of those
    clearfixes, so its intended grouping doesn't work either. Mechanical w-100 swap.
  • No test asserts the new markup. testHomepage covers assertResponseIsSuccessful and
    the claim text; nothing covers the three stat items, the number formatting, or the 'N/A'
    fallback path. Something like assertCount(3, $crawler->filter('.hero-search-stats li')) would
    lock the feature in. (The ?? 0 fix is implicitly covered — testHomepage doesn't call
    initializePackages(), so it already exercises the empty-table path.)

Assets / polish

  • logo-composer.png is 104,378 B — 5× the new 20,654 B webp, 290×356 rendered at ~52×64,
    and now unconditional in the hero where it previously carried d-none d-lg-inline-block. It's
    the heavy asset on the page this PR set out to lighten.
  • Two competing Packagist logos are live — the sharp webp on /, the 100×100
    logo-small.png everywhere else. The "current one is blurry" motivation applies site-wide, so
    this is half-fixed. Also no <picture>/PNG fallback for the webp.
  • js-search-field-wrapper is still the styling hook. The dead float: none is gone as
    suggested, but presentation still hangs off a js- prefixed class that no JS reads (one
    occurrence in the whole codebase, the template itself). A .hero-search-field class would be
    clearer — needs a template change, so it wasn't worth bundling here.
  • Mobile hero padding is unchanged. The @media (max-width: 575px) block shrinks the
    logos and title but leaves padding: 48px 20px 40px on .wrapper-search-hero .container.
  • Cosmetic: .hero-search-intro .hero-search-claim { margin: 0 auto } (specificity 0-2-0)
    loses margin-top/margin-bottom to .wrapper-search .container p (0-2-1), so the effective
    margin-top is 8px, not 0. Harmless, but the margin: 0 auto reads as if it takes effect.

Comment thread css/app.scss
Comment thread js/search.js Outdated
@EdouardCourty

Copy link
Copy Markdown
Author

I figured the two hero buttons did not stretch when in mobile mode, which resulted in the two buttons not having the same size when on mobile.

I added stretch properties to them :
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[UI] Homepage redesign

2 participants