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
Use it
Free to use · credit appreciated1 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.
<!-- Design Lounge Nº 515 · "Quern score ladder" · www.designlounge.live -->
# Quern score ladder
> **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
… 240 more lines. Copy or download for the full brief. Loading the source…Brief
280 linesBuild 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
- 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. - Column labels, visual only: Place, Player, Shift, Score. They do not scroll with the rows.
- 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.
- 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-selectedandtabindexfollow the tabs pattern below. - 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.
- 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
scrollTopto 0 and runs the same test. - 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.
- 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).
- 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.”
- 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-labelledbypointing at the selected tab. - Scores use
en-GBgrouping: 1712 renders as 1,712. 48210 renders as 48,210. No decimals. No value like 99.99. - 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: brandp,h1“Ladder”, andp.wherewith two lines “Mill house” and “Sunday table”.p.lede: “Points from finished hands. Your place stays on the sheet.”div[role=tablist]labelled “Period”, threebutton[role=tab].- A visual column row,
aria-hidden="true". div[role=tabpanel]#panelcontains.scroller(tabindex="0",aria-label="Ranked players") and anol#listofli.row.#pinis a sibling under the panel, hidden when Leah’s row is fully visible. Its inner.pin-rowrepeats the same four cells. The pin isaria-hiddenis wrong here: give the pin row anaria-label, but keep the elementhiddenwhen 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. Setaria-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
| Role | Family | Size | Weight | Line-height | Tracking | Case |
|---|---|---|---|---|---|---|
| Brand | Public Sans | 12px | 600 | 1.2 | 0.18em | Uppercase |
| Title | Newsreader | 44px | 600 | 0.95 | -0.03em | Sentence |
| Where, lede | Public Sans | 14px / 15px | 400 | 1.35 | 0 | Sentence |
| Tab | Public Sans | 15px | 500, 600 if selected | 1 | 0 | Sentence, as written |
| Column label | Public Sans | 11px | 600 | 1 | 0.14em | Uppercase |
| Rank | Public Sans | 16px | 600 | 1 | 0 | Tabular nums |
| Name | Public Sans | 16px | 600 | 1 | 0 | Sentence |
| You tag | Public Sans | 11px | 600 | 1 | 0.14em | Uppercase |
| Shift | Public Sans | 13px | 600 | 1 | 0 | Tabular nums |
| Score | Public Sans | 16px | 600 | 1 | 0 | Tabular nums, right aligned |
Newsreader on the title: font-variation-settings: "opsz" 72. Fallback: Newsreader, Georgia, serif. Public Sans, “Avenir Next”, sans-serif.
Motion
| Thing | Trigger | Property | From | To | Duration | Easing | Reduced motion |
|---|---|---|---|---|---|---|---|
| Tab colour and underline | selection change | color, border-color | muted / transparent | ink / ink | 160ms | cubic-bezier(0.2, 0.7, 0.2, 1) | transition none, state still changes |
| Row ground | hover | background | sheet | hover tint, or you-hover on the you row | 120ms | same easing | transition 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
--clayoutline, offset 3px. - Row, rest: 64px, top 1px
--line-2, ground--sheet. - Row, hover: ground
--hover. The you row hovers to--you-hoverinstead. - You row: ground
--you,box-shadow: inset 3px 0 0 var(--clay), and an uppercase “You” tag in--clayafter the name. - Shift up:
--up, chevron pathM6 14l6-6 6 6. Shift down:--down, chevron pathM6 10l6 6 6-6. Icon 16px, stroke 1.75px, round caps. - Pin: same geometry as a you row, plus a 1px
--linetop border on the pin wrapper.hiddenwhen 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"witharia-label="Period". Each tab hasrole="tab",aria-selected,aria-controls="panel", and rovingtabindex(0 on the selected tab, -1 on the others). - The panel is
role="tabpanel"witharia-labelledbyset to the selected tab id. - Each
lihas anaria-labelthat 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") witharia-label="Ranked players"so keyboard users can scroll it. - The pin is
hiddenwhen not needed. When needed, setaria-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-visibleonly. - Contrast:
--inkon--sheetis well above 7:1.--ink-2#3E5160on--sheetis about 7.9:1.--up#145C3Aand--down#8C2F28on--sheetboth clear 4.5:1. You tag--clayon--youis 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 becomes40px 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: reduceremoves 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:
| Player | Score | Delta |
|---|---|---|
| Tomas Reed | 1840 | 2 |
| Leah Moss | 1712 | 1 |
| Anika Shah | 1690 | -1 |
| Owen Blake | 1544 | 0 |
| Helen Cho | 1498 | 3 |
| Idris Lang | 1402 | 0 |
| Mara Voss | 1388 | -2 |
| Jules Okada | 1290 | 0 |
| Jonah Keller | 1214 | 0 |
| Ellis Quinn | 1096 | 0 |
| Freya Holm | 980 | 0 |
| Ned Albright | 864 | 0 |
This week:
| Player | Score | Delta |
|---|---|---|
| Anika Shah | 9420 | 1 |
| Helen Cho | 9104 | 0 |
| Owen Blake | 8870 | -2 |
| Tomas Reed | 8640 | 0 |
| Idris Lang | 8012 | 1 |
| Leah Moss | 7644 | 3 |
| Freya Holm | 7200 | 0 |
| Mara Voss | 6988 | -1 |
| Jules Okada | 6540 | 0 |
| Jonah Keller | 6102 | 0 |
| Ellis Quinn | 5404 | 0 |
| Ned Albright | 4980 | 0 |
All:
| Player | Score | Delta |
|---|---|---|
| Helen Cho | 48210 | 0 |
| Anika Shah | 45102 | 1 |
| Owen Blake | 44080 | 0 |
| Tomas Reed | 39840 | -1 |
| Mara Voss | 36220 | 0 |
| Idris Lang | 34110 | 0 |
| Freya Holm | 31004 | 2 |
| Jules Okada | 28440 | 0 |
| Leah Moss | 26118 | -1 |
| Jonah Keller | 24002 | 0 |
| Ellis Quinn | 17640 | 0 |
| Ned Albright | 15420 | 0 |
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
- Tags
On the shelves