mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 08:36:25 +03:00
Update
This commit is contained in:
@@ -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
@@ -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 || '';
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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 +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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user