Design Lounge
Install

Nº 515 of 520

Quern score ladder

Today, This week, and All reorder twelve Quern scores. Your row stays marked, and a few rows show places gained or lost.

Preview

Open full size
1280 × 800 · 12.0 KB · 398 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Quern score ladder" (piece game-leaderboard) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

280 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

The ladder for Quern, a fictional parlour game scored in whole points at the mill house. Three periods share one sheet: Today, This week, and All. Each period is a list of the same twelve players with different scores, so the order changes when the tab changes. Rank is the row index after a numeric sort, not a number typed into the markup. Leah Moss is the player. Her row is clay-tinted. If a scroll would take that row out of the list viewport, a copy of it pins to the bottom of the sheet. A few rows carry a shift: places up or places down since the previous cut of that same period. The shift is not a second score and it is not a percentage.

The sheet is cool paper on a slightly deeper ground. Newsreader for the title, Public Sans for the rows, one clay accent for “you”. Hairlines separate rows. No glow, no purple, no decimal scores.

Reference behaviour

  1. First frame is the Today tab, aria-selected="true", panel labelled by that tab. The list is already filled. Do not wait for a fetch. Scroll position is 0.
  2. Column labels, visual only: Place, Player, Shift, Score. They do not scroll with the rows.
  3. Today order, after sorting score descending, is the table in “This demo” below. Place 1 is Tomas Reed with 1,840. Leah Moss is place 2 with 1,712, marked You. She is inside the viewport, so the pin is hidden.
  4. Click or activate This week. The list rebuilds from that period’s scores, sorted again. Place 1 becomes Anika Shah with 9,420. Leah Moss is place 6 with 7,644, still inside the viewport, pin hidden. The tab underline moves to This week. aria-selected and tabindex follow the tabs pattern below.
  5. Click All. Place 1 is Helen Cho with 48,210. Leah Moss is place 9 with 26,118. She sits below the fold of the scrolling list, so the pin is visible and shows place 9, Leah Moss, You, down 1, 26,118. The in-list row is still in the DOM, marked the same way.
  6. Scrolling the list so Leah’s in-list row is fully inside the scroller hides the pin. Scrolling until that row is clipped shows the pin again. Switching tabs resets scrollTop to 0 and runs the same test.
  7. Ranks and scores on screen are the sorted numbers. Switching tabs must change who is first. Do not keep one painted order and only restyle it.
  8. A shift appears only when the stored delta is non-zero. Up is a chevron and the absolute number in green. Down is a chevron and the absolute number in brick red. Zero renders an empty cell so the score column stays aligned. The accessible name of the chevron cell is “Up 2 places” or “Down 1 place” (singular when the absolute value is 1).
  9. A polite live region on each tab change says “{Period}. You are place {n} with {score}.” Example: “Today. You are place 2 with 1,712.”
  10. Keyboard on the tab list: ArrowRight and ArrowLeft move, wrapping. Home selects Today. End selects All. The newly selected tab receives focus. The panel has aria-labelledby pointing at the selected tab.
  11. Scores use en-GB grouping: 1712 renders as 1,712. 48210 renders as 48,210. No decimals. No value like 99.99.
  12. Do not use localStorage, cookies, alert, console.log, or document.write.

Structure

