mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 17:46:36 +03:00
Add new design context document
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
/* Session-aware URLs for the document's own images (section foils, rail
|
||||
textures, placeholder brand assets, the components photo).
|
||||
|
||||
The submit-flow picker server exits the moment /submit resolves, and article
|
||||
images only load when a view opens, which is always after that exit. The doc
|
||||
session lives on, so once design-context.js announces one on
|
||||
window.dcxDocSession, every document asset routes through it (the session's
|
||||
/assets route arrives with that announcement). Until then paths pass through
|
||||
untouched and the page's own origin serves them — which is the whole story in
|
||||
document mode, where the picker server stays up. */
|
||||
export const dcxAsset = (assetPath) => {
|
||||
const session = window.dcxDocSession;
|
||||
if (!session?.base || !session?.token) return assetPath;
|
||||
return `${session.base}${assetPath}?token=${encodeURIComponent(session.token)}`;
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import { dcxAsset } from './assets.js';
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const SECTION_META = [
|
||||
{
|
||||
labels: ["Who they are"],
|
||||
slug: "who-they-are",
|
||||
variant: "people",
|
||||
lede: "The core people this experience must speak to.",
|
||||
icon: "audience-groups-foil.png",
|
||||
},
|
||||
{
|
||||
labels: ["Emotional journey", "Emotional state"],
|
||||
slug: "emotional-journey",
|
||||
variant: "journey",
|
||||
lede: "The change in confidence the experience should create.",
|
||||
icon: "emotional-journey-foil.png",
|
||||
},
|
||||
{
|
||||
labels: ["Needs"],
|
||||
slug: "needs",
|
||||
variant: "list",
|
||||
lede: "What the experience must make clear and easy.",
|
||||
icon: "needs-foil.png",
|
||||
},
|
||||
{
|
||||
labels: ["Trust triggers"],
|
||||
slug: "trust-triggers",
|
||||
variant: "list",
|
||||
lede: "The signals that turn interest into confidence.",
|
||||
icon: "trust-triggers-foil.png",
|
||||
},
|
||||
{
|
||||
labels: ["Who must not be excluded"],
|
||||
slug: "inclusion",
|
||||
variant: "list",
|
||||
lede: "Access requirements that belong in the core experience.",
|
||||
icon: "inclusion-foil.png",
|
||||
},
|
||||
];
|
||||
|
||||
let syncFrame = 0;
|
||||
|
||||
const findMeta = (label) => SECTION_META.find((meta) => meta.labels.includes(label));
|
||||
|
||||
const unwrapColumns = (article) => {
|
||||
article.querySelectorAll(":scope > .dcx-cols").forEach((columns) => {
|
||||
const fragment = document.createDocumentFragment();
|
||||
[...columns.children].forEach((child) => fragment.appendChild(child));
|
||||
columns.replaceWith(fragment);
|
||||
});
|
||||
};
|
||||
|
||||
const enhanceAudienceArticle = (article) => {
|
||||
if (article.dataset.dcxAudienceEnhanced === "true") return;
|
||||
|
||||
article.classList.add("dcx-audience");
|
||||
article.querySelector(":scope > header")?.classList.add("dcx-audience-hero");
|
||||
unwrapColumns(article);
|
||||
|
||||
const sections = [...article.querySelectorAll(":scope > .dcx-block[data-label]")];
|
||||
sections.forEach((section, index) => {
|
||||
const label = section.dataset.label || "";
|
||||
const meta = findMeta(label);
|
||||
if (!meta) return;
|
||||
|
||||
const oldLabel = section.querySelector(":scope > .dcx-block-label");
|
||||
oldLabel?.remove();
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "dcx-audience-section-body";
|
||||
while (section.firstChild) body.appendChild(section.firstChild);
|
||||
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "dcx-audience-section-heading";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.className = "dcx-audience-section-title";
|
||||
title.id = `dcx-audience-${meta.slug}-title`;
|
||||
title.textContent = label;
|
||||
|
||||
const lede = document.createElement("p");
|
||||
lede.className = "dcx-audience-section-lede";
|
||||
lede.textContent = meta.lede;
|
||||
heading.append(title, lede);
|
||||
|
||||
const figure = document.createElement("figure");
|
||||
figure.className = "dcx-audience-section-icon";
|
||||
figure.setAttribute("data-dcx-hide-on-error", "");
|
||||
figure.setAttribute("aria-hidden", "true");
|
||||
|
||||
const image = document.createElement("img");
|
||||
image.src = dcxAsset(`/assets/audience/${meta.icon}`);
|
||||
image.alt = "";
|
||||
image.width = 256;
|
||||
image.height = 256;
|
||||
image.decoding = "async";
|
||||
if (index > 0) image.loading = "lazy";
|
||||
figure.appendChild(image);
|
||||
|
||||
const header = document.createElement("header");
|
||||
header.className = "dcx-audience-section-head";
|
||||
header.append(heading, figure);
|
||||
|
||||
section.classList.add("dcx-audience-section", `dcx-audience-section--${meta.variant}`);
|
||||
section.id = `dcx-audience-${meta.slug}`;
|
||||
if (!article.dataset.dcxCategory) {
|
||||
section.setAttribute("role", "region");
|
||||
section.setAttribute("aria-labelledby", title.id);
|
||||
}
|
||||
|
||||
if (meta.variant === "journey") {
|
||||
const journey = body.querySelector(".dcx-callout-pair");
|
||||
if (journey) {
|
||||
journey.classList.add("dcx-audience-journey");
|
||||
if (journey.children.length > 1) {
|
||||
const arrow = document.createElement("div");
|
||||
arrow.className = "dcx-audience-journey-arrow";
|
||||
arrow.setAttribute("aria-hidden", "true");
|
||||
arrow.textContent = "→";
|
||||
journey.children[0].after(arrow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (meta.variant === "list") {
|
||||
body.querySelector(".dcx-list")?.classList.add("dcx-audience-list");
|
||||
}
|
||||
|
||||
section.append(header, body);
|
||||
});
|
||||
|
||||
article.dataset.dcxAudienceEnhanced = "true";
|
||||
};
|
||||
|
||||
const syncAudience = () => {
|
||||
syncFrame = 0;
|
||||
const expander = document.querySelector(".dcx-expander");
|
||||
if (!expander) return;
|
||||
|
||||
const continuousArticle = expander.querySelector('.dcx-main > .dcx-article[data-dcx-category="audience"]');
|
||||
if (continuousArticle) {
|
||||
enhanceAudienceArticle(continuousArticle);
|
||||
return;
|
||||
}
|
||||
|
||||
const audienceItem = expander.querySelector('.dcx-nav-list li[data-category="audience"].is-active');
|
||||
const article = expander.querySelector(".dcx-main > .dcx-article");
|
||||
if (audienceItem && article) enhanceAudienceArticle(article);
|
||||
};
|
||||
|
||||
const scheduleSync = () => {
|
||||
if (syncFrame) return;
|
||||
syncFrame = requestAnimationFrame(syncAudience);
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(scheduleSync);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
window.addEventListener("pageshow", scheduleSync);
|
||||
scheduleSync();
|
||||
})();
|
||||
@@ -0,0 +1,330 @@
|
||||
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();
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,688 @@
|
||||
import RULES from '../../data/hook-rules.json';
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const STORAGE_KEY = "dcx-hooks-preview-v1";
|
||||
const MOBILE_FAMILIES = window.matchMedia("(max-width: 560px)");
|
||||
const REDUCED_MOTION = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const FAMILY_META = {
|
||||
fingerprints: {
|
||||
label: "Fingerprints",
|
||||
description: "Recurring signatures found across generated interfaces.",
|
||||
},
|
||||
slop: {
|
||||
label: "UI tells",
|
||||
description: "Common generated-UI habits that make a design feel interchangeable.",
|
||||
},
|
||||
quality: {
|
||||
label: "Quality floor",
|
||||
description: "Measurable defects in legibility, hierarchy, overflow, and system consistency.",
|
||||
},
|
||||
};
|
||||
const DISCIPLINE_ORDER = [
|
||||
"Visual Details",
|
||||
"Typography",
|
||||
"Color & Contrast",
|
||||
"Layout & Space",
|
||||
"Motion",
|
||||
"Imagery",
|
||||
"Copy",
|
||||
"Quality",
|
||||
];
|
||||
|
||||
const initialState = () => ({
|
||||
enabled: true,
|
||||
activeFamily: "fingerprints",
|
||||
disabled: ["em-dash-overuse"],
|
||||
custom: [],
|
||||
});
|
||||
|
||||
const loadState = () => {
|
||||
const fallback = initialState();
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || "null");
|
||||
if (!parsed || typeof parsed !== "object") return fallback;
|
||||
return {
|
||||
enabled: parsed.enabled !== false,
|
||||
activeFamily: FAMILY_META[parsed.activeFamily] ? parsed.activeFamily : fallback.activeFamily,
|
||||
disabled: Array.isArray(parsed.disabled)
|
||||
? parsed.disabled.filter((id) => typeof id === "string")
|
||||
: fallback.disabled,
|
||||
custom: Array.isArray(parsed.custom)
|
||||
? parsed.custom.filter((rule) => rule && typeof rule.id === "string" && typeof rule.name === "string")
|
||||
: [],
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const state = loadState();
|
||||
const disabledRules = new Set(state.disabled);
|
||||
const disciplineAnimations = new WeakMap();
|
||||
const customFormAnimations = new WeakMap();
|
||||
let syncFrame = 0;
|
||||
|
||||
const persist = () => {
|
||||
state.disabled = [...disabledRules];
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch {
|
||||
// The file:// preview may deny storage; the in-memory controls still work.
|
||||
}
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
|
||||
const slugify = (value) => String(value)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "custom-rule";
|
||||
|
||||
const compactDescription = (value) => {
|
||||
const text = String(value).trim();
|
||||
const firstSentence = text.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim();
|
||||
return firstSentence || text;
|
||||
};
|
||||
|
||||
const templateMarkup = () => `
|
||||
<article class="dcx-article">
|
||||
<header>
|
||||
<h2 class="dcx-title">Hooks</h2>
|
||||
<p class="dcx-lede">Checks that catch design regressions while you work.</p>
|
||||
</header>
|
||||
<section class="dcx-block" data-label="How it runs">
|
||||
<span class="dcx-block-label">How it runs</span>
|
||||
<div class="dcx-hooks-intro">
|
||||
<p class="dcx-hooks-definition">Hooks watch interface changes and surface problems before they spread.</p>
|
||||
<div class="dcx-hooks-status" data-hooks-status>
|
||||
<div class="dcx-hooks-status-copy">
|
||||
<strong data-hooks-master-copy>Enable hooks</strong>
|
||||
<p data-hooks-master-detail>Preview only — project settings are unchanged.</p>
|
||||
</div>
|
||||
<div class="dcx-hooks-status-control">
|
||||
<span class="dcx-hooks-status-state" data-hooks-master-state>On</span>
|
||||
<label class="dcx-hooks-switch dcx-hooks-switch--master">
|
||||
<input type="checkbox" role="switch" data-hooks-master aria-label="Enable design hooks">
|
||||
<span aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="dcx-hooks-flow">
|
||||
<div>
|
||||
<dt>On each edit</dt>
|
||||
<dd>Checks the changed UI.</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>At session end</dt>
|
||||
<dd>Runs a full pass on touched UI files.</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
<section class="dcx-block" data-label="Built-in rules">
|
||||
<span class="dcx-block-label">Built-in rules</span>
|
||||
<div class="dcx-hooks-browser" data-hooks-browser>
|
||||
<div class="dcx-hooks-families" role="tablist" aria-label="Rule families" data-hooks-families></div>
|
||||
<div class="dcx-hooks-rule-panel" id="dcx-hooks-rule-panel" role="tabpanel">
|
||||
<div class="dcx-hooks-toolbar">
|
||||
<label class="dcx-hooks-search">
|
||||
<input type="search" autocomplete="off" aria-label="Search rules" placeholder="Search rules" data-hooks-search>
|
||||
</label>
|
||||
<p class="dcx-hooks-summary" data-hooks-summary aria-live="polite"></p>
|
||||
</div>
|
||||
<div class="dcx-hooks-rule-groups" data-hooks-rule-groups></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="dcx-block" data-label="Custom rules">
|
||||
<span class="dcx-block-label">Custom rules</span>
|
||||
<div class="dcx-hooks-custom" data-hooks-custom>
|
||||
<div class="dcx-hooks-custom-toolbar">
|
||||
<p data-hooks-custom-count>No custom rules.</p>
|
||||
<button class="dcx-hooks-button" type="button" data-hooks-add aria-expanded="false" aria-controls="dcx-hooks-custom-form">Add rule</button>
|
||||
</div>
|
||||
<form class="dcx-hooks-custom-form" id="dcx-hooks-custom-form" data-hooks-form hidden>
|
||||
<label>
|
||||
<span>Rule name</span>
|
||||
<input name="name" required maxlength="80" placeholder="e.g. Approved corner radius">
|
||||
</label>
|
||||
<label>
|
||||
<span>Category</span>
|
||||
<select name="discipline">
|
||||
<option>Visual Details</option>
|
||||
<option>Typography</option>
|
||||
<option>Color & Contrast</option>
|
||||
<option>Layout & Space</option>
|
||||
<option>Motion</option>
|
||||
<option>Imagery</option>
|
||||
<option>Copy</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="dcx-hooks-custom-form-description">
|
||||
<span>What should it catch?</span>
|
||||
<textarea name="description" required rows="3" maxlength="240" placeholder="Describe the condition and the correction."></textarea>
|
||||
</label>
|
||||
<div class="dcx-hooks-form-actions">
|
||||
<button class="dcx-hooks-button dcx-hooks-button--quiet" type="button" data-hooks-cancel>Cancel</button>
|
||||
<button class="dcx-hooks-button" type="submit">Save rule</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="dcx-hooks-custom-list" data-hooks-custom-list></div>
|
||||
<p class="dcx-hooks-storage-note">Preview only — saved in this browser; custom rules do not run.</p>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
`;
|
||||
|
||||
const renameInterfaceToHooks = () => {
|
||||
const tile = document.querySelector('.dcx-tile[data-category="interface"]');
|
||||
if (tile) {
|
||||
tile.dataset.category = "hooks";
|
||||
tile.dataset.name = "Hooks";
|
||||
tile.setAttribute("aria-label", "Open Hooks");
|
||||
const title = tile.querySelector(".dcx-tile-title");
|
||||
if (title) title.textContent = "Hooks";
|
||||
}
|
||||
|
||||
const shellTemplate = document.querySelector("#dcx-shell-template");
|
||||
const navItem = shellTemplate?.content.querySelector('li[data-category="interface"]');
|
||||
const navLink = navItem?.querySelector(".dcx-nav-link");
|
||||
if (navItem && navLink) {
|
||||
navItem.dataset.category = "hooks";
|
||||
navLink.href = "#hooks";
|
||||
navLink.dataset.dcxNav = "hooks";
|
||||
navLink.textContent = "Hooks";
|
||||
}
|
||||
};
|
||||
|
||||
const installTemplate = () => {
|
||||
if (document.querySelector("#dcx-detail-hooks")) return;
|
||||
const template = document.createElement("template");
|
||||
template.id = "dcx-detail-hooks";
|
||||
template.innerHTML = templateMarkup();
|
||||
document.querySelector("#dcx-detail-interface")?.after(template);
|
||||
};
|
||||
|
||||
const familyRules = (family) => RULES.filter((rule) => rule.group === family);
|
||||
const isEnabled = (id) => !disabledRules.has(id);
|
||||
|
||||
const revealSelectedFamily = (target) => {
|
||||
if (!MOBILE_FAMILIES.matches) return;
|
||||
const selected = target.querySelector('[data-hooks-family][aria-selected="true"]');
|
||||
if (!selected) return;
|
||||
|
||||
const viewport = target.getBoundingClientRect();
|
||||
const item = selected.getBoundingClientRect();
|
||||
let delta = 0;
|
||||
if (item.left < viewport.left + 3) delta = item.left - viewport.left - 3;
|
||||
else if (item.right > viewport.right - 3) delta = item.right - viewport.right + 3;
|
||||
if (Math.abs(delta) < 1) return;
|
||||
target.scrollTo({
|
||||
left: Math.max(0, target.scrollLeft + delta),
|
||||
behavior: REDUCED_MOTION.matches ? "auto" : "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
const setDisciplineOpen = (details, expanded) => {
|
||||
const panel = details.querySelector(":scope > .dcx-hooks-disclosure");
|
||||
const inner = panel?.querySelector(":scope > .dcx-hooks-disclosure-inner");
|
||||
if (!panel || !inner) {
|
||||
details.open = expanded;
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = disciplineAnimations.get(details);
|
||||
const currentHeight = panel.getBoundingClientRect().height;
|
||||
const currentOpacity = Number.parseFloat(getComputedStyle(panel).opacity) || 0;
|
||||
previous?.cancel();
|
||||
|
||||
if (REDUCED_MOTION.matches) {
|
||||
disciplineAnimations.delete(details);
|
||||
details.classList.remove("is-closing");
|
||||
details.open = expanded;
|
||||
panel.style.removeProperty("height");
|
||||
panel.style.removeProperty("opacity");
|
||||
return;
|
||||
}
|
||||
|
||||
details.open = true;
|
||||
details.classList.toggle("is-closing", !expanded);
|
||||
const fromHeight = previous ? currentHeight : expanded ? 0 : currentHeight;
|
||||
const fromOpacity = previous ? currentOpacity : expanded ? 0 : 1;
|
||||
const toHeight = expanded ? inner.scrollHeight : 0;
|
||||
const toOpacity = expanded ? 1 : 0;
|
||||
|
||||
const animation = panel.animate([
|
||||
{ height: `${fromHeight}px`, opacity: fromOpacity },
|
||||
{ height: `${toHeight}px`, opacity: toOpacity },
|
||||
], {
|
||||
duration: 360,
|
||||
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
fill: "both",
|
||||
});
|
||||
disciplineAnimations.set(details, animation);
|
||||
|
||||
animation.finished.then(() => {
|
||||
if (disciplineAnimations.get(details) !== animation) return;
|
||||
disciplineAnimations.delete(details);
|
||||
details.classList.remove("is-closing");
|
||||
details.open = expanded;
|
||||
animation.cancel();
|
||||
panel.style.removeProperty("height");
|
||||
panel.style.removeProperty("opacity");
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const setCustomFormOpen = (form, expanded) => {
|
||||
const previous = customFormAnimations.get(form);
|
||||
const currentHeight = form.hidden ? 0 : form.getBoundingClientRect().height;
|
||||
const currentOpacity = form.hidden ? 0 : Number.parseFloat(getComputedStyle(form).opacity) || 1;
|
||||
previous?.cancel();
|
||||
|
||||
if (REDUCED_MOTION.matches) {
|
||||
customFormAnimations.delete(form);
|
||||
form.hidden = !expanded;
|
||||
return;
|
||||
}
|
||||
|
||||
if (expanded) form.hidden = false;
|
||||
const toHeight = expanded ? form.scrollHeight : 0;
|
||||
form.style.overflow = "clip";
|
||||
const animation = form.animate([
|
||||
{ height: `${currentHeight}px`, opacity: currentOpacity },
|
||||
{ height: `${toHeight}px`, opacity: expanded ? 1 : 0 },
|
||||
], {
|
||||
duration: 320,
|
||||
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
fill: "both",
|
||||
});
|
||||
customFormAnimations.set(form, animation);
|
||||
|
||||
animation.finished.then(() => {
|
||||
if (customFormAnimations.get(form) !== animation) return;
|
||||
customFormAnimations.delete(form);
|
||||
form.hidden = !expanded;
|
||||
animation.cancel();
|
||||
form.style.removeProperty("height");
|
||||
form.style.removeProperty("opacity");
|
||||
form.style.removeProperty("overflow");
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const toggleDiscipline = (article, summary) => {
|
||||
const disclosure = summary.parentElement;
|
||||
const expanded = !disclosure.open || disclosure.classList.contains("is-closing");
|
||||
const query = article.querySelector("[data-hooks-search]")?.value.trim();
|
||||
if (expanded && !query) {
|
||||
disclosure.parentElement?.querySelectorAll(":scope > .dcx-hooks-discipline[open]").forEach((sibling) => {
|
||||
if (sibling !== disclosure) setDisciplineOpen(sibling, false);
|
||||
});
|
||||
}
|
||||
setDisciplineOpen(disclosure, expanded);
|
||||
};
|
||||
|
||||
const renderFamilies = (article) => {
|
||||
const target = article.querySelector("[data-hooks-families]");
|
||||
if (!target) return;
|
||||
target.innerHTML = Object.entries(FAMILY_META).map(([id, meta]) => {
|
||||
const rules = familyRules(id);
|
||||
const enabled = rules.filter((rule) => isEnabled(rule.id)).length;
|
||||
const selected = state.activeFamily === id;
|
||||
return `
|
||||
<button
|
||||
id="dcx-hooks-family-${id}"
|
||||
class="dcx-hooks-family${selected ? " is-active" : ""}"
|
||||
type="button"
|
||||
role="tab"
|
||||
tabindex="${selected ? "0" : "-1"}"
|
||||
aria-selected="${selected}"
|
||||
aria-label="${escapeHtml(meta.label)}, ${enabled} of ${rules.length} selected"
|
||||
aria-controls="dcx-hooks-rule-panel"
|
||||
data-hooks-family="${id}"
|
||||
>
|
||||
<span class="dcx-hooks-family-name">${escapeHtml(meta.label)}</span>
|
||||
<span class="dcx-hooks-family-count">${enabled}/${rules.length}</span>
|
||||
</button>
|
||||
`;
|
||||
}).join("");
|
||||
const panel = article.querySelector("#dcx-hooks-rule-panel");
|
||||
panel?.setAttribute("aria-labelledby", `dcx-hooks-family-${state.activeFamily}`);
|
||||
requestAnimationFrame(() => revealSelectedFamily(target));
|
||||
};
|
||||
|
||||
const renderRules = (article) => {
|
||||
const target = article.querySelector("[data-hooks-rule-groups]");
|
||||
const summary = article.querySelector("[data-hooks-summary]");
|
||||
const search = article.querySelector("[data-hooks-search]");
|
||||
if (!target || !summary) return;
|
||||
|
||||
const query = (search?.value || "").trim().toLowerCase();
|
||||
const rules = familyRules(state.activeFamily);
|
||||
const filtered = rules.filter((rule) => !query
|
||||
|| `${rule.id} ${rule.name} ${rule.description} ${rule.discipline}`.toLowerCase().includes(query));
|
||||
const enabled = rules.filter((rule) => isEnabled(rule.id)).length;
|
||||
summary.textContent = query
|
||||
? `${filtered.length} matching ${filtered.length === 1 ? "rule" : "rules"}`
|
||||
: `${enabled} of ${rules.length} selected`;
|
||||
|
||||
const groups = new Map();
|
||||
filtered.forEach((rule) => {
|
||||
if (!groups.has(rule.discipline)) groups.set(rule.discipline, []);
|
||||
groups.get(rule.discipline).push(rule);
|
||||
});
|
||||
|
||||
const orderedGroups = [...groups.entries()].sort(([a], [b]) => {
|
||||
const ai = DISCIPLINE_ORDER.indexOf(a);
|
||||
const bi = DISCIPLINE_ORDER.indexOf(b);
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi) || a.localeCompare(b);
|
||||
});
|
||||
|
||||
if (!orderedGroups.length) {
|
||||
target.innerHTML = '<p class="dcx-hooks-empty">No rules match this search.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
target.innerHTML = orderedGroups.map(([discipline, entries], index) => {
|
||||
const disclosureId = `dcx-hooks-${state.activeFamily}-${slugify(discipline)}`;
|
||||
const summaryId = `${disclosureId}-summary`;
|
||||
return `
|
||||
<details class="dcx-hooks-discipline" ${query || index === 0 ? "open" : ""}>
|
||||
<summary id="${summaryId}">
|
||||
<span>${escapeHtml(discipline)}</span>
|
||||
<span>${entries.length}</span>
|
||||
</summary>
|
||||
<div class="dcx-hooks-disclosure" id="${disclosureId}" role="region" aria-labelledby="${summaryId}">
|
||||
<div class="dcx-hooks-disclosure-inner">
|
||||
<ul class="dcx-hooks-rules">
|
||||
${entries.map((rule) => `
|
||||
<li class="dcx-hooks-rule" data-rule-id="${escapeHtml(rule.id)}">
|
||||
<div class="dcx-hooks-rule-copy">
|
||||
<strong>${escapeHtml(rule.name)}</strong>
|
||||
<p>${escapeHtml(compactDescription(rule.description))}</p>
|
||||
</div>
|
||||
<label class="dcx-hooks-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
data-hooks-rule="${escapeHtml(rule.id)}"
|
||||
aria-label="Enable ${escapeHtml(rule.name)}"
|
||||
${isEnabled(rule.id) ? "checked" : ""}
|
||||
>
|
||||
<span aria-hidden="true"></span>
|
||||
</label>
|
||||
</li>
|
||||
`).join("")}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}).join("");
|
||||
};
|
||||
|
||||
const renderCustom = (article) => {
|
||||
const target = article.querySelector("[data-hooks-custom-list]");
|
||||
const count = article.querySelector("[data-hooks-custom-count]");
|
||||
if (!target) return;
|
||||
|
||||
if (count) {
|
||||
count.textContent = state.custom.length
|
||||
? `${state.custom.length} custom ${state.custom.length === 1 ? "rule" : "rules"}`
|
||||
: "No custom rules.";
|
||||
}
|
||||
|
||||
if (!state.custom.length) {
|
||||
target.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
target.innerHTML = "";
|
||||
state.custom.forEach((rule) => {
|
||||
const row = document.createElement("article");
|
||||
row.className = "dcx-hooks-custom-rule";
|
||||
|
||||
const copy = document.createElement("div");
|
||||
copy.className = "dcx-hooks-rule-copy";
|
||||
const id = document.createElement("code");
|
||||
id.textContent = rule.id;
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = rule.name;
|
||||
const description = document.createElement("p");
|
||||
description.textContent = rule.description;
|
||||
const discipline = document.createElement("span");
|
||||
discipline.className = "dcx-hooks-custom-discipline";
|
||||
discipline.textContent = rule.discipline;
|
||||
copy.append(id, name, description, discipline);
|
||||
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "dcx-hooks-custom-controls";
|
||||
const toggle = document.createElement("label");
|
||||
toggle.className = "dcx-hooks-switch";
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.setAttribute("role", "switch");
|
||||
input.setAttribute("aria-label", `Enable ${rule.name}`);
|
||||
input.dataset.hooksCustomRule = rule.id;
|
||||
input.checked = rule.enabled !== false;
|
||||
const track = document.createElement("span");
|
||||
track.setAttribute("aria-hidden", "true");
|
||||
toggle.append(input, track);
|
||||
|
||||
const remove = document.createElement("button");
|
||||
remove.className = "dcx-hooks-remove";
|
||||
remove.type = "button";
|
||||
remove.dataset.hooksRemove = rule.id;
|
||||
remove.setAttribute("aria-label", `Remove ${rule.name}`);
|
||||
remove.textContent = "Remove";
|
||||
controls.append(toggle, remove);
|
||||
row.append(copy, controls);
|
||||
target.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
const syncMaster = (article) => {
|
||||
const input = article.querySelector("[data-hooks-master]");
|
||||
const status = article.querySelector("[data-hooks-status]");
|
||||
const copy = article.querySelector("[data-hooks-master-copy]");
|
||||
const detail = article.querySelector("[data-hooks-master-detail]");
|
||||
const stateText = article.querySelector("[data-hooks-master-state]");
|
||||
if (!input || !status || !copy || !detail || !stateText) return;
|
||||
|
||||
input.checked = state.enabled;
|
||||
status.classList.toggle("is-paused", !state.enabled);
|
||||
copy.textContent = "Enable hooks";
|
||||
detail.textContent = "Preview only — project settings are unchanged.";
|
||||
stateText.textContent = state.enabled ? "On" : "Off";
|
||||
};
|
||||
|
||||
const renderArticle = (article) => {
|
||||
syncMaster(article);
|
||||
renderFamilies(article);
|
||||
renderRules(article);
|
||||
renderCustom(article);
|
||||
};
|
||||
|
||||
const initializeMountedArticles = () => {
|
||||
syncFrame = 0;
|
||||
document.querySelectorAll('.dcx-article[data-dcx-category="hooks"]').forEach((article) => {
|
||||
if (article.dataset.dcxHooksReady === "true") return;
|
||||
article.dataset.dcxHooksReady = "true";
|
||||
renderArticle(article);
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleSync = () => {
|
||||
if (syncFrame) return;
|
||||
syncFrame = requestAnimationFrame(initializeMountedArticles);
|
||||
};
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const article = event.target.closest('.dcx-article[data-dcx-category="hooks"]');
|
||||
if (!article) return;
|
||||
|
||||
const summary = event.target.closest(".dcx-hooks-discipline > summary");
|
||||
if (summary) {
|
||||
event.preventDefault();
|
||||
toggleDiscipline(article, summary);
|
||||
return;
|
||||
}
|
||||
|
||||
const family = event.target.closest("[data-hooks-family]");
|
||||
if (family) {
|
||||
const restoreFocus = family === document.activeElement;
|
||||
state.activeFamily = family.dataset.hooksFamily;
|
||||
persist();
|
||||
renderFamilies(article);
|
||||
renderRules(article);
|
||||
if (restoreFocus) {
|
||||
article.querySelector(`[data-hooks-family="${state.activeFamily}"]`)?.focus({ preventScroll: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const add = event.target.closest("[data-hooks-add]");
|
||||
if (add) {
|
||||
const form = article.querySelector("[data-hooks-form]");
|
||||
if (!form) return;
|
||||
setCustomFormOpen(form, true);
|
||||
add.setAttribute("aria-expanded", "true");
|
||||
form.querySelector("input[name='name']")?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const cancel = event.target.closest("[data-hooks-cancel]");
|
||||
if (cancel) {
|
||||
const form = article.querySelector("[data-hooks-form]");
|
||||
form?.reset();
|
||||
if (form) setCustomFormOpen(form, false);
|
||||
const addButton = article.querySelector("[data-hooks-add]");
|
||||
addButton?.setAttribute("aria-expanded", "false");
|
||||
addButton?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const remove = event.target.closest("[data-hooks-remove]");
|
||||
if (remove) {
|
||||
state.custom = state.custom.filter((rule) => rule.id !== remove.dataset.hooksRemove);
|
||||
persist();
|
||||
renderCustom(article);
|
||||
(article.querySelector("[data-hooks-remove]") || article.querySelector("[data-hooks-add]"))
|
||||
?.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("input", (event) => {
|
||||
if (!event.target.matches("[data-hooks-search]")) return;
|
||||
const article = event.target.closest('.dcx-article[data-dcx-category="hooks"]');
|
||||
if (article) renderRules(article);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
const summary = event.target.closest(".dcx-hooks-discipline > summary");
|
||||
if (summary && ["Enter", " "].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
if (!event.repeat) {
|
||||
const article = summary.closest('.dcx-article[data-dcx-category="hooks"]');
|
||||
if (article) toggleDiscipline(article, summary);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const family = event.target.closest("[data-hooks-family]");
|
||||
if (!family || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
|
||||
const article = family.closest('.dcx-article[data-dcx-category="hooks"]');
|
||||
const buttons = [...article.querySelectorAll("[data-hooks-family]")];
|
||||
const index = buttons.indexOf(family);
|
||||
if (index < 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
const direction = ["ArrowRight", "ArrowDown"].includes(event.key) ? 1 : -1;
|
||||
const nextIndex = event.key === "Home"
|
||||
? 0
|
||||
: event.key === "End"
|
||||
? buttons.length - 1
|
||||
: (index + direction + buttons.length) % buttons.length;
|
||||
buttons[nextIndex].click();
|
||||
article.querySelector(`[data-hooks-family="${state.activeFamily}"]`)?.focus();
|
||||
});
|
||||
|
||||
document.addEventListener("change", (event) => {
|
||||
const article = event.target.closest('.dcx-article[data-dcx-category="hooks"]');
|
||||
if (!article) return;
|
||||
|
||||
if (event.target.matches("[data-hooks-master]")) {
|
||||
state.enabled = event.target.checked;
|
||||
persist();
|
||||
syncMaster(article);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.matches("[data-hooks-rule]")) {
|
||||
if (event.target.checked) disabledRules.delete(event.target.dataset.hooksRule);
|
||||
else disabledRules.add(event.target.dataset.hooksRule);
|
||||
persist();
|
||||
renderFamilies(article);
|
||||
const query = article.querySelector("[data-hooks-search]")?.value.trim();
|
||||
const summary = article.querySelector("[data-hooks-summary]");
|
||||
if (!query && summary) {
|
||||
const rules = familyRules(state.activeFamily);
|
||||
summary.textContent = `${rules.filter((rule) => isEnabled(rule.id)).length} of ${rules.length} selected`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.matches("[data-hooks-custom-rule]")) {
|
||||
const rule = state.custom.find((entry) => entry.id === event.target.dataset.hooksCustomRule);
|
||||
if (rule) {
|
||||
rule.enabled = event.target.checked;
|
||||
persist();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("submit", (event) => {
|
||||
const form = event.target.closest("[data-hooks-form]");
|
||||
if (!form) return;
|
||||
event.preventDefault();
|
||||
const article = form.closest('.dcx-article[data-dcx-category="hooks"]');
|
||||
if (!article) return;
|
||||
|
||||
const data = new FormData(form);
|
||||
const name = String(data.get("name") || "").trim();
|
||||
const description = String(data.get("description") || "").trim();
|
||||
const discipline = String(data.get("discipline") || "Visual Details");
|
||||
if (!name || !description) return;
|
||||
|
||||
const base = slugify(name);
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
const existing = new Set([...RULES.map((rule) => rule.id), ...state.custom.map((rule) => rule.id)]);
|
||||
while (existing.has(id)) {
|
||||
id = `${base}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
state.custom.push({ id, name, description, discipline, enabled: true });
|
||||
persist();
|
||||
form.reset();
|
||||
setCustomFormOpen(form, false);
|
||||
const addButton = article.querySelector("[data-hooks-add]");
|
||||
addButton?.setAttribute("aria-expanded", "false");
|
||||
renderCustom(article);
|
||||
addButton?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
renameInterfaceToHooks();
|
||||
installTemplate();
|
||||
|
||||
const observer = new MutationObserver(scheduleSync);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
window.addEventListener("pageshow", scheduleSync);
|
||||
scheduleSync();
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
if (window.__dcxSettingsInstalled) return;
|
||||
window.__dcxSettingsInstalled = true;
|
||||
|
||||
const REDUCED_MOTION = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const COMMANDS = [
|
||||
{
|
||||
label: "Open",
|
||||
description: "Pick up where you left off.",
|
||||
command: "/impeccable design-context open",
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
description: "Make a few changes to this design context.",
|
||||
command: "/impeccable design-context edit",
|
||||
},
|
||||
{
|
||||
label: "Export",
|
||||
description: "Save a copy to share or keep elsewhere.",
|
||||
command: "/impeccable design-context export",
|
||||
},
|
||||
{
|
||||
label: "Import",
|
||||
description: "Bring a saved design context into this project.",
|
||||
command: "/impeccable design-context import",
|
||||
},
|
||||
];
|
||||
|
||||
const settingsIcon = `
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 2.8v2.1M12 19.1v2.1M4.2 7.3l1.8 1M18 15.7l1.8 1M2.9 15.2l2-.7M19.1 9.5l2-.7M7.2 3.7l1.1 1.8M15.7 18.5l1.1 1.8"></path>
|
||||
<circle cx="12" cy="12" r="5.1"></circle>
|
||||
<circle cx="12" cy="12" r="1.75"></circle>
|
||||
</svg>`;
|
||||
|
||||
const closeIcon = `
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"></path>
|
||||
</svg>`;
|
||||
|
||||
const installTopbarActions = (root) => {
|
||||
const topbar = root?.querySelector?.(".dcx-topbar");
|
||||
const templateClose = topbar?.querySelector(".dcx-close");
|
||||
if (!topbar || !templateClose || topbar.querySelector("[data-dcx-settings-open]")) return;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "dcx-topbar-actions";
|
||||
|
||||
const trigger = document.createElement("button");
|
||||
trigger.className = "dcx-settings-trigger";
|
||||
trigger.type = "button";
|
||||
trigger.dataset.dcxSettingsOpen = "";
|
||||
trigger.setAttribute("aria-label", "Design context settings");
|
||||
trigger.setAttribute("aria-haspopup", "dialog");
|
||||
trigger.setAttribute("aria-controls", "dcx-context-settings");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
trigger.innerHTML = settingsIcon;
|
||||
|
||||
const request = topbar.querySelector(".dcx-request");
|
||||
(request || templateClose).before(actions);
|
||||
if (request) actions.append(request);
|
||||
actions.append(trigger, templateClose);
|
||||
};
|
||||
|
||||
const shellTemplate = document.querySelector("#dcx-shell-template");
|
||||
installTopbarActions(shellTemplate?.content);
|
||||
document.querySelectorAll(".dcx-expander").forEach(installTopbarActions);
|
||||
|
||||
let modal = document.querySelector("#dcx-context-settings");
|
||||
if (!modal) {
|
||||
modal = document.createElement("dialog");
|
||||
modal.id = "dcx-context-settings";
|
||||
modal.className = "picker-modal dcx-settings-modal";
|
||||
modal.setAttribute("aria-labelledby", "dcx-context-settings-title");
|
||||
modal.setAttribute("aria-describedby", "dcx-context-settings-lede");
|
||||
modal.innerHTML = `
|
||||
<div class="picker-modal-inner dcx-settings-panel" data-dcx-command-context>
|
||||
<header class="picker-modal-head dcx-settings-head">
|
||||
<div>
|
||||
<h2 id="dcx-context-settings-title">Design context commands</h2>
|
||||
<p id="dcx-context-settings-lede">Choose what you’d like to do with this design context.</p>
|
||||
</div>
|
||||
<button class="dcx-settings-close" type="button" data-dcx-settings-close aria-label="Close settings">
|
||||
${closeIcon}
|
||||
</button>
|
||||
</header>
|
||||
<div class="dcx-settings-commands">
|
||||
${COMMANDS.map(({ label, description, command }) => `
|
||||
<section class="dcx-settings-command">
|
||||
<div class="dcx-settings-command-copy">
|
||||
<h3>${label}</h3>
|
||||
<p>${description}</p>
|
||||
</div>
|
||||
<div class="dcx-command-copy">
|
||||
<span class="dcx-command-copy__prompt" aria-hidden="true">$</span>
|
||||
<code>${command}</code>
|
||||
<button class="dcx-command-copy__button" type="button" data-dcx-copy-command="${command}" aria-label="Copy ${label.toLowerCase()} command">
|
||||
<svg class="dcx-command-copy__copy-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
<svg class="dcx-command-copy__check-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 6 9 17l-5-5"></path>
|
||||
</svg>
|
||||
<span class="dcx-command-copy__label">Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>`).join("")}
|
||||
</div>
|
||||
<p class="dcx-command-status" role="status" aria-live="polite"></p>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
|
||||
let invoker = null;
|
||||
let closeTimer = 0;
|
||||
|
||||
const resetCopyState = () => {
|
||||
modal.querySelectorAll("[data-dcx-copy-command]").forEach((button) => {
|
||||
window.clearTimeout(button._dcxCopyTimer);
|
||||
button.classList.remove("copied");
|
||||
button.removeAttribute("data-copied");
|
||||
});
|
||||
const status = modal.querySelector(".dcx-command-status");
|
||||
if (status) status.textContent = "";
|
||||
};
|
||||
|
||||
const openSettings = (trigger) => {
|
||||
window.clearTimeout(closeTimer);
|
||||
resetCopyState();
|
||||
invoker = trigger;
|
||||
trigger.setAttribute("aria-expanded", "true");
|
||||
if (!modal.open) modal.showModal();
|
||||
requestAnimationFrame(() => {
|
||||
modal.classList.add("is-visible");
|
||||
modal.querySelector("[data-dcx-settings-close]")?.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
|
||||
const closeSettings = ({ restoreFocus = true, instant = false } = {}) => {
|
||||
if (!modal.open) return;
|
||||
window.clearTimeout(closeTimer);
|
||||
modal.classList.remove("is-visible");
|
||||
|
||||
const finish = () => {
|
||||
if (!modal.open) return;
|
||||
modal.close();
|
||||
resetCopyState();
|
||||
invoker?.setAttribute?.("aria-expanded", "false");
|
||||
if (restoreFocus && invoker instanceof HTMLElement && invoker.isConnected) {
|
||||
invoker.focus({ preventScroll: true });
|
||||
}
|
||||
invoker = null;
|
||||
};
|
||||
|
||||
if (instant || REDUCED_MOTION.matches) finish();
|
||||
else closeTimer = window.setTimeout(finish, 200);
|
||||
};
|
||||
|
||||
window.closeDcxSettings = closeSettings;
|
||||
|
||||
document.addEventListener("click", async (event) => {
|
||||
const openButton = event.target.closest?.("[data-dcx-settings-open]");
|
||||
if (openButton) {
|
||||
event.preventDefault();
|
||||
openSettings(openButton);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest?.("[data-dcx-settings-close]")) {
|
||||
closeSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
modal.addEventListener("click", (event) => {
|
||||
if (event.target !== modal) return;
|
||||
const rect = modal.getBoundingClientRect();
|
||||
const outside = event.clientX < rect.left
|
||||
|| event.clientX > rect.right
|
||||
|| event.clientY < rect.top
|
||||
|| event.clientY > rect.bottom;
|
||||
if (outside) closeSettings();
|
||||
});
|
||||
|
||||
modal.addEventListener("cancel", (event) => {
|
||||
event.preventDefault();
|
||||
closeSettings();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!modal.open) return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
closeSettings();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
|
||||
const focusable = [...modal.querySelectorAll("button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])")]
|
||||
.filter((element) => element.getClientRects().length > 0 && !element.hidden);
|
||||
if (!focusable.length) {
|
||||
event.preventDefault();
|
||||
modal.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (event.shiftKey && (active === first || !modal.contains(active))) {
|
||||
event.preventDefault();
|
||||
last.focus({ preventScroll: true });
|
||||
} else if (!event.shiftKey && (active === last || !modal.contains(active))) {
|
||||
event.preventDefault();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
}, true);
|
||||
|
||||
document.addEventListener("dcx:document-mounted", (event) => {
|
||||
installTopbarActions(event.target);
|
||||
});
|
||||
})();
|
||||
@@ -1,13 +1,15 @@
|
||||
/* Design context document, the questionnaire's final act and its own surface.
|
||||
*
|
||||
* When a run reaches the review screen this module saves the answers, then
|
||||
* swaps the picker for the eight-category design context document. The mosaic
|
||||
* landing, tile-to-fullscreen morph, sidebar shell, and article vocabulary are
|
||||
* ported unchanged from docs/design-context-categorization/design-context.html;
|
||||
* what changed is the content: the prototype rendered one example project, this
|
||||
* renders the interview that just ended. Everything is assembled client-side
|
||||
* before the POST resolves, because the server's exit on /submit is the
|
||||
* completion signal the agent waits on, and after it there is nothing to fetch.
|
||||
* This module is the document's data layer. It reads the finished interview,
|
||||
* builds each category's article into the #dcx-detail-* templates, reveals the
|
||||
* tile shell, and keeps the live edit session. What it no longer owns is the
|
||||
* presentation: the mosaic morph, sidebar, scroll-spy, and section styling are
|
||||
* the engine's, in scripts/dcx/, ported from the standalone design context
|
||||
* demo. It mounts those same templates as one continuous document.
|
||||
*
|
||||
* Everything is assembled client-side before the POST resolves, because the
|
||||
* server's exit on /submit is the completion signal the agent waits on, and
|
||||
* after it there is nothing to fetch.
|
||||
*
|
||||
* The document is also openable on its own, long after that run. The boot
|
||||
* contract says which of the two this page is, and document mode renders from
|
||||
@@ -252,16 +254,6 @@ const empty = (title, body) => `
|
||||
|
||||
const note = (text) => `<p class="dcx-fan-note">${text}</p>`;
|
||||
|
||||
/* A value the document lets a person change in place.
|
||||
|
||||
The binding id is the whole address: the session resolves it to a file and
|
||||
a path, so nothing on this side has to know where the text lives. The
|
||||
original travels with it because an edit reports what it replaced, and a
|
||||
field edited twice still has to report the value the store started from.
|
||||
Editing itself is switched on after render, and only where a session can
|
||||
accept it. */
|
||||
const editable = (bindingId, text) => `<span class="dcx-editable" data-dcx-binding="${escapeHtml(bindingId)}" data-dcx-original="${escapeHtml(text)}">${escapeHtml(text)}</span>`;
|
||||
|
||||
/* Chat-round material renders when the agent passed it along, and says where
|
||||
it lives when it did not — an interview that skipped a question is a fact
|
||||
the document reports, not a gap it papers over. */
|
||||
@@ -403,8 +395,8 @@ function buildAudience(s, name) {
|
||||
const audience = s.context?.audience || {};
|
||||
const parts = [heading(1, 'Audience', 'Who it is for, emotional state, needs, trust triggers.', name)];
|
||||
const who = [
|
||||
audience.primary && { dt: 'Primary', dd: editable('audience.primary', audience.primary) },
|
||||
audience.secondary && { dt: 'Secondary', dd: editable('audience.secondary', audience.secondary) },
|
||||
audience.primary && { dt: 'Primary', dd: escapeHtml(audience.primary) },
|
||||
audience.secondary && { dt: 'Secondary', dd: escapeHtml(audience.secondary) },
|
||||
].filter(Boolean);
|
||||
parts.push(block('Who they are', who.length
|
||||
? defs(who)
|
||||
@@ -412,8 +404,8 @@ function buildAudience(s, name) {
|
||||
/* Arrival-only context keeps the old single-callout block; a leaving line
|
||||
widens it into the two-beat journey, side by side. */
|
||||
if (audience.emotion || audience.leaving) {
|
||||
const arrival = audience.emotion ? callout('On arrival', editable('audience.emotion', audience.emotion), true) : '';
|
||||
const leaving = audience.leaving ? callout('Leaving with', editable('audience.leaving', audience.leaving), true) : '';
|
||||
const arrival = audience.emotion ? callout('On arrival', escapeHtml(audience.emotion), true) : '';
|
||||
const leaving = audience.leaving ? callout('Leaving with', escapeHtml(audience.leaving), true) : '';
|
||||
if (arrival && leaving) {
|
||||
parts.push(block('Emotional journey', `<div class="dcx-callout-pair">${arrival}${leaving}</div>`));
|
||||
} else {
|
||||
@@ -478,7 +470,7 @@ function buildProduct(s, name) {
|
||||
const product = s.context?.product || {};
|
||||
const parts = [heading(2, 'Product', 'Purpose, surfaces, use cases, what must be clear first.', name)];
|
||||
const purposeCallout = product.purpose
|
||||
? callout(product.name || name || 'This product', editable('product.purpose', product.purpose), false,
|
||||
? callout(product.name || name || 'This product', escapeHtml(product.purpose), false,
|
||||
product.success ? `\n <p class="dcx-callout-success">${escapeHtml(product.success)}</p>` : '')
|
||||
: fromChat('The purpose and success definition were confirmed', '<code>PRODUCT.md · Product Purpose</code>');
|
||||
const platform = typeof product.platform === 'string' && product.platform.trim()
|
||||
@@ -489,8 +481,8 @@ function buildProduct(s, name) {
|
||||
: purposeCallout));
|
||||
if (product.positioning && (product.positioning.not || product.positioning.this)) {
|
||||
const cells = [
|
||||
product.positioning.not && callout('Not this', editable('product.positioning.not', product.positioning.not)),
|
||||
product.positioning.this && callout('This', editable('product.positioning.this', product.positioning.this), true),
|
||||
product.positioning.not && callout('Not this', escapeHtml(product.positioning.not)),
|
||||
product.positioning.this && callout('This', escapeHtml(product.positioning.this), true),
|
||||
].filter(Boolean).join('');
|
||||
parts.push(block('Positioning', `<div class="dcx-callout-pair">${cells}</div>`));
|
||||
}
|
||||
@@ -526,7 +518,7 @@ function buildBrand(s, name) {
|
||||
words alone over the pointer to the durable copy when only they arrived;
|
||||
the plain pointer otherwise. */
|
||||
parts.push(block('Personality', brand.personality
|
||||
? callout(brand.words?.join(' · ') || 'Voice', editable('brand.personality', brand.personality), true)
|
||||
? callout(brand.words?.join(' · ') || 'Voice', escapeHtml(brand.personality), true)
|
||||
: (Array.isArray(brand.words) && brand.words.length
|
||||
? callout(brand.words.join(' · '), 'Three words, voice, and tone were confirmed in chat, before the browser questionnaire. <code>PRODUCT.md · Brand Personality</code> is the durable copy.', true)
|
||||
: fromChat('Three words, voice, and tone were confirmed', '<code>PRODUCT.md · Brand Personality</code>'))));
|
||||
@@ -726,11 +718,6 @@ function buildColor(s, name) {
|
||||
<p>${escapeHtml(ROLE_STORY[entry.role] || '')}</p>
|
||||
<code>${escapeHtml(formatOklch(entry.hex))}</code>
|
||||
<span class="dcx-ink-pair"><span class="dcx-ink-tag">Ink</span><span class="dcx-ink-sample" style="--ink-ground:${entry.hex}; --ink-text:${inkHex};">${inkHex}</span></span>
|
||||
<span class="dcx-swatch-actions">
|
||||
<button class="dcx-edit" type="button" data-edit-color="${entry.role.toLowerCase()}">Edit color</button>
|
||||
<input class="dcx-native-color" type="color" value="${entry.hex}" data-color-input-for="${entry.role.toLowerCase()}"
|
||||
tabindex="-1" aria-label="Pick a new ${escapeHtml(entry.role)} color" />
|
||||
</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}</div>`
|
||||
@@ -959,8 +946,23 @@ const BUILDERS = {
|
||||
interface: buildInterface,
|
||||
};
|
||||
|
||||
/* What each chosen surface is, in the document's own register. The new document
|
||||
replaces the material article's preview boards with these definitions
|
||||
(dcx-detail.js reads window.dcxSurfaceDefs); persuade and experience carry the
|
||||
standalone demo's sentences verbatim. */
|
||||
const MODE_DEFS = {
|
||||
persuade: 'A public-facing page that introduces the experience and guides visitors toward its primary action.',
|
||||
operate: 'A working surface for completing tasks, where familiar patterns and a predictable layout come first.',
|
||||
read: 'A reading surface for understanding, where type, structure, and pacing carry the page.',
|
||||
experience: 'A project-led page for presenting selected work, its context, and its outcomes.',
|
||||
};
|
||||
|
||||
function renderDocument() {
|
||||
const snapshot = takeSnapshot();
|
||||
window.dcxSurfaceDefs = snapshot.surfaces.map((surface) => ({
|
||||
label: surface.label,
|
||||
description: MODE_DEFS[surface.mode] || surface.goal || '',
|
||||
}));
|
||||
const name = snapshot.context?.product?.name || '';
|
||||
for (const [id, build] of Object.entries(BUILDERS)) {
|
||||
const template = document.getElementById(`dcx-detail-${id}`);
|
||||
@@ -1068,24 +1070,20 @@ document.addEventListener('picker:screenchange', () => {
|
||||
$('[data-doc-retry]')?.addEventListener('click', finishSequence);
|
||||
|
||||
/* ============================================================
|
||||
Reveal + expander — ported from the prototype. The morph,
|
||||
subnav, hash routing, fan, and stroke sizing are its code;
|
||||
only the theme toggle and site header went (the picker has
|
||||
neither).
|
||||
Reveal — the shell's own entrance, and the stroke sizing its
|
||||
tile vignettes need. The document itself (morph, sidebar,
|
||||
scroll-spy, hash routing) is the engine's, in scripts/dcx/.
|
||||
============================================================ */
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const MORPH_MS = reduceMotion ? 0 : 540;
|
||||
const READY_MS = reduceMotion ? 0 : 260;
|
||||
|
||||
const tiles = $$('.dcx-tile');
|
||||
const names = {};
|
||||
tiles.forEach((tile) => { names[tile.dataset.category] = tile.dataset.name; });
|
||||
const shellTemplate = document.getElementById('dcx-shell-template');
|
||||
|
||||
let current = null;
|
||||
let revealed = false;
|
||||
|
||||
/* The mounted document is the new engine's (picker/scripts/dcx/dcx-document.js);
|
||||
these are the two facts the data layer still needs from it. */
|
||||
const dcxCurrentCategory = () => document.querySelector('.dcx-expander[data-dcx-document]')?.dataset.dcxCurrentCategory
|
||||
|| window.dcxDocument?.currentCategory()
|
||||
|| '';
|
||||
const dcxCategoryLabel = (category) => $(`.dcx-tile[data-category="${category}"]`)?.dataset.name || 'Design context';
|
||||
|
||||
function revealDocument() {
|
||||
revealed = true;
|
||||
document.body.classList.add('dcx-open');
|
||||
@@ -1097,213 +1095,8 @@ function revealDocument() {
|
||||
sizeDrawStrokes();
|
||||
});
|
||||
});
|
||||
const initial = location.hash.replace('#', '');
|
||||
if (initial && initial in names) {
|
||||
window.setTimeout(() => openCategory(initial, false), 120);
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(value) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
return navigator.clipboard.writeText(value).catch(() => fallbackCopy(value));
|
||||
}
|
||||
fallbackCopy(value);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function fallbackCopy(value) {
|
||||
const field = document.createElement('textarea');
|
||||
field.value = value;
|
||||
field.setAttribute('readonly', '');
|
||||
field.style.position = 'fixed';
|
||||
field.style.opacity = '0';
|
||||
document.body.appendChild(field);
|
||||
field.select();
|
||||
document.execCommand('copy');
|
||||
field.remove();
|
||||
}
|
||||
|
||||
function initFan(root) {
|
||||
$$('.dcx-fan', root).forEach((fan) => {
|
||||
const panels = $$('.dcx-fan-panel', fan);
|
||||
if (!panels.length) return;
|
||||
|
||||
function setActive(index) {
|
||||
fan.classList.add('is-engaged');
|
||||
panels.forEach((panel, i) => {
|
||||
panel.classList.toggle('is-active', i === index);
|
||||
panel.classList.toggle('is-neighbor', Math.abs(i - index) === 1);
|
||||
});
|
||||
}
|
||||
|
||||
function clearActive() {
|
||||
fan.classList.remove('is-engaged');
|
||||
panels.forEach((panel) => panel.classList.remove('is-active', 'is-neighbor'));
|
||||
}
|
||||
|
||||
fan.addEventListener('mousemove', (event) => {
|
||||
const rect = fan.getBoundingClientRect();
|
||||
const progress = Math.min(0.999, Math.max(0, (event.clientX - rect.left) / rect.width));
|
||||
let active = 0;
|
||||
panels.forEach((panel, i) => {
|
||||
const left = parseFloat(panel.style.getPropertyValue('--panel-left')) / 100;
|
||||
if (progress >= left) active = i;
|
||||
});
|
||||
setActive(active);
|
||||
});
|
||||
fan.addEventListener('mouseleave', clearActive);
|
||||
|
||||
panels.forEach((panel, i) => {
|
||||
panel.addEventListener('focus', () => setActive(i));
|
||||
panel.addEventListener('blur', () => {
|
||||
if (!fan.matches(':focus-within')) clearActive();
|
||||
});
|
||||
panel.addEventListener('click', () => {
|
||||
const value = panel.dataset.copyColor;
|
||||
if (!value) return;
|
||||
copyText(value);
|
||||
const label = panel.querySelector('.dcx-fan-name');
|
||||
const original = panel.dataset.colorName || 'Color';
|
||||
panel.classList.add('is-copied');
|
||||
if (label) label.textContent = 'Copied!';
|
||||
window.clearTimeout(panel._copyTimer);
|
||||
panel._copyTimer = window.setTimeout(() => {
|
||||
panel.classList.remove('is-copied');
|
||||
if (label) label.textContent = original;
|
||||
}, 900);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildSubnav(expander, activeId) {
|
||||
$$('.dcx-nav-list li', expander).forEach((li) => {
|
||||
const isActive = li.dataset.category === activeId;
|
||||
li.classList.toggle('is-active', isActive);
|
||||
const subnav = li.querySelector('.dcx-subnav');
|
||||
if (!subnav) return;
|
||||
subnav.innerHTML = '';
|
||||
if (!isActive) return;
|
||||
$$('.dcx-main .dcx-block[data-label]', expander).forEach((blockEl, i) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'dcx-sub-link';
|
||||
btn.type = 'button';
|
||||
btn.textContent = blockEl.dataset.label.replace(/&/g, '&');
|
||||
btn.setAttribute('data-dcx-subsection', String(i));
|
||||
subnav.appendChild(btn);
|
||||
});
|
||||
});
|
||||
$$('.dcx-nav-link', expander).forEach((link) => {
|
||||
if (link.dataset.dcxNav === activeId) link.setAttribute('aria-current', 'page');
|
||||
else link.removeAttribute('aria-current');
|
||||
});
|
||||
}
|
||||
|
||||
function scrollToBlock(expander, index) {
|
||||
const blocks = $$('.dcx-main .dcx-block[data-label]', expander);
|
||||
const target = blocks[Number(index)];
|
||||
if (!target) return;
|
||||
const main = expander.querySelector('.dcx-main');
|
||||
const top = target.getBoundingClientRect().top - main.getBoundingClientRect().top + main.scrollTop - 18;
|
||||
main.scrollTo({ top: Math.max(0, top), behavior: reduceMotion ? 'auto' : 'smooth' });
|
||||
}
|
||||
|
||||
function renderCategory(id, expander, updateHash) {
|
||||
const template = document.getElementById(`dcx-detail-${id}`);
|
||||
if (!template) return;
|
||||
|
||||
expander.querySelector('.dcx-current').textContent = names[id] || id;
|
||||
|
||||
const main = expander.querySelector('.dcx-main');
|
||||
main.innerHTML = '';
|
||||
main.appendChild(template.content.cloneNode(true));
|
||||
main.scrollTop = 0;
|
||||
initFan(main);
|
||||
|
||||
if (current) {
|
||||
const tile = $(`.dcx-tile--${id}`);
|
||||
current.id = id;
|
||||
current.tile = tile;
|
||||
current.rect = tile.getBoundingClientRect();
|
||||
}
|
||||
|
||||
buildSubnav(expander, id);
|
||||
if (updateHash) history.pushState({ category: id }, '', `#${id}`);
|
||||
}
|
||||
|
||||
function openCategory(id, updateHash) {
|
||||
if (!(id in names)) return;
|
||||
if (current && current.id === id) return;
|
||||
closeCategory(false);
|
||||
|
||||
const tile = $(`.dcx-tile--${id}`);
|
||||
if (!tile) return;
|
||||
|
||||
const rect = tile.getBoundingClientRect();
|
||||
const expander = document.createElement('section');
|
||||
expander.className = 'dcx-expander';
|
||||
expander.setAttribute('role', 'dialog');
|
||||
expander.setAttribute('aria-modal', 'true');
|
||||
expander.setAttribute('aria-label', `${names[id]} details`);
|
||||
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));
|
||||
document.body.appendChild(expander);
|
||||
document.body.classList.add('is-locked');
|
||||
current = { id: null, expander, tile, rect, opener: tile };
|
||||
renderCategory(id, expander, false);
|
||||
current.id = id;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
expander.classList.add('is-full');
|
||||
window.setTimeout(() => {
|
||||
expander.classList.add('is-ready');
|
||||
expander.querySelector('.dcx-close')?.focus({ preventScroll: true });
|
||||
}, READY_MS);
|
||||
});
|
||||
|
||||
expander.querySelector('.dcx-close').addEventListener('click', () => closeCategory(true));
|
||||
|
||||
expander.querySelector('.dcx-nav').addEventListener('click', (event) => {
|
||||
const subLink = event.target.closest('[data-dcx-subsection]');
|
||||
if (subLink) {
|
||||
scrollToBlock(expander, subLink.getAttribute('data-dcx-subsection'));
|
||||
return;
|
||||
}
|
||||
const navLink = event.target.closest('[data-dcx-nav]');
|
||||
if (!navLink) return;
|
||||
event.preventDefault();
|
||||
if (current && navLink.dataset.dcxNav === current.id) return;
|
||||
renderCategory(navLink.dataset.dcxNav, expander, true);
|
||||
});
|
||||
|
||||
if (updateHash) history.pushState({ category: id }, '', `#${id}`);
|
||||
}
|
||||
|
||||
function closeCategory(updateHash) {
|
||||
if (!current) return;
|
||||
const { expander, rect, opener } = current;
|
||||
expander.classList.remove('is-ready', 'is-full');
|
||||
expander.style.top = `${rect.top}px`;
|
||||
expander.style.left = `${rect.left}px`;
|
||||
expander.style.width = `${rect.width}px`;
|
||||
expander.style.height = `${rect.height}px`;
|
||||
window.setTimeout(() => {
|
||||
expander.remove();
|
||||
document.body.classList.remove('is-locked');
|
||||
}, MORPH_MS);
|
||||
current = null;
|
||||
if (opener) opener.focus({ preventScroll: true });
|
||||
if (updateHash && location.hash) history.pushState(null, '', location.pathname + location.search);
|
||||
}
|
||||
|
||||
tiles.forEach((tile) => {
|
||||
tile.addEventListener('click', () => openCategory(tile.dataset.category, true));
|
||||
});
|
||||
|
||||
/* Vignette draw animations use the homepage's stroke-dasharray: 100
|
||||
(user units), but non-scaling-stroke makes Chromium measure dashes in
|
||||
screen pixels. Translate: --pl = 100 viewBox units at the rendered
|
||||
@@ -1330,39 +1123,18 @@ window.addEventListener('resize', () => {
|
||||
drawResizeTimer = window.setTimeout(() => { if (revealed) sizeDrawStrokes(); }, 150);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && revealed) closeCategory(true);
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
if (!revealed) return;
|
||||
const id = location.hash.replace('#', '');
|
||||
if (id && id in names) {
|
||||
if (current) renderCategory(id, current.expander, false);
|
||||
else openCategory(id, false);
|
||||
} else {
|
||||
closeCategory(false);
|
||||
}
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
Live edit session — the document as a working surface.
|
||||
|
||||
The picker server forks a doc-session sibling on submit and hands
|
||||
this tab its address and token. From then on the document is
|
||||
editable through two paths:
|
||||
|
||||
- Simple edits (a palette color) POST /doc/edit and the session
|
||||
applies them itself: answers.json and DESIGN.md are rewritten by
|
||||
a deterministic function, no model in the loop.
|
||||
- Anything needing judgment (fonts, freeform asks) POSTs
|
||||
/doc/request; the agent long-polls the queue, does the work, and
|
||||
replies. The tray shows each request move pending -> working ->
|
||||
done.
|
||||
this tab its address and token. A change the reader wants goes to
|
||||
the agent as a request: POST /doc/request, the agent long-polls the
|
||||
queue, does the work, and replies. The tray shows each request move
|
||||
pending -> working -> done.
|
||||
|
||||
The tab learns about the outside world the way live mode's browser
|
||||
does, scaled to polling: /doc/state every couple of seconds, and a
|
||||
version bump means re-fetch answers.json and re-render.
|
||||
version bump means re-read the store and rebuild.
|
||||
============================================================ */
|
||||
|
||||
let docSession = null;
|
||||
@@ -1377,6 +1149,9 @@ const docLive = () => Boolean(docSession);
|
||||
|
||||
function startDocSession(doc) {
|
||||
docSession = doc;
|
||||
/* The dcx modules build their image URLs through dcxAsset(), which routes via
|
||||
the session from the moment one exists — before the first refresh below. */
|
||||
window.dcxDocSession = doc;
|
||||
document.body.classList.add('dcx-live');
|
||||
/* Rebuild the templates with this session's URLs: brand-asset images can
|
||||
only load through the session, because the picker server exits right
|
||||
@@ -1412,16 +1187,12 @@ async function pollDocState() {
|
||||
renderTray();
|
||||
if (state.version !== docVersion) {
|
||||
docVersion = state.version;
|
||||
/* Something moved on disk: a save of this tab's own, a request the
|
||||
agent finished, or a value it settled while doing either. Re-read
|
||||
both halves of the store and rebuild, including the article that is
|
||||
open, since the templates alone are not what anyone is looking at. */
|
||||
/* Something moved on disk: a request the agent finished, or a value it
|
||||
settled while doing so. Re-read both halves of the store and rebuild;
|
||||
the document engine re-mounts the open document and holds the reader's
|
||||
scroll position. */
|
||||
await adoptStoreState();
|
||||
const openScroll = current?.expander?.querySelector('.dcx-main')?.scrollTop ?? 0;
|
||||
refreshDocument();
|
||||
const main = current?.expander?.querySelector('.dcx-main');
|
||||
if (main) main.scrollTop = openScroll;
|
||||
markEditables();
|
||||
}
|
||||
schedulePoll(2000);
|
||||
} catch {
|
||||
@@ -1482,151 +1253,7 @@ const adoptStoreState = () => Promise.all([adoptAnswers(), adoptContext()]);
|
||||
|
||||
function refreshDocument() {
|
||||
renderDocument();
|
||||
if (current) renderCategory(current.id, current.expander, false);
|
||||
markEditables();
|
||||
}
|
||||
|
||||
/* ---------- Staged edits: the pending ledger and the save bar ----------
|
||||
|
||||
Edits land in the page immediately and on disk deliberately. That split is
|
||||
what lets a person try three changes and keep two: until Apply, nothing has
|
||||
been written, and the document is only showing what it would look like.
|
||||
|
||||
The ledger keys on the binding id and keeps the FIRST original it saw, so a
|
||||
field edited three times still reports the value the store actually holds.
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
const staged = new Map();
|
||||
/* One-off cards in the tray, for outcomes that are not a queued request. */
|
||||
const trayNotes = [];
|
||||
const saveBar = $('[data-dcx-savebar]');
|
||||
let applying = false;
|
||||
let wasShowing = false;
|
||||
|
||||
function stage(bindingId, from, to) {
|
||||
if (!bindingId) return;
|
||||
const existing = staged.get(bindingId);
|
||||
if (to === (existing ? existing.from : from)) staged.delete(bindingId);
|
||||
else staged.set(bindingId, { from: existing ? existing.from : from, to });
|
||||
renderSaveBar();
|
||||
}
|
||||
|
||||
function renderSaveBar() {
|
||||
if (!saveBar) return;
|
||||
const count = staged.size;
|
||||
saveBar.hidden = !count || !docLive();
|
||||
saveBar.toggleAttribute('data-applying', applying);
|
||||
if (saveBar.hidden) return;
|
||||
const label = $('[data-dcx-apply-label]', saveBar);
|
||||
const counter = $('[data-dcx-apply-count]', saveBar);
|
||||
label.textContent = applying ? 'Applying' : `Apply ${count === 1 ? 'change' : 'changes'}`;
|
||||
counter.textContent = String(count);
|
||||
counter.hidden = applying;
|
||||
$('[data-dcx-apply]', saveBar).disabled = applying;
|
||||
$('[data-dcx-discard]', saveBar).disabled = applying;
|
||||
$('[data-dcx-apply]', saveBar).setAttribute(
|
||||
'aria-label',
|
||||
`Apply ${count} ${count === 1 ? 'change' : 'changes'} to the design context`,
|
||||
);
|
||||
if (!wasShowing) {
|
||||
saveBar.setAttribute('data-just-appeared', '');
|
||||
setTimeout(() => saveBar.removeAttribute('data-just-appeared'), 700);
|
||||
}
|
||||
wasShowing = true;
|
||||
}
|
||||
|
||||
/* Editing is offered only where it can be accepted, and re-armed after every
|
||||
render because the article is rebuilt rather than patched. */
|
||||
function markEditables() {
|
||||
const live = docLive() && !applying;
|
||||
for (const node of $$('[data-dcx-binding]')) {
|
||||
node.contentEditable = live ? 'plaintext-only' : 'false';
|
||||
const id = node.dataset.dcxBinding;
|
||||
const pendingValue = staged.get(id)?.to;
|
||||
// A re-render rebuilt this element from the store, so anything staged
|
||||
// against it has to be written back on: the bar still counts it.
|
||||
if (pendingValue !== undefined && node.textContent !== pendingValue) {
|
||||
node.textContent = pendingValue;
|
||||
}
|
||||
node.toggleAttribute('data-dcx-dirty', pendingValue !== undefined);
|
||||
}
|
||||
renderSaveBar();
|
||||
}
|
||||
|
||||
/* plaintext-only keeps pasted markup out; this is the second half of that,
|
||||
because a browser without the mode still allows rich text. */
|
||||
document.addEventListener('input', (event) => {
|
||||
const node = event.target.closest?.('[data-dcx-binding]');
|
||||
if (!node) return;
|
||||
stage(node.dataset.dcxBinding, node.dataset.dcxOriginal ?? '', node.textContent.trim());
|
||||
node.toggleAttribute('data-dcx-dirty', staged.has(node.dataset.dcxBinding));
|
||||
});
|
||||
|
||||
/* ---------- Palette swatches stage like everything else ---------- */
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-edit-color]');
|
||||
if (!button) return;
|
||||
const input = button.parentElement.querySelector(`[data-color-input-for="${button.dataset.editColor}"]`);
|
||||
input?.click();
|
||||
});
|
||||
|
||||
document.addEventListener('change', (event) => {
|
||||
const input = event.target.closest?.('[data-color-input-for]');
|
||||
if (!input) return;
|
||||
stageColorEdit(input.dataset.colorInputFor, input.value.toUpperCase());
|
||||
});
|
||||
|
||||
function stageColorEdit(role, hex) {
|
||||
const field = form.elements[`palette-${role}`];
|
||||
if (!field || field.value.toUpperCase() === hex) return;
|
||||
const previous = field.value.toUpperCase();
|
||||
field.value = hex;
|
||||
refreshDocument();
|
||||
stage(`palette.${role}`, previous, hex);
|
||||
}
|
||||
|
||||
/* ---------- Apply and discard ---------- */
|
||||
|
||||
$('[data-dcx-apply]')?.addEventListener('click', async () => {
|
||||
if (!staged.size || applying || !docLive()) return;
|
||||
const count = staged.size;
|
||||
if (!window.confirm(`Apply ${count} ${count === 1 ? 'change' : 'changes'} to the design context?`)) return;
|
||||
|
||||
const changes = [...staged].map(([bindingId, { from, to }]) => ({ bindingId, from, to }));
|
||||
applying = true;
|
||||
markEditables();
|
||||
try {
|
||||
const result = await docPost('/doc/save', { changes });
|
||||
docVersion = result.version;
|
||||
staged.clear();
|
||||
trayNote(`Applied ${count} ${count === 1 ? 'change' : 'changes'}`, 'done');
|
||||
} catch (error) {
|
||||
trayNote('Those changes could not be saved. They are still here.', 'error');
|
||||
} finally {
|
||||
applying = false;
|
||||
markEditables();
|
||||
}
|
||||
});
|
||||
|
||||
$('[data-dcx-discard]')?.addEventListener('click', async () => {
|
||||
if (!staged.size || applying) return;
|
||||
const count = staged.size;
|
||||
if (!window.confirm(`Discard ${count} ${count === 1 ? 'change' : 'changes'}?`)) return;
|
||||
staged.clear();
|
||||
// The store is the rollback: re-reading it puts every field back.
|
||||
await adoptStoreState();
|
||||
refreshDocument();
|
||||
markEditables();
|
||||
});
|
||||
|
||||
function trayNote(message, status) {
|
||||
trayNotes.push({ id: `note-${trayNotes.length}`, status, message });
|
||||
renderTray();
|
||||
setTimeout(() => {
|
||||
trayNotes.shift();
|
||||
renderTray();
|
||||
}, 6000);
|
||||
window.dcxDocument?.remount();
|
||||
}
|
||||
|
||||
/* ---------- Complex edits: the request modal ---------- */
|
||||
@@ -1637,9 +1264,10 @@ document.addEventListener('click', (event) => {
|
||||
const trigger = event.target.closest('[data-dcx-request], [data-dcx-request-kind]');
|
||||
if (!trigger || !requestModal) return;
|
||||
requestKind = trigger.dataset.dcxRequestKind || 'freeform';
|
||||
const category = current ? names[current.id] : 'Design context';
|
||||
const category = dcxCurrentCategory();
|
||||
const scopeName = category ? dcxCategoryLabel(category) : 'Design context';
|
||||
$('[data-dcx-request-scope]', requestModal).textContent = requestKind === 'font'
|
||||
? 'Typography change' : `${category} change`;
|
||||
? 'Typography change' : `${scopeName} change`;
|
||||
$('[data-dcx-request-fonts]', requestModal).hidden = requestKind !== 'font';
|
||||
const prompt = $('.dcx-request-prompt', requestModal);
|
||||
prompt.value = '';
|
||||
@@ -1669,7 +1297,7 @@ $('[data-dcx-request-send]', requestModal)?.addEventListener('click', async () =
|
||||
const result = await docPost('/doc/request', {
|
||||
kind: requestKind,
|
||||
prompt,
|
||||
category: current ? current.id : '',
|
||||
category: dcxCurrentCategory(),
|
||||
payload: uploaded.length ? { fonts: uploaded } : {},
|
||||
});
|
||||
docVersion = result.version;
|
||||
@@ -1692,18 +1320,13 @@ const TRAY_LABELS = {
|
||||
};
|
||||
|
||||
function renderTray() {
|
||||
/* Notes are transient outcomes of a save; requests are work the agent owes. */
|
||||
/* One card per request: work the agent owes, and how far along it is. */
|
||||
if (!tray) return;
|
||||
const items = trayRequests.slice(-4);
|
||||
const offline = docSession && !docOnline;
|
||||
tray.hidden = !offline && items.length === 0 && trayNotes.length === 0;
|
||||
tray.hidden = !offline && items.length === 0;
|
||||
tray.innerHTML = [
|
||||
offline ? '<div class="dcx-tray-item" data-status="offline"><span class="dcx-tray-dot"></span><div><p class="dcx-tray-prompt">Edit session offline</p><p class="dcx-tray-note">Changes stay in this tab; reconnecting…</p></div></div>' : '',
|
||||
...trayNotes.map((entry) => `
|
||||
<div class="dcx-tray-item" data-status="${escapeHtml(entry.status)}">
|
||||
<span class="dcx-tray-dot"></span>
|
||||
<div><p class="dcx-tray-prompt">${escapeHtml(entry.message)}</p></div>
|
||||
</div>`),
|
||||
...items.map((entry) => `
|
||||
<div class="dcx-tray-item" data-status="${escapeHtml(entry.status)}">
|
||||
<span class="dcx-tray-dot"></span>
|
||||
|
||||
Reference in New Issue
Block a user