From e80f580c22cc85d033a79dce4fbac16a2bba22ae Mon Sep 17 00:00:00 2001 From: Divya Mahadevan Date: Mon, 14 Sep 2026 18:22:06 +0100 Subject: [PATCH] Make themes tabs on the landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onboarding and Importing projects were stacked sections, so finding a prototype meant scrolling past every lane of the theme you were not looking at. They are tabs now: one theme's lanes on the page at a time, with the count of prototypes behind each tab so the unselected theme is not a closed door. The selected theme lives in the query string (#/?theme=importing-projects) for the same reason prototypes are deep-linkable — you can send someone a link that opens where you are. Drops the "Jump to a lane" box. It was built for a page running several screens; the tabs now do that job and carry the counts, so keeping both was two navigation systems for one short page. The lane headings lose the tabIndex and focus styles that only existed to receive focus from it. Co-Authored-By: Claude Opus 5 --- src/Home.tsx | 138 ++++++++++++++++++++++++++++-------------- src/styles/global.css | 93 ++++++++++++++-------------- 2 files changed, 140 insertions(+), 91 deletions(-) diff --git a/src/Home.tsx b/src/Home.tsx index 592d0ec..de2bacc 100644 --- a/src/Home.tsx +++ b/src/Home.tsx @@ -1,48 +1,15 @@ +import { useRef } from 'react' +import { useSearchParams } from 'react-router-dom' import { Alert } from './kit' import { THEMES, type Lane, type Theme } from './lanes' import { prototypesInLane } from './prototypes/registry' -/** - * Jump straight to a lane, with how many prototypes are in each. - * - * Buttons rather than anchors: this app uses hash routing, so an in-page - * `#lane-id` fragment would be read as a route. Focus moves to the heading as - * well as scrolling, so it works for keyboard and screen reader users too. - */ -function JumpTo() { - const go = (laneId: string) => { - const heading = document.getElementById(`${laneId}-title`) - heading?.scrollIntoView({ block: 'start' }) - heading?.focus() - } - - return ( - - ) -} - function LaneCard({ lane }: { lane: Lane }) { const prototypes = prototypesInLane(lane.id) return (
-

+

