diff --git a/NOTICE.md b/NOTICE.md
index 0468271c9..8b3884fd6 100644
--- a/NOTICE.md
+++ b/NOTICE.md
@@ -9,3 +9,21 @@ The `skill/reference/ios.md` and `skill/reference/android.md` platform reference
**Original work:** https://github.com/ehmo/platform-design-skills
**Original license:** MIT
**Author:** ehmo
+
+## Icon specimens in the design picker
+
+`picker/data/icon-packs.json`, and the copy of it built into `skill/scripts/picker/`, holds twenty-four glyphs from each of the icon families the picker offers, redistributed unmodified apart from the removal of attributes the specimen does not draw with. They are shown so a user can compare the families and choose one; the choice, not this file, is what a project then installs. `scripts/vendor-icons.mjs` records the exact version each set was taken from.
+
+| Set | License | Source |
+|---|---|---|
+| Lucide | ISC | https://github.com/lucide-icons/lucide |
+| Feather | MIT | https://github.com/feathericons/feather |
+| Phosphor | MIT | https://github.com/phosphor-icons/core |
+| Heroicons | MIT | https://github.com/tailwindlabs/heroicons |
+| Tabler | MIT | https://github.com/tabler/tabler-icons |
+| Radix Icons | MIT | https://github.com/radix-ui/icons |
+| Bootstrap Icons | MIT | https://github.com/twbs/icons |
+| Iconoir | MIT | https://github.com/iconoir-icons/iconoir |
+| Octicons | MIT | https://github.com/primer/octicons |
+| Hugeicons (free core) | MIT | https://github.com/hugeicons/hugeicons-react |
+| Remix Icon | Apache-2.0 | https://github.com/Remix-Design/RemixIcon |
diff --git a/picker/data/icon-packs.json b/picker/data/icon-packs.json
new file mode 100644
index 000000000..11caacc00
--- /dev/null
+++ b/picker/data/icon-packs.json
@@ -0,0 +1 @@
+{"generated":"2026-07-26","concepts":["home","search","user","settings","bell","heart","star","calendar","mail","message","trash","download","upload","plus","check","close","chevron-right","arrow-right","menu","filter","edit","lock","eye","external-link"],"packs":[{"id":"lucide","name":"Lucide","license":"ISC","url":"https://lucide.dev","note":"Feather’s successor. Same 2px round-cap hand, far wider catalog, still shipping.","grid":"24","weight":"2px stroke","count":2007,"version":"1.27.0","viewBox":"0 0 24 24","attrs":{"fill":"none","stroke":"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round"},"glyphs":[{"concept":"home","name":"home","body":""},{"concept":"search","name":"search","body":""},{"concept":"user","name":"user","body":""},{"concept":"settings","name":"settings","body":""},{"concept":"bell","name":"bell","body":""},{"concept":"heart","name":"heart","body":""},{"concept":"star","name":"star","body":""},{"concept":"calendar","name":"calendar","body":""},{"concept":"mail","name":"mail","body":""},{"concept":"message","name":"message-circle","body":""},{"concept":"trash","name":"trash-2","body":""},{"concept":"download","name":"download","body":""},{"concept":"upload","name":"upload","body":""},{"concept":"plus","name":"plus","body":""},{"concept":"check","name":"check","body":""},{"concept":"close","name":"x","body":""},{"concept":"chevron-right","name":"chevron-right","body":""},{"concept":"arrow-right","name":"arrow-right","body":""},{"concept":"menu","name":"menu","body":""},{"concept":"filter","name":"filter","body":""},{"concept":"edit","name":"pencil","body":""},{"concept":"lock","name":"lock","body":""},{"concept":"eye","name":"eye","body":""},{"concept":"external-link","name":"external-link","body":""}]},{"id":"feather","name":"Feather","license":"MIT","url":"https://feathericons.com","note":"The 2px original most stroke sets descend from. Small, even, no longer growing.","grid":"24","weight":"2px stroke","count":287,"version":"4.29.2","viewBox":"0 0 24 24","attrs":{"fill":"none","stroke":"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round"},"glyphs":[{"concept":"home","name":"home","body":""},{"concept":"search","name":"search","body":""},{"concept":"user","name":"user","body":""},{"concept":"settings","name":"settings","body":""},{"concept":"bell","name":"bell","body":""},{"concept":"heart","name":"heart","body":""},{"concept":"star","name":"star","body":""},{"concept":"calendar","name":"calendar","body":""},{"concept":"mail","name":"mail","body":""},{"concept":"message","name":"message-circle","body":""},{"concept":"trash","name":"trash-2","body":""},{"concept":"download","name":"download","body":""},{"concept":"upload","name":"upload","body":""},{"concept":"plus","name":"plus","body":""},{"concept":"check","name":"check","body":""},{"concept":"close","name":"x","body":""},{"concept":"chevron-right","name":"chevron-right","body":""},{"concept":"arrow-right","name":"arrow-right","body":""},{"concept":"menu","name":"menu","body":""},{"concept":"filter","name":"filter","body":""},{"concept":"edit","name":"edit-3","body":""},{"concept":"lock","name":"lock","body":""},{"concept":"eye","name":"eye","body":""},{"concept":"external-link","name":"external-link","body":""}]},{"id":"phosphor","name":"Phosphor","license":"MIT","url":"https://phosphoricons.com","note":"Drawn on a 256 grid with a soft geometric hand. Six weights upstream, regular here.","grid":"256","weight":"regular weight","count":1512,"version":"2.1.1","viewBox":"0 0 256 256","attrs":{"fill":"currentColor"},"glyphs":[{"concept":"home","name":"house","body":""},{"concept":"search","name":"magnifying-glass","body":""},{"concept":"user","name":"user","body":""},{"concept":"settings","name":"gear-six","body":""},{"concept":"bell","name":"bell","body":""},{"concept":"heart","name":"heart","body":""},{"concept":"star","name":"star","body":""},{"concept":"calendar","name":"calendar","body":""},{"concept":"mail","name":"envelope","body":""},{"concept":"message","name":"chat-circle","body":""},{"concept":"trash","name":"trash","body":""},{"concept":"download","name":"download","body":""},{"concept":"upload","name":"upload","body":""},{"concept":"plus","name":"plus","body":""},{"concept":"check","name":"check","body":""},{"concept":"close","name":"x","body":""},{"concept":"chevron-right","name":"caret-right","body":""},{"concept":"arrow-right","name":"arrow-right","body":""},{"concept":"menu","name":"list","body":""},{"concept":"filter","name":"funnel","body":""},{"concept":"edit","name":"pencil","body":""},{"concept":"lock","name":"lock","body":""},{"concept":"eye","name":"eye","body":""},{"concept":"external-link","name":"arrow-square-out","body":""}]},{"id":"heroicons","name":"Heroicons","license":"MIT","url":"https://heroicons.com","note":"Tailwind’s set. 1.5px strokes at 24, with solid and 20px mini twins for dense UI.","grid":"24","weight":"1.5px stroke","count":324,"version":"2.2.0","viewBox":"0 0 24 24","attrs":{"fill":"none","stroke":"currentColor","stroke-width":"1.5","stroke-linecap":"round","stroke-linejoin":"round"},"glyphs":[{"concept":"home","name":"home","body":""},{"concept":"search","name":"magnifying-glass","body":""},{"concept":"user","name":"user","body":""},{"concept":"settings","name":"cog","body":""},{"concept":"bell","name":"bell","body":""},{"concept":"heart","name":"heart","body":""},{"concept":"star","name":"star","body":""},{"concept":"calendar","name":"calendar","body":""},{"concept":"mail","name":"envelope","body":""},{"concept":"message","name":"chat-bubble-left","body":""},{"concept":"trash","name":"trash","body":""},{"concept":"download","name":"arrow-down-tray","body":""},{"concept":"upload","name":"arrow-up-tray","body":""},{"concept":"plus","name":"plus","body":""},{"concept":"check","name":"check","body":""},{"concept":"close","name":"x-mark","body":""},{"concept":"chevron-right","name":"chevron-right","body":""},{"concept":"arrow-right","name":"arrow-right","body":""},{"concept":"menu","name":"bars-3","body":""},{"concept":"filter","name":"funnel","body":""},{"concept":"edit","name":"pencil","body":""},{"concept":"lock","name":"lock-closed","body":""},{"concept":"eye","name":"eye","body":""},{"concept":"external-link","name":"arrow-top-right-on-square","body":""}]},{"id":"tabler","name":"Tabler","license":"MIT","url":"https://tabler.io/icons","note":"The widest stroke catalog here, uniform enough that mixed rows read as one hand.","grid":"24","weight":"2px stroke","count":5112,"version":"3.45.0","viewBox":"0 0 24 24","attrs":{"fill":"none","stroke":"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round"},"glyphs":[{"concept":"home","name":"home","body":""},{"concept":"search","name":"search","body":""},{"concept":"user","name":"user","body":""},{"concept":"settings","name":"settings","body":""},{"concept":"bell","name":"bell","body":""},{"concept":"heart","name":"heart","body":""},{"concept":"star","name":"star","body":""},{"concept":"calendar","name":"calendar","body":""},{"concept":"mail","name":"mail","body":""},{"concept":"message","name":"message-circle","body":""},{"concept":"trash","name":"trash","body":""},{"concept":"download","name":"download","body":""},{"concept":"upload","name":"upload","body":""},{"concept":"plus","name":"plus","body":""},{"concept":"check","name":"check","body":""},{"concept":"close","name":"x","body":""},{"concept":"chevron-right","name":"chevron-right","body":""},{"concept":"arrow-right","name":"arrow-right","body":""},{"concept":"menu","name":"menu-2","body":""},{"concept":"filter","name":"filter","body":""},{"concept":"edit","name":"pencil","body":""},{"concept":"lock","name":"lock","body":""},{"concept":"eye","name":"eye","body":""},{"concept":"external-link","name":"external-link","body":""}]},{"id":"radix","name":"Radix Icons","license":"MIT","url":"https://www.radix-ui.com/icons","note":"A 15px grid built for product chrome. Crisp at control size, thin if you scale it up.","grid":"15","weight":"filled paths","count":332,"version":"112af91ad275a63c3a29b0da2588342af74ef9bf","viewBox":"0 0 15 15","attrs":{"fill":"none"},"glyphs":[{"concept":"home","name":"home","body":""},{"concept":"search","name":"magnifying-glass","body":""},{"concept":"user","name":"person","body":""},{"concept":"settings","name":"gear","body":""},{"concept":"bell","name":"bell","body":""},{"concept":"heart","name":"heart","body":""},{"concept":"star","name":"star","body":""},{"concept":"calendar","name":"calendar","body":""},{"concept":"mail","name":"envelope-closed","body":""},{"concept":"message","name":"chat-bubble","body":""},{"concept":"trash","name":"trash","body":""},{"concept":"download","name":"download","body":""},{"concept":"upload","name":"upload","body":""},{"concept":"plus","name":"plus","body":""},{"concept":"check","name":"check","body":""},{"concept":"close","name":"cross-1","body":""},{"concept":"chevron-right","name":"chevron-right","body":""},{"concept":"arrow-right","name":"arrow-right","body":""},{"concept":"menu","name":"hamburger-menu","body":""},{"concept":"filter","name":"mixer-horizontal","body":""},{"concept":"edit","name":"pencil-1","body":""},{"concept":"lock","name":"lock-closed","body":""},{"concept":"eye","name":"eye-open","body":""},{"concept":"external-link","name":"external-link","body":""}]},{"id":"bootstrap","name":"Bootstrap Icons","license":"MIT","url":"https://icons.getbootstrap.com","note":"Filled paths on a 16 grid, so weight holds at any size. More literal than the stroke sets.","grid":"16","weight":"filled paths","count":1408,"version":"1.13.1","viewBox":"0 0 16 16","attrs":{"fill":"currentColor"},"glyphs":[{"concept":"home","name":"house","body":""},{"concept":"search","name":"search","body":""},{"concept":"user","name":"person","body":""},{"concept":"settings","name":"gear","body":""},{"concept":"bell","name":"bell","body":""},{"concept":"heart","name":"heart","body":""},{"concept":"star","name":"star","body":""},{"concept":"calendar","name":"calendar","body":""},{"concept":"mail","name":"envelope","body":""},{"concept":"message","name":"chat","body":""},{"concept":"trash","name":"trash","body":""},{"concept":"download","name":"download","body":""},{"concept":"upload","name":"upload","body":""},{"concept":"plus","name":"plus","body":""},{"concept":"check","name":"check","body":""},{"concept":"close","name":"x","body":""},{"concept":"chevron-right","name":"chevron-right","body":""},{"concept":"arrow-right","name":"arrow-right","body":""},{"concept":"menu","name":"list","body":""},{"concept":"filter","name":"funnel","body":""},{"concept":"edit","name":"pencil","body":""},{"concept":"lock","name":"lock","body":""},{"concept":"eye","name":"eye","body":""},{"concept":"external-link","name":"box-arrow-up-right","body":""}]},{"id":"iconoir","name":"Iconoir","license":"MIT","url":"https://iconoir.com","note":"1.5px strokes on 24 with an open, geometric hand. Roomier than Lucide at the same size.","grid":"24","weight":"1.5px stroke","count":1383,"version":"7.11.1","viewBox":"0 0 24 24","attrs":{"fill":"none","stroke":"currentColor","stroke-width":"1.5","stroke-linecap":"round","stroke-linejoin":"round"},"glyphs":[{"concept":"home","name":"home","body":""},{"concept":"search","name":"search","body":""},{"concept":"user","name":"user","body":""},{"concept":"settings","name":"settings","body":""},{"concept":"bell","name":"bell","body":""},{"concept":"heart","name":"heart","body":""},{"concept":"star","name":"star","body":""},{"concept":"calendar","name":"calendar","body":""},{"concept":"mail","name":"mail","body":""},{"concept":"message","name":"chat-bubble","body":""},{"concept":"trash","name":"trash","body":""},{"concept":"download","name":"download","body":""},{"concept":"upload","name":"upload","body":""},{"concept":"plus","name":"plus","body":""},{"concept":"check","name":"check","body":""},{"concept":"close","name":"xmark","body":""},{"concept":"chevron-right","name":"nav-arrow-right","body":""},{"concept":"arrow-right","name":"arrow-right","body":""},{"concept":"menu","name":"menu","body":""},{"concept":"filter","name":"filter","body":""},{"concept":"edit","name":"edit-pencil","body":""},{"concept":"lock","name":"lock","body":""},{"concept":"eye","name":"eye","body":""},{"concept":"external-link","name":"open-new-window","body":""}]},{"id":"octicons","name":"Octicons","license":"MIT","url":"https://primer.style/octicons","note":"GitHub’s set, redrawn per size rather than scaled. Compact and developer-fluent.","grid":"24","weight":"filled paths","count":350,"version":"19.31.0","viewBox":"0 0 24 24","attrs":{"fill":"currentColor"},"glyphs":[{"concept":"home","name":"home-24","body":""},{"concept":"search","name":"search-24","body":""},{"concept":"user","name":"person-24","body":""},{"concept":"settings","name":"gear-24","body":""},{"concept":"bell","name":"bell-24","body":""},{"concept":"heart","name":"heart-24","body":""},{"concept":"star","name":"star-24","body":""},{"concept":"calendar","name":"calendar-24","body":""},{"concept":"mail","name":"mail-24","body":""},{"concept":"message","name":"comment-24","body":""},{"concept":"trash","name":"trash-24","body":""},{"concept":"download","name":"download-24","body":""},{"concept":"upload","name":"upload-24","body":""},{"concept":"plus","name":"plus-24","body":""},{"concept":"check","name":"check-24","body":""},{"concept":"close","name":"x-24","body":""},{"concept":"chevron-right","name":"chevron-right-24","body":""},{"concept":"arrow-right","name":"arrow-right-24","body":""},{"concept":"menu","name":"three-bars-24","body":""},{"concept":"filter","name":"filter-24","body":""},{"concept":"edit","name":"pencil-24","body":""},{"concept":"lock","name":"lock-24","body":""},{"concept":"eye","name":"eye-24","body":""},{"concept":"external-link","name":"link-external-24","body":""}]},{"id":"hugeicons","name":"Hugeicons","license":"MIT (free core)","url":"https://hugeicons.com","note":"1.5px rounded strokes on 24. The free core is one style; the other styles are paid.","grid":"24","weight":"1.5px stroke","count":5443,"version":"4.2.3","viewBox":"0 0 24 24","attrs":{"fill":"none","stroke":"currentColor","stroke-width":"1.5","stroke-linecap":"round","stroke-linejoin":"round"},"glyphs":[{"concept":"home","name":"Home01Icon","body":""},{"concept":"search","name":"Search01Icon","body":""},{"concept":"user","name":"UserIcon","body":""},{"concept":"settings","name":"Settings01Icon","body":""},{"concept":"bell","name":"Notification01Icon","body":""},{"concept":"heart","name":"FavouriteIcon","body":""},{"concept":"star","name":"StarIcon","body":""},{"concept":"calendar","name":"Calendar01Icon","body":""},{"concept":"mail","name":"Mail01Icon","body":""},{"concept":"message","name":"BubbleChatIcon","body":""},{"concept":"trash","name":"Delete02Icon","body":""},{"concept":"download","name":"Download01Icon","body":""},{"concept":"upload","name":"Upload01Icon","body":""},{"concept":"plus","name":"Add01Icon","body":""},{"concept":"check","name":"Tick02Icon","body":""},{"concept":"close","name":"Cancel01Icon","body":""},{"concept":"chevron-right","name":"ChevronRightIcon","body":""},{"concept":"arrow-right","name":"ArrowRight02Icon","body":""},{"concept":"menu","name":"Menu01Icon","body":""},{"concept":"filter","name":"FilterIcon","body":""},{"concept":"edit","name":"PencilEdit01Icon","body":""},{"concept":"lock","name":"SquareLock01Icon","body":""},{"concept":"eye","name":"ViewIcon","body":""},{"concept":"external-link","name":"ExternalLinkIcon","body":""}]},{"id":"remix","name":"Remix Icon","license":"Apache-2.0","url":"https://remixicon.com","note":"Line and fill twins across a broad catalog. Neutral, a touch heavier than the 2px sets.","grid":"24","weight":"filled paths","count":1539,"version":"4.9.1","viewBox":"0 0 24 24","attrs":{"fill":"currentColor"},"glyphs":[{"concept":"home","name":"home-line","body":""},{"concept":"search","name":"search-line","body":""},{"concept":"user","name":"user-line","body":""},{"concept":"settings","name":"settings-3-line","body":""},{"concept":"bell","name":"notification-2-line","body":""},{"concept":"heart","name":"heart-line","body":""},{"concept":"star","name":"star-line","body":""},{"concept":"calendar","name":"calendar-line","body":""},{"concept":"mail","name":"mail-line","body":""},{"concept":"message","name":"message-line","body":""},{"concept":"trash","name":"delete-bin-line","body":""},{"concept":"download","name":"download-line","body":""},{"concept":"upload","name":"upload-line","body":""},{"concept":"plus","name":"add-line","body":""},{"concept":"check","name":"check-line","body":""},{"concept":"close","name":"close-line","body":""},{"concept":"chevron-right","name":"arrow-right-s-line","body":""},{"concept":"arrow-right","name":"arrow-right-line","body":""},{"concept":"menu","name":"menu-line","body":""},{"concept":"filter","name":"filter-line","body":""},{"concept":"edit","name":"pencil-line","body":""},{"concept":"lock","name":"lock-line","body":""},{"concept":"eye","name":"eye-line","body":""},{"concept":"external-link","name":"external-link-line","body":""}]}]}
diff --git a/picker/pages/index.astro b/picker/pages/index.astro
index f1a060608..4564f0c78 100644
--- a/picker/pages/index.astro
+++ b/picker/pages/index.astro
@@ -2,6 +2,7 @@
import Picker from '../layouts/Picker.astro';
import Artboard from '../components/Artboard.astro';
import QuestionScreen from '../components/QuestionScreen.astro';
+import iconPacks from '../data/icon-packs.json';
const roles = [
['primary', 'Primary'],
@@ -27,6 +28,17 @@ const scales = [
// 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]];
+// Only the row copy is rendered here. The glyphs are 109KB of markup for sets
+// the run will use one of, so they are fetched when the screen first opens.
+const iconRows = iconPacks.packs.map((pack) => ({
+ id: pack.id,
+ name: pack.name,
+ license: pack.license,
+ url: pack.url,
+ note: pack.note,
+ meta: `${pack.grid} grid / ${pack.weight} / ${pack.license}`,
+}));
+
// The five questions that are answered by looking at the same wireframe page.
// Each one only rewrites the artboard slots it owns, so by the last screen the
// page is showing every decision made before it. Motion is the one question
@@ -78,7 +90,7 @@ const questions = [
{
value: 'balanced',
title: 'Balanced',
- desc: 'An aligned grid with a few deliberate breaks in it.',
+ desc: 'An aligned grid with a few deliberate breaks that carry the emphasis.',
},
{
value: 'editorial',
@@ -593,7 +605,88 @@ const questions = [
{questions.map((question) => )}
-
+
+
+
+
Choose an icon set
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ At 16px
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Your visual direction is ready.
@@ -718,11 +811,15 @@ const questions = [
'button:not([disabled]), input:not([disabled]):not([type="hidden"]):not([tabindex="-1"]), select:not([disabled]), textarea:not([disabled]), a[href]',
)];
- // The start screen is not a step, so the stepper counts from screen 02.
+ // Neither the start screen nor the review is a question, so the stepper
+ // counts from screen 02 and fills on the last one, icons. Review holds it
+ // full rather than counting past the end.
+ const STEPS = 10;
const progress = $('.picker-progress');
const setProgress = (index, name) => {
- progress.dataset.step = String(index);
- $('[data-progress-index]').textContent = String(Math.max(index, 1));
+ const step = Math.min(Math.max(index, 1), STEPS);
+ progress.dataset.step = String(step);
+ $('[data-progress-index]').textContent = String(step);
$('[data-progress-name]').textContent = name || '';
};
diff --git a/picker/scripts/palette-picker.js b/picker/scripts/palette-picker.js
index 719899324..34587ac92 100644
--- a/picker/scripts/palette-picker.js
+++ b/picker/scripts/palette-picker.js
@@ -421,6 +421,24 @@ function wireListScroll(list) {
const syncScrollButtons = wireListScroll(fontOptions);
wireListScroll(scaleOptions);
+/* Screens where the cursor previews and the click commits need one rule for
+ what the preview falls back to, and `focusout` on its own is not it. Clicking
+ a row blurs whatever held focus before the browser focuses that row's input,
+ and the intermediate event carries no relatedTarget, so a listener that
+ trusts it shows the old answer for a frame at the exact moment the user picks
+ a new one. Waiting a frame lets the pointer settle the question: still inside
+ the list means the user is browsing and the preview is already right. */
+function restWhenIdle(list, rest) {
+ let queued;
+ return () => {
+ cancelAnimationFrame(queued);
+ queued = requestAnimationFrame(() => {
+ if (list.matches(':hover')) return;
+ rest(list.contains(document.activeElement) ? document.activeElement : null);
+ });
+ };
+}
+
/* 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.
@@ -579,6 +597,140 @@ new ResizeObserver(fitScaleSheet).observe(scaleSheet);
document.fonts?.addEventListener('loadingdone', fitScaleSheet);
syncTypeScale(scaleOptions.querySelector('input:checked'));
+/* Screen 11: the icon specimen. Every pack draws the same twenty-four concepts,
+ so the sheet compares hands rather than catalogs. The drawings are vendored
+ at build time and fetched on first arrival: eleven packs of markup is more
+ than the first paint should carry for a screen most runs reach late.
+
+ The cursor previews and the click commits, the same contract screen 03 uses
+ for color strategies. What differs is that no CSS can swap a drawing, so the
+ preview is painted here rather than remapped through custom properties. */
+const iconOptions = document.querySelector('[data-icon-options]');
+const iconSheet = document.querySelector('[data-icon-sheet]');
+const iconField = iconSheet.querySelector('[data-icon-field]');
+const iconStrip = iconSheet.querySelector('[data-icon-strip]');
+const iconPackInputs = ['name', 'license', 'url'].map((key) => [
+ key,
+ document.querySelector(`[name="icon-pack-${key}"]`),
+]);
+let iconPacks;
+let iconRequest;
+let iconPainted;
+
+wireListScroll(iconOptions);
+
+const checkedIconPack = () => iconOptions.querySelector('input[name="icon-pack"]:checked');
+const iconRowInput = (node) => node?.closest('.picker-icon-row')?.querySelector('input');
+
+/* Packs disagree on grid size and on whether they are drawn as strokes or as
+ filled paths, so the wrapper carries the pack's own viewBox and paint rather
+ than one house style the drawings were never made for. */
+function iconSvg(pack, glyph) {
+ const attrs = Object.entries(pack.attrs)
+ .map(([key, value]) => `${key}="${value}"`)
+ .join(' ');
+ return ``;
+}
+
+function paintIconPack(id) {
+ const pack = iconPacks?.get(id);
+ if (!pack || iconPainted === id) return;
+ iconPainted = id;
+ delete iconField.dataset.empty;
+ iconField.innerHTML = pack.glyphs
+ .map((glyph) => `${iconSvg(pack, glyph)}`)
+ .join('');
+ iconStrip.innerHTML = pack.glyphs.map((glyph) => iconSvg(pack, glyph)).join('');
+}
+
+/* The answer carries the pack's name, license, and home page, not just its
+ slug: whoever reads the answers has to credit it without looking it up. */
+function commitIconPack(input) {
+ for (const [key, field] of iconPackInputs) field.value = input.dataset[`pack${key[0].toUpperCase()}${key.slice(1)}`];
+ paintIconPack(input.value);
+}
+
+function loadIconPacks() {
+ iconRequest ??= fetch('/icon-packs.json')
+ .then((response) => (response.ok ? response.json() : Promise.reject()))
+ .then((data) => {
+ iconPacks = new Map(data.packs.map((pack) => [pack.id, pack]));
+ paintIconPack(checkedIconPack().value);
+ })
+ .catch(() => {
+ iconField.dataset.empty = '';
+ iconField.textContent = 'Icon sets could not be loaded.';
+ });
+ return iconRequest;
+}
+
+iconOptions.addEventListener('pointerover', (event) => {
+ const input = iconRowInput(event.target);
+ if (input) paintIconPack(input.value);
+});
+iconOptions.addEventListener('focusin', (event) => {
+ const input = iconRowInput(event.target);
+ if (input) paintIconPack(input.value);
+});
+/* With the pointer away, the keyboard is driving: the row it is on is the one
+ being asked about, and only once focus has left too is the answer itself. */
+const restIconPreview = restWhenIdle(iconOptions, (focused) => {
+ paintIconPack(iconRowInput(focused)?.value ?? checkedIconPack().value);
+});
+iconOptions.addEventListener('pointerleave', restIconPreview);
+iconOptions.addEventListener('focusout', restIconPreview);
+iconOptions.onchange = ({ target }) => {
+ if (target.matches('input[name="icon-pack"]')) commitIconPack(target);
+};
+
+commitIconPack(checkedIconPack());
+
+/* Screen 06: the motion scene. Its three energies are told apart by amplitude
+ on one shared 8s timeline, and the loudest difference between them, whether
+ the page arrives or is simply there, is over inside the first fifth of it.
+ A visitor who hovers a row five seconds into the loop is therefore comparing
+ two energies on press depth alone, which is why hovering restarts the scene:
+ the answer to "what does this one look like" is the scene from its first
+ frame. CSS cannot rewind an animation, so this is the one part of the screen
+ that is not a custom property.
+
+ Only a change of energy restarts it. Sliding the pointer across a row it is
+ already previewing would otherwise keep the page in a permanent entrance. */
+const motionOptions = document.querySelector('[data-question="motion"] .picker-strategy-choices');
+const motionScene = document.querySelector('.picker-preview-motion');
+const checkedMotion = () => motionOptions.querySelector('input:checked').value;
+let motionShown;
+
+function replayMotion(energy) {
+ if (energy === motionShown) return;
+ motionShown = energy;
+ // The hover rules resolve on their own; this only puts the timeline back to
+ // its first frame, pseudo-elements and all.
+ for (const animation of motionScene.getAnimations({ subtree: true })) {
+ animation.cancel();
+ animation.play();
+ }
+}
+
+const motionRowValue = (node) => node?.closest('.picker-strategy-option')?.querySelector('input').value;
+
+motionOptions.addEventListener('pointerover', (event) => {
+ const value = motionRowValue(event.target);
+ if (value) replayMotion(value);
+});
+motionOptions.addEventListener('focusin', (event) => {
+ const value = motionRowValue(event.target);
+ if (value) replayMotion(value);
+});
+const restMotionPreview = restWhenIdle(motionOptions, (focused) => {
+ replayMotion(motionRowValue(focused) ?? checkedMotion());
+});
+motionOptions.addEventListener('pointerleave', restMotionPreview);
+motionOptions.addEventListener('focusout', restMotionPreview);
+motionOptions.addEventListener('change', ({ target }) => {
+ if (target.matches('input[name="motion-energy"]')) replayMotion(target.value);
+});
+
/* 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.
Neither is parsed here: the questionnaire validates at the end. */
@@ -1044,6 +1196,14 @@ document.addEventListener('picker:screenchange', (event) => {
keyboardInRail = false;
applyHoist({ force: true });
}
+ // A hidden screen has no animations to rewind, so the scene starts on the
+ // frame after the one that revealed it, not on the frame that asked.
+ if (event.detail.screen === '06') {
+ requestAnimationFrame(() => {
+ motionShown = null;
+ replayMotion(checkedMotion());
+ });
+ }
// 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.
@@ -1053,6 +1213,14 @@ document.addEventListener('picker:screenchange', (event) => {
scaleOptions.querySelector('input:checked')?.parentElement.scrollIntoView({ block: 'center' });
});
}
+ // The specimen is fetched the first time the screen is asked for, and the
+ // scroll waits a frame for the same reason screen 05's does.
+ if (event.detail.screen === '11') {
+ void loadIconPacks();
+ requestAnimationFrame(() => {
+ checkedIconPack()?.parentElement.scrollIntoView({ block: 'center' });
+ });
+ }
});
try {
diff --git a/picker/styles/picker.css b/picker/styles/picker.css
index 689682122..cbdb23a41 100644
--- a/picker/styles/picker.css
+++ b/picker/styles/picker.css
@@ -283,7 +283,8 @@ body.picker-page {
.picker-shell:has(#picker-form[data-current="07"]) .picker-hero-art,
.picker-shell:has(#picker-form[data-current="08"]) .picker-hero-art,
.picker-shell:has(#picker-form[data-current="09"]) .picker-hero-art,
-.picker-shell:has(#picker-form[data-current="10"]) .picker-hero-art {
+.picker-shell:has(#picker-form[data-current="10"]) .picker-hero-art,
+.picker-shell:has(#picker-form[data-current="11"]) .picker-hero-art {
opacity: 0;
}
@@ -1230,7 +1231,10 @@ body.picker-page {
}
.picker-strategy-grid {
- max-height: calc(100svh - 346px);
+ /* Named so the option panel can cap itself against the same budget: its
+ height no longer comes from the column, so nothing else would stop it. */
+ --pk-column: calc(100svh - 346px);
+ max-height: var(--pk-column);
display: grid;
grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
gap: 50px;
@@ -1368,14 +1372,27 @@ body.picker-page {
grid-template-rows: minmax(0, 1fr);
}
+/* Equal rows, sized by the tallest one's copy rather than by how many there
+ are. Stretching the tracks to the artboard's height instead makes a row on a
+ three-option screen half again as tall as the same row on a four-option one,
+ and the difference is spent on empty space under two lines of text. The
+ panel hugs the rows it has, so nothing draws an empty band underneath. */
.picker-strategy-choices.picker-type-options {
- height: 100%;
- align-content: stretch;
- grid-template-rows: repeat(var(--pk-choices, 4), minmax(0, 1fr));
+ align-self: start;
+ max-height: var(--pk-column);
+ /* Four is the largest option count in the run, so the row is a quarter of the
+ column whether a screen has four of them or three, and one formula gives
+ every screen in the run the same band. Below 432px of column they shrink
+ together; the row padding absorbs it before the copy does. The 2px is the
+ panel's own hairline, which the column budget does not account for. */
+ grid-template-rows: none;
+ grid-auto-rows: min(108px, calc((var(--pk-column) - 2px) / 4));
}
+/* Centred rather than top-aligned: whatever the row has left over after the
+ label and its sentence reads as air around them, not as a gap underneath. */
.picker-strategy-choices.picker-type-options .picker-strategy-option {
- align-content: start;
+ align-content: center;
}
.picker-strategy-choices .picker-strategy-copy {
@@ -2581,6 +2598,199 @@ body.picker-page {
font-style: normal;
}
+/* ============================================================
+ Screen 11: the icon set. Screen 05's frame again, because the question has
+ the same shape: more candidates than fit the rail, and one specimen that
+ redraws as the cursor moves down the list.
+ ============================================================ */
+.picker-icons {
+ display: grid;
+ gap: clamp(24px, 4.4svh, 40px);
+ padding-block: clamp(32px, 5svh, 48px) clamp(24px, 3.5svh, 48px);
+}
+
+.picker-icons-grid {
+ max-height: calc(100svh - 420px);
+ display: grid;
+ grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
+ gap: 50px;
+ align-items: stretch;
+}
+
+/* Eleven rows scroll, so this list takes screen 04's rail rather than screen
+ 03's fixed row split. States come from the standard option row; what the
+ row adds is the pack's own numbers and the way out to its site. */
+.picker-icon-options .picker-strategy-option {
+ padding-block: 16px;
+ /* The link sits in this gutter. */
+ padding-inline-end: 60px;
+}
+
+.picker-icon-options .picker-strategy-copy {
+ gap: 5px;
+}
+
+.picker-icon-options .picker-strategy-title {
+ font-family: var(--ks-font);
+ font-size: 21px;
+ letter-spacing: -0.015em;
+}
+
+.picker-icon-options .picker-strategy-desc {
+ max-width: 40ch;
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+/* Grid, weight, license: the three facts that decide this question before any
+ description does. Measurements, so they are set in the mono face. */
+.picker-icon-meta {
+ color: var(--ks-text-faint);
+ font-family: var(--ks-mono);
+ font-size: 0.7rem;
+}
+
+/* An anchor inside a