This commit is contained in:
Abdul Wahab
2026-09-01 10:02:39 +05:00
parent 1d192d1f39
commit 1be3f1145b
12 changed files with 1417 additions and 306 deletions
+18
View File
@@ -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 |
File diff suppressed because one or more lines are too long
+102 -5
View File
@@ -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) => <QuestionScreen {...question} />)}
<section class="picker-screen" data-screen="11" data-step="Review" aria-hidden="true" aria-labelledby="picker-finish-copy">
<section class="picker-screen" data-screen="11" data-step="Icons" aria-hidden="true" aria-labelledby="picker-icons-title">
<div class="picker-container">
<div class="picker-icons">
<h2 id="picker-icons-title" class="picker-question-title">Choose an icon set</h2>
<div class="picker-icons-grid">
<div class="picker-type-rail">
<fieldset class="picker-strategy-options picker-type-options picker-icon-options" data-icon-options>
<legend>Icon set</legend>
{iconRows.map((pack, index) => (
<div class="picker-icon-row">
<label class="picker-strategy-option">
<input
type="radio"
name="icon-pack"
value={pack.id}
data-pack-name={pack.name}
data-pack-license={pack.license}
data-pack-url={pack.url}
checked={index === 0}
/>
<span class="picker-strategy-copy">
<span class="picker-strategy-title">{pack.name}</span>
<span class="picker-icon-meta">{pack.meta}</span>
<span class="picker-strategy-desc">{pack.note}</span>
</span>
</label>
<a
class="picker-icon-out"
href={pack.url}
target="_blank"
rel="noreferrer"
aria-label={`Open the ${pack.name} site in a new tab`}
>
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="M11 4.5h4.5V9M15.5 4.5 9.5 10.5M13 11.5v3.5a.5.5 0 0 1-.5.5h-7a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5H9"></path>
</svg>
</a>
</div>
))}
</fieldset>
<div class="picker-type-scroll">
<button class="picker-icon-button" type="button" data-list-scroll="-1" aria-label="Scroll icon sets 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 icon sets 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>
</div>
<div class="picker-icon-sheet" data-icon-sheet aria-hidden="true">
<div class="picker-icon-field" data-icon-field></div>
<div class="picker-icon-strip">
<span class="picker-icon-strip-label">At 16px</span>
<div class="picker-icon-strip-row" data-icon-strip></div>
</div>
</div>
</div>
<input type="hidden" name="icon-pack-name" />
<input type="hidden" name="icon-pack-license" />
<input type="hidden" name="icon-pack-url" />
<div class="picker-actions-stack">
<button class="ks-button ks-button-primary" type="button" data-advance="next">
Select this set
<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="12" 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>
@@ -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 || '';
};
+168
View File
@@ -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 `<svg viewBox="${pack.viewBox}" ${attrs} aria-hidden="true">${glyph.body}</svg>`;
}
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) => `<span class="picker-icon-cell">${iconSvg(pack, glyph)}</span>`)
.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 {
+216 -6
View File
@@ -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 <label> still checks the radio on its way to the new tab,
so the way out is a sibling of the row and sits above it. */
.picker-icon-row {
position: relative;
display: grid;
}
.picker-icon-options .picker-icon-row:not(:last-child) .picker-strategy-option {
border-bottom: 1px solid var(--ks-rule);
}
.picker-icon-out {
position: absolute;
top: 14px;
right: 16px;
width: 30px;
height: 30px;
display: grid;
place-items: center;
color: var(--ks-text-mute-deep);
border: 1px solid transparent;
border-radius: 2px;
transition:
color 180ms var(--ks-ease),
border-color 180ms var(--ks-ease);
}
.picker-icon-out svg {
width: 15px;
height: 15px;
fill: none;
stroke: currentColor;
stroke-width: 1.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.picker-icon-out:hover {
color: var(--ks-patina);
border-color: var(--ks-rule);
}
.picker-icon-out:focus-visible {
outline: 2px solid var(--ks-patina);
outline-offset: 2px;
}
/* Like the scale sheet, the specimen is picker chrome in the picker's own
theme: it answers a question about drawing, and the palette has had its
screens already. One field divided by hairlines, not twenty-four bordered
tiles on a dark ground, which is the icon grid this project spends its
detector telling people not to ship. */
.picker-icon-sheet {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
background: var(--ks-lacquer-raised);
border: 1px solid var(--ks-rule);
border-radius: 2px;
overflow: hidden;
}
.picker-icon-strip {
display: flex;
align-items: center;
gap: 16px;
/* A label row, so it gives height back to the field on a short screen instead
of holding a comfortable padding the specimen has to pay for. */
padding: clamp(9px, 1.5svh, 14px) clamp(16px, 2.2%, 24px);
}
/* The division is the grid's own gap over a rule-colored ground, so it reads as
one continuous hairline and no cell owns an edge. */
.picker-icon-field {
min-height: 0;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-auto-rows: minmax(0, 1fr);
gap: 1px;
background: var(--ks-rule);
border-bottom: 1px solid var(--ks-rule);
}
/* Nothing to divide when the fetch fails, so the field drops the hairlines and
carries the reason in the space the glyphs would have used. */
.picker-icon-field[data-empty] {
grid-template-columns: minmax(0, 1fr);
place-items: center;
padding: 24px;
background: var(--ks-lacquer-raised);
color: var(--ks-text-muted);
text-align: center;
}
.picker-icon-cell {
min-width: 0;
min-height: 0;
display: grid;
place-items: center;
background: var(--ks-lacquer-raised);
color: var(--ks-champagne);
}
.picker-icon-cell svg {
width: clamp(22px, 2.2vw, 32px);
height: auto;
}
/* The frame is short and wide before it is anything else, so on a laptop the
same twenty-four glyphs lie down into three long rows rather than four
letterbox ones. Nothing is dropped and nothing is scaled down. */
@media (max-height: 830px) {
.picker-icon-field {
grid-template-columns: repeat(8, minmax(0, 1fr));
}
}
/* A pack is chosen for interface work, and interface work happens at 16px.
The row underneath is the same twenty-four glyphs at the size they will
actually be read, which is where a busy set gives itself away. */
.picker-icon-strip-label {
flex: none;
color: var(--ks-text-faint);
font-family: var(--ks-mono);
font-size: 0.7rem;
}
.picker-icon-strip-row {
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 8px 11px;
color: var(--ks-text-muted);
}
.picker-icon-strip-row svg {
width: 16px;
height: 16px;
}
.picker-type-scroll {
display: flex;
gap: 8px;
+15 -11
View File
@@ -15,11 +15,18 @@
than overwrite each other.
============================================================ */
/* The four answers differ in material, not in vertical rhythm: a tint, a
hairline, an inset, an edge. None of them pads the proof band, because on the
desktop artboard that band is a fixed grid row and the row heights belong to
the layout answer on screen 07. Padding it was not buying air, it was
claiming room the row did not have: the copy overflowed the padding box
downward, which is why a tinted band read as squeezed and sat low in its own
tint, and why the open answer quietly stretched the row and shifted every
section under it. */
/* ── Open space ──────────────────────────────────────────────
Every rule off and every ground the same, so the only thing left holding
the sections apart is the air between them. The band the proof sits in
gets more of it, because with nothing drawn the page has to spend room to
say the same thing. */
the sections apart is the air between them. */
#picker-form:has(.picker-strategy-option:hover input[name="boundary-style"][value="open-space"]) .picker-preview-boundaries,
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="boundary-style"])):has(input[name="boundary-style"][value="open-space"]:checked)) [data-carry] {
--pvs-divider-w: 0px;
@@ -29,7 +36,6 @@
--pvs-panel: transparent;
--pvs-panel-edge-w: 0px;
--pvs-panel-pad: 0px;
--pvs-proof-pad-y: 4.2%;
--pvs-proof-inset: 0px;
}
@@ -46,7 +52,6 @@
--pvs-panel: transparent;
--pvs-panel-edge-w: 0px;
--pvs-panel-pad: 0px;
--pvs-proof-pad-y: 2%;
--pvs-proof-inset: 0px;
}
@@ -62,7 +67,6 @@
--pvs-panel: transparent;
--pvs-panel-edge-w: 0px;
--pvs-panel-pad: 0px;
--pvs-proof-pad-y: 2.8%;
--pvs-proof-inset: 0px;
}
@@ -80,7 +84,6 @@
--pvs-panel-edge: color-mix(in oklab, var(--pvs-ink) 20%, var(--pvs-ground));
--pvs-panel-edge-w: 1px;
--pvs-panel-pad: 5%;
--pvs-proof-pad-y: 2.4%;
--pvs-proof-inset: var(--pvs-gutter);
}
@@ -90,7 +93,6 @@
only exists where an inset was asked for. */
[data-carry] .ps-desktop > .ps-proof {
margin-inline: var(--pvs-proof-inset, 0px);
padding-block: var(--pvs-proof-pad-y, 2%);
border: var(--pvs-panel-edge-w) solid var(--pvs-panel-edge);
border-radius: var(--pvs-radius-surface);
}
@@ -103,13 +105,15 @@
}
/* Percentage padding resolves against the card's own width, so one value
holds at both artboard sizes instead of swamping the handset. */
holds at both artboard sizes instead of swamping the handset. The depth
answer can ask for an inset of its own (see --pvs-tint-pad in depth.css);
the wider of the two wins, so neither answer can undo the other. */
[data-carry] .ps-gallery-item {
padding: var(--pvs-panel-pad, 0px);
padding: max(var(--pvs-panel-pad, 0px), var(--pvs-tint-pad, 0px));
}
/* The handset's card row is already the block closest to the footer, so it
takes a shorter version of the same padding rather than growing into it. */
[data-carry] .ps-phone-body .ps-gallery-item {
padding: calc(var(--pvs-panel-pad, 0px) * 0.5);
padding: calc(max(var(--pvs-panel-pad, 0px), var(--pvs-tint-pad, 0px)) * 0.5);
}
+10 -1
View File
@@ -32,11 +32,17 @@
--pvs-shadow-surface: none;
--pvs-tint-chrome: none;
--pvs-tint-card: none;
--pvs-tint-pad: 0px;
}
/* ── Layered ─────────────────────────────────────────────────
Two tonal steps off the ground, cards further up than chrome, and not one
shadow on the page. */
shadow on the page.
A tonal step is only read as a step if some of it is visible, so this is the
one answer that asks for padding of its own. It is a floor rather than a
replacement: the boundary answer that already draws panels keeps its wider
inset. */
#picker-form:has(.picker-strategy-option:hover input[name="depth-style"][value="layered"]) .picker-preview-depth,
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="depth-style"])):has(input[name="depth-style"][value="layered"]:checked)) [data-carry] {
--pvs-shadow-chrome: none;
@@ -51,6 +57,7 @@
color-mix(in oklab, var(--pvs-ink) 12%, transparent),
color-mix(in oklab, var(--pvs-ink) 12%, transparent)
);
--pvs-tint-pad: 3.5%;
}
/* ── Soft lift ───────────────────────────────────────────────
@@ -66,6 +73,7 @@
--pvs-shadow-surface: 0 2px 5px rgb(0 0 0 / 0.14);
--pvs-tint-chrome: none;
--pvs-tint-card: none;
--pvs-tint-pad: 0px;
}
/* ── Floating ────────────────────────────────────────────────
@@ -81,6 +89,7 @@
--pvs-shadow-surface: 0 6px 14px rgb(0 0 0 / 0.22);
--pvs-tint-chrome: none;
--pvs-tint-card: none;
--pvs-tint-pad: 0px;
}
/* Tonal steps paint as a ground, over whatever ground the boundary answer
+225 -58
View File
@@ -1,82 +1,205 @@
/* ============================================================
Screen 07: layout structure.
Four grids on the same page. The answer is read off the columns, the page
margins, the vertical bands, and whether a block is allowed to leave its
cell, so each option rewrites all four rather than nudging one number.
Every other structural screen changes something you can see on its own: a
divider is there or it is not, a corner is round or it is square. This one
changes proportion, and a proportion is invisible without a ruler. Four
pages with the columns nudged a few points apart are four identical pages.
So the ruler is drawn. The measure below is a real twelve-column grid, and
the three disciplined answers place every block on an exact column count:
what changes between them is which spans the page spends its columns on.
Freeform is then the one answer that crosses the lines, and it reads as a
decision because the thing it is breaking is on screen next to it.
Two selectors per option, the shape screen 03 established. The first is the
hover preview and belongs to this screen alone. The second is the committed
answer and reaches every artboard marked data-carry, which is this screen
plus boundaries, corners, and depth, so each later question is asked about
the page as it has been chosen so far.
the page as it has been chosen so far. The measure itself does not travel:
it is an instrument for this question, drawn only where it is asked.
============================================================ */
/* ── The measure ─────────────────────────────────────────────
Twelve columns and eleven gutters inside the page margins, so a column is
(100% - 11 x 1.6%) / 12. The span variables are the width of n columns
including the gutters they swallow, which is what a grid track needs and
what makes an answer legible: --pvs-w7 against --pvs-w5 is a seven-five
hero, and it is written that way rather than as 57.6667%.
Percentages resolve against the box they are used in. Every section's
content box is the page width, so spans hold there; the gallery sits inside
one editorial track, so its gutter is scaled by that track's share. The
ladder runs to ten because the widest gallery track is a ten-column one,
and each answer's gallery gutter is derived from the number here. */
[data-carry] {
--pvs-gut: 1.6%;
--pvs-w1: 6.8667%;
--pvs-w2: 15.3333%;
--pvs-w3: 23.8%;
--pvs-w4: 32.2667%;
--pvs-w5: 40.7333%;
--pvs-w6: 49.2%;
--pvs-w7: 57.6667%;
--pvs-w8: 66.1333%;
--pvs-w9: 74.6%;
--pvs-w10: 83.0667%;
}
/* The page's own margins are asymmetric under two of the answers, and a
drawn grid makes it obvious when only some rows honor them. Chrome and the
editorial band are brought onto the same measure as the hero and the proof
band. Scoped to the structural artboards: screens 03 and 04 are judging
color and glyphs, and their page is not under a ruler. */
[data-carry] :is(.ps-nav, .ps-editorial, .ps-footer) {
padding-right: var(--pvs-gutter-end);
}
/* The proof band divides in four, which on a twelve-column page means each
claim takes three columns and each divider sits in a gutter. It was four
equal shares of whatever was left after a fixed 25px gap, which lands a few
pixels off every column line: close enough to pass unnoticed on a bare
page, and the one row that looks wrong once the lines are drawn. */
[data-carry] .ps-desktop > .ps-proof {
grid-template-columns:
var(--pvs-w3) auto
var(--pvs-w3) auto
var(--pvs-w3) auto
minmax(0, 1fr);
column-gap: calc(var(--pvs-gut) / 2);
}
/* Eleven hairlines, one down the middle of each gutter, rather than the
filled columns a grid tool draws. Filled columns cover four fifths of the
page and turn it into wallpaper at any tint dark enough to see, and the
gutter is ten pixels wide at this size, so painting it whole gives a stripe
rather than a line. The centre line carries the same information and
carries it better: where two blocks are on the grid, the mark lands dead
centre in the gap between them, and where one is not, it does not.
Mixed from the artboard's own ink so the field survives a drenched ground,
and kept under the page so nothing is read through it. */
.picker-preview-layout .ps-desktop {
position: relative;
isolation: isolate;
}
.picker-preview-layout .ps-desktop::before {
--pvs-mark: calc(var(--pvs-w1) + var(--pvs-gut) / 2);
content: "";
position: absolute;
z-index: -1;
inset: 0 var(--pvs-gutter-end) 0 var(--pvs-gutter);
background-image: repeating-linear-gradient(
to right,
transparent 0 calc(var(--pvs-mark) - 0.5px),
color-mix(in oklab, var(--pvs-ink) 17%, transparent)
calc(var(--pvs-mark) - 0.5px) calc(var(--pvs-mark) + 0.5px),
transparent calc(var(--pvs-mark) + 0.5px) calc(var(--pvs-w1) + var(--pvs-gut))
);
pointer-events: none;
}
/* The handset has one column, so twelve bands would say nothing there. What
the answer changes on a phone is the measure itself and whether anything
is allowed to cross it, so the two content edges are the whole ruler. */
.picker-preview-layout .ps-phone-body {
position: relative;
isolation: isolate;
}
.picker-preview-layout .ps-phone-body::before {
content: "";
position: absolute;
z-index: -1;
top: 0;
bottom: 0;
left: var(--pvs-phone-gutter, 6%);
right: var(--pvs-phone-gutter, 6%);
border-inline: 1px solid color-mix(in oklab, var(--pvs-ink) 12%, transparent);
pointer-events: none;
}
/* ── Simple grid ─────────────────────────────────────────────
Halves and quarters, both margins equal, bands of near-equal weight. The
thing to notice is that there is nothing to notice. */
Six and six, four cards of two, equal margins, bands of near-equal weight.
Everything starts and stops on a line, and the thing to notice is that
there is nothing to notice. */
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="simple-grid"]) .picker-preview-layout,
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"])):has(input[name="layout-structure"][value="simple-grid"]:checked)) [data-carry] {
--pvs-gutter: 5.5%;
--pvs-gutter-end: 5.5%;
--pvs-hero-cols: minmax(0, 1fr) minmax(0, 1fr);
--pvs-hero-gap: 5.5%;
--pvs-editorial-cols: 22.4% minmax(0, 1fr);
--pvs-editorial-gap: 5.5%;
--pvs-gutter: 6%;
--pvs-gutter-end: 6%;
--pvs-hero-cols: var(--pvs-w6) minmax(0, 1fr);
--pvs-hero-gap: var(--pvs-gut);
--pvs-editorial-cols: var(--pvs-w4) minmax(0, 1fr);
--pvs-editorial-gap: var(--pvs-gut);
/* Four equal cards of two columns need no ratio. The gap is the page
gutter restated as a share of the eight-column track it sits inside. */
--pvs-gallery-cols: repeat(4, minmax(0, 1fr));
--pvs-gallery-gap: 5.5%;
--pvs-gallery-gap: calc(var(--pvs-gut) * 100 / 66.1333);
--pvs-section-align: start;
--pvs-rows: 9.4% 45.5% 9% 27.8% 8.3%;
--pvs-phone-gutter: 6%;
--pvs-phone-gutter: 7%;
--pvs-phone-gallery-cols: 1fr 1fr;
}
/* ── Balanced ────────────────────────────────────────────────
The grid still runs the page, but the hero leans and the first card is
given more room than its neighbours. Two breaks, both on purpose. */
The same grid, spent unevenly. The hero leans five-seven and the lead card
takes three columns where its neighbours take two. Both breaks land on the
lines: the emphasis is chosen, the discipline is not loosened. */
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="balanced"]) .picker-preview-layout,
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"])):has(input[name="layout-structure"][value="balanced"]:checked)) [data-carry] {
--pvs-gutter: 5.5%;
--pvs-gutter-end: 4.5%;
--pvs-hero-cols: 42% minmax(0, 1fr);
--pvs-hero-gap: 9%;
--pvs-editorial-cols: 29% minmax(0, 1fr);
--pvs-editorial-gap: 6%;
--pvs-gallery-cols: 1.35fr 0.88fr 0.88fr 0.88fr;
--pvs-gallery-gap: 3.5%;
--pvs-gutter: 6%;
--pvs-gutter-end: 6%;
--pvs-hero-cols: var(--pvs-w5) minmax(0, 1fr);
--pvs-hero-gap: var(--pvs-gut);
--pvs-editorial-cols: var(--pvs-w3) minmax(0, 1fr);
--pvs-editorial-gap: var(--pvs-gut);
/* Three columns then three of two. The tracks are fr in the same
proportion as the spans, because fr cannot take a percentage variable,
and the gap is the page gutter inside a nine-column track. */
--pvs-gallery-cols: 23.8fr 15.3333fr 15.3333fr 15.3333fr;
--pvs-gallery-gap: calc(var(--pvs-gut) * 100 / 74.6);
--pvs-section-align: center;
--pvs-rows: 9.4% 50.6% 9% 22.7% 8.3%;
--pvs-phone-gutter: 6%;
--pvs-phone-gallery-cols: 1.35fr 0.85fr;
}
/* ── Editorial ───────────────────────────────────────────────
A wide standing column against a narrow portrait, uneven margins, a hero
band that takes most of the page, and a lead card at twice its neighbours.
Copy hangs from the top of each band the way a column of text hangs from a
rule. */
Uneven margins, a standing text column at seven against a portrait at five,
a single column of section copy beside a ten-column gallery, and a hero
band that takes most of the page. Still every edge on a line: this is the
grid used with nerve, not abandoned. Copy hangs from the top of each band
the way a column of text hangs from a rule. */
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="editorial"]) .picker-preview-layout,
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"])):has(input[name="layout-structure"][value="editorial"]:checked)) [data-carry] {
--pvs-gutter: 9.5%;
--pvs-gutter-end: 2.5%;
--pvs-hero-cols: 56% minmax(0, 1fr);
--pvs-hero-gap: 11%;
--pvs-editorial-cols: 14% minmax(0, 1fr);
--pvs-editorial-gap: 7%;
--pvs-gallery-cols: 2.2fr 0.8fr 0.8fr 0.8fr;
--pvs-gallery-gap: 3%;
--pvs-gutter: 11%;
--pvs-gutter-end: 4%;
--pvs-hero-cols: var(--pvs-w7) minmax(0, 1fr);
--pvs-hero-gap: var(--pvs-gut);
--pvs-editorial-cols: var(--pvs-w2) minmax(0, 1fr);
--pvs-editorial-gap: var(--pvs-gut);
/* A lead card at four columns against three of two, in a ten-column track. */
--pvs-gallery-cols: 32.2667fr 15.3333fr 15.3333fr 15.3333fr;
--pvs-gallery-gap: calc(var(--pvs-gut) * 100 / 83.0667);
--pvs-section-align: start;
--pvs-rows: 8% 57.5% 8.5% 18.5% 7.5%;
--pvs-phone-gutter: 11%;
--pvs-phone-bleed: -9%;
--pvs-phone-bleed-w: 118%;
--pvs-phone-gallery-cols: 1.9fr 0.7fr;
--pvs-phone-bleed: -11%;
--pvs-phone-bleed-w: 122%;
}
/* ── Freeform ────────────────────────────────────────────────
Columns of no particular relation, and blocks that leave their cells. */
Columns of no particular relation, and blocks that leave the field. The
escapes are listed under the drift flag below. */
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="freeform"]) .picker-preview-layout,
:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"])):has(input[name="layout-structure"][value="freeform"]:checked)) [data-carry] {
--pvs-gutter: 4%;
--pvs-gutter-end: 7%;
--pvs-hero-cols: 46% minmax(0, 1fr);
--pvs-hero-gap: 2%;
--pvs-gutter: 3.5%;
--pvs-gutter-end: 9%;
--pvs-hero-cols: 44% minmax(0, 1fr);
--pvs-hero-gap: 3%;
--pvs-editorial-cols: 37% minmax(0, 1fr);
--pvs-editorial-gap: 2.5%;
--pvs-gallery-cols: 1.45fr 0.72fr 1.1fr 0.83fr;
@@ -84,22 +207,49 @@
--pvs-section-align: end;
--pvs-rows: 9.4% 48% 8.4% 26.2% 8%;
--pvs-phone-gutter: 4%;
--pvs-phone-gallery-cols: 1.5fr 0.9fr;
--pvs-phone-bleed: 0 -9%;
--pvs-phone-bleed-w: 109%;
--pvs-drift: 1;
}
/* Drift is declared on every artboard and multiplied by a flag only freeform
raises, so the other three options pay nothing for the rule existing. The
offsets are transforms: a block that steps out of its cell does not push
the rest of the page around to make room, which is what makes the overlap
read as intent rather than as a broken grid. */
/* ── Leaving the grid ────────────────────────────────────────
Drift is declared on every artboard and multiplied by a flag only freeform
raises, so the other three pay nothing for the rules existing.
Each escape is a transform or a negative margin, never a change of flow: a
block that steps out does not push the rest of the page around to make room
for it, which is the difference between a composition and a broken grid.
The moves are chosen so that nothing lands on top of running text. A bar
half-covered by a picture is what a mistake looks like; a picture running
off the edge of the page is what a decision looks like. */
/* The picture takes the right margin and keeps going, clipped by the frame
the way a full-bleed image is clipped by the browser window. The width has
to grow with the offset: the picture carries an explicit width, so a
negative margin alone would slide it rather than stretch it. */
[data-carry] .ps-hero > .ps-image {
width: calc(100% + var(--pvs-drift, 0) * 16%);
translate: calc(var(--pvs-drift, 0) * 6%) 0;
}
/* The copy sinks below the picture's top edge, so the two blocks in the hero
no longer share a line. z-index is raised with the same flag: the actions
below cross onto the picture and have to sit over it. */
[data-carry] .ps-hero-copy {
position: relative;
z-index: var(--pvs-drift, 0);
translate: calc(var(--pvs-drift, 0) * 4%) calc(var(--pvs-drift, 0) * 9%);
translate: 0 calc(var(--pvs-drift, 0) * 12%);
}
[data-carry] .ps-hero > .ps-image {
translate: calc(var(--pvs-drift, 0) * -5%) calc(var(--pvs-drift, 0) * -7%);
/* The one overlap, and the only block allowed one. Two filled controls onto a
flat picture is a composition that holds anywhere, and it is the one pair
in the wireframe where the block on top is opaque and the block underneath
has nothing to read: the same move over a line of copy is what a mistake
looks like. */
[data-carry] .ps-hero-copy .ps-actions {
translate: calc(var(--pvs-drift, 0) * 62%) 0;
}
/* Drift is a desktop move. The handset has one column and a stack sized in
@@ -109,22 +259,39 @@
translate: 0 calc(var(--pvs-drift, 0) * 14%);
}
/* The last card leaves the same way the picture did, so the escape reads as
one decision about the right edge rather than two unrelated nudges. Both
axes are named because this card is also an even one, and translate is a
single property. */
[data-carry] .ps-desktop .ps-gallery-item:last-child {
margin-right: calc(var(--pvs-drift, 0) * -18%);
translate: calc(var(--pvs-drift, 0) * 22%) calc(var(--pvs-drift, 0) * 14%);
}
[data-carry] .ps-desktop .ps-editorial-copy {
translate: 0 calc(var(--pvs-drift, 0) * -12%);
}
/* ── The handset ─────────────────────────────────────────────
One column still answers the question, in the margins and in whether
anything is allowed to break the column: the picture is the one block given
that permission, full bleed under Editorial. The rhythm between blocks is
left alone, because the handset's stack is sized in pixels and a couple of
points of extra gap pushes the card row through the bottom of the frame at
the narrowest window the picker supports. */
One column still answers the question: in the margin, in how unevenly the
two cards divide the row, and in whether anything is allowed to cross the
measure. The picture is the block given that permission, both edges under
Editorial and the outer edge alone under Freeform. The rhythm between
blocks is left alone, because the handset's stack is sized in pixels and a
couple of points of extra gap pushes the card row through the bottom of the
frame at the narrowest window the picker supports. */
[data-carry] .ps-phone-body {
padding-inline: var(--pvs-phone-gutter, 6%);
}
/* The picture carries an explicit width, so a negative margin moves it rather
than widening it. Both are named together or the bleed slides the block
sideways instead of stretching it past the measure. */
[data-carry] .ps-phone-body > .ps-image {
margin-inline: var(--pvs-phone-bleed, 0);
width: var(--pvs-phone-bleed-w, auto);
width: var(--pvs-phone-bleed-w, 100%);
}
[data-carry] .ps-phone-body .ps-gallery {
grid-template-columns: var(--pvs-phone-gallery-cols, 1.05fr 0.95fr);
}
+117 -224
View File
@@ -2,24 +2,38 @@
Screen 06: motion energy.
One scene, three energies. A synthetic pointer walks the same route through
the same wireframe on all three options and clicks the same four things: a
nav item, the second of two hero buttons, a choice in the proof row, and a
card. What differs is what the page does about it.
the same wireframe on all three options and clicks the same two things: the
second of the two hero buttons, and a card in the gallery. What differs is
what the page does about it.
Two clicks, not four. A visitor comparing three energies is reading the
difference between them, and a scene with something happening every second
spends the visitor's attention on following the pointer instead. The two that
are left are the two where the energies actually separate: a control
answering a press, and a selection that either lands or is staged.
The whole scene runs on one 8s timeline with every beat written as a
percentage of it, and nothing is offset by animation-delay. That is what
keeps a four-element orchestrated response in step after an hour of looping,
and it is why a frozen frame of this screen is always coherent.
keeps an orchestrated response in step after an hour of looping, and it is
why a frozen frame of this screen is always coherent.
The three options are not three sets of keyframes. They set the amplitude of
the shared ones: how far a press travels, whether an entrance starts from
nothing, whether siblings dim, and above all whether a state change steps or
eases. Restrained's step-start puts every change on the frame the click
nothing, whether siblings recede, and above all whether a state change steps
or eases. Restrained's step-start puts every change on the frame the click
lands, which is what "state changes only" means in practice.
The script restarts the timeline whenever the previewed option changes. The
entrance is the loudest difference between the three, and it happens in the
first fifth of the loop: without a restart, a visitor who hovers a row four
seconds in is comparing two energies by their press depth alone.
============================================================ */
.picker-preview-motion {
--mt: 8s;
--mt-ease: cubic-bezier(0.16, 1, 0.3, 1);
/* Ease-out-cubic, not the expo curve this started on. Expo puts four fifths
of the travel in the first fifth of the duration, so lengthening a band it
runs on buys a longer tail and not a slower arrival. */
--mt-ease: cubic-bezier(0.33, 1, 0.68, 1);
/* Restrained is the default state of every slot below: changes land whole,
nothing travels, nothing arrives. */
@@ -27,6 +41,7 @@
--mt-press-scale: 1;
--mt-lift: 0px;
--mt-dim: 1;
--mt-sink: 0px;
--mt-in-o: 1;
--mt-in-y: 0px;
--mt-in-clip: 0%;
@@ -45,12 +60,20 @@
--mt-press-scale: 0.94;
}
/* The siblings recede by 0.78 and 2px rather than by the 0.45 this started at.
Dimming is composited against whatever ground the color strategy chose, and
against a saturated primary a deep dim drags a brown thumbnail into olive and
a gold one into moss: the gallery stops reading as four cards standing back
and starts reading as a palette bug. Held this shallow the hue survives on
every ground, and the 2px carries the recession that the missing opacity
was carrying, in a channel no palette can distort. */
#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion,
#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion {
--mt-tf: ease;
--mt-press-scale: 0.94;
--mt-lift: -4px;
--mt-dim: 0.45;
--mt-dim: 0.78;
--mt-sink: 2px;
--mt-in-o: 0;
--mt-in-y: 14px;
--mt-in-clip: 100%;
@@ -94,198 +117,79 @@
animation: mt-click var(--mt) linear infinite;
}
/* Parked below the page until the entrance has finished, because a pointer
moving through a page that is still arriving is two things to watch. */
@keyframes mt-path {
0%, 6% { translate: 34cqw 96cqh; }
16%, 19% { translate: 76.9cqw 4.8cqh; }
31%, 33% { translate: 21.8cqw 45.8cqh; }
45%, 47% { translate: 54.9cqw 65.1cqh; }
58%, 60.5% { translate: 73.8cqw 80.2cqh; }
72%, 100% { translate: 34cqw 96cqh; }
0%, 24% { translate: 34cqw 96cqh; }
33%, 36% { translate: 21.8cqw 45.8cqh; }
50%, 54% { translate: 73.8cqw 80.2cqh; }
70%, 100% { translate: 34cqw 96cqh; }
}
@keyframes mt-click {
0%, 17.6% { scale: 0.35; opacity: 0; }
18% { scale: 0.35; opacity: 0.55; }
20.4% { scale: 1.5; opacity: 0; }
0%, 33.6% { scale: 0.35; opacity: 0; }
34% { scale: 0.35; opacity: 0.55; }
36.4% { scale: 1.5; opacity: 0; }
31.6% { scale: 0.35; opacity: 0; }
32% { scale: 0.35; opacity: 0.55; }
34.4% { scale: 1.5; opacity: 0; }
45.6% { scale: 0.35; opacity: 0; }
46% { scale: 0.35; opacity: 0.55; }
48.4% { scale: 1.5; opacity: 0; }
58.6% { scale: 0.35; opacity: 0; }
59% { scale: 0.35; opacity: 0.55; }
61.4%, 100% { scale: 1.5; opacity: 0; }
50.6% { scale: 0.35; opacity: 0; }
51% { scale: 0.35; opacity: 0.55; }
53.4%, 100% { scale: 1.5; opacity: 0; }
}
/* ── Beat one: the nav item at 18% ───────────────────────────── */
.picker-preview-motion .ps-nav-bars i {
position: relative;
}
.picker-preview-motion .ps-nav-bars i::after {
content: "";
position: absolute;
inset: auto 0 -4px 0;
height: 2px;
background-color: var(--pvs-signal);
transform: scaleX(0);
}
.picker-preview-motion .ps-nav-bars i:first-child {
animation: mt-nav-off var(--mt) var(--mt-tf) infinite;
}
/* The outgoing marker collapses toward the item taking over and the incoming
one grows from the side it came from, so the pair reads as one marker
changing places rather than two independent bars. */
.picker-preview-motion .ps-nav-bars i:first-child::after {
transform-origin: right;
animation: mt-rule-out var(--mt) var(--mt-tf) infinite;
}
.picker-preview-motion .ps-nav-bars i:nth-child(3) {
animation:
mt-nav-on var(--mt) var(--mt-tf) infinite,
mt-press-nav var(--mt) linear infinite;
}
.picker-preview-motion .ps-nav-bars i:nth-child(3)::after {
transform-origin: left;
animation: mt-rule-in var(--mt) var(--mt-tf) infinite;
}
@keyframes mt-nav-off {
0%, 18% { background-color: var(--pvs-cta); }
21%, 88% { background-color: var(--pvs-bars); }
91%, 100% { background-color: var(--pvs-cta); }
}
@keyframes mt-nav-on {
0%, 18% { background-color: var(--pvs-bars); }
21%, 88% { background-color: var(--pvs-cta); }
91%, 100% { background-color: var(--pvs-bars); }
}
@keyframes mt-rule-out {
0%, 18% { transform: scaleX(1); }
21%, 88% { transform: scaleX(0); }
91%, 100% { transform: scaleX(1); }
}
@keyframes mt-rule-in {
0%, 18% { transform: scaleX(0); }
21%, 88% { transform: scaleX(1); }
91%, 100% { transform: scaleX(0); }
}
@keyframes mt-press-nav {
0%, 17.6% { scale: 1; }
18.4% { scale: var(--mt-press-scale); }
20% { scale: 1; }
100% { scale: 1; }
}
/* ── Beat two: the hero buttons as one control, at 32% ───────── */
/* ── Beat one: the hero buttons as one control, at 34% ───────── */
/* The pressed button takes the fill on the frame of the click and the one it
came from fades out behind it, which is the order that keeps a filled button
on the page throughout. Cross-fading the pair in opposite directions instead
is where the wash came from: a fill and a ghost sit a long way apart in
lightness once the strategy is drenched, primary as the ground puts the
filled state on the page's ink, and for the length of the fade the hero held
two buttons of a grey belonging to neither. */
.picker-preview-motion .ps-actions i:first-child {
animation: mt-seg-off var(--mt) var(--mt-tf) infinite;
}
.picker-preview-motion .ps-actions i:last-child {
animation:
mt-seg-on var(--mt) var(--mt-tf) infinite,
mt-seg-on var(--mt) steps(1, start) infinite,
mt-press-seg var(--mt) linear infinite;
}
/* 120ms for the fade out, and the reset gets the same. */
@keyframes mt-seg-off {
0%, 32% { background-color: var(--pvs-cta); }
35%, 88% { background-color: var(--pvs-ghost); }
91%, 100% { background-color: var(--pvs-cta); }
0%, 34% { background-color: var(--pvs-cta); }
35.5%, 88% { background-color: var(--pvs-ghost); }
89.5%, 100% { background-color: var(--pvs-cta); }
}
@keyframes mt-seg-on {
0%, 32% { background-color: var(--pvs-ghost); }
35%, 88% { background-color: var(--pvs-cta); }
91%, 100% { background-color: var(--pvs-ghost); }
0%, 34% { background-color: var(--pvs-ghost); }
35.5%, 88% { background-color: var(--pvs-cta); }
89.5%, 100% { background-color: var(--pvs-ghost); }
}
@keyframes mt-press-seg {
0%, 31.6% { scale: 1; }
32.4% { scale: var(--mt-press-scale); }
34% { scale: 1; }
0%, 33.6% { scale: 1; }
34.4% { scale: var(--mt-press-scale); }
36% { scale: 1; }
100% { scale: 1; }
}
/* ── Beat three: the proof row as a choice, at 46% ───────────── */
/* The row's four claims become four options, which is what gives the pointer
something to choose. Selection is a halo on the marker and the claim beside
it darkening: the dots keep the accent colors the palette gave them, because
this screen is not the one relitigating color. */
.picker-preview-motion .ps-proof-item:nth-of-type(1) i {
animation: mt-halo-off var(--mt) var(--mt-tf) infinite;
}
.picker-preview-motion .ps-proof-item:nth-of-type(1) span {
animation: mt-claim-off var(--mt) var(--mt-tf) infinite;
}
.picker-preview-motion .ps-proof-item:nth-of-type(3) i {
animation:
mt-halo-on var(--mt) var(--mt-tf) infinite,
mt-press-choice var(--mt) linear infinite;
}
.picker-preview-motion .ps-proof-item:nth-of-type(3) span {
animation: mt-claim-on var(--mt) var(--mt-tf) infinite;
}
@keyframes mt-halo-off {
0%, 46% { box-shadow: 0 0 0 3px color-mix(in oklab, var(--pvs-cta) 30%, transparent); }
49%, 88% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--pvs-cta) 0%, transparent); }
91%, 100% { box-shadow: 0 0 0 3px color-mix(in oklab, var(--pvs-cta) 30%, transparent); }
}
@keyframes mt-halo-on {
0%, 46% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--pvs-cta) 0%, transparent); }
49%, 88% { box-shadow: 0 0 0 3px color-mix(in oklab, var(--pvs-cta) 30%, transparent); }
91%, 100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--pvs-cta) 0%, transparent); }
}
@keyframes mt-claim-off {
0%, 46% { background-color: var(--pvs-headline); }
49%, 88% { background-color: var(--pvs-bars); }
91%, 100% { background-color: var(--pvs-headline); }
}
@keyframes mt-claim-on {
0%, 46% { background-color: var(--pvs-bars); }
49%, 88% { background-color: var(--pvs-headline); }
91%, 100% { background-color: var(--pvs-bars); }
}
@keyframes mt-press-choice {
0%, 45.6% { scale: 1; }
46.4% { scale: var(--mt-press-scale); }
48% { scale: 1; }
100% { scale: 1; }
}
/* ── Beat four: the card at 59% ──────────────────────────────── */
/* ── Beat two: the card at 51% ───────────────────────────────── */
/* Where the three energies separate most. All three mark the card as chosen.
Responsive stops there. Choreographed lifts it off the page and takes its
neighbours down 80ms later, which is the difference between feedback and
something staged. */
Responsive adds the press. Choreographed lifts it off the page and takes its
neighbours back 80ms later, which is the difference between feedback and
something staged.
The fill starts as the chosen surface at zero alpha rather than as the
keyword: `transparent` is a black nobody asked for, and a cross-fade through
it is how a green page comes to flash a grey card. */
.picker-preview-motion .ps-gallery-item:nth-child(3) {
animation:
mt-card-on var(--mt) var(--mt-tf) infinite,
mt-card-lift var(--mt) var(--mt-tf) infinite;
mt-card-lift var(--mt) var(--mt-tf) infinite,
mt-press-card var(--mt) linear infinite;
}
.picker-preview-motion .ps-gallery-item:not(:nth-child(3)) {
@@ -293,38 +197,50 @@
}
@keyframes mt-card-on {
0%, 59% {
background-color: transparent;
0%, 51% {
background-color: color-mix(in oklab, var(--pvs-surface) 0%, transparent);
box-shadow: inset 0 0 0 0 var(--pvs-cta);
}
62%, 88% {
52.5%, 88% {
background-color: var(--pvs-surface);
box-shadow: inset 0 0 0 1.5px var(--pvs-cta);
}
91%, 100% {
background-color: transparent;
89.5%, 100% {
background-color: color-mix(in oklab, var(--pvs-surface) 0%, transparent);
box-shadow: inset 0 0 0 0 var(--pvs-cta);
}
}
/* Lift and press are on the independent properties rather than both on
transform, so one can run on the option's curve while the other stays
linear. */
@keyframes mt-card-lift {
0%, 59% { transform: translateY(0); }
62%, 88% { transform: translateY(var(--mt-lift)); }
91%, 100% { transform: translateY(0); }
0%, 51% { translate: 0 0; }
53.5%, 88% { translate: 0 var(--mt-lift); }
89.5%, 100% { translate: 0 0; }
}
@keyframes mt-press-card {
0%, 50.6% { scale: 1; }
51.4% { scale: var(--mt-press-scale); }
53% { scale: 1; }
100% { scale: 1; }
}
@keyframes mt-card-dim {
0%, 60% { filter: opacity(1); }
63%, 88% { filter: opacity(var(--mt-dim)); }
91%, 100% { filter: opacity(1); }
0%, 52% { filter: opacity(1); translate: 0 0; }
54%, 88% { filter: opacity(var(--mt-dim)); translate: 0 var(--mt-sink); }
89.5%, 100% { filter: opacity(1); translate: 0 0; }
}
/* ── The entrance, for the option that has one ───────────────── */
/* Six bands down the page, 80ms apart, each arriving over 360ms: 760ms from
the first movement to the last, inside the band an authored entrance is
allowed to take. Restrained and Responsive run the same animations with the
travel set to zero, so the page is simply already there.
/* Six bands down the page, 200ms apart, each arriving over 560ms: 1.56s from
the first movement to the last. Slow enough to watch a band arrive and name
what arrived, and staggered widely enough to read as a sequence rather than
as one block fading in, which is the whole point of showing it. Restrained
and Responsive run the same animations with the travel set to zero, so the
page is simply already there.
The bands are containers rather than leaves. Every element that changes
state during the loop is inside one, so an entrance never has to share a
@@ -359,31 +275,31 @@
@keyframes mt-in-1 {
0% { opacity: var(--mt-in-o); translate: 0 var(--mt-in-y); }
4.5%, 95% { opacity: 1; translate: 0 0; }
7%, 95% { opacity: 1; translate: 0 0; }
100% { opacity: var(--mt-in-o); translate: 0 0; }
}
@keyframes mt-in-2 {
0%, 1% { opacity: var(--mt-in-o); translate: 0 var(--mt-in-y); }
5.5%, 95% { opacity: 1; translate: 0 0; }
0%, 2.5% { opacity: var(--mt-in-o); translate: 0 var(--mt-in-y); }
9.5%, 95% { opacity: 1; translate: 0 0; }
100% { opacity: var(--mt-in-o); translate: 0 0; }
}
@keyframes mt-in-4 {
0%, 3% { opacity: var(--mt-in-o); translate: 0 var(--mt-in-y); }
7.5%, 95% { opacity: 1; translate: 0 0; }
0%, 7.5% { opacity: var(--mt-in-o); translate: 0 var(--mt-in-y); }
14.5%, 95% { opacity: 1; translate: 0 0; }
100% { opacity: var(--mt-in-o); translate: 0 0; }
}
@keyframes mt-in-5 {
0%, 4% { opacity: var(--mt-in-o); translate: 0 var(--mt-in-y); }
8.5%, 95% { opacity: 1; translate: 0 0; }
0%, 10% { opacity: var(--mt-in-o); translate: 0 var(--mt-in-y); }
17%, 95% { opacity: 1; translate: 0 0; }
100% { opacity: var(--mt-in-o); translate: 0 0; }
}
@keyframes mt-in-6 {
0%, 5% { opacity: var(--mt-in-o); translate: 0 var(--mt-in-y); }
9.5%, 95% { opacity: 1; translate: 0 0; }
0%, 12.5% { opacity: var(--mt-in-o); translate: 0 var(--mt-in-y); }
19.5%, 95% { opacity: 1; translate: 0 0; }
100% { opacity: var(--mt-in-o); translate: 0 0; }
}
@@ -391,11 +307,11 @@
rather than rises, because a photograph revealing itself is the moment a
staged entrance is built around. */
@keyframes mt-in-image {
0%, 2% {
0%, 5% {
opacity: var(--mt-in-o);
clip-path: inset(0 0 var(--mt-in-clip) 0);
}
6.5%, 95% {
12%, 95% {
opacity: 1;
clip-path: inset(0 0 0% 0);
}
@@ -411,28 +327,24 @@
without the movement. */
@media (prefers-reduced-motion: reduce) {
/* Pseudo-elements are not valid inside :is() and get dropped from the list
rather than failing loudly, so the two that animate are cancelled on their
own lines. */
rather than failing loudly, so the one that animates is cancelled on its
own line. */
.picker-preview-motion .ps-cursor::after,
.picker-preview-motion .ps-nav-bars i::after,
.picker-preview-motion :is(
.ps-cursor,
.ps-nav,
.ps-nav-bars i,
.ps-hero-copy > *,
.ps-image,
.ps-actions i,
.ps-proof-item,
.ps-proof-item i,
.ps-proof-item span,
.ps-editorial-copy > *,
.ps-gallery,
.ps-gallery-item,
.ps-footer
) {
/* The declarations being cancelled are per-element and more specific than
any list can be without repeating all fifteen selectors. A preference
the visitor set in their operating system outranks them. */
any list can be without repeating all ten selectors. A preference the
visitor set in their operating system outranks them. */
animation: none !important;
}
@@ -440,18 +352,6 @@
translate: 73.8cqw 80.2cqh;
}
.picker-preview-motion .ps-nav-bars i:first-child {
background-color: var(--pvs-bars);
}
.picker-preview-motion .ps-nav-bars i:nth-child(3) {
background-color: var(--pvs-cta);
}
.picker-preview-motion .ps-nav-bars i:nth-child(3)::after {
transform: scaleX(1);
}
.picker-preview-motion .ps-actions i:first-child {
background-color: var(--pvs-ghost);
}
@@ -460,21 +360,14 @@
background-color: var(--pvs-cta);
}
.picker-preview-motion .ps-proof-item:nth-of-type(3) i {
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pvs-cta) 30%, transparent);
}
.picker-preview-motion .ps-proof-item:nth-of-type(3) span {
background-color: var(--pvs-headline);
}
.picker-preview-motion .ps-gallery-item:nth-child(3) {
background-color: var(--pvs-surface);
box-shadow: inset 0 0 0 1.5px var(--pvs-cta);
transform: translateY(var(--mt-lift));
translate: 0 var(--mt-lift);
}
.picker-preview-motion .ps-gallery-item:not(:nth-child(3)) {
filter: opacity(var(--mt-dim));
translate: 0 var(--mt-sink);
}
}
+6
View File
@@ -19,6 +19,11 @@ const runtimeAssets = ['hero-dark.jpg', 'kinpaku-gold-leaf.jpg'];
// The page links ./favicon.svg, so it is also served from the output root.
const faviconSource = path.join(assetsSource, 'favicon.svg');
const faviconOutput = path.join(outputDir, 'favicon.svg');
// The icon specimen is fetched at runtime rather than inlined, so it ships
// beside the page instead of going through Vite. Regenerate it with
// `node scripts/vendor-icons.mjs`.
const iconDataSource = path.join(root, 'picker/data/icon-packs.json');
const iconDataOutput = path.join(outputDir, 'icon-packs.json');
await rm(buildDir, { recursive: true, force: true });
execFileSync(
@@ -31,6 +36,7 @@ await rm(outputDir, { recursive: true, force: true });
await cp(buildDir, outputDir, { recursive: true });
await mkdir(assetsOutput, { recursive: true });
await copyFile(faviconSource, faviconOutput);
await copyFile(iconDataSource, iconDataOutput);
for (const asset of runtimeAssets) {
await copyFile(path.join(assetsSource, asset), path.join(assetsOutput, asset));
}
+538
View File
@@ -0,0 +1,538 @@
#!/usr/bin/env node
/**
* Vendor one fixed specimen from each open-source icon pack the picker offers.
*
* node scripts/vendor-icons.mjs # rebuild from the pinned versions
* node scripts/vendor-icons.mjs --latest # re-resolve each pack's latest first
* node scripts/vendor-icons.mjs --dry-run # resolve and report, write nothing
*
* Every pack draws the same 24 concepts, so the specimen grid compares packs
* rather than vocabularies. Output is picker/data/icon-packs.json: pack
* metadata plus sanitized inner SVG markup, small enough to ship with the
* picker and offline once it is there.
*/
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const outputPath = path.join(root, 'picker/data/icon-packs.json');
const dryRun = process.argv.includes('--dry-run');
const useLatest = process.argv.includes('--latest');
/** The specimen. Order is the reading order of the grid. */
const 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',
];
/**
* Candidate names per concept, best glyph first. Packs disagree on almost
* every noun here, so the resolver walks the list against each pack's real
* file listing and takes the first hit. Anything this misses gets an explicit
* entry in the pack's `overrides`.
*/
const ALIASES = {
home: ['home', 'house'],
search: ['search', 'magnifying-glass'],
user: ['user', 'person', 'account'],
settings: ['settings', 'gear', 'gear-six', 'cog'],
bell: ['bell', 'notification'],
heart: ['heart', 'favourite'],
star: ['star'],
calendar: ['calendar', 'calendar-blank', 'calendar-days', 'calendar3'],
mail: ['mail', 'envelope', 'envelope-closed', 'email'],
message: [
'message-circle', 'chat-circle', 'chat-bubble-left', 'comment',
'chat-bubble', 'message-square', 'chat-dots', 'message', 'chat',
],
trash: ['trash-2', 'trash', 'trash-can', 'delete-bin'],
download: ['download', 'download-simple', 'arrow-down-tray'],
upload: ['upload', 'upload-simple', 'arrow-up-tray'],
plus: ['plus', 'add'],
check: ['check', 'checkmark', 'tick'],
close: ['x', 'x-mark', 'cross-1', 'xmark', 'close'],
'chevron-right': ['chevron-right', 'caret-right', 'nav-arrow-right', 'arrow-right-s'],
'arrow-right': ['arrow-right'],
menu: ['menu', 'bars-3', 'hamburger-menu', 'three-bars', 'list', 'menu-2'],
filter: ['filter', 'funnel', 'funnel-simple', 'mixer-horizontal'],
edit: ['pencil', 'pencil-1', 'pencil-simple', 'edit-pencil', 'edit-2', 'edit'],
lock: ['lock', 'lock-closed', 'lock-key'],
eye: ['eye', 'eye-open', 'view'],
'external-link': [
'external-link', 'arrow-square-out', 'arrow-top-right-on-square',
'box-arrow-up-right', 'link-external', 'open-new-window',
'square-arrow-out-up-right', 'share-box',
],
};
/**
* One row per pack. `dir` and `suffix` describe where the variant we want
* lives; `count` is measured from the real listing of that variant, not
* copied from a marketing page. `note` is the row's one line of editorial.
*/
const PACKS = [
{
id: 'lucide',
name: 'Lucide',
license: 'ISC',
url: 'https://lucide.dev',
note: 'Feather\u2019s successor. Same 2px round-cap hand, far wider catalog, still shipping.',
grid: '24',
weight: '2px stroke',
source: { kind: 'npm', pkg: 'lucide-static', version: '1.27.0' },
dir: 'icons/',
viewBox: '0 0 24 24',
attrs: {
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
},
{
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',
source: { kind: 'npm', pkg: 'feather-icons', version: '4.29.2' },
dir: 'dist/icons/',
viewBox: '0 0 24 24',
attrs: {
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
},
{
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',
source: { kind: 'npm', pkg: '@phosphor-icons/core', version: '2.1.1' },
dir: 'assets/regular/',
viewBox: '0 0 256 256',
attrs: { fill: 'currentColor' },
},
{
id: 'heroicons',
name: 'Heroicons',
license: 'MIT',
url: 'https://heroicons.com',
note: 'Tailwind\u2019s set. 1.5px strokes at 24, with solid and 20px mini twins for dense UI.',
grid: '24',
weight: '1.5px stroke',
source: { kind: 'npm', pkg: 'heroicons', version: '2.2.0' },
dir: '24/outline/',
viewBox: '0 0 24 24',
attrs: {
fill: 'none',
stroke: 'currentColor',
'stroke-width': '1.5',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
},
{
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',
source: { kind: 'npm', pkg: '@tabler/icons', version: '3.45.0' },
dir: 'icons/outline/',
viewBox: '0 0 24 24',
attrs: {
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
},
{
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',
source: { kind: 'gh', repo: 'radix-ui/icons', version: '112af91ad275a63c3a29b0da2588342af74ef9bf' },
dir: 'packages/radix-icons/icons/',
viewBox: '0 0 15 15',
attrs: { fill: 'none' },
},
{
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',
source: { kind: 'npm', pkg: 'bootstrap-icons', version: '1.13.1' },
dir: 'icons/',
exclude: (name) => name.endsWith('-fill'),
viewBox: '0 0 16 16',
attrs: { fill: 'currentColor' },
},
{
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',
source: { kind: 'npm', pkg: 'iconoir', version: '7.11.1' },
dir: 'icons/regular/',
viewBox: '0 0 24 24',
attrs: {
fill: 'none',
stroke: 'currentColor',
'stroke-width': '1.5',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
},
{
id: 'octicons',
name: 'Octicons',
license: 'MIT',
url: 'https://primer.style/octicons',
note: 'GitHub\u2019s set, redrawn per size rather than scaled. Compact and developer-fluent.',
grid: '24',
weight: 'filled paths',
source: { kind: 'npm', pkg: '@primer/octicons', version: '19.31.0' },
dir: 'build/svg/',
suffix: '-24',
viewBox: '0 0 24 24',
attrs: { fill: 'currentColor' },
},
{
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',
source: { kind: 'npm', pkg: '@hugeicons/core-free-icons', version: '4.2.3' },
dir: 'dist/esm/',
extension: '.js',
viewBox: '0 0 24 24',
attrs: {
fill: 'none',
stroke: 'currentColor',
'stroke-width': '1.5',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
},
{
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',
source: { kind: 'npm', pkg: 'remixicon', version: '4.9.1' },
dir: 'icons/',
suffix: '-line',
viewBox: '0 0 24 24',
attrs: { fill: 'currentColor' },
},
];
/** Explicit picks where the alias walk lands on a worse glyph, or nothing. */
const OVERRIDES = {
lucide: { edit: 'pencil', menu: 'menu' },
feather: { edit: 'edit-3', menu: 'menu' },
phosphor: { menu: 'list', settings: 'gear-six' },
// Tabler's menu is two rules, and Bootstrap files the funnel apart from the
// stacked-rule filter. Both rows compare better against the other nine.
tabler: { menu: 'menu-2' },
bootstrap: { menu: 'list', message: 'chat', settings: 'gear', filter: 'funnel' },
octicons: { menu: 'three-bars', settings: 'gear', message: 'comment' },
radix: { menu: 'hamburger-menu', settings: 'gear', filter: 'mixer-horizontal' },
// Iconoir's x.svg is the letter, not the close control.
iconoir: { close: 'xmark' },
// Remix files a hand bell under bell and a hex plate under settings, so the
// two rows that should read as notification and preferences do not.
remix: { bell: 'notification-2', settings: 'settings-3' },
// Hugeicons names in PascalCase and numbers its variants, so nothing here is
// guessable from the concept. Every pick is deliberate.
hugeicons: {
home: 'Home01Icon', search: 'Search01Icon', user: 'UserIcon',
settings: 'Settings01Icon', bell: 'Notification01Icon', heart: 'FavouriteIcon',
star: 'StarIcon', calendar: 'Calendar01Icon', mail: 'Mail01Icon',
message: 'BubbleChatIcon', trash: 'Delete02Icon', download: 'Download01Icon',
upload: 'Upload01Icon', plus: 'Add01Icon', check: 'Tick02Icon',
close: 'Cancel01Icon', 'chevron-right': 'ChevronRightIcon',
// ArrowRight01 is Hugeicons' chevron, and LockIcon is a keyhole disc.
'arrow-right': 'ArrowRight02Icon', menu: 'Menu01Icon', filter: 'FilterIcon',
edit: 'PencilEdit01Icon', lock: 'SquareLock01Icon', eye: 'ViewIcon',
'external-link': 'ExternalLinkIcon',
},
};
const ALLOWED_TAGS = new Set([
'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse', 'g',
]);
const ALLOWED_ATTRS = new Set([
'd', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2',
'width', 'height', 'points', 'transform', 'fill', 'fill-rule', 'clip-rule',
'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
'stroke-miterlimit', 'stroke-dasharray', 'opacity', 'fill-opacity',
'stroke-opacity',
]);
async function getJson(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${response.status} ${url}`);
return response.json();
}
async function getText(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${response.status} ${url}`);
return response.text();
}
/** Resolve the version to pin, then list every file the package ships. */
async function listFiles(pack) {
const source = useLatest ? { ...pack.source, version: null } : pack.source;
if (source.kind === 'npm') {
const version = source.version
|| (await getJson(`https://data.jsdelivr.com/v1/packages/npm/${source.pkg}`)).tags.latest;
const listing = await getJson(
`https://data.jsdelivr.com/v1/packages/npm/${source.pkg}@${version}?structure=flat`,
);
return {
version,
base: `https://cdn.jsdelivr.net/npm/${source.pkg}@${version}`,
files: listing.files.map((file) => file.name.replace(/^\//, '')),
};
}
// GitHub sources are pinned to a commit: branches move, and a specimen that
// silently redraws itself is worse than one that fails loudly.
const version = source.version
|| (await getJson(`https://api.github.com/repos/${source.repo}/commits/HEAD`)).sha;
const tree = await getJson(
`https://api.github.com/repos/${source.repo}/git/trees/${version}?recursive=1`,
);
return {
version,
base: `https://raw.githubusercontent.com/${source.repo}/${version}`,
files: tree.tree.filter((node) => node.type === 'blob').map((node) => node.path),
};
}
function basenames(pack, files) {
const extension = pack.extension || '.svg';
const map = new Map();
for (const file of files) {
if (!file.startsWith(pack.dir) || !file.endsWith(extension)) continue;
const name = file.slice(pack.dir.length, -extension.length);
// Remix nests by category (icons/system/home-line.svg); everything else is
// flat, and a nested name would only collide with itself.
map.set(name.includes('/') ? name.slice(name.lastIndexOf('/') + 1) : name, file);
}
return map;
}
function variantNames(pack, names) {
const suffix = pack.suffix || '';
return [...names]
.filter((name) => name.endsWith(suffix))
.filter((name) => !pack.exclude?.(name));
}
function resolveName(pack, concept, names) {
const suffix = pack.suffix || '';
const override = OVERRIDES[pack.id]?.[concept];
const candidates = override ? [override] : ALIASES[concept];
for (const candidate of candidates) {
for (const name of [
`${candidate}${suffix}`,
candidate,
`${candidate}-01${suffix}`,
`${candidate}-1${suffix}`,
]) {
if (names.has(name) && !pack.exclude?.(name)) return name;
}
}
return null;
}
/** Keep drawing elements and drawing attributes. Drop everything else. */
function sanitize(markup) {
const body = markup
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<\?[\s\S]*?\?>/g, '')
.replace(/<(title|desc|style|script|metadata)\b[\s\S]*?<\/\1>/gi, '')
.replace(/<\/?svg\b[^>]*>/gi, '');
let dropped = 0;
const cleaned = body.replace(/<\/?([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/g, (match, rawTag, rawAttrs) => {
const tag = rawTag.toLowerCase();
if (!ALLOWED_TAGS.has(tag)) {
dropped += 1;
return '';
}
if (match.startsWith('</')) return `</${tag}>`;
// Names carry digits (x1, y2), so the class cannot be letters only.
const attrs = [...rawAttrs.matchAll(/([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*"([^"]*)"/g)]
.map(([, key, value]) => [key.toLowerCase(), value.trim()])
.filter(([key]) => ALLOWED_ATTRS.has(key));
// Tabler opens every icon with an invisible bounding path, and a few packs
// carry the same trick. It draws nothing, so it only costs bytes.
const paint = Object.fromEntries(attrs);
if (paint.fill === 'none' && paint.stroke === 'none') return '';
if (paint.opacity === '0') return '';
const rendered = attrs.map(([key, value]) => `${key}="${value}"`).join(' ');
const selfClosing = match.endsWith('/>');
return `<${tag}${rendered ? ` ${rendered}` : ''}${selfClosing ? '/>' : '>'}`;
});
return { body: cleaned.replace(/>\s+</g, '><').replace(/\s+/g, ' ').trim(), dropped };
}
/**
* Hugeicons ships React-shaped tuples rather than SVG files:
* [["path", { d: "...", key: "0" }], ...]. Same drawing, one hop away.
*/
function tuplesToMarkup(module) {
const start = module.indexOf('[', module.indexOf('='));
const end = module.lastIndexOf(']');
if (start === -1 || end < start) throw new Error('No icon tuple array found');
const literal = module
.slice(start, end + 1)
.replace(/([{,])\s*([A-Za-z][A-Za-z0-9]*)\s*:/g, '$1"$2":');
const tuples = JSON.parse(literal);
return tuples
.map(([tag, props]) => {
const attrs = Object.entries(props)
.filter(([key]) => key !== 'key')
.map(([key, value]) => [key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`), value])
.map(([key, value]) => `${key}="${value}"`)
.join(' ');
return `<${tag} ${attrs}/>`;
})
.join('');
}
const rootAttr = (markup, name) => markup.match(new RegExp(`<svg[^>]*\\b${name}="([^"]*)"`, 'i'))?.[1];
async function vendorPack(pack) {
const { version, base, files } = await listFiles(pack);
const names = basenames(pack, files);
const count = variantNames(pack, names.keys()).length;
if (!count) throw new Error(`${pack.id}: no files under ${pack.dir}`);
const glyphs = [];
const misses = [];
let droppedTotal = 0;
let viewBoxMismatch = null;
for (const concept of CONCEPTS) {
const name = resolveName(pack, concept, names);
if (!name) {
misses.push(concept);
continue;
}
const raw = await getText(`${base}/${encodeURI(names.get(name))}`);
const markup = pack.extension === '.js' ? tuplesToMarkup(raw) : raw;
if (pack.extension !== '.js') {
const viewBox = rootAttr(markup, 'viewBox');
if (viewBox && viewBox !== pack.viewBox) viewBoxMismatch ??= `${name}: ${viewBox}`;
}
const { body, dropped } = sanitize(markup);
droppedTotal += dropped;
if (!body) throw new Error(`${pack.id}/${name}: sanitized to nothing`);
glyphs.push({ concept, name, body });
}
return {
pack: {
id: pack.id,
name: pack.name,
license: pack.license,
url: pack.url,
note: pack.note,
grid: pack.grid,
weight: pack.weight,
count,
version,
viewBox: pack.viewBox,
attrs: pack.attrs,
glyphs,
},
report: { misses, dropped: droppedTotal, viewBoxMismatch },
};
}
const results = await Promise.all(PACKS.map(async (pack) => {
try {
return { pack, ...(await vendorPack(pack)) };
} catch (error) {
return { pack, error };
}
}));
let failed = false;
const packs = [];
for (const result of results) {
if (result.error) {
failed = true;
console.error(`FAIL ${result.pack.id}: ${result.error.message}`);
continue;
}
const { pack, report } = result;
packs.push(pack);
const flags = [
report.misses.length ? `MISSING ${report.misses.join(', ')}` : null,
report.viewBoxMismatch ? `VIEWBOX ${report.viewBoxMismatch}` : null,
report.dropped ? `dropped ${report.dropped} tags` : null,
].filter(Boolean);
if (report.misses.length) failed = true;
console.log(
`${pack.id.padEnd(11)} v${String(pack.version).slice(0, 12).padEnd(13)}`
+ `${String(pack.count).padStart(5)} glyphs ${pack.glyphs.length}/${CONCEPTS.length}`
+ `${flags.length ? ` ${flags.join(' | ')}` : ''}`,
);
console.log(` ${pack.glyphs.map((glyph) => `${glyph.concept}=${glyph.name}`).join(' ')}`);
}
if (!dryRun && packs.length) {
const payload = {
generated: new Date().toISOString().slice(0, 10),
concepts: CONCEPTS,
packs,
};
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(payload)}\n`);
const bytes = Buffer.byteLength(JSON.stringify(payload));
console.log(`\nWrote ${path.relative(root, outputPath)} (${(bytes / 1024).toFixed(0)} KB)`);
}
process.exit(failed ? 1 : 0);
+1 -1
View File
@@ -308,7 +308,7 @@ The `html` and `css` fields must be **self-contained, drop-in snippets** that re
1. **Tailwind expansion.** If the source uses Tailwind (className="bg-primary text-white rounded-lg px-6 py-3"), expand every utility to literal CSS properties in the `css` string. Do **not** reference Tailwind classes; do **not** assume a Tailwind CSS bundle is loaded. Each component is self-contained.
2. **Token resolution.** If the project exposes tokens as CSS custom properties on `:root` (e.g. `--color-primary`, `--radius-md`), reference them via `var(--color-primary)`; they inherit through the shadow DOM and stay live-bound. If tokens live only in JS theme objects (styled-components, CSS-in-JS), resolve to literal values at generation time.
3. **Icons.** Inline as SVG. Do not reference Lucide/Heroicons packages, icon fonts, or `<img src="...">`. A typical icon is 16-24px; copy the SVG path data directly.
3. **Icons.** Inline as SVG. Do not reference Lucide/Heroicons packages, icon fonts, or `<img src="...">`. A typical icon is 16-24px; copy the SVG path data directly. This is about how a snippet ships, not about which family the project draws from: when the design names an icon set, keep using that set's glyphs and paste their path data in.
4. **States.** Include `:hover`, `:focus-visible`, and (if meaningful) `:active` rules inline. A static default-only snapshot makes the panel feel dead. Hover + focus rules in the CSS make it feel alive.
5. **Reset bloat.** Extract only the component's *distinctive* CSS (background, color, padding, border-radius, typography, transition). Skip universal resets (`box-sizing: border-box`, `line-height: inherit`, `-webkit-font-smoothing`). The panel already has a neutral canvas; don't re-ship resets.
6. **Scoped class names.** Prefix every class with `ds-` (e.g. `ds-btn-primary`, `ds-input-search`) so component CSS doesn't collide with other components' CSS in the same shadow DOM.