This commit is contained in:
Abdul Wahab
2026-09-01 10:02:39 +05:00
parent 8489467b99
commit ba9b1cd8c3
3 changed files with 287 additions and 33 deletions
+11 -3
View File
@@ -407,7 +407,7 @@ const scaleRows = [['H1', 6], ['H2', 5], ['H3', 4], ['H4', 3], ['H5', 2], ['H6',
<div class="ps-editorial">
<div class="ps-editorial-copy">
<strong data-type-section-title></strong>
<span><i data-type-section-body></i><i data-type-section-body></i></span>
<p class="pt-section-body" data-type-section-body></p>
<em data-type-section-link></em>
</div>
<div class="ps-gallery">
@@ -439,7 +439,7 @@ const scaleRows = [['H1', 6], ['H2', 5], ['H3', 4], ['H4', 3], ['H5', 2], ['H6',
</div>
<div class="ps-editorial-copy">
<strong data-type-section-title></strong>
<span><i data-type-section-body></i><i data-type-section-body></i></span>
<p class="pt-section-body" data-type-section-body></p>
</div>
<div class="ps-gallery">
<div class="ps-gallery-item"><i></i><span><b data-type-gallery-title></b><b data-type-gallery-meta></b></span></div>
@@ -633,9 +633,17 @@ const scaleRows = [['H1', 6], ['H2', 5], ['H3', 4], ['H4', 3], ['H5', 2], ['H6',
<p class="picker-modal-status" data-custom-status aria-live="polite"></p>
<p class="picker-modal-hint" id="picker-font-modal-hint" data-custom-hint aria-live="polite"></p>
<div class="picker-modal-actions">
<button class="picker-back" type="button" data-font-custom-close>Cancel</button>
<button class="ks-button ks-button-primary" type="button" data-font-custom-save>Use these fonts</button>
<button
class="ks-button ks-button-secondary"
type="button"
data-font-custom-save
aria-describedby="picker-font-modal-hint"
disabled
>Use these fonts</button>
</div>
</div>
</dialog>
+173 -15
View File
@@ -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.
+103 -15
View File
@@ -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 {