import './dcx-settings.js';
import { dcxAsset } from './assets.js';
(() => {
"use strict";
const renameIconographyToComponents = () => {
const tile = document.querySelector('.dcx-tile[data-category="iconography"]');
if (tile) {
tile.dataset.category = "components";
tile.dataset.name = "Components";
tile.setAttribute("aria-label", "Open Components");
const title = tile.querySelector(".dcx-tile-title");
if (title) title.textContent = "Components";
}
const shellTemplate = document.querySelector("#dcx-shell-template");
const navItem = shellTemplate?.content.querySelector('li[data-category="iconography"]');
const navLink = navItem?.querySelector(".dcx-nav-link");
if (navItem && navLink) {
navItem.dataset.category = "components";
navLink.href = "#components";
navLink.dataset.dcxNav = "components";
navLink.textContent = "Components";
}
};
renameIconographyToComponents();
const CATEGORIES = [
"audience",
"product",
"brand",
"color",
"typography",
"components",
"material",
"hooks",
];
const REDUCED_MOTION = window.matchMedia("(prefers-reduced-motion: reduce)");
const MOBILE_NAV = window.matchMedia("(max-width: 920px)");
const CATEGORY_SET = new Set(CATEGORIES);
const categoryFromHash = (hash) => {
if (hash === "interface"
|| hash === "dcx-category-interface"
|| hash.startsWith("dcx-interface-")) return "hooks";
if (hash === "iconography"
|| hash === "dcx-category-iconography"
|| hash.startsWith("dcx-iconography-")) return "material";
return CATEGORIES.find((category) => hash === category
|| hash === `dcx-category-${category}`
|| hash.startsWith(`dcx-${category}-`)) || "";
};
const INITIAL_HASH = location.hash.slice(1);
let pendingInitialCategory = categoryFromHash(INITIAL_HASH);
let pendingInitialHash = pendingInitialCategory ? INITIAL_HASH : "";
let documentState = null;
let revealFrame = 0;
let pendingObserver = null;
const slugify = (value) => value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
const categoryName = (category) => document
.querySelector(`.dcx-tile[data-category="${category}"]`)
?.dataset.name || category;
const sourceCategory = (category) => category === "components" ? "interface" : category;
const templatesReady = () => CATEGORIES.every((category) => document
.querySelector(`#dcx-detail-${sourceCategory(category)}`)
?.content.querySelector(".dcx-article"))
&& Boolean(document.querySelector("#dcx-detail-iconography")
?.content.querySelector(".dcx-article"));
const getScrollTargetTop = (state, target) => {
if (!target) return 0;
const mainRect = state.main.getBoundingClientRect();
return Math.max(0, target.getBoundingClientRect().top - mainRect.top + state.main.scrollTop - 18);
};
const targetFromHash = (state, hash, category) => {
const article = state.main.querySelector(`:scope > .dcx-article[data-dcx-category="${category}"]`);
if (hash === "iconography"
|| hash === "dcx-category-iconography"
|| hash.startsWith("dcx-iconography-")) {
return state.main.querySelector("#dcx-material-iconography") || article;
}
if (!hash || hash === category) return article;
return state.main.querySelector(`#${CSS.escape(hash)}`) || article;
};
const copyText = async (value) => {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return true;
}
} catch {}
const previousFocus = document.activeElement;
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
let copied = false;
try {
copied = document.execCommand("copy");
} catch {}
textarea.remove();
if (previousFocus instanceof HTMLElement && previousFocus.isConnected) {
previousFocus.focus({ preventScroll: true });
}
return copied;
};
const initializeColorFans = (root) => {
root.querySelectorAll(".dcx-fan:not([data-dcx-document-ready])").forEach((fan) => {
const panels = [...fan.querySelectorAll(".dcx-fan-panel")];
if (!panels.length) return;
fan.dataset.dcxDocumentReady = "true";
const engage = (activeIndex) => {
fan.classList.add("is-engaged");
panels.forEach((panel, index) => {
panel.classList.toggle("is-active", index === activeIndex);
panel.classList.toggle("is-neighbor", Math.abs(index - activeIndex) === 1);
});
};
const reset = () => {
fan.classList.remove("is-engaged");
panels.forEach((panel) => panel.classList.remove("is-active", "is-neighbor"));
};
fan.addEventListener("mousemove", (event) => {
const rect = fan.getBoundingClientRect();
const relativeX = Math.min(0.999, Math.max(0, (event.clientX - rect.left) / rect.width));
let activeIndex = 0;
panels.forEach((panel, index) => {
const left = Number.parseFloat(panel.style.getPropertyValue("--panel-left")) / 100;
if (relativeX >= left) activeIndex = index;
});
engage(activeIndex);
});
fan.addEventListener("mouseleave", reset);
panels.forEach((panel, index) => {
panel.addEventListener("focus", () => engage(index));
panel.addEventListener("blur", () => {
if (!fan.matches(":focus-within")) reset();
});
panel.addEventListener("click", () => {
const color = panel.dataset.copyColor;
if (!color) return;
copyText(color);
const name = panel.querySelector(".dcx-fan-name");
const original = panel.dataset.colorName || "Color";
panel.classList.add("is-copied");
if (name) name.textContent = "Copied!";
window.clearTimeout(panel._dcxCopyTimer);
panel._dcxCopyTimer = window.setTimeout(() => {
panel.classList.remove("is-copied");
if (name) name.textContent = original;
}, 900);
});
});
});
};
const createDocumentSection = (label, markup) => {
const section = document.createElement("section");
section.className = "dcx-block";
section.dataset.label = label;
const blockLabel = document.createElement("span");
blockLabel.className = "dcx-block-label";
blockLabel.textContent = label;
const content = document.createElement("template");
content.innerHTML = markup;
section.append(blockLabel, content.content);
return section;
};
const BRAND_PLACEHOLDER_ASSETS = [
{
src: "/assets/brand/placeholders/hanazono-primary-mark.png",
kind: "logo",
title: "Primary mark",
alt: "Abstract botanical primary mark in textured gold leaf and patina",
width: 768,
height: 768,
},
{
src: "/assets/brand/placeholders/hanazono-atelier-seal.png",
kind: "logo",
title: "Atelier seal",
alt: "Circular floral atelier seal in textured gold leaf and patina",
width: 768,
height: 768,
},
{
src: "/assets/brand/placeholders/hanazono-seasonal-moodboard.webp",
kind: "moodboard",
title: "Seasonal composition",
alt: "Editorial moodboard of flowers, handmade paper, gold leaf, and vermilion thread",
width: 960,
height: 720,
},
{
src: "/assets/brand/placeholders/hanazono-material-reference.webp",
kind: "reference",
title: "Material direction",
alt: "Material study of lacquer, washi paper, gold leaf, and verdigris patina",
width: 960,
height: 720,
},
];
const humanizeAssetName = (value, fallback) => {
const name = String(value || "")
.split(/[\\/]/)
.pop()
?.replace(/\.[a-z0-9]+$/i, "")
.replace(/[-_]+/g, " ")
.trim();
if (!name) return fallback;
return name.replace(/\b\w/g, (letter) => letter.toUpperCase());
};
const renderedBrandAssets = (article) => {
const assets = [];
const collect = (selector, kind, fallback) => {
article.querySelectorAll(selector).forEach((figure, index) => {
const image = figure.querySelector("img");
if (!image) return;
const note = figure.querySelector(".dcx-asset-caption p")?.textContent.trim();
const file = figure.querySelector(".dcx-asset-caption code")?.textContent.trim()
|| image.getAttribute("src")
|| "";
const title = note || humanizeAssetName(file, `${fallback} ${index + 1}`);
assets.push({
src: image.getAttribute("src") || image.src,
kind,
title,
alt: note || `${title} ${kind === "logo" ? "logo" : "brand image"}`,
width: kind === "logo" ? 1200 : 1600,
height: kind === "logo" ? 1200 : 1200,
});
});
};
collect(':scope > .dcx-block[data-label="Marks"] .dcx-mark', "logo", "Logo");
collect(':scope > .dcx-block[data-label="Boards and references"] .dcx-board', "image", "Brand image");
article.querySelectorAll(':scope > .dcx-block[data-label="Marks"], :scope > .dcx-block[data-label="Boards and references"], :scope > .dcx-block[data-label="Assets provided"]')
.forEach((block) => block.remove());
return assets;
};
const composeBrandAssetsArticle = (article) => {
const uploaded = renderedBrandAssets(article);
const assets = uploaded.length ? uploaded : BRAND_PLACEHOLDER_ASSETS;
const section = createDocumentSection("Brand assets", "");
section.dataset.dcxBrandAssets = uploaded.length ? "uploaded" : "placeholder";
const grid = document.createElement("ul");
grid.className = "dcx-brand-assets-grid";
grid.setAttribute("aria-label", "Available brand assets");
assets.forEach((asset) => {
const item = document.createElement("li");
item.className = "dcx-brand-asset";
item.dataset.assetKind = asset.kind;
const figure = document.createElement("figure");
const frame = document.createElement("div");
frame.className = "dcx-brand-asset-frame";
const image = document.createElement("img");
image.src = asset.src.startsWith('/assets/') ? dcxAsset(asset.src) : asset.src;
image.alt = asset.alt;
image.width = asset.width;
image.height = asset.height;
image.loading = "lazy";
image.decoding = "async";
frame.appendChild(image);
const caption = document.createElement("figcaption");
const title = document.createElement("strong");
title.textContent = asset.title;
const kind = document.createElement("span");
kind.textContent = asset.kind === "logo"
? "Logo"
: asset.kind === "moodboard"
? "Moodboard"
: asset.kind === "reference"
? "Reference"
: "Image";
caption.append(title, kind);
figure.append(frame, caption);
item.appendChild(figure);
grid.appendChild(item);
});
section.appendChild(grid);
article.appendChild(section);
const lede = article.querySelector(":scope > header > .dcx-lede");
if (lede) lede.textContent = "Identity, voice, references, taste boundaries, and available assets.";
};
const componentCardMedia = () => dcxAsset("/assets/components/hanazono-ikebana-card.jpg");
const composeComponentsArticle = (article) => {
article.querySelectorAll(":scope > .dcx-block[data-label]").forEach((block) => block.remove());
const buttons = createDocumentSection("Buttons", `
Variants
Primary
Secondary
Tertiary
Outline
Ghost
Danger
Danger soft
Sizes
With icons
Search
Add member
Email
Delete
Icon only
Loading
Uploading…
`);
const fields = createDocumentSection("Input fields", `
States
Email address
name@example.com
Default
Project type
Ceremony florals
Filled
Start date
Soon
Enter a date in DD/MM/YYYY format.
Sizes
Small
Hana
Medium
Hanazono Atelier
Large
Private celebration
With leading content
Search
Spring arrangements
Budget
$ 4,800
Website
hanazono.jp
Multiline
Tell us about the setting
A quiet evening ceremony with seasonal branches, soft candlelight, and room for the architecture to breathe.
240 characters remaining
`);
const cards = createDocumentSection("Cards", `
Content cards
Editorial
Seasonal notes
Materials, timing, and the decisions behind the work.
Read the story →
Testimonial
“The room felt entirely transformed, but still like us.”
Mika & Ren
Details
Autumn ceremony
Season Late autumn
Setting Kyoto townhouse
With media
Garden study
Line, pause, and negative space
A restrained floral study built around one deliberate gesture.
Material note
Dark ceramic, soft bloom
The same image can lead with subject or material detail.
Horizontal
Featured story
A study in asymmetry
How a single stem, a dark vessel, and generous space hold the composition together.
Explore the study →
With actions
Consultation
Plan the first conversation
Share the date and setting so the studio can prepare.
Book now
Commission guide
Understand the process
Read the stages, timing, and what the studio needs from you.
View the guide
`);
article.append(buttons, fields, cards);
};
const mountArticles = (state) => {
const fragment = document.createDocumentFragment();
const documentTitle = document.createElement("h1");
documentTitle.className = "dcx-document-title";
documentTitle.textContent = "Design context";
fragment.appendChild(documentTitle);
CATEGORIES.forEach((category) => {
const template = document.querySelector(`#dcx-detail-${sourceCategory(category)}`);
const clone = template.content.cloneNode(true);
const article = clone.querySelector(".dcx-article");
if (!article) return;
if (category === "components") {
composeComponentsArticle(article);
const title = article.querySelector(":scope > header > .dcx-title");
const lede = article.querySelector(":scope > header > .dcx-lede");
if (title) title.textContent = "Components";
if (lede) lede.textContent = "A compact visual inventory of reusable interface patterns.";
}
if (category === "brand") composeBrandAssetsArticle(article);
if (category === "material") {
const accessibility = createDocumentSection("Accessibility", `
Contrast on paper
The accent color is reserved for display-scale text. Small links, labels, and inline commands use high-contrast ink on the page ground.
Reading comfort
Body copy is set in the committed body face at a 1.65–1.8 line height and a 65–75ch reading measure.
Keyboard focus
Interactive elements show a visible focus outline with a clear offset.
Texture & text
Text never sits directly on high-contrast texture. Move texture to an edge, or put a solid veil behind the copy.
`);
const motion = article.querySelector(':scope > .dcx-block[data-label="Motion per surface"], :scope > .dcx-block[data-label="Motion"]');
const layout = article.querySelector(':scope > .dcx-block[data-label="Layout structure"]');
if (motion) motion.after(accessibility);
else if (layout) layout.before(accessibility);
else article.appendChild(accessibility);
const iconographyArticle = document.querySelector("#dcx-detail-iconography")
?.content.querySelector(".dcx-article");
const library = iconographyArticle
?.querySelector(':scope > .dcx-block[data-label="Library"]')
?.cloneNode(true);
if (library) {
library.dataset.label = "Iconography";
library.dataset.dcxMovedIconography = "true";
const label = library.querySelector(":scope > .dcx-block-label");
if (label) label.textContent = "Iconography";
article.appendChild(library);
}
const lede = article.querySelector(":scope > header > .dcx-lede");
if (lede) lede.textContent = "Motion, accessibility, layout structure, boundaries, corners, depth, and iconography.";
}
article.querySelector(":scope > header > .dcx-eyebrow")?.remove();
article.dataset.dcxCategory = category;
article.id = `dcx-category-${category}`;
article.classList.add("dcx-document-article");
article.setAttribute("aria-label", categoryName(category));
article.querySelectorAll(".dcx-block[data-label]").forEach((block) => {
block.id = `dcx-${category}-${slugify(block.dataset.label || "section")}`;
});
fragment.appendChild(article);
});
state.main.replaceChildren(fragment);
state.main.dataset.dcxContinuous = "true";
state.expander.dataset.dcxDocument = "true";
initializeColorFans(state.main);
state.expander.dispatchEvent(new CustomEvent("dcx:document-mounted", { bubbles: true }));
};
const audienceSubnav = (article) => {
const blocks = [...article.querySelectorAll(":scope > .dcx-block[data-label]")];
const byLabel = new Map(blocks.map((block) => [block.dataset.label, block]));
return [
{ label: "People", target: byLabel.get("Who they are") },
{ label: "Decision factors", target: byLabel.get("Needs") || byLabel.get("Trust triggers") },
{ label: "Inclusion", target: byLabel.get("Who must not be excluded") },
].filter((item) => item.target);
};
const genericSubnav = (article) => [...article.querySelectorAll(":scope > .dcx-block[data-label]")]
.map((block) => ({ label: block.dataset.label, target: block }));
const renderSubnav = (state, category, article) => {
const descriptors = category === "audience" ? audienceSubnav(article) : genericSubnav(article);
const activeItem = state.nav.querySelector(`.dcx-nav-list > li[data-category="${category}"]`);
const subnav = activeItem?.querySelector(":scope > .dcx-subnav");
if (!subnav) return;
const signature = descriptors.map(({ label, target }) => `${label}:${target.id}`).join("|");
const measure = () => {
const links = [...subnav.querySelectorAll(":scope > .dcx-sub-link")];
const heights = links.map((link) => link.getBoundingClientRect().height);
if (!heights.some((height) => height > 0)) return;
const summary = category === "audience";
const gap = summary ? 0 : 2;
const padding = summary ? 14 : 10;
const height = heights.reduce((total, value) => total + value, 0)
+ Math.max(0, links.length - 1) * gap
+ padding;
subnav.style.setProperty("--dcx-subnav-height", `${Math.ceil(height)}px`);
};
if (subnav.dataset.dcxDocumentSignature === signature) {
measure();
return;
}
const links = descriptors.map(({ label, target }) => {
const link = document.createElement("a");
link.className = "dcx-sub-link";
link.href = `#${target.id}`;
link.textContent = label;
link.dataset.dcxDocumentTarget = target.id;
if (category === "audience") link.dataset.dcxRailSummary = "";
return link;
});
subnav.replaceChildren(...links);
subnav.dataset.dcxDocumentSignature = signature;
subnav.classList.toggle("dcx-subnav--summary", category === "audience");
measure();
};
const prepareSubnavs = (state) => {
CATEGORIES.forEach((category) => {
const article = state.main.querySelector(`:scope > .dcx-article[data-dcx-category="${category}"]`);
if (article) renderSubnav(state, category, article);
});
};
const setActiveCategory = (state, category, { updateHash = false } = {}) => {
if (!CATEGORY_SET.has(category)) return;
const article = state.main.querySelector(`:scope > .dcx-article[data-dcx-category="${category}"]`);
if (!article) return;
const previousCategory = state.currentCategory;
if (previousCategory === category) {
renderSubnav(state, category, article);
state.expander.querySelector(".dcx-current").textContent = categoryName(category);
return;
}
if (previousCategory) {
state.expander.dispatchEvent(new CustomEvent("dcx:continuouscategorywillchange", {
bubbles: true,
detail: {
from: previousCategory,
to: category,
source: state.pinnedCategory === category ? "navigation" : "scroll",
},
}));
}
state.currentCategory = category;
state.expander.dataset.dcxCurrentCategory = category;
state.expander.querySelector(".dcx-current").textContent = categoryName(category);
state.nav.querySelectorAll(".dcx-nav-list > li[data-category]").forEach((item) => {
const active = item.dataset.category === category;
item.classList.toggle("is-active", active);
const link = item.querySelector(":scope > .dcx-nav-link");
const subnav = item.querySelector(":scope > .dcx-subnav");
if (active) {
link?.setAttribute("aria-current", "page");
link?.setAttribute("aria-expanded", "true");
subnav?.removeAttribute("aria-hidden");
if (subnav) subnav.inert = false;
} else {
link?.removeAttribute("aria-current");
link?.removeAttribute("aria-expanded");
subnav?.setAttribute("aria-hidden", "true");
if (subnav) subnav.inert = true;
}
});
renderSubnav(state, category, article);
if (MOBILE_NAV.matches) {
const activeItem = state.nav.querySelector(`.dcx-nav-list > li[data-category="${category}"]`);
requestAnimationFrame(() => {
activeItem?.scrollIntoView({
behavior: REDUCED_MOTION.matches ? "auto" : "smooth",
block: "nearest",
inline: "nearest",
});
});
}
state.expander.dispatchEvent(new CustomEvent("dcx:continuouscategorychange", {
bubbles: true,
detail: { category, article },
}));
if (updateHash) {
history.replaceState({ category }, "", `#${category}`);
}
};
const findVisibleCategory = (state) => {
const articles = [...state.main.querySelectorAll(":scope > .dcx-article[data-dcx-category]")];
if (!articles.length) return "audience";
if (state.pinnedCategory) return state.pinnedCategory;
const mainRect = state.main.getBoundingClientRect();
const marker = mainRect.top + state.main.clientHeight * 0.24;
let visible = articles[0];
articles.forEach((article) => {
if (article.getBoundingClientRect().top <= marker) visible = article;
});
if (state.main.scrollTop + state.main.clientHeight >= state.main.scrollHeight - 4) {
visible = articles.at(-1);
}
return visible.dataset.dcxCategory;
};
const handleMainScroll = (state) => {
if (state.scrollFrame) return;
state.scrollFrame = requestAnimationFrame(() => {
state.scrollFrame = 0;
const category = findVisibleCategory(state);
if (category !== state.currentCategory) setActiveCategory(state, category, { updateHash: true });
});
};
const releasePinnedCategory = (state) => {
state.pinnedCategory = "";
window.clearTimeout(state.pinTimer);
};
const scrollToTarget = (state, target, category, {
instant = false,
historyMode = "push",
hash = category,
} = {}) => {
if (!target || !CATEGORY_SET.has(category)) return;
state.pinnedCategory = category;
setActiveCategory(state, category);
const top = target === state.main.firstElementChild ? 0 : getScrollTargetTop(state, target);
state.main.scrollTo({
top,
behavior: instant || REDUCED_MOTION.matches ? "auto" : "smooth",
});
if (historyMode === "push") history.pushState({ category }, "", `#${hash}`);
if (historyMode === "replace") history.replaceState({ category }, "", `#${hash}`);
window.clearTimeout(state.pinTimer);
state.pinTimer = window.setTimeout(() => {
state.pinnedCategory = "";
handleMainScroll(state);
}, instant ? 80 : 760);
};
const scrollToCategory = (state, category, options = {}) => {
const article = state.main.querySelector(`:scope > .dcx-article[data-dcx-category="${category}"]`);
scrollToTarget(state, article, category, options);
};
const handleNavigation = (state, event) => {
const categoryLink = event.target.closest(".dcx-nav-link[data-dcx-nav]");
if (categoryLink && state.nav.contains(categoryLink)) {
event.preventDefault();
const category = categoryLink.dataset.dcxNav;
scrollToCategory(state, category);
return;
}
const subsection = event.target.closest(".dcx-sub-link[data-dcx-document-target]");
if (!subsection || !state.nav.contains(subsection)) return;
event.preventDefault();
const target = state.main.querySelector(`#${CSS.escape(subsection.dataset.dcxDocumentTarget)}`);
scrollToTarget(state, target, state.currentCategory, {
historyMode: "replace",
hash: subsection.dataset.dcxDocumentTarget,
});
};
const closeDocument = (state, { updateHistory = true } = {}) => {
if (!state || state.closing) return;
window.closeDcxSettings?.({ restoreFocus: false, instant: true });
state.closing = true;
state.expander.dataset.dcxClosing = "true";
releasePinnedCategory(state);
if (state.scrollFrame) cancelAnimationFrame(state.scrollFrame);
if (state.contentFrame) cancelAnimationFrame(state.contentFrame);
if (state.layoutFrame) cancelAnimationFrame(state.layoutFrame);
state.contentObserver?.disconnect();
state.layoutObserver?.disconnect();
state.main.removeEventListener("scroll", state.onScroll);
state.main.removeEventListener("wheel", state.onManualScroll);
state.main.removeEventListener("touchstart", state.onManualScroll);
state.main.removeEventListener("pointerdown", state.onManualScroll);
state.nav.removeEventListener("click", state.onNavClick);
const tile = document.querySelector(`.dcx-tile[data-category="${state.currentCategory}"]`) || state.opener;
const rect = tile?.getBoundingClientRect() || state.originRect;
state.expander.classList.remove("is-ready", "is-full");
state.expander.style.top = `${rect.top}px`;
state.expander.style.left = `${rect.left}px`;
state.expander.style.width = `${rect.width}px`;
state.expander.style.height = `${rect.height}px`;
if (updateHistory) {
history.pushState(null, "", `${location.pathname}${location.search}`);
}
const finish = () => {
state.expander.remove();
document.body.classList.remove("is-locked");
if (state.background) {
state.background.inert = false;
state.background.removeAttribute("aria-hidden");
}
state.opener?.focus({ preventScroll: true });
if (documentState === state) documentState = null;
};
if (REDUCED_MOTION.matches) finish();
else state.closeTimer = window.setTimeout(finish, 540);
};
const openDocument = (category, { historyMode = "push", opener = null, targetHash = category } = {}) => {
if (!CATEGORY_SET.has(category) || !templatesReady()) return false;
if (documentState) {
scrollToCategory(documentState, category, { historyMode });
return true;
}
const sourceTile = opener || document.querySelector(`.dcx-tile[data-category="${category}"]`);
const shellTemplate = document.querySelector("#dcx-shell-template");
if (!sourceTile || !shellTemplate) return false;
const rect = sourceTile.getBoundingClientRect();
const expander = document.createElement("section");
expander.className = "dcx-expander";
expander.dataset.dcxDocument = "true";
expander.setAttribute("role", "dialog");
expander.setAttribute("aria-modal", "true");
expander.setAttribute("aria-label", "Design context document");
expander.style.top = `${rect.top}px`;
expander.style.left = `${rect.left}px`;
expander.style.width = `${rect.width}px`;
expander.style.height = `${rect.height}px`;
expander.appendChild(shellTemplate.content.cloneNode(true));
const background = document.querySelector("[data-dcx-shell]");
if (background) {
background.inert = true;
background.setAttribute("aria-hidden", "true");
}
document.body.appendChild(expander);
document.body.classList.add("is-locked");
const state = {
expander,
main: expander.querySelector(".dcx-main"),
nav: expander.querySelector(".dcx-nav"),
opener: sourceTile,
originRect: rect,
background,
currentCategory: "",
pinnedCategory: category,
pinTimer: 0,
scrollFrame: 0,
contentFrame: 0,
layoutFrame: 0,
initialCategory: category,
initialHash: targetHash,
settleUntil: performance.now() + 3200,
userInteracted: false,
closing: false,
};
documentState = state;
mountArticles(state);
prepareSubnavs(state);
state.onScroll = () => handleMainScroll(state);
state.onManualScroll = () => {
state.userInteracted = true;
state.layoutObserver?.disconnect();
releasePinnedCategory(state);
};
state.onNavClick = (event) => {
state.userInteracted = true;
handleNavigation(state, event);
};
state.contentObserver = new MutationObserver(() => {
if (state.contentFrame) return;
state.contentFrame = requestAnimationFrame(() => {
state.contentFrame = 0;
initializeColorFans(state.main);
setActiveCategory(state, state.currentCategory);
if (!state.userInteracted && performance.now() < state.settleUntil) {
const target = targetFromHash(state, state.initialHash, state.initialCategory);
scrollToTarget(state, target, state.initialCategory, {
instant: true,
historyMode: "replace",
hash: state.initialHash,
});
}
});
});
state.contentObserver.observe(state.main, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["id", "data-dcx-audience-enhanced", "data-dcx-detail-enhanced"],
});
state.layoutObserver = new ResizeObserver(() => {
if (state.layoutFrame || state.userInteracted || performance.now() >= state.settleUntil) return;
state.layoutFrame = requestAnimationFrame(() => {
state.layoutFrame = 0;
const target = targetFromHash(state, state.initialHash, state.initialCategory);
scrollToTarget(state, target, state.initialCategory, {
instant: true,
historyMode: "replace",
hash: state.initialHash,
});
});
});
state.main.querySelectorAll(":scope > .dcx-article[data-dcx-category]").forEach((article) => {
state.layoutObserver.observe(article);
});
state.main.addEventListener("scroll", state.onScroll, { passive: true });
state.main.addEventListener("wheel", state.onManualScroll, { passive: true });
state.main.addEventListener("touchstart", state.onManualScroll, { passive: true });
state.main.addEventListener("pointerdown", state.onManualScroll, { passive: true });
state.nav.addEventListener("click", state.onNavClick);
expander.querySelector(".dcx-close")?.addEventListener("click", () => closeDocument(state));
setActiveCategory(state, category);
if (historyMode === "push") history.pushState({ category }, "", `#${targetHash}`);
if (historyMode === "replace") history.replaceState({ category }, "", `#${targetHash}`);
requestAnimationFrame(() => {
expander.classList.add("is-full");
const target = targetFromHash(state, targetHash, category);
scrollToTarget(state, target, category, { instant: true, historyMode: "none", hash: targetHash });
window.setTimeout(() => {
expander.classList.add("is-ready");
expander.querySelector(".dcx-close")?.focus({ preventScroll: true });
}, REDUCED_MOTION.matches ? 0 : 260);
});
[180, 520, 1100, 1800, 3000].forEach((delay) => {
window.setTimeout(() => {
if (documentState === state && !state.userInteracted) {
const target = targetFromHash(state, state.initialHash, category);
scrollToTarget(state, target, category, {
instant: true,
historyMode: "replace",
hash: state.initialHash,
});
}
}, delay);
});
return true;
};
const handleTileClick = (event) => {
const tile = event.target.closest(".dcx-tile[data-category]");
if (!tile || !document.querySelector("[data-dcx-shell]")?.contains(tile)) return;
if (!CATEGORY_SET.has(tile.dataset.category)) return;
if (!templatesReady()) return;
event.preventDefault();
event.stopImmediatePropagation();
openDocument(tile.dataset.category, { opener: tile });
};
const handleKeydown = (event) => {
if (event.key === "Escape" && document.querySelector("#dcx-context-settings[open]")) return;
if (event.key === "Escape" && documentState) {
event.preventDefault();
event.stopImmediatePropagation();
closeDocument(documentState);
return;
}
if (documentState && ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "].includes(event.key)) {
documentState.userInteracted = true;
releasePinnedCategory(documentState);
}
};
const handlePopstate = (event) => {
const hash = location.hash.slice(1);
const category = categoryFromHash(hash);
if (documentState?.closing && category) {
event.stopImmediatePropagation();
window.closeDcxSettings?.({ restoreFocus: false, instant: true });
const closingState = documentState;
window.clearTimeout(closingState.closeTimer);
closingState.expander.remove();
if (closingState.background) {
closingState.background.inert = false;
closingState.background.removeAttribute("aria-hidden");
}
document.body.classList.remove("is-locked");
documentState = null;
openDocument(category, { historyMode: "none", targetHash: hash });
return;
}
if (!documentState) {
if (!category) {
pendingInitialCategory = "";
pendingInitialHash = "";
return;
}
event.stopImmediatePropagation();
if (templatesReady()) {
openDocument(category, { historyMode: "none", targetHash: hash });
} else {
pendingInitialCategory = category;
pendingInitialHash = hash;
if (!pendingObserver) {
pendingObserver = new MutationObserver(schedulePendingDocument);
pendingObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class", "hidden"],
});
}
schedulePendingDocument();
}
return;
}
event.stopImmediatePropagation();
if (category) {
const target = targetFromHash(documentState, hash, category);
scrollToTarget(documentState, target, category, { historyMode: "none", hash });
} else {
closeDocument(documentState, { updateHistory: false });
}
};
const openPendingDocument = () => {
revealFrame = 0;
if (!pendingInitialCategory) {
if (document.body.classList.contains("dcx-open") && templatesReady()) {
pendingObserver?.disconnect();
pendingObserver = null;
}
return;
}
if (documentState) return;
const shell = document.querySelector("[data-dcx-shell]");
if (!document.body.classList.contains("dcx-open") || shell?.hidden || !templatesReady()) return;
const category = pendingInitialCategory;
const targetHash = pendingInitialHash || category;
if (openDocument(category, { historyMode: "replace", targetHash })) {
pendingInitialCategory = "";
pendingInitialHash = "";
pendingObserver?.disconnect();
pendingObserver = null;
}
};
const schedulePendingDocument = () => {
if (revealFrame) return;
revealFrame = requestAnimationFrame(openPendingDocument);
};
const handleCommandCopy = async (event) => {
const button = event.target.closest?.("[data-dcx-copy-command]");
if (!button) return;
const command = button.dataset.dcxCopyCommand;
const status = button.closest("[data-dcx-command-context]")
?.querySelector(".dcx-command-status");
const copied = await copyText(command);
window.clearTimeout(button._dcxCopyTimer);
button.classList.remove("copied");
void button.offsetWidth;
button.classList.toggle("copied", copied);
button.dataset.copied = String(copied);
if (status) status.textContent = copied ? "Command copied." : "The command could not be copied.";
button._dcxCopyTimer = window.setTimeout(() => {
button.classList.remove("copied");
button.removeAttribute("data-copied");
if (status) status.textContent = "";
}, 1200);
};
/* Integration hook: the data layer re-renders the detail templates when the
store moves (a request the agent finished, a value it settled). The mounted
document is built from clones, so it re-mounts here, holding the reader's
place. Enhancers re-run on their own observers; scroll is restored after
two frames so their layout has landed. */
const remountDocument = () => {
if (!documentState || documentState.closing) return;
const state = documentState;
const category = state.currentCategory || state.initialCategory;
const scrollTop = state.main.scrollTop;
state.userInteracted = true;
releasePinnedCategory(state);
mountArticles(state);
prepareSubnavs(state);
state.currentCategory = "";
setActiveCategory(state, category);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
state.main.scrollTop = scrollTop;
});
});
};
window.dcxDocument = {
isOpen: () => Boolean(documentState),
currentCategory: () => documentState?.currentCategory || "",
remount: remountDocument,
};
if (pendingInitialCategory) {
history.replaceState(history.state, "", `${location.pathname}${location.search}`);
}
document.addEventListener("click", handleTileClick, true);
document.addEventListener("click", handleCommandCopy);
document.addEventListener("keydown", handleKeydown, true);
window.addEventListener("popstate", handlePopstate, true);
pendingObserver = new MutationObserver(schedulePendingDocument);
pendingObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "hidden"] });
schedulePendingDocument();
})();