diff --git a/picker/pages/index.astro b/picker/pages/index.astro index dfa1cd460..cffc8d797 100644 --- a/picker/pages/index.astro +++ b/picker/pages/index.astro @@ -194,7 +194,6 @@ const roles = [ - @@ -267,8 +266,11 @@ const roles = [
+
+
+
@@ -301,6 +303,7 @@ const roles = [
+
@@ -377,8 +380,11 @@ const roles = [
+
+
+
@@ -411,6 +417,7 @@ const roles = [
+
@@ -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); }