1280 x 800, sheet 840px centred on #E7EEF2
┌──────────── 840px sheet ────────────┐
│ QUERN                         Mill  │  header, padding 28px 28px 0
│ Ladder                        house │
│                               Sunday│
│                               table │
│ Points from finished hands. Your    │  lede, margin 14px 28px 0
│ place stays on the sheet.           │
│ [ Today ]  This week   All          │  tab row 44px, 2px ink underline
│ PLACE    PLAYER    SHIFT    SCORE   │  36px, uppercase 11px
│ 1  Tomas Reed       ^2      1,840   │  row 64px
│ 2  Leah Moss  YOU   ^1      1,712   │  clay row, 3px clay inset
│ 3  Anika Shah       v1      1,690   │
│ ... list scrolls ...                │
│ (pin, only if Leah is clipped)      │  64px, same grid as a row
└─────────────────────────────────────┘
Side ground is (1280 - 840) / 2 = 220px each side.
  • main.sheet: 840px wide, height 100%, flex column, hairline left and right, ground --sheet.
  • header.top: brand p, h1 “Ladder”, and p.where with two lines “Mill house” and “Sunday table”.
  • p.lede: “Points from finished hands. Your place stays on the sheet.”
  • div[role=tablist] labelled “Period”, three button[role=tab].
  • A visual column row, aria-hidden="true".
  • div[role=tabpanel]#panel contains .scroller (tabindex="0", aria-label="Ranked players") and an ol#list of li.row.
  • #pin is a sibling under the panel, hidden when Leah’s row is fully visible. Its inner .pin-row repeats the same four cells. The pin is aria-hidden is wrong here: give the pin row an aria-label, but keep the element hidden when it is not shown so it leaves the accessibility tree. When shown, the in-list row remains reachable by scrolling, and the pin is a visual duplicate. Set aria-hidden="true" on the pin while it is visible too, so the name is not read twice. The live region already states her place.
  • One p#live, visually hidden, aria-live="polite".

Tokens

:root {
  --paper: #E7EEF2;     /* page ground, outside the sheet */
  --sheet: #F7FBFC;     /* sheet and default row */
  --ink: #14202B;       /* title, names, selected tab, score */
  --ink-2: #3E5160;     /* brand, meta, unselected tab, rank */
  --line: #D3DEE6;      /* sheet edge, tab rule, pin rule */
  --line-2: #E1E8EE;    /* row rule */
  --hover: #F2F6F8;     /* row hover */
  --clay: #C4512C;      /* you tag, you bar, focus */
  --you: #F8E8DC;       /* you row and pin */
  --you-hover: #F4E0D2;
  --up: #145C3A;        /* places gained */
  --down: #8C2F28;      /* places lost */
  --ease: cubic-bezier(0.2, 0.7, 0.2, 1);
  --sheet-w: 840px;
  --row-h: 64px;
  --pad-x: 28px;
  --dur-tab: 160ms;
  --dur-row: 120ms;
}

Row grid: 72px minmax(0, 1fr) 88px 112px, column gap 8px, horizontal padding 28px.

Typography

RoleFamilySizeWeightLine-heightTrackingCase
BrandPublic Sans12px6001.20.18emUppercase
TitleNewsreader44px6000.95-0.03emSentence
Where, ledePublic Sans14px / 15px4001.350Sentence
TabPublic Sans15px500, 600 if selected10Sentence, as written
Column labelPublic Sans11px60010.14emUppercase
RankPublic Sans16px60010Tabular nums
NamePublic Sans16px60010Sentence
You tagPublic Sans11px60010.14emUppercase
ShiftPublic Sans13px60010Tabular nums
ScorePublic Sans16px60010Tabular nums, right aligned

Newsreader on the title: font-variation-settings: "opsz" 72. Fallback: Newsreader, Georgia, serif. Public Sans, “Avenir Next”, sans-serif.

Motion

ThingTriggerPropertyFromToDurationEasingReduced motion
Tab colour and underlineselection changecolor, border-colormuted / transparentink / ink160mscubic-bezier(0.2, 0.7, 0.2, 1)transition none, state still changes
Row groundhoverbackgroundsheethover tint, or you-hover on the you row120mssame easingtransition none

No row-insert animation. Rebuilding the list is instant. No counting-up of scores. No looping motion.

