diff --git a/picker/pages/index.astro b/picker/pages/index.astro index cbca7d431..d1f319ee5 100644 --- a/picker/pages/index.astro +++ b/picker/pages/index.astro @@ -407,7 +407,7 @@ const scaleRows = [['H1', 6], ['H2', 5], ['H3', 4], ['H4', 3], ['H5', 2], ['H6',
- +

- +

diff --git a/picker/scripts/palette-picker.js b/picker/scripts/palette-picker.js index 6d17373bc..7057c1688 100644 --- a/picker/scripts/palette-picker.js +++ b/picker/scripts/palette-picker.js @@ -31,7 +31,7 @@ let fontManifest; is judged on texture, and real prose pulls the eye into reading it. */ const LOREM = { sentence: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.', - lines: ['Ut enim ad minim veniam, quis nostrud exercitation', 'ullamco laboris nisi ut aliquip ex ea commodo.'], + paragraph: 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo.', }; /* The desktop artboard sets three cards and the phone two, so a fourth would @@ -328,8 +328,7 @@ function syncFontPair(pair) { document.querySelectorAll('[data-type-section-title]').forEach((node) => { node.textContent = preview.sectionTitle; }); - fillIndexed(desktop, '[data-type-section-body]', LOREM.lines); - fillIndexed(phoneBody, '[data-type-section-body]', LOREM.lines); + for (const node of document.querySelectorAll('[data-type-section-body]')) node.textContent = LOREM.paragraph; document.querySelectorAll('[data-type-section-link]').forEach((node) => { node.textContent = preview.sectionLink; }); @@ -346,7 +345,13 @@ function syncFontPair(pair) { document.querySelector('[name="font-body-source"]').value = pair.body.source || ''; } -function addPairCard(pair, { checked = false, prepend = false } = {}) { +/* Where each pair belongs when it is not the chosen one. The rail renders a + rotation of this list rather than the list itself, so hoisting the answer + to the top never loses the order the rest were dealt in. An uploaded pair + joins at the front: it is the one the user made. */ +const pairOrder = []; + +function addPairCard(pair, { checked = false, first = false } = {}) { const node = pairTemplate.content.firstElementChild.cloneNode(true); const input = node.querySelector('input'); input.value = pair.id; @@ -362,23 +367,32 @@ function addPairCard(pair, { checked = false, prepend = false } = {}) { node.querySelector('[data-pair-heading]').textContent = pair.heading.family; node.querySelector('[data-pair-body]').textContent = pair.body.family; node.querySelector('[data-pair-why]').textContent = pair.why; - if (prepend) fontOptions.prepend(node); - else fontOptions.append(node); + if (first) pairOrder.unshift(node); + else pairOrder.push(node); + fontOptions.append(node); return node; } +function removePairCard(node) { + const index = pairOrder.indexOf(node); + if (index !== -1) pairOrder.splice(index, 1); + node.remove(); +} + function renderFontPairs(manifest, fallback) { fontManifest = normalizeFontManifest(manifest); fontOptions.toggleAttribute('data-fallback', fallback); manifest.pairs.forEach((pair, index) => addPairCard(pair, { checked: index === 0 })); loadFontStylesheet(manifest.pairs); syncFontPair(manifest.pairs[0]); + applyHoist(); } fontOptions.onchange = ({ target }) => { if (!target.matches('input[name="font-pair"]')) return; const pair = fontManifest.pairs.find(({ id }) => id === target.value); if (pair) syncFontPair(pair); + requestHoist(); }; /* Scroll by whole rows so an option never ends up half in frame, and disable @@ -408,6 +422,103 @@ function wireListScroll(list) { const syncScrollButtons = wireListScroll(fontOptions); wireListScroll(scaleOptions); +/* The chosen pair takes the top of the rail, so the answer is the first thing + the list shows and the rest keep their dealt order underneath it. + + The move is not made at the moment of choosing, and that is the whole + design. Reordering under a live cursor drags the row the user just clicked + out from under the pointer and parks a different pair where the next click + is already aimed. Reordering on a radio group's arrow keys is worse: every + press both moves focus and commits, so a list that re-sorts per press + re-sorts between presses and the group cannot be crossed at all. + + So the rail reorders only while nobody is working it. A selection sets the + request; the pointer leaving, or focus leaving, spends it. Arriving on the + screen spends it too, which is the backstop if a settle is ever missed. */ +const typeRail = fontOptions.closest('.picker-type-rail'); +const RAIL_NAV_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', ' ']); +let hoistPending = false; +let pointerInRail = false; +let keyboardInRail = false; +let hoistFlash; + +/* :hover covers the one case the pointer events miss: the cursor already + resting where the rail appears, which fires no enter of its own. */ +const railBusy = () => pointerInRail + || typeRail.matches(':hover') + || (keyboardInRail && typeRail.contains(document.activeElement)); + +/* Nothing is under the cursor when the rows move, so the move itself needs no + transition to be readable. What it needs is somewhere for the eye to land + after it: the row that just took the top comes up bright and settles to its + resting checked state. Color only, so the acknowledgement cannot disturb + the list it is pointing at. */ +function flashHoisted(node) { + clearTimeout(hoistFlash); + for (const other of pairOrder) other.removeAttribute('data-hoisted'); + // Reading a layout property between the two writes is what restarts the + // animation on a row that is still marked from the previous hoist. + void node.offsetWidth; + node.dataset.hoisted = ''; + hoistFlash = setTimeout(() => node.removeAttribute('data-hoisted'), 700); +} + +function applyHoist({ force = false } = {}) { + hoistPending = false; + const chosen = fontOptions.querySelector('input[name="font-pair"]:checked')?.closest('.picker-type-option'); + if (!chosen) return; + const wanted = [chosen, ...pairOrder.filter((node) => node !== chosen)]; + const shown = [...fontOptions.querySelectorAll('.picker-type-option')]; + const moved = wanted.some((node, index) => node !== shown[index]); + if (moved) { + // Rows move as real nodes so tab order, reading order, and what is on + // screen stay one order. Re-parenting can drop focus on the way, which + // would strand a keyboard user outside the group they were just in. + const focused = document.activeElement; + for (const node of wanted) fontOptions.append(node); + if (fontOptions.contains(focused) && document.activeElement !== focused) { + focused.focus({ preventScroll: true }); + } + } + if (!moved && !force) return; + fontOptions.scrollTo({ top: 0 }); + syncScrollButtons(); + if (moved) flashHoisted(chosen); +} + +function requestHoist() { + hoistPending = true; + if (!railBusy()) applyHoist(); +} + +function settleHoist() { + if (hoistPending && !railBusy()) applyHoist(); +} + +typeRail.addEventListener('pointerenter', () => { + pointerInRail = true; +}); + +// A frame of slack so :hover has resolved before the guard reads it. +typeRail.addEventListener('pointerleave', () => { + pointerInRail = false; + requestAnimationFrame(settleHoist); +}); + +typeRail.addEventListener('pointerdown', () => { + keyboardInRail = false; +}); + +typeRail.addEventListener('keydown', ({ key }) => { + if (RAIL_NAV_KEYS.has(key)) keyboardInRail = true; +}); + +typeRail.addEventListener('focusout', ({ relatedTarget }) => { + if (typeRail.contains(relatedTarget)) return; + keyboardInRail = false; + settleHoist(); +}); + /* Type scale. The numbers are the real ones: step n is 16px * ratio^n, and a Golden Ratio @@ -476,6 +587,8 @@ const fontModal = document.querySelector('[data-font-modal]'); const customStatus = fontModal.querySelector('[data-custom-status]'); const customFile = (role) => fontModal.querySelector(`[data-custom-file="${role}"]`); const customUrl = (role) => fontModal.querySelector(`[data-custom-url="${role}"]`); +const customSave = fontModal.querySelector('[data-font-custom-save]'); +const customHint = fontModal.querySelector('[data-custom-hint]'); const FONT_SOURCE_SEP = '\n'; const FONT_FILE_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']); const customFilesByRole = { heading: [], body: [] }; @@ -526,6 +639,7 @@ function renderCustomFileList(role) { customFilesByRole[role] = customFilesByRole[role].filter((entry) => !sameFontFile(entry, file)); setChosenFontFiles(customFile(role), customFilesByRole[role]); renderCustomFileList(role); + syncCustomSave(); customStatus.textContent = ''; }; item.append(name, remove); @@ -533,6 +647,31 @@ function renderCustomFileList(role) { } } +/* Either kind of source satisfies a role: a URL or at least one file. */ +const roleHasSource = (role) => customFilesByRole[role].length > 0 || customUrl(role).value.trim() !== ''; + +/* Both roles are required. A heading-only sheet used to commit and let the + body fall back to whichever pair happened to be selected, which is a + substitution the user never asked for and never saw. + + The hint carries the reason. A commit that is dead with no explanation + leaves the user clicking an inert control to find out why, so the footer + names the role that is still open and the button is never the only + feedback. It reads as instruction rather than correction: nothing here is + wrong yet, the sheet is just unfinished. */ +function syncCustomSave() { + const missing = ['heading', 'body'].filter((role) => !roleHasSource(role)); + customSave.disabled = missing.length > 0; + if (missing.length === 2) { + customHint.textContent = 'Heading and body each need a URL or a file.'; + } else if (missing.length === 1) { + const role = missing[0] === 'heading' ? 'Heading' : 'Body'; + customHint.textContent = `${role} still needs a URL or a file.`; + } else { + customHint.textContent = ''; + } +} + function parseFontSources(source) { if (!source) return []; if (source.includes(FONT_SOURCE_SEP)) return source.split(FONT_SOURCE_SEP).filter(Boolean); @@ -545,8 +684,13 @@ document.querySelector('[data-font-custom-open]').onclick = () => { customFilesByRole[role] = []; customFile(role).value = ''; setChosenFontFiles(customFile(role), []); + // The URL goes with the files. Clearing half the sheet leaves a field + // holding an address whose companion upload is already gone, and the + // commit would then read as available for work the user did last time. + customUrl(role).value = ''; renderCustomFileList(role); } + syncCustomSave(); fontModal.showModal(); }; @@ -564,7 +708,9 @@ for (const role of ['heading', 'body']) { setChosenFontFiles(target, customFilesByRole[role]); target.value = ''; renderCustomFileList(role); + syncCustomSave(); }; + customUrl(role).oninput = syncCustomSave; } async function uploadFontFile(file) { @@ -591,7 +737,7 @@ async function resolveCustomFace(role) { return url ? { family: url.split('/').pop().replace(/\.[^.]+$/, '') || 'Custom', source: url } : null; } -document.querySelector('[data-font-custom-save]').onclick = async () => { +customSave.onclick = async () => { customStatus.textContent = 'Saving…'; let heading; let body; @@ -601,24 +747,30 @@ document.querySelector('[data-font-custom-save]').onclick = async () => { customStatus.textContent = error.message; return; } - if (!heading && !body) { - customStatus.textContent = 'Add a URL or a file for at least one role.'; + // The button is already gated on both roles; this is the same rule stated + // where the pair is built, so no path can assemble a half pair. + if (!heading || !body) { + customStatus.textContent = 'Add a URL or a file for both the heading and the body.'; return; } - const current = fontManifest.pairs.find(({ id }) => id === fontOptions.querySelector('input:checked')?.value); const pair = { id: 'custom', name: 'Custom', - heading: heading || current.heading, - body: body || current.body, + heading, + body, why: 'Your own faces', }; fontManifest.pairs = [pair, ...fontManifest.pairs.filter(({ id }) => id !== 'custom')]; - addPairCard(pair, { prepend: true }); + // A second upload replaces the first rather than stacking a second row of + // the same id, which the rail would then have to order against itself. + for (const node of [...pairOrder]) { + if (node.querySelector('input').value === 'custom') removePairCard(node); + } + addPairCard(pair, { checked: true, first: true }); loadCustomFace(pair); - fontOptions.querySelector('input[value="custom"]').checked = true; syncFontPair(pair); - fontOptions.scrollTo({ top: 0 }); + // The dialog holds the pointer and focus, so the rail is free to reorder. + applyHoist({ force: true }); fontModal.close(); }; @@ -884,6 +1036,12 @@ document.addEventListener('picker:screenchange', (event) => { // picker's own theme, not a third artboard painted in the user's palette. const target = { '03': strategyPreview, '04': typePreview }[event.detail.screen]; if (target) syncCommittedPalette(target); + // Arriving is the quietest moment there is, so the rail settles here even + // if it is already in order: the chosen pair is the row you land on. + if (event.detail.screen === '04') { + keyboardInRail = false; + applyHoist({ force: true }); + } // A hidden sheet measures zero, so the fit can only be resolved on arrival. // The scroll waits a frame: the screen change focuses the first control after // this event, and that scrolls the list back to the top. diff --git a/picker/styles/picker.css b/picker/styles/picker.css index 4869b3660..bd9c695d8 100644 --- a/picker/styles/picker.css +++ b/picker/styles/picker.css @@ -60,6 +60,13 @@ body.picker-page { .picker-page .ks-button.ks-button-primary { position: relative; isolation: isolate; +} + +/* The paint is gated on :not([disabled]) so it never competes with the disabled + state below. Without the gate this selector is (0,3,0) and the kit's + `.ks-button[disabled]` is (0,2,0), so a disabled button kept the full gold + fill and read as clickable. */ +.picker-page .ks-button.ks-button-primary:not([disabled]) { color: var(--ks-dark-ink); background: var(--ks-kinpaku); border-color: var(--ks-kinpaku); @@ -77,18 +84,18 @@ body.picker-page { transition: opacity 180ms var(--ks-ease); } -.picker-page .ks-button.ks-button-primary:hover { +.picker-page .ks-button.ks-button-primary:not([disabled]):hover { color: var(--ks-dark-ink); background: var(--ks-kinpaku-vivid); border-color: var(--ks-kinpaku-vivid); transform: translateY(-1px); } -.picker-page .ks-button.ks-button-primary:hover::before { +.picker-page .ks-button.ks-button-primary:not([disabled]):hover::before { opacity: 0.26; } -.picker-page .ks-button.ks-button-primary:active { +.picker-page .ks-button.ks-button-primary:not([disabled]):active { background: var(--ks-kinpaku-rich); border-color: var(--ks-kinpaku-rich); transform: translateY(0); @@ -101,6 +108,22 @@ body.picker-page { opacity: 0; } +/* A disabled button answering the pointer is the other half of the same bug: + the kit's `.ks-button.ks-button-secondary:hover` is (0,3,0) and outranks its + own `.ks-button[disabled]` at (0,2,0), so the tint and the lift still fired + on a button that cannot be pressed. Restating the state here puts it at + (0,3,0) at rest and (0,4,0) under the pointer, which clears every variant + rule in the kit without an !important. */ +.picker-page .ks-button[disabled], +.picker-page .ks-button[disabled]:hover, +.picker-page .ks-button[disabled]:active { + color: var(--ks-text-mute-deep); + background: transparent; + border-color: var(--ks-rule); + cursor: not-allowed; + transform: none; +} + .picker-shell { position: relative; min-height: 100vh; @@ -2122,7 +2145,6 @@ body.picker-page { .ps-proof-item i, .ps-proof-item span, .ps-editorial-copy strong, - .ps-editorial-copy span i, .ps-editorial-copy > em, .ps-gallery-item > i, .ps-gallery-item b, @@ -2130,7 +2152,8 @@ body.picker-page { .ps-footer-mark, .ps-phone-footer i, .pt-headline, - .pt-body + .pt-body, + .pt-section-body ) { transition: background-color 280ms var(--ks-ease), @@ -2217,6 +2240,34 @@ body.picker-page { scrollbar-color: color-mix(in oklab, var(--ks-patina-deep) 35%, transparent) transparent; } +/* The rail keeps the chosen pair in the first row, and it makes that move only + once the pointer and focus have both left the list, so no row is ever pulled + out from under a cursor and arrow keys can cross the group without it + re-sorting between presses. Waiting costs the user the sight of the travel, + so the row that has taken the top says so once: its wash and its spine come + up bright and settle to the resting checked state. Both keyframes animate + color and nothing else, so the acknowledgement cannot move the list it is + drawing the eye to. */ +@keyframes picker-pair-hoisted { + from { + background-color: color-mix(in oklab, var(--ks-patina) 32%, var(--ks-lacquer-raised)); + } +} + +@keyframes picker-pair-hoisted-spine { + from { + background-color: var(--ks-patina-pale); + } +} + +.picker-type-option[data-hoisted] { + animation: picker-pair-hoisted 640ms var(--ks-ease); +} + +.picker-type-option[data-hoisted]::before { + animation: picker-pair-hoisted-spine 640ms var(--ks-ease); +} + .picker-type-controls { display: flex; align-items: center; @@ -2670,6 +2721,20 @@ body.picker-page { font-size: 0.82rem; } +.picker-modal-hint:empty { + display: none; +} + +/* What the sheet is still waiting for, in the voice of the field labels above + it rather than an error. Nothing is wrong yet: the form is unfinished, so + this is muted caption ink and carries no vermilion and no icon. It sits in + the footer because that is where the button it explains is. */ +.picker-modal-hint { + color: var(--ks-text-muted); + font-size: 0.82rem; + line-height: 1.45; +} + /* Cancel then commit, both hard against the right padding edge. The dialog is a fixed 520px behind a 1200px width gate and the pair needs 247px of it, so wrapping can only ever fire on a stray long label; nowrap keeps the two @@ -2994,6 +3059,25 @@ body.picker-page { line-height: 1.55; } +/* The section paragraph is one element, so its leading is the face's own and + runs unbroken from the first line to the last. It was two stacked bars in + the wireframe, and two elements set two lines with the container's gap + between them, which read as a pair of unrelated lines rather than a + paragraph. Micro size over a near-full-column measure wants more leading + than the single-line labels around it, hence the step past their 1.4. + + No measure cap: the artboard is a fixed-proportion frame with a ceiling of + its own, so the copy column stops growing before the line does. Measured + 67 to 75 characters on the desktop board and 38 to 51 on the handset, + across every width from 1280 up. */ +.pt-section-body { + margin: 0; + color: var(--pvs-copy); + font-family: var(--pt-body); + font-size: var(--pt-micro); + line-height: 1.6; +} + /* Phone artboard type: the same ladder re-based to handset scale, held above the legibility floor rather than scaled straight down. */ .picker-preview-type .ps-phone { @@ -3013,11 +3097,16 @@ body.picker-page { Overflow stays visible: a bar had to be kept inside its box, but a line of text hangs its descenders below the baseline, and the trimmed box ends at - the baseline. Hiding the overflow would cut the tail off every g and p. */ + the baseline. Hiding the overflow would cut the tail off every g and p. + + The nowrap below is the default for a one-line label, and the proof and + card labels opt back out of it further down. Every selector in this list + is one class and one type, so those two rules win on source order. Adding + a more specific one here (a descendant pair, say) silently raises the whole + :is() above them and puts the clipping back. */ .picker-preview-type :is( .ps-nav-bars i, .ps-proof-item span, - .ps-editorial-copy span i, .ps-editorial-copy > em, .ps-gallery-item b, .ps-footer-links i, @@ -3214,12 +3303,6 @@ body.picker-page { text-wrap: balance; } -.picker-preview-type .ps-editorial-copy span i { - overflow: visible; - color: var(--pvs-copy); - white-space: normal; -} - .picker-preview-type .ps-editorial-copy > em { width: auto; height: auto; @@ -3331,7 +3414,6 @@ body.picker-page { .ps-proof-item i, .ps-proof-item span, .ps-editorial-copy strong, - .ps-editorial-copy span i, .ps-editorial-copy > em, .ps-gallery-item > i, .ps-gallery-item b, @@ -3339,7 +3421,8 @@ body.picker-page { .ps-footer-mark, .ps-phone-footer i, .pt-headline, - .pt-body + .pt-body, + .pt-section-body ) { transition: none; } @@ -3347,6 +3430,11 @@ body.picker-page { .picker-card[data-exit] { animation: none; } + + .picker-type-option[data-hoisted], + .picker-type-option[data-hoisted]::before { + animation: none; + } } .picker-width-gate {