{lane.title} {prototypes.length > 0 && ( @@ -81,12 +48,87 @@ function LaneCard({ lane }: { lane: Lane }) { ) } -function ThemeSection({ theme }: { theme: Theme }) { +/** Total across the theme's lanes — the tab has to speak for a list that is + * not on screen. */ +function countInTheme(theme: Theme) { + return theme.lanes.reduce((total, lane) => total + prototypesInLane(lane.id).length, 0) +} + +/** + * One tab per theme. Follows the ARIA tabs pattern: the strip is a single tab + * stop and the arrow keys move between tabs, so a keyboard user does not have + * to tab past every theme to reach the list. + */ +function ThemeTabs({ + activeId, + onSelect, +}: { + activeId: string + onSelect: (themeId: string) => void +}) { + const tabs = useRef>({}) + + const onKeyDown = (event: React.KeyboardEvent) => { + const current = THEMES.findIndex((theme) => theme.id === activeId) + let next = current + + if (event.key === 'ArrowRight') next = (current + 1) % THEMES.length + else if (event.key === 'ArrowLeft') next = (current - 1 + THEMES.length) % THEMES.length + else if (event.key === 'Home') next = 0 + else if (event.key === 'End') next = THEMES.length - 1 + else return + + event.preventDefault() + const { id } = THEMES[next] + onSelect(id) + tabs.current[id]?.focus() + } + return ( -
-

- {theme.title} -

+
+ {THEMES.map((theme) => { + const selected = theme.id === activeId + const count = countInTheme(theme) + return ( + + ) + })} +
+ ) +} + +/* Only the selected panel is rendered. The tab is the panel's accessible name, + so there is no visible theme heading any more — it would just repeat it. */ +function ThemePanel({ theme }: { theme: Theme }) { + return ( +
{theme.lanes.map((lane) => ( @@ -97,6 +139,13 @@ function ThemeSection({ theme }: { theme: Theme }) { } export function Home() { + /* The selected theme lives in the query string, so a link to the page can + open on a particular tab — the same reason prototypes are deep-linkable. + `replace` because tabbing through themes should not fill the back button. */ + const [params, setParams] = useSearchParams() + const activeTheme = THEMES.find((theme) => theme.id === params.get('theme')) ?? THEMES[0] + const selectTheme = (themeId: string) => setParams({ theme: themeId }, { replace: true }) + return (
@@ -119,12 +168,9 @@ export function Home() { - -
- {THEMES.map((theme) => ( - - ))} + +
) diff --git a/src/styles/global.css b/src/styles/global.css index 6c08dde..075d918 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -191,8 +191,8 @@ hr.divider { /* ---------------- Themes and lanes ---------------- */ /* A "theme" is one of the areas in the GitHub story (onboarding, importing - projects). A "lane" is one question within a theme, for one audience. - Prototypes will be listed inside their lane. */ + projects), shown as a tab. A "lane" is one question within a theme, for one + audience. Prototypes are listed inside their lane. */ .theme-list { display: flex; flex-direction: column; @@ -205,10 +205,54 @@ hr.divider { gap: var(--space-2); } -.theme-head { - padding-bottom: var(--space-1); +/* Themes are tabs: only one theme's lanes are on the page at a time. The strip + sits on a full-width rule, with the selected tab's underline drawn over it. */ +.theme-tabs { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); border-bottom: 2px solid var(--rpf-grey-150); } +.theme-tab { + appearance: none; + background: none; + border: none; + border-bottom: 3px solid transparent; + margin-bottom: -2px; + padding: var(--space-1) var(--space-2); + font-family: inherit; + font-size: var(--fs-15); + line-height: var(--lh-15); + font-weight: var(--fw-bold); + color: var(--rpf-text-secondary); + cursor: pointer; +} +.theme-tab:hover { + color: var(--rpf-text); +} +.theme-tab[aria-selected='true'] { + color: var(--rpf-black); + border-bottom-color: var(--rpf-link-text); +} +.theme-tab:focus-visible { + outline: 3px solid var(--rpf-link-text); + outline-offset: 2px; +} +/* How many prototypes are behind the tab, so the unselected theme is not a + closed door — you can see whether it is worth opening. */ +.theme-tab-count { + margin-left: var(--space-1); + border-radius: 999px; + padding: 0 var(--space-1); + background: var(--rpf-grey-150); + font-weight: var(--fw-regular); + font-size: var(--fs-075); + color: var(--rpf-text-secondary); +} + +.theme:focus { + outline: none; +} /* Stacked, always. Side by side was fine when a lane was just a heading and "No prototypes yet", but once lanes list several prototypes each, two narrow @@ -1275,53 +1319,12 @@ body.full-screen-prototype .surface-footer { color: var(--rpf-text); } -/* ---------------- Landing page at scale ---------------- */ -/* With fifteen to twenty prototypes the page runs several screens, so it needs - somewhere to start from and cards that stay scannable. */ -.jump-to { - display: flex; - flex-direction: column; - gap: var(--space-1); - padding: var(--space-2); - border: 1px solid var(--rpf-grey-150); - border-radius: var(--radius-sm); - background: var(--rpf-off-white); - font-size: var(--fs-075); -} -.jump-to-row { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: var(--space-1) var(--space-2); -} -.jump-to-theme { - font-weight: var(--fw-bold); - min-width: 9rem; -} -.jump-to-lanes { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); -} -.jump-to .link-button { - font-size: var(--fs-075); -} - .lane-heading { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--space-1) var(--space-2); } -/* Focus lands here from the jump list; it should be visible but not look - permanently selected. */ -.lane-heading:focus { - outline: none; -} -.lane-heading:focus-visible { - outline: 3px solid var(--rpf-link-text); - outline-offset: 4px; -} .lane-count { font-weight: var(--fw-regular); font-size: var(--fs-075);