States

  • Tab, rest: transparent, type --ink-2, 2px transparent bottom border, height 44px, padding 0 14px. The border sits on the shared 1px rule (margin-bottom: -1px).
  • Tab, hover: type --ink.
  • Tab, selected: weight 600, type --ink, bottom border --ink.
  • Tab, focus-visible: 2px --clay outline, offset 3px.
  • Row, rest: 64px, top 1px --line-2, ground --sheet.
  • Row, hover: ground --hover. The you row hovers to --you-hover instead.
  • You row: ground --you, box-shadow: inset 3px 0 0 var(--clay), and an uppercase “You” tag in --clay after the name.
  • Shift up: --up, chevron path M6 14l6-6 6 6. Shift down: --down, chevron path M6 10l6 6 6-6. Icon 16px, stroke 1.75px, round caps.
  • Pin: same geometry as a you row, plus a 1px --line top border on the pin wrapper. hidden when Leah’s row rectangle lies fully inside the scroller rectangle (1px tolerance).
  • Scroller: overflow-y: auto, overflow-x: hidden, overscroll-behavior: contain, thin scrollbar. The page itself does not scroll at 1280.
  • Empty shift cell still occupies the third grid column.

Accessibility

  • Tabs follow the tabs pattern. role="tablist" with aria-label="Period". Each tab has role="tab", aria-selected, aria-controls="panel", and roving tabindex (0 on the selected tab, -1 on the others).
  • The panel is role="tabpanel" with aria-labelledby set to the selected tab id.
  • Each li has an aria-label that includes place, name, the shift sentence when there is one, the grouped score, the word “points”, and ”, you” on Leah’s row. Example: “Place 2, Leah Moss, up 1 place, 1,712 points, you”.
  • Column headers are aria-hidden="true" because the row label already speaks the fields.
  • The scroller is a tab stop (tabindex="0") with aria-label="Ranked players" so keyboard users can scroll it.
  • The pin is hidden when not needed. When needed, set aria-hidden="true" on it as well, so assistive tech reads the in-list row rather than a second copy. The live region announces the new place on every tab change.
  • Focus ring: 2px solid --clay, offset 3px, on :focus-visible only.
  • Contrast: --ink on --sheet is well above 7:1. --ink-2 #3E5160 on --sheet is about 7.9:1. --up #145C3A and --down #8C2F28 on --sheet both clear 4.5:1. You tag --clay on --you is about 6:1.
  • Hit targets: tabs are 44px tall. Rows are 64px. The scroller is the scroll control.

Responsive rules

  • At 1280 and above: sheet 840px, centred, page overflow: hidden, list scrolls inside the sheet.
  • 641px to 900px: sheet width 100%, drop the side borders, keep the same row grid.
  • Below 640px: the page may scroll, the inner scroller becomes overflow: visible, horizontal padding drops to 16px, the row grid becomes 40px minmax(0, 1fr) 64px 80px, and the title drops to 36px. Names truncate with ellipsis. The you row is still marked. The pin logic can stay, but if the page itself scrolls, prefer keeping Leah’s row in the document order so she is not trapped in a nested scroller.
  • Do not turn the ladder into cards. It stays a ranked list.

Acceptance checklist

Always

  • Three tabs share one list. Selecting a tab replaces the rows from that period’s scores.
  • Rank is assigned after sorting by score descending. A tie breaks by name ascending. This demo has no ties.
  • The player’s row is marked in the list and remains on screen: either inside the viewport, or as a pin when the list row is clipped.
  • Scores are integers with thousands separators. No decimals and no fake precision such as 99.99.
  • A shift shows on only some rows, as places up or down, with a direction in the accessible name.
  • Arrow keys, Home, and End move the tab selection. The selected tab is focusable. The others are tabindex="-1".
  • A polite live region states the period and the player’s place and score after each change.
  • Focus-visible is a 2px clay outline. Tabs are at least 44px tall.
  • prefers-reduced-motion: reduce removes the colour transitions. Tabs and rows still update.
  • The page does not scroll sideways at 1280. The list may scroll vertically inside the sheet.

