@@ -509,11 +516,20 @@ const roles = [
data-custom-url={role}
/>
or
-
+
+
+
+
+
+
+
))}
diff --git a/picker/scripts/palette-picker.js b/picker/scripts/palette-picker.js
index 0392699a8..382c4ad4f 100644
--- a/picker/scripts/palette-picker.js
+++ b/picker/scripts/palette-picker.js
@@ -387,9 +387,77 @@ 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 FONT_SOURCE_SEP = '\n';
+const FONT_FILE_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
+const customFilesByRole = { heading: [], body: [] };
+
+function fontFileExtension(name) {
+ const index = name.lastIndexOf('.');
+ return index === -1 ? '' : name.slice(index).toLowerCase();
+}
+
+function isFontFile(file) {
+ return FONT_FILE_EXTENSIONS.has(fontFileExtension(file.name));
+}
+
+function setChosenFontFiles(input, files) {
+ const transfer = new DataTransfer();
+ for (const file of files) transfer.items.add(file);
+ input.files = transfer.files;
+}
+
+function sameFontFile(a, b) {
+ return a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
+}
+
+function mergeFontFiles(existing, incoming) {
+ const merged = [...existing];
+ for (const file of incoming) {
+ if (!merged.some((entry) => sameFontFile(entry, file))) merged.push(file);
+ }
+ return merged;
+}
+
+function renderCustomFileList(role) {
+ const list = fontModal.querySelector(`[data-custom-file-list="${role}"]`);
+ const files = customFilesByRole[role];
+ list.replaceChildren();
+ list.hidden = files.length === 0;
+ for (const file of files) {
+ const item = document.createElement('li');
+ item.className = 'picker-modal-file-item';
+ const name = document.createElement('span');
+ name.textContent = file.name;
+ const remove = document.createElement('button');
+ remove.type = 'button';
+ remove.className = 'picker-modal-file-remove';
+ remove.setAttribute('aria-label', `Remove ${file.name}`);
+ remove.textContent = '×';
+ remove.onclick = () => {
+ customFilesByRole[role] = customFilesByRole[role].filter((entry) => !sameFontFile(entry, file));
+ setChosenFontFiles(customFile(role), customFilesByRole[role]);
+ renderCustomFileList(role);
+ customStatus.textContent = '';
+ };
+ item.append(name, remove);
+ list.append(item);
+ }
+}
+
+function parseFontSources(source) {
+ if (!source) return [];
+ if (source.includes(FONT_SOURCE_SEP)) return source.split(FONT_SOURCE_SEP).filter(Boolean);
+ return [source];
+}
document.querySelector('[data-font-custom-open]').onclick = () => {
customStatus.textContent = '';
+ for (const role of ['heading', 'body']) {
+ customFilesByRole[role] = [];
+ customFile(role).value = '';
+ setChosenFontFiles(customFile(role), []);
+ renderCustomFileList(role);
+ }
fontModal.showModal();
};
@@ -397,22 +465,38 @@ document.querySelector('[data-font-custom-close]').onclick = () => fontModal.clo
for (const role of ['heading', 'body']) {
customFile(role).onchange = ({ target }) => {
- const label = fontModal.querySelector(`[data-custom-file-name="${role}"]`);
- label.textContent = target.files[0]?.name || 'No file chosen';
+ const incoming = [...target.files].filter(isFontFile);
+ if (incoming.length !== target.files.length) {
+ customStatus.textContent = 'Only .woff2, .woff, .ttf, and .otf files are accepted.';
+ } else {
+ customStatus.textContent = '';
+ }
+ customFilesByRole[role] = mergeFontFiles(customFilesByRole[role], incoming);
+ setChosenFontFiles(target, customFilesByRole[role]);
+ target.value = '';
+ renderCustomFileList(role);
};
}
+async function uploadFontFile(file) {
+ const response = await fetch('/font-upload', {
+ method: 'POST',
+ headers: { 'X-Font-Filename': file.name, 'Content-Type': 'application/octet-stream' },
+ body: file,
+ });
+ const result = await response.json();
+ if (!response.ok) throw new Error(result.error || 'Upload failed');
+ return result.path;
+}
+
async function resolveCustomFace(role) {
- const file = customFile(role).files[0];
- if (file) {
- const response = await fetch('/font-upload', {
- method: 'POST',
- headers: { 'X-Font-Filename': file.name, 'Content-Type': 'application/octet-stream' },
- body: file,
- });
- const result = await response.json();
- if (!response.ok) throw new Error(result.error || 'Upload failed');
- return { family: file.name.replace(/\.[^.]+$/, ''), source: result.path };
+ const files = customFilesByRole[role].filter(isFontFile);
+ if (files.length) {
+ const paths = await Promise.all(files.map((file) => uploadFontFile(file)));
+ return {
+ family: files[0].name.replace(/\.[^.]+$/, ''),
+ source: paths.join(FONT_SOURCE_SEP),
+ };
}
const url = customUrl(role).value.trim();
return url ? { family: url.split('/').pop().replace(/\.[^.]+$/, '') || 'Custom', source: url } : null;
@@ -454,15 +538,17 @@ document.querySelector('[data-font-custom-save]').onclick = async () => {
function loadCustomFace({ heading, body }) {
for (const face of [heading, body]) {
if (!face?.source) continue;
- if (/\.(css)(\?|$)/i.test(face.source) || !/\.(woff2?|ttf|otf)(\?|$)/i.test(face.source)) {
+ if (/\.(css)(\?|$)/i.test(face.source) || (!face.source.includes(FONT_SOURCE_SEP) && !/\.(woff2?|ttf|otf)(\?|$)/i.test(face.source))) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = face.source;
document.head.append(link);
continue;
}
- const src = face.source.startsWith('http') ? face.source : `/fonts/${face.source.split('/').pop()}`;
- document.fonts.add(new FontFace(face.family, `url(${src})`));
+ for (const entry of parseFontSources(face.source)) {
+ const src = entry.startsWith('http') ? entry : `/fonts/${entry.split('/').pop()}`;
+ document.fonts.add(new FontFace(face.family, `url(${src})`));
+ }
document.fonts.load(`16px "${face.family}"`);
}
}
diff --git a/picker/styles/picker.css b/picker/styles/picker.css
index 841f3f747..a0dae60e7 100644
--- a/picker/styles/picker.css
+++ b/picker/styles/picker.css
@@ -146,6 +146,10 @@ body.picker-page {
padding-inline: 56px;
}
+.picker-screen[data-screen="01"] .picker-container {
+ padding-inline: 75px;
+}
+
.picker-start {
max-width: 620px;
display: grid;
@@ -1670,15 +1674,37 @@ body.picker-page {
border-radius: 50%;
}
-.picker-strategy-preview:not(.picker-preview-type) .ps-proof-item:nth-child(2) i {
+.picker-strategy-preview:not(.picker-preview-type) .ps-proof {
+ grid-template-columns:
+ minmax(0, 1fr) auto
+ minmax(0, 1fr) auto
+ minmax(0, 1fr) auto
+ minmax(0, 1fr);
+}
+
+.picker-strategy-preview:not(.picker-preview-type) .ps-proof-item + .ps-proof-item {
+ border-left: 0;
+}
+
+.picker-strategy-preview:not(.picker-preview-type) .ps-proof-divider {
+ width: 1px;
+ align-self: stretch;
+ background-color: var(--pvs-rule);
+}
+
+.picker-strategy-preview:not(.picker-preview-type) .ps-phone-body .ps-proof {
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+}
+
+.picker-strategy-preview:not(.picker-preview-type) .ps-proof-item:nth-of-type(2) i {
background-color: var(--pvs-accent-b);
}
-.picker-strategy-preview:not(.picker-preview-type) .ps-proof-item:nth-child(3) i {
+.picker-strategy-preview:not(.picker-preview-type) .ps-proof-item:nth-of-type(3) i {
background-color: var(--pvs-accent-c);
}
-.picker-strategy-preview:not(.picker-preview-type) .ps-proof-item:nth-child(4) i {
+.picker-strategy-preview:not(.picker-preview-type) .ps-proof-item:nth-of-type(4) i {
background-color: var(--pvs-accent-d);
}
@@ -2106,7 +2132,9 @@ body.picker-page {
/* The rail keeps the row height owned by the preview: the fieldset scrolls
inside it instead of stretching the page when six tall cards stack up. The
- control row underneath is what tells you the list runs past its frame. */
+ control row underneath is what tells you the list runs past its frame. On
+ wide screens the rail stops growing at 570px so a tall preview does not
+ turn six font cards into a page-length column. */
.picker-type-rail {
min-width: 0;
min-height: 0;
@@ -2115,6 +2143,12 @@ body.picker-page {
gap: 14px;
}
+@media (min-width: 1200px) {
+ .picker-type-rail {
+ max-height: 570px;
+ }
+}
+
.picker-type-options {
min-height: 0;
overflow-y: auto;
@@ -2205,7 +2239,7 @@ body.picker-page {
.picker-modal-head h2 {
color: var(--ks-champagne);
- font-family: var(--ks-font-display);
+ font-family: var(--ks-font);
font-size: 1.7rem;
font-weight: 400;
line-height: 1.1;
@@ -2268,6 +2302,11 @@ body.picker-page {
text-transform: uppercase;
}
+.picker-modal-file-block {
+ display: grid;
+ gap: 10px;
+}
+
.picker-modal-file {
display: flex;
align-items: center;
@@ -2281,10 +2320,13 @@ body.picker-page {
height: 1px;
overflow: hidden;
clip-path: inset(50%);
+ white-space: nowrap;
}
.picker-modal-file-button {
flex: none;
+ display: inline-flex;
+ align-items: center;
padding: 8px 14px;
border: 1px solid var(--ks-rule);
border-radius: 2px;
@@ -2304,15 +2346,59 @@ body.picker-page {
outline-offset: 2px;
}
-.picker-modal-file-name {
+.picker-modal-file-list {
+ display: grid;
+ gap: 6px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.picker-modal-file-item {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ background: var(--ks-lacquer-deep);
+ border: 1px solid var(--ks-rule);
+ border-radius: 2px;
+}
+
+.picker-modal-file-item span {
min-width: 0;
overflow: hidden;
- color: var(--ks-text-muted);
+ color: var(--ks-text);
font-size: 0.8rem;
text-overflow: ellipsis;
white-space: nowrap;
}
+.picker-modal-file-remove {
+ flex: none;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border: 0;
+ border-radius: 2px;
+ background: transparent;
+ color: var(--ks-text-muted);
+ font-size: 1.1rem;
+ line-height: 1;
+ cursor: pointer;
+ transition: color 180ms var(--ks-ease), background-color 180ms var(--ks-ease);
+}
+
+.picker-modal-file-remove:hover {
+ color: var(--ks-champagne);
+ background: color-mix(in oklab, var(--ks-patina) 10%, transparent);
+}
+
+.picker-modal-file-remove:focus-visible {
+ outline: 1px solid var(--ks-kinpaku);
+ outline-offset: 2px;
+}
+
.picker-modal-status:empty {
display: none;
}
@@ -2323,9 +2409,11 @@ body.picker-page {
}
.picker-modal-actions {
- display: grid;
- justify-items: center;
- gap: 12px;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 18px;
}
/* Screen 04 mirrors screen 03's options panel. */
@@ -2570,6 +2658,40 @@ body.picker-page {
color: var(--pvs-title);
}
+.picker-preview-type .ps-proof {
+ grid-template-columns:
+ minmax(0, 1fr) auto
+ minmax(0, 1fr) auto
+ minmax(0, 1fr) auto
+ minmax(0, 1fr);
+}
+
+.picker-preview-type .ps-proof-item + .ps-proof-item {
+ border-left: 0;
+}
+
+.picker-preview-type .ps-proof-divider {
+ width: 1px;
+ align-self: stretch;
+ background-color: var(--pvs-rule);
+}
+
+.picker-preview-type .ps-phone-body .ps-proof {
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+}
+
+.picker-preview-type .ps-proof-item:nth-of-type(2) i {
+ background-color: var(--pvs-accent-b);
+}
+
+.picker-preview-type .ps-proof-item:nth-of-type(3) i {
+ background-color: var(--pvs-accent-c);
+}
+
+.picker-preview-type .ps-proof-item:nth-of-type(4) i {
+ background-color: var(--pvs-accent-d);
+}
+
.picker-preview-type .ps-proof-item span {
color: var(--pvs-bars);
}