This commit is contained in:
Abdul Wahab
2026-09-01 10:02:39 +05:00
parent f3c26f1f42
commit 336ee34b73
3 changed files with 369 additions and 28 deletions
+105 -5
View File
@@ -7,6 +7,23 @@ const roles = [
['tertiary', 'Tertiary'],
['neutral', 'Neutral'],
];
// Musical intervals, smallest step first. The ratio is the multiplier between
// one size and the next; the description says what that much contrast is for,
// since the number alone tells nobody which to pick.
const scales = [
['minor-second', 1.067, 'Minor Second', 'Almost no contrast. For dense tools where weight and color carry hierarchy instead of size.'],
['major-second', 1.125, 'Major Second', 'A quiet step. Long documents and data screens that need many levels close together.'],
['minor-third', 1.2, 'Minor Third', 'The workhorse. Clear order without shouting, and it survives on small screens.'],
['major-third', 1.25, 'Major Third', 'Confident separation. A safe default when the page has both marketing and interface on it.'],
['perfect-fourth', 1.333, 'Perfect Fourth', 'Real jumps between levels. Landing pages where the headline has to lead.'],
['augmented-fourth', 1.414, 'Augmented Fourth', 'Sharp contrast. Editorial layouts built from few sizes used decisively.'],
['perfect-fifth', 1.5, 'Perfect Fifth', 'Display type against small text, with little in between. Poster thinking.'],
['golden-ratio', 1.618, 'Golden Ratio', 'The widest range here. Beautiful with one big statement, awkward with six levels.'],
];
// Exponent above the 16px base: P sits at the base, H1 six steps up.
const scaleRows = [['H1', 6], ['H2', 5], ['H3', 4], ['H4', 3], ['H5', 2], ['H6', 1], ['P', 0]];
---
<Picker>
@@ -348,10 +365,10 @@ const roles = [
<div class="picker-type-controls">
<div class="picker-type-scroll">
<button class="picker-icon-button" type="button" data-font-scroll="-1" aria-label="Scroll font pairs up">
<button class="picker-icon-button" type="button" data-list-scroll="-1" aria-label="Scroll font pairs up">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m4.5 12.5 5.5-5 5.5 5"></path></svg>
</button>
<button class="picker-icon-button" type="button" data-font-scroll="1" aria-label="Scroll font pairs down">
<button class="picker-icon-button" type="button" data-list-scroll="1" aria-label="Scroll font pairs down">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m4.5 7.5 5.5 5 5.5-5"></path></svg>
</button>
</div>
@@ -465,7 +482,90 @@ const roles = [
</div>
</section>
<section class="picker-screen" data-screen="05" data-step="Review" aria-hidden="true" aria-labelledby="picker-finish-copy">
<section class="picker-screen" data-screen="05" data-step="Type scale" aria-hidden="true" aria-labelledby="picker-scale-title">
<div class="picker-container">
<div class="picker-scale">
<div class="picker-question-head">
<h2 id="picker-scale-title" class="picker-question-title">Choose a type scale</h2>
<p class="picker-question-lede">
A type scale is the short list of sizes every heading and paragraph is allowed to use, each one a
fixed ratio above the last. Setting it here means every screen inherits the same steps, so hierarchy
is decided once instead of re-guessed in each component.
</p>
</div>
<div class="picker-scale-grid">
<div class="picker-type-rail">
<fieldset class="picker-strategy-options picker-type-options picker-scale-options" data-scale-options>
<legend>Type scale</legend>
{scales.map(([slug, ratio, name, description]) => (
<label class="picker-strategy-option">
<input
type="radio"
name="type-scale"
value={slug}
data-ratio={ratio}
data-scale-name={name}
checked={slug === 'minor-third'}
/>
<span class="picker-strategy-copy">
<span class="picker-strategy-title">
<b class="picker-scale-ratio">{ratio.toFixed(3)}</b>
{name}
</span>
<span class="picker-strategy-desc">{description}</span>
</span>
</label>
))}
</fieldset>
<div class="picker-type-controls">
<div class="picker-type-scroll">
<button class="picker-icon-button" type="button" data-list-scroll="-1" aria-label="Scroll type scales up">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m4.5 12.5 5.5-5 5.5 5"></path></svg>
</button>
<button class="picker-icon-button" type="button" data-list-scroll="1" aria-label="Scroll type scales down">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m4.5 7.5 5.5 5 5.5-5"></path></svg>
</button>
</div>
<p class="picker-scale-note">Values at a 16px base, specimen scaled to fit</p>
</div>
</div>
<div class="picker-scale-sheet" data-scale-sheet aria-hidden="true">
{scaleRows.map(([tag, step]) => (
<div class="ts-row" data-scale-row={step}>
<p class="ts-sample" data-scale-sample></p>
<span class="ts-tag">{tag}</span>
<span class="ts-values">
<b data-scale-px></b>
<i data-scale-rem></i>
</span>
</div>
))}
</div>
</div>
<input type="hidden" name="type-scale-ratio" />
<div class="picker-actions-stack">
<button class="ks-button ks-button-primary" type="button" data-advance="next">
Select this scale
<span class="ks-button-arrow" aria-hidden="true">
<svg viewBox="0 0 16 8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="square">
<path d="M0 4h14M10 0l4 4-4 4"></path>
</svg>
</span>
</button>
<button class="picker-back" type="button" data-advance="prev">Back</button>
</div>
</div>
</div>
</section>
<section class="picker-screen" data-screen="06" data-step="Review" aria-hidden="true" aria-labelledby="picker-finish-copy">
<div class="picker-container">
<div class="picker-placeholder">
<p id="picker-finish-copy">Your visual direction is ready.</p>
@@ -486,10 +586,10 @@ const roles = [
<div class="picker-progress" aria-hidden="true">
<div class="picker-progress-track">
<i></i><i></i><i></i><i></i>
<i></i><i></i><i></i><i></i><i></i>
</div>
<p class="picker-progress-label">
<span class="picker-progress-index"><b data-progress-index>1</b> / 4</span>
<span class="picker-progress-index"><b data-progress-index>1</b> / 5</span>
<span class="picker-progress-name" data-progress-name></span>
</p>
</div>
+101 -20
View File
@@ -17,6 +17,8 @@ const strategyPreview = document.querySelector('[data-strategy-preview]');
const typePreview = document.querySelector('[data-type-preview]');
const fontOptions = document.querySelector('[data-font-options]');
const pairTemplate = document.querySelector('[data-pair-card]');
const scaleOptions = document.querySelector('[data-scale-options]');
const scaleSheet = document.querySelector('[data-scale-sheet]');
const states = new Map();
const canvases = new WeakMap();
let cards = [];
@@ -289,9 +291,12 @@ function syncFontPair(pair) {
const desktop = typePreview.querySelector('.ps-desktop');
const phoneBody = typePreview.querySelector('.ps-phone-body');
const phoneFooter = typePreview.querySelector('.ps-phone-footer');
typePreview.style.setProperty('--pt-heading', fontStack(pair.heading.family));
typePreview.style.setProperty('--pt-body', fontStack(pair.body.family));
typePreview.style.setProperty('--pt-heading-weight', pair.heading.weight);
// The scale sheet is set in the pair chosen here, so it travels with it.
for (const target of [typePreview, scaleSheet]) {
target.style.setProperty('--pt-heading', fontStack(pair.heading.family));
target.style.setProperty('--pt-body', fontStack(pair.body.family));
target.style.setProperty('--pt-heading-weight', pair.heading.weight);
}
for (const node of document.querySelectorAll('[data-type-brand]')) node.textContent = preview.brand;
fillIndexed(desktop, '[data-type-nav]', preview.nav);
for (const node of document.querySelectorAll('[data-type-nav-action]')) node.textContent = preview.navAction;
@@ -359,26 +364,93 @@ fontOptions.onchange = ({ target }) => {
if (pair) syncFontPair(pair);
};
// Scroll by whole cards so a row never ends up half in frame.
const scrollButtons = [...document.querySelectorAll('[data-font-scroll]')];
for (const button of scrollButtons) {
button.onclick = () => {
const step = fontOptions.querySelector('.picker-type-option')?.offsetHeight || 100;
fontOptions.scrollBy({ top: step * Number(button.dataset.fontScroll), behavior: 'smooth' });
/* Scroll by whole rows so an option never ends up half in frame, and disable
an arrow at the end it points to, since a live arrow that does nothing is
the reason the list looked unscrollable in the first place. */
function wireListScroll(list) {
const buttons = [...list.closest('.picker-type-rail').querySelectorAll('[data-list-scroll]')];
const sync = () => {
const room = list.scrollHeight - list.clientHeight;
for (const button of buttons) {
const down = button.dataset.listScroll === '1';
const spent = down ? list.scrollTop >= room - 1 : list.scrollTop <= 1;
button.disabled = room < 2 || spent;
}
};
}
function syncScrollButtons() {
const room = fontOptions.scrollHeight - fontOptions.clientHeight;
for (const button of scrollButtons) {
const down = button.dataset.fontScroll === '1';
const spent = down ? fontOptions.scrollTop >= room - 1 : fontOptions.scrollTop <= 1;
button.disabled = room < 2 || spent;
for (const button of buttons) {
button.onclick = () => {
const step = list.querySelector('.picker-strategy-option')?.offsetHeight || 100;
list.scrollBy({ top: step * Number(button.dataset.listScroll), behavior: 'smooth' });
};
}
list.addEventListener('scroll', sync, { passive: true });
new ResizeObserver(sync).observe(list);
return sync;
}
fontOptions.addEventListener('scroll', syncScrollButtons, { passive: true });
new ResizeObserver(syncScrollButtons).observe(fontOptions);
const syncScrollButtons = wireListScroll(fontOptions);
wireListScroll(scaleOptions);
/* Type scale.
The numbers are the real ones: step n is 16px * ratio^n, and a Golden Ratio
H1 really is 287px. The specimen cannot be, because 287px of "This is the
Golden Ratio scale" is eight times the width of the sheet, and a sheet fitted
to that H1 would set its paragraph at 2px.
So the rendering compresses the exponent by half, which keeps every scale in
its own character (a Minor Second sheet still reads as nearly flat, a Golden
Ratio one as dramatic) while bringing the range from 18x down to about 4x.
The measured fit below then scales the whole sheet if even that overflows.
The quoted px and rem stay untouched, which is the point of showing them. */
const SCALE_BASE = 16;
const SCALE_BODY_PX = 13;
const SCALE_COMPRESSION = 0.5;
const scaleRows = [...scaleSheet.querySelectorAll('[data-scale-row]')];
const scaleRatioInput = document.querySelector('[name="type-scale-ratio"]');
const trimZeros = (value) => value.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1');
/* One pass is exact: every size is linear in the fit factor, and the row boxes
the text has to fit inside do not move when it changes. */
function fitScaleSheet() {
scaleSheet.style.setProperty('--ts-fit', '1');
if (!scaleSheet.clientHeight) return;
let over = 1;
for (const row of scaleRows) {
const sample = row.querySelector('[data-scale-sample]');
over = Math.max(
over,
sample.scrollWidth / Math.max(sample.clientWidth, 1),
sample.scrollHeight / Math.max(row.clientHeight, 1),
);
}
if (over > 1.001) scaleSheet.style.setProperty('--ts-fit', (1 / over).toFixed(4));
}
function syncTypeScale(input) {
const ratio = Number(input.dataset.ratio);
const name = input.dataset.scaleName;
for (const row of scaleRows) {
const step = Number(row.dataset.scaleRow);
const px = SCALE_BASE * ratio ** step;
row.style.setProperty('--ts-size', (SCALE_BODY_PX * ratio ** (step * SCALE_COMPRESSION)).toFixed(3));
row.querySelector('[data-scale-sample]').textContent = `This is the ${name} scale`;
row.querySelector('[data-scale-px]').textContent = `${Math.round(px)}px`;
row.querySelector('[data-scale-rem]').textContent = `${trimZeros((px / SCALE_BASE).toFixed(2))}rem`;
}
scaleRatioInput.value = ratio.toFixed(3);
fitScaleSheet();
}
scaleOptions.onchange = ({ target }) => {
if (target.matches('input[name="type-scale"]')) syncTypeScale(target);
};
new ResizeObserver(fitScaleSheet).observe(scaleSheet);
// Every face swap changes the width of the same string, fit included.
document.fonts?.addEventListener('loadingdone', fitScaleSheet);
syncTypeScale(scaleOptions.querySelector('input:checked'));
/* Custom fonts. A URL is carried through as-is; an uploaded face is handed to
the server, which stores the bytes and returns the path the answers record.
@@ -791,8 +863,17 @@ scroller.addEventListener('scroll', () => {
}, { passive: true });
document.addEventListener('picker:screenchange', (event) => {
activate(event.detail.screen === '02');
const target = { '03': strategyPreview, '04': typePreview }[event.detail.screen];
const target = { '03': strategyPreview, '04': typePreview, '05': scaleSheet }[event.detail.screen];
if (target) syncCommittedPalette(target);
// 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.
if (event.detail.screen === '05') {
fitScaleSheet();
requestAnimationFrame(() => {
scaleOptions.querySelector('input:checked')?.parentElement.scrollIntoView({ block: 'center' });
});
}
});
try {
+163 -3
View File
@@ -251,7 +251,8 @@ body.picker-page {
.picker-shell:has(#picker-form[data-current="02"]) .picker-hero-art,
.picker-shell:has(#picker-form[data-current="03"]) .picker-hero-art,
.picker-shell:has(#picker-form[data-current="04"]) .picker-hero-art {
.picker-shell:has(#picker-form[data-current="04"]) .picker-hero-art,
.picker-shell:has(#picker-form[data-current="05"]) .picker-hero-art {
opacity: 0;
}
@@ -269,7 +270,7 @@ body.picker-page {
.picker-progress-track {
display: grid;
grid-template-columns: repeat(4, 34px);
grid-template-columns: repeat(5, 34px);
gap: 6px;
}
@@ -282,7 +283,8 @@ body.picker-page {
.picker-progress[data-step="1"] .picker-progress-track i:nth-child(-n + 1),
.picker-progress[data-step="2"] .picker-progress-track i:nth-child(-n + 2),
.picker-progress[data-step="3"] .picker-progress-track i:nth-child(-n + 3),
.picker-progress[data-step="4"] .picker-progress-track i {
.picker-progress[data-step="4"] .picker-progress-track i:nth-child(-n + 4),
.picker-progress[data-step="5"] .picker-progress-track i {
background: var(--ks-kinpaku);
}
@@ -2164,6 +2166,164 @@ body.picker-page {
gap: 12px;
}
/* Screen 05 keeps screen 04's frame and swaps the artboards for a specimen
sheet. It is the one question that needs explaining before it can be
answered, so the title carries a lede; every other screen asks something the
options themselves make obvious. */
.picker-scale {
display: grid;
gap: clamp(24px, 4.4svh, 40px);
padding-block: clamp(32px, 5svh, 48px) clamp(24px, 3.5svh, 48px);
}
.picker-question-head {
display: grid;
justify-items: center;
gap: clamp(12px, 2svh, 18px);
}
.picker-question-lede {
max-width: 62ch;
color: var(--ks-text-muted);
font-size: 0.95rem;
line-height: 1.65;
text-align: center;
text-wrap: pretty;
}
/* Eight intervals and a lede leave less room than screen 04 has, so the sheet
is capped harder. The subtraction covers the title, the lede, and the CTA
stack, which is what keeps Select this scale above the fold. */
.picker-scale-grid {
max-height: calc(100svh - 420px);
display: grid;
grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
gap: 50px;
align-items: stretch;
}
/* Eight rows scroll rather than share the height four strategy rows can split,
so this list takes screen 04's rail and screen 03's copy scale, not screen
03's fixed four-row grid. */
.picker-scale-options .picker-strategy-copy {
gap: 4px;
}
.picker-scale-options .picker-strategy-title {
display: flex;
align-items: baseline;
font-family: var(--ks-font);
font-size: 20px;
letter-spacing: -0.015em;
}
.picker-scale-options .picker-strategy-desc {
max-width: 38ch;
font-size: 14px;
line-height: 1.45;
}
/* The ratio leads the name: it is the thing being chosen, and eight of them
in a column read as a ladder when the numbers line up. */
.picker-scale-ratio {
margin-inline-end: 12px;
color: var(--ks-text-faint);
font-family: var(--ks-mono);
font-size: 0.94rem;
font-weight: 400;
font-variant-numeric: tabular-nums;
letter-spacing: 0;
transition: color 180ms var(--ks-ease);
}
.picker-strategy-option:has(input:checked) .picker-scale-ratio {
color: color-mix(in oklab, var(--ks-patina) 70%, var(--ks-champagne));
}
.picker-scale-note {
color: var(--ks-text-faint);
font-size: 0.76rem;
letter-spacing: 0.04em;
}
/* Paper, set in the palette the user committed and the pair they just chose,
so the scale is judged in the design it will actually live in. */
.picker-scale-sheet {
--ts-fit: 1;
--pv-neutral: var(--ks-lacquer-raised);
--pv-n-ink: var(--ks-champagne);
--pt-heading: var(--ks-font-display);
--pt-body: var(--ks-font);
--pt-heading-weight: 400;
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: repeat(7, minmax(0, 1fr));
padding: clamp(14px, 2.4%, 28px) clamp(18px, 3.2%, 36px);
background: var(--pv-neutral);
color: var(--pv-n-ink);
border-radius: 4px;
overflow: hidden;
}
.ts-row {
min-width: 0;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 2.6em auto;
align-items: center;
gap: clamp(12px, 2vw, 24px);
border-bottom: 1px dotted color-mix(in oklab, var(--pv-n-ink) 26%, transparent);
}
.ts-row:last-child {
border-bottom: 0;
}
.ts-sample {
min-width: 0;
overflow: hidden;
font-family: var(--pt-heading);
font-weight: var(--pt-heading-weight);
font-size: calc(var(--ts-size) * var(--ts-fit) * 1px);
line-height: 1.12;
white-space: nowrap;
}
.ts-row[data-scale-row="0"] .ts-sample {
font-family: var(--pt-body);
font-weight: 400;
}
/* The tag and the numbers are chrome, not specimen: they hold one size while
everything to their left moves, which is what makes the movement legible. */
.ts-tag {
color: color-mix(in oklab, var(--pv-n-ink) 62%, transparent);
font-family: var(--ks-font);
font-size: 0.82rem;
letter-spacing: 0.06em;
}
.ts-values {
display: grid;
justify-items: end;
gap: 2px;
font-family: var(--ks-mono);
font-size: 0.68rem;
font-variant-numeric: tabular-nums;
line-height: 1.25;
}
.ts-values b {
color: color-mix(in oklab, var(--pv-n-ink) 88%, transparent);
font-weight: 500;
}
.ts-values i {
color: color-mix(in oklab, var(--pv-n-ink) 52%, transparent);
font-style: normal;
}
.picker-type-scroll {
display: flex;
gap: 8px;