This demo

  • Product is Quern. Title is “Ladder”. Lede is “Points from finished hands. Your place stays on the sheet.” Where-line is “Mill house” / “Sunday table”.
  • Tabs read “Today”, “This week”, “All”. First frame is Today.
  • The player is Leah Moss. Her tag reads “You”.
  • Today place 1 is Tomas Reed, 1,840, up 2. Leah Moss is place 2, 1,712, up 1. The pin is hidden.
  • This week place 1 is Anika Shah, 9,420, up 1. Leah Moss is place 6, 7,644, up 3.
  • All place 1 is Helen Cho, 48,210. Leah Moss is place 9, 26,118, down 1, and the pin is visible at scroll 0.
  • The other shifts match the tables in the implementation notes. Rows with delta 0 show no chevron.
  • Palette is paper #E7EEF2, sheet #F7FBFC, ink #14202B, clay #C4512C. Type is Newsreader and Public Sans.

Period tables

Delta is places, not points. Positive is up. Store it beside the score. Do not compute it by comparing tabs.

Today:

PlayerScoreDelta
Tomas Reed18402
Leah Moss17121
Anika Shah1690-1
Owen Blake15440
Helen Cho14983
Idris Lang14020
Mara Voss1388-2
Jules Okada12900
Jonah Keller12140
Ellis Quinn10960
Freya Holm9800
Ned Albright8640

This week:

PlayerScoreDelta
Anika Shah94201
Helen Cho91040
Owen Blake8870-2
Tomas Reed86400
Idris Lang80121
Leah Moss76443
Freya Holm72000
Mara Voss6988-1
Jules Okada65400
Jonah Keller61020
Ellis Quinn54040
Ned Albright49800

All:

PlayerScoreDelta
Helen Cho482100
Anika Shah451021
Owen Blake440800
Tomas Reed39840-1
Mara Voss362200
Idris Lang341100
Freya Holm310042
Jules Okada284400
Leah Moss26118-1
Jonah Keller240020
Ellis Quinn176400
Ned Albright154200

Implementation notes

Sort, then number. The arrays above happen to be sorted. Sort anyway so a future edit cannot drift the printed place away from the score.

var rows = boards[key].slice().sort(function (a, b) {
  return b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
});
rows.forEach(function (r, i) {
  var rank = i + 1;
  var you = r[0] === 'Leah Moss';
  /* li.row, class you, aria-label, four cells */
});
scroller.scrollTop = 0;
syncPin();

Common mistake: painting the rank from a field in the data, then sorting the scores and leaving the old rank stuck on the row.

Pin only when the marked row is clipped. Compare rectangles after layout. A sticky row cannot pull a below-the-fold row into view, so the pin is a second render of the same cells.

function syncPin() {
  var row = list.querySelector('.you');
  var sb = scroller.getBoundingClientRect();
  var rb = row.getBoundingClientRect();
  var clipped = rb.bottom > sb.bottom + 1 || rb.top < sb.top - 1;
  pin.hidden = !clipped;
  pin.setAttribute('aria-hidden', 'true');
}
scroller.addEventListener('scroll', syncPin);

The panel must be flex: 1; min-height: 0; display: flex and the scroller flex: 1; min-height: 0; overflow-y: auto. If the scroller grows with the list, nothing clips, the pin never appears, and the body cuts off place 12.

Tabs. Roving tabindex, not a radio group styled as links.

function select(i) {
  tabs.forEach(function (tab, n) {
    var on = n === i;
    tab.setAttribute('aria-selected', on ? 'true' : 'false');
    tab.tabIndex = on ? 0 : -1;
  });
  panel.setAttribute('aria-labelledby', tabs[i].id);
  render(keys[i]);
}

Common mistake: leaving every tab at tabindex="0", so Tab visits all three, and forgetting to reset scroll, so All opens halfway down yesterday’s offset.

Details

Palette
Type
Newsreader · Public Sans
Motion
Subtle motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

60fps

Show one small moment at a time, and let the tags do the sorting.

All sources