Add new design context document

This commit is contained in:
Abdul Wahab
2026-09-01 10:06:21 +05:00
parent bba73a2283
commit a5cf9266e6
58 changed files with 7397 additions and 569 deletions
+15
View File
@@ -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)}`;
};
+163
View File
@@ -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();
})();
+330
View File
@@ -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
+688
View File
@@ -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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
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
+226
View File
@@ -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 youd 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);
});
})();
+62 -439
View File
@@ -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 &middot; 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 &middot; Brand Personality</code> is the durable copy.', true)
: fromChat('Three words, voice, and tone were confirmed', '<code>PRODUCT.md &middot; 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(/&amp;/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&hellip;</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>