mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 15:16:35 +03:00
331 lines
15 KiB
JavaScript
331 lines
15 KiB
JavaScript
import { dcxAsset } from './assets.js';
|
|
|
|
(() => {
|
|
"use strict";
|
|
|
|
const DETAIL_META = {
|
|
product: {
|
|
"Purpose": ["Why the product exists and what success looks like.", "/assets/product/product-purpose-foil.png"],
|
|
"Positioning": ["The strategic space this product should occupy.", "/assets/product/product-positioning-foil.png"],
|
|
"Primary conversion": ["The single action the experience should make inevitable.", "/assets/product/product-primary-conversion-foil.png"],
|
|
"What must be clear first": ["The facts people need before anything else.", "/assets/product/product-clear-first-foil.png"],
|
|
"Product principles": ["The rules that keep every product decision aligned.", "/assets/product/product-principles-foil.png"],
|
|
"Operating context": ["Where and how the product must work.", "/assets/product/product-operating-context-foil.png"],
|
|
"Surfaces": ["The chosen experiences and the job each one performs.", "/assets/product/product-surfaces-foil.png"],
|
|
},
|
|
brand: {
|
|
"Personality": ["The character every expression should carry.", "/assets/brand/brand-personality-foil.png"],
|
|
"Voice": ["How the brand sounds—and what it deliberately avoids.", "/assets/brand/brand-voice-foil.png"],
|
|
"Principles": ["The rules that turn character into consistent choices.", "/assets/brand/brand-principles-foil.png"],
|
|
"Commitments": ["The promises the brand must keep.", "/assets/brand/brand-commitments-foil.png"],
|
|
"Named references": ["Useful signals to borrow without becoming an imitation."],
|
|
"Anti-reference": ["The direction the brand must deliberately avoid."],
|
|
"Marks": ["The identity assets already available to the system."],
|
|
"Boards and references": ["Visual evidence that should inform the work."],
|
|
"Assets provided": ["The source material available for production."],
|
|
"Brand assets": ["Logos, moodboards, and visual references available to the system."],
|
|
},
|
|
color: {
|
|
"The cue": ["", "/assets/color/color-palette-foil.png"],
|
|
"Also generated": ["Adjacent directions retained as useful context."],
|
|
"Palette": ["The core color relationships for the experience.", "/assets/color/color-palette-foil.png"],
|
|
"Strategy per surface": ["How the palette changes emphasis across contexts.", "/assets/color/color-strategy-per-surface-foil.png"],
|
|
},
|
|
typography: {
|
|
"The pair": ["Two typefaces with distinct, complementary jobs.", "/assets/typography/typography-pair-foil.png"],
|
|
"Type scale": ["The hierarchy that gives content pace and proportion.", "/assets/typography/typography-type-scale-foil.png"],
|
|
"In running text": ["How the pair behaves when the content gets real."],
|
|
},
|
|
components: {
|
|
"Buttons": ["Variants, sizes, icons, and loading states."],
|
|
"Input fields": ["States, sizes, affixes, and multiline input."],
|
|
"Cards": ["Content, media, horizontal, and action compositions."],
|
|
},
|
|
material: {
|
|
"The page, as chosen": ["What each selected surface represents in this design context."],
|
|
"Motion per surface": ["How movement supports each context without becoming spectacle."],
|
|
"Motion": ["How movement should support the experience."],
|
|
"Accessibility": ["The rules that keep the material system readable and operable."],
|
|
"Layout structure": ["The spatial system that organizes the page.", "/assets/material/material-layout-structure-foil.png"],
|
|
"Boundaries per surface": ["Where separation is visible—and where space does the work.", "/assets/material/material-boundaries-per-surface-foil.png"],
|
|
"Corners per surface": ["How edge character changes with context.", "/assets/material/material-corners-per-surface-foil.png"],
|
|
"Depth per surface": ["How hierarchy is expressed without decorative elevation.", "/assets/material/material-depth-per-surface-foil.png"],
|
|
"Iconography": ["The chosen icon library, its character, grid, stroke, and license."],
|
|
},
|
|
hooks: {
|
|
"How it runs": [""],
|
|
"Built-in rules": ["Choose what the detector watches."],
|
|
"Custom rules": [""],
|
|
},
|
|
};
|
|
|
|
let syncFrame = 0;
|
|
|
|
const slugify = (value) => value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-|-$/g, "");
|
|
|
|
const wrapPrincipleContent = (section, category) => {
|
|
section.querySelectorAll(":scope .dcx-principles > li").forEach((item) => {
|
|
let copy = item.querySelector(":scope > .dcx-detail-principle-copy");
|
|
if (!copy) {
|
|
copy = document.createElement("div");
|
|
copy.className = "dcx-detail-principle-copy";
|
|
while (item.firstChild) copy.appendChild(item.firstChild);
|
|
item.appendChild(copy);
|
|
}
|
|
if (category === "product" && section.dataset.label === "Product principles") {
|
|
const detail = copy.querySelector(":scope > strong:first-child")?.nextSibling;
|
|
if (detail?.nodeType === Node.TEXT_NODE) {
|
|
detail.textContent = detail.textContent.replace(/^\s*[—–-]+\s*/, " ");
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const demoteBodyHeadings = (body) => {
|
|
body.querySelectorAll("h3").forEach((heading) => {
|
|
const replacement = document.createElement("h4");
|
|
[...heading.attributes].forEach((attribute) => {
|
|
replacement.setAttribute(attribute.name, attribute.value);
|
|
});
|
|
while (heading.firstChild) replacement.appendChild(heading.firstChild);
|
|
heading.replaceWith(replacement);
|
|
});
|
|
};
|
|
|
|
const placeProductPlatform = (section, category) => {
|
|
if (category !== "product" || section.dataset.label !== "Purpose") return;
|
|
|
|
const callout = section.querySelector(":scope .dcx-purpose .dcx-callout");
|
|
const name = callout?.querySelector(":scope > .dcx-callout-name");
|
|
const platform = section.querySelector(":scope .dcx-purpose > .dcx-platform-pill");
|
|
if (!callout || !name || !platform) return;
|
|
|
|
platform.classList.remove("dcx-chip");
|
|
name.after(platform);
|
|
};
|
|
|
|
const removeProvenanceNotes = (section) => {
|
|
section.querySelectorAll(":scope .dcx-fan-note").forEach((note) => note.remove());
|
|
};
|
|
|
|
const compactColorArticle = (article) => {
|
|
if (article.dataset.dcxColorCompacted === "true") return;
|
|
|
|
const sections = new Map(
|
|
[...article.querySelectorAll(":scope > .dcx-block[data-label]")]
|
|
.map((section) => [section.dataset.label, section]),
|
|
);
|
|
const cueSection = sections.get("The cue");
|
|
const paletteSection = sections.get("Palette");
|
|
const strategySection = sections.get("Strategy per surface");
|
|
const cue = cueSection?.querySelector(":scope > .dcx-cue");
|
|
const fan = paletteSection?.querySelector(":scope > .dcx-fan");
|
|
|
|
cueSection?.querySelector(":scope > .dcx-fan-note")?.remove();
|
|
strategySection?.querySelector(":scope > .dcx-fan-note")?.remove();
|
|
sections.get("Roles and values")?.remove();
|
|
sections.get("Interview direction")?.remove();
|
|
|
|
if (cue && fan) {
|
|
const cueCard = cue.querySelector(":scope > .dcx-cue-card");
|
|
const panels = [...fan.querySelectorAll(":scope > .dcx-fan-panel")];
|
|
if (cueCard && panels.length) {
|
|
cueCard.querySelector(":scope > .dcx-cue-tag")?.remove();
|
|
cueCard.querySelector(":scope > .dcx-cue-name")?.remove();
|
|
|
|
const bands = document.createElement("ul");
|
|
bands.className = "dcx-cue-bands";
|
|
bands.setAttribute("aria-label", "Committed palette");
|
|
|
|
panels.forEach((panel) => {
|
|
const role = panel.querySelector(":scope .dcx-fan-name")?.textContent.trim() || "Color";
|
|
const value = panel.querySelector(":scope .dcx-fan-value")?.textContent.trim() || "";
|
|
const color = panel.style.getPropertyValue("--panel-swatch");
|
|
const ink = panel.style.getPropertyValue("--panel-ink");
|
|
|
|
const item = document.createElement("li");
|
|
item.className = "dcx-cue-band-item";
|
|
|
|
const swatch = document.createElement("div");
|
|
swatch.className = "dcx-cue-band";
|
|
swatch.style.setProperty("--band-color", color);
|
|
swatch.style.setProperty("--band-ink", ink);
|
|
|
|
const hex = document.createElement("code");
|
|
hex.className = "dcx-cue-band-value";
|
|
hex.textContent = value;
|
|
swatch.appendChild(hex);
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "dcx-cue-band-label";
|
|
label.textContent = role;
|
|
item.append(swatch, label);
|
|
bands.appendChild(item);
|
|
});
|
|
|
|
cueCard.querySelectorAll(":scope > .dcx-cue-role").forEach((role) => role.remove());
|
|
cueCard.appendChild(bands);
|
|
paletteSection.remove();
|
|
}
|
|
}
|
|
|
|
const heroLede = article.querySelector(":scope > header > .dcx-lede");
|
|
if (heroLede) heroLede.textContent = "Committed palette and per-surface strategy.";
|
|
article.dataset.dcxColorCompacted = "true";
|
|
};
|
|
|
|
const replaceMaterialPagePreviews = (article) => {
|
|
const section = article.querySelector(':scope > .dcx-block[data-label="The page, as chosen"]');
|
|
const previews = section?.querySelector(":scope > .dcx-surfaces");
|
|
if (!previews) return;
|
|
|
|
/* One definition per chosen surface, published by the data layer at render
|
|
time (design-context.js renderDocument). If the list is missing the
|
|
original preview boards stay, which is a visible fallback rather than an
|
|
empty section. */
|
|
const entries = Array.isArray(window.dcxSurfaceDefs) ? window.dcxSurfaceDefs : [];
|
|
if (!entries.length) return;
|
|
|
|
const definitions = document.createElement("dl");
|
|
definitions.className = "dcx-defs";
|
|
entries.forEach(({ label, description }) => {
|
|
const item = document.createElement("div");
|
|
item.className = "dcx-def";
|
|
const term = document.createElement("dt");
|
|
term.textContent = label;
|
|
const detail = document.createElement("dd");
|
|
detail.textContent = description;
|
|
item.append(term, detail);
|
|
definitions.appendChild(item);
|
|
});
|
|
|
|
previews.replaceWith(definitions);
|
|
};
|
|
|
|
const enhanceSection = (section, category, iconIndex) => {
|
|
const label = section.dataset.label || "Section";
|
|
const [ledeText, icon] = DETAIL_META[category]?.[label] || ["The decisions that define this part of the system."];
|
|
const slug = `${category}-${slugify(label)}`;
|
|
|
|
section.querySelector(":scope > .dcx-block-label")?.remove();
|
|
|
|
const body = document.createElement("div");
|
|
body.className = "dcx-detail-section-body";
|
|
while (section.firstChild) body.appendChild(section.firstChild);
|
|
|
|
const heading = document.createElement("div");
|
|
heading.className = "dcx-detail-section-heading";
|
|
|
|
const title = document.createElement("h3");
|
|
title.className = "dcx-detail-section-title";
|
|
title.id = `dcx-${slug}-title`;
|
|
title.textContent = label;
|
|
|
|
heading.appendChild(title);
|
|
if (ledeText) {
|
|
const lede = document.createElement("p");
|
|
lede.className = "dcx-detail-section-lede";
|
|
lede.textContent = ledeText;
|
|
heading.appendChild(lede);
|
|
}
|
|
|
|
const header = document.createElement("header");
|
|
header.className = "dcx-detail-section-head";
|
|
header.appendChild(heading);
|
|
|
|
if (icon) {
|
|
header.classList.add("dcx-detail-section-head--with-icon");
|
|
const figure = document.createElement("figure");
|
|
figure.className = "dcx-detail-section-icon";
|
|
figure.setAttribute("aria-hidden", "true");
|
|
figure.setAttribute("data-dcx-hide-on-error", "");
|
|
|
|
const image = document.createElement("img");
|
|
image.src = dcxAsset(icon);
|
|
image.alt = "";
|
|
image.width = 256;
|
|
image.height = 256;
|
|
image.decoding = "async";
|
|
if (section.closest(".dcx-article")?.dataset.dcxCategory || iconIndex > 0) image.loading = "lazy";
|
|
figure.appendChild(image);
|
|
header.appendChild(figure);
|
|
}
|
|
|
|
section.classList.add("dcx-detail-section", `dcx-detail-section--${slugify(label)}`);
|
|
section.id = `dcx-${slug}`;
|
|
if (!section.closest(".dcx-article")?.dataset.dcxCategory) {
|
|
section.setAttribute("role", "region");
|
|
section.setAttribute("aria-labelledby", title.id);
|
|
}
|
|
section.append(header, body);
|
|
|
|
demoteBodyHeadings(body);
|
|
body.querySelector(":scope > .dcx-list")?.classList.add("dcx-detail-list");
|
|
body.querySelector(":scope > .dcx-defs")?.classList.add("dcx-detail-defs");
|
|
wrapPrincipleContent(section, category);
|
|
placeProductPlatform(section, category);
|
|
removeProvenanceNotes(section);
|
|
};
|
|
|
|
const enhanceArticle = (article, category) => {
|
|
if (article.dataset.dcxDetailEnhanced === category) return;
|
|
|
|
if (category === "color") compactColorArticle(article);
|
|
if (category === "typography") {
|
|
article.querySelectorAll(":scope .dcx-pair-why").forEach((note) => note.remove());
|
|
article.querySelector(':scope > .dcx-block[data-label="Interview direction"]')?.remove();
|
|
}
|
|
if (category === "iconography") {
|
|
article.querySelector(':scope > .dcx-block[data-label="The hand"]')?.remove();
|
|
}
|
|
if (category === "material") {
|
|
replaceMaterialPagePreviews(article);
|
|
article.querySelectorAll(":scope > .dcx-block[data-label] > .dcx-fan-note")
|
|
.forEach((note) => note.remove());
|
|
}
|
|
article.classList.add("dcx-detail-article", `dcx-detail-article--${category}`);
|
|
article.querySelector(":scope > header")?.classList.add("dcx-detail-hero");
|
|
|
|
let iconIndex = 0;
|
|
article.querySelectorAll(":scope > .dcx-block[data-label]").forEach((section) => {
|
|
const hasIcon = Boolean(DETAIL_META[category]?.[section.dataset.label]?.[1]);
|
|
enhanceSection(section, category, iconIndex);
|
|
if (hasIcon) iconIndex += 1;
|
|
});
|
|
|
|
article.dataset.dcxDetailEnhanced = category;
|
|
};
|
|
|
|
const syncDetails = () => {
|
|
syncFrame = 0;
|
|
const expander = document.querySelector(".dcx-expander");
|
|
const continuousArticles = expander?.querySelectorAll('.dcx-main > .dcx-article[data-dcx-category]');
|
|
if (continuousArticles?.length) {
|
|
continuousArticles.forEach((article) => {
|
|
const category = article.dataset.dcxCategory;
|
|
if (category !== "audience" && DETAIL_META[category]) enhanceArticle(article, category);
|
|
});
|
|
return;
|
|
}
|
|
|
|
const activeItem = expander?.querySelector(".dcx-nav-list > li.is-active[data-category]");
|
|
const article = expander?.querySelector(".dcx-main > .dcx-article");
|
|
const category = activeItem?.dataset.category;
|
|
if (!article || !category || category === "audience" || !DETAIL_META[category]) return;
|
|
enhanceArticle(article, category);
|
|
};
|
|
|
|
const scheduleSync = () => {
|
|
if (syncFrame) return;
|
|
syncFrame = requestAnimationFrame(syncDetails);
|
|
};
|
|
|
|
const observer = new MutationObserver(scheduleSync);
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
window.addEventListener("pageshow", scheduleSync);
|
|
scheduleSync();
|
|
})();
|