Add new design context document
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
@@ -0,0 +1,415 @@
|
||||
[
|
||||
{
|
||||
"id": "side-tab",
|
||||
"name": "Side-tab accent border",
|
||||
"description": "Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.",
|
||||
"group": "slop",
|
||||
"discipline": "Visual Details"
|
||||
},
|
||||
{
|
||||
"id": "border-accent-on-rounded",
|
||||
"name": "Border accent on rounded element",
|
||||
"description": "Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.",
|
||||
"group": "slop",
|
||||
"discipline": "Visual Details"
|
||||
},
|
||||
{
|
||||
"id": "overused-font",
|
||||
"name": "Overused font",
|
||||
"description": "Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.",
|
||||
"group": "slop",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "flat-type-hierarchy",
|
||||
"name": "Flat type hierarchy",
|
||||
"description": "Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).",
|
||||
"group": "slop",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "gradient-text",
|
||||
"name": "Gradient text",
|
||||
"description": "Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.",
|
||||
"group": "slop",
|
||||
"discipline": "Color & Contrast"
|
||||
},
|
||||
{
|
||||
"id": "ai-color-palette",
|
||||
"name": "AI color palette",
|
||||
"description": "Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.",
|
||||
"group": "slop",
|
||||
"discipline": "Color & Contrast"
|
||||
},
|
||||
{
|
||||
"id": "cream-palette",
|
||||
"name": "Cream / beige palette",
|
||||
"description": "A warm cream or beige page background has become the default \"tasteful\" AI surface, reached for by reflex. Choose a background that comes from a deliberate palette, not the safe warm off-white.",
|
||||
"group": "slop",
|
||||
"discipline": "Color & Contrast"
|
||||
},
|
||||
{
|
||||
"id": "nested-cards",
|
||||
"name": "Nested cards",
|
||||
"description": "Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.",
|
||||
"group": "slop",
|
||||
"discipline": "Layout & Space"
|
||||
},
|
||||
{
|
||||
"id": "monotonous-spacing",
|
||||
"name": "Monotonous spacing",
|
||||
"description": "The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.",
|
||||
"group": "slop",
|
||||
"discipline": "Layout & Space"
|
||||
},
|
||||
{
|
||||
"id": "bounce-easing",
|
||||
"name": "Bounce or elastic easing",
|
||||
"description": "Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.",
|
||||
"group": "slop",
|
||||
"discipline": "Motion"
|
||||
},
|
||||
{
|
||||
"id": "pulsing-dot",
|
||||
"name": "Pulsing status dot",
|
||||
"description": "Small pulsing status dots simulate liveness decoratively. Reserve pulse animation for indicators tied to genuinely live, changing data; a static indicator with clear labeling is honest and calmer.",
|
||||
"group": "slop",
|
||||
"discipline": "Motion"
|
||||
},
|
||||
{
|
||||
"id": "blinking-cursor",
|
||||
"name": "Decorative blinking cursor",
|
||||
"description": "A blinking text cursor animated into a hero or landing section simulates typing where no input exists. It borrows the dev-tool aesthetic as decoration. Real editable fields draw their own caret; anywhere else, let the composition hold attention without a fake prompt.",
|
||||
"group": "slop",
|
||||
"discipline": "Motion"
|
||||
},
|
||||
{
|
||||
"id": "shape-assembled-illustration",
|
||||
"name": "Shape-assembled illustration",
|
||||
"description": "A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.",
|
||||
"group": "slop",
|
||||
"discipline": "Imagery"
|
||||
},
|
||||
{
|
||||
"id": "dark-glow",
|
||||
"name": "Glowing shadow accents",
|
||||
"description": "Colored glow shadows — a zero-offset chromatic halo (box- or text-shadow) on any background, or any colored blurred shadow on a dark background — are the default \"cool\" look of AI-generated UIs. Use neutral elevation shadows and subtle, purposeful lighting instead.",
|
||||
"group": "slop",
|
||||
"discipline": "Color & Contrast"
|
||||
},
|
||||
{
|
||||
"id": "radial-halo",
|
||||
"name": "Radial-gradient background halo",
|
||||
"description": "A chromatic radial-gradient wash — saturated at the center, fading to transparent — used as a decorative background glow on a dark page. Same tell as glowing shadows, drawn with a gradient instead of a shadow. Ground the surface with a solid or subtly shifted background instead.",
|
||||
"group": "slop",
|
||||
"discipline": "Color & Contrast"
|
||||
},
|
||||
{
|
||||
"id": "radial-spotlight-glow",
|
||||
"name": "Decorative radial spotlight glow",
|
||||
"description": "A soft, low-opacity accent-colored radial gradient fading to transparent, dropped behind a hero or section as a \"spotlight.\" It is a reflex AI decoration — the translucent cousin of the saturated radial halo. Let the surface stand on its own, or light the composition with a deliberate material accent rather than a floating colored haze.",
|
||||
"group": "slop",
|
||||
"discipline": "Color & Contrast"
|
||||
},
|
||||
{
|
||||
"id": "marquee",
|
||||
"name": "Auto-scrolling marquee",
|
||||
"description": "Continuously auto-scrolling content demands attention it has not earned and hides half its content at any moment. Reserve motion for content that changes; let readers move at their own pace.",
|
||||
"group": "slop",
|
||||
"discipline": "Motion"
|
||||
},
|
||||
{
|
||||
"id": "icon-tile-stack",
|
||||
"name": "Icon tile stacked above heading",
|
||||
"description": "A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.",
|
||||
"group": "slop",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "italic-serif-display",
|
||||
"name": "Italic serif display headline",
|
||||
"description": "Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.",
|
||||
"group": "slop",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "hero-eyebrow-chip",
|
||||
"name": "Hero eyebrow / pill chip",
|
||||
"description": "A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.",
|
||||
"group": "slop",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "kicker-above-heading",
|
||||
"name": "Kicker / eyebrow label above heading",
|
||||
"description": "A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.",
|
||||
"group": "slop",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "numbered-section-labels",
|
||||
"name": "Tiny numbered section labels",
|
||||
"description": "Small numeric index labels riding next to section headings, repeated section after section, are AI editorial scaffolding — a page numbering its own chapters instead of earning structure. Let hierarchy, content, and rhythm carry the sequence.",
|
||||
"group": "slop",
|
||||
"discipline": "Layout & Space"
|
||||
},
|
||||
{
|
||||
"id": "em-dash-overuse",
|
||||
"name": "Em-dash overuse",
|
||||
"description": "Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.",
|
||||
"group": "slop",
|
||||
"discipline": "Copy"
|
||||
},
|
||||
{
|
||||
"id": "marketing-buzzword",
|
||||
"name": "Marketing buzzword",
|
||||
"description": "Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.",
|
||||
"group": "slop",
|
||||
"discipline": "Copy"
|
||||
},
|
||||
{
|
||||
"id": "aphoristic-cadence",
|
||||
"name": "Aphoristic-cadence copy",
|
||||
"description": "Three or more sections landing on a short rebuttal sentence (\"X. No Y.\" / \"X. Just Y.\") or a manufactured-contrast aphorism (\"Not a feature. A platform.\") reads as AI cadence, not voice. Once is fine; the pattern is the tell.",
|
||||
"group": "slop",
|
||||
"discipline": "Copy"
|
||||
},
|
||||
{
|
||||
"id": "oversized-h1",
|
||||
"name": "Oversized hero headline",
|
||||
"description": "A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.",
|
||||
"group": "slop",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "extreme-negative-tracking",
|
||||
"name": "Crushed letter spacing",
|
||||
"description": "Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.",
|
||||
"group": "slop",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "broken-image",
|
||||
"name": "Broken or placeholder image",
|
||||
"description": "<img> tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.",
|
||||
"group": "quality",
|
||||
"discipline": "Imagery"
|
||||
},
|
||||
{
|
||||
"id": "script-error",
|
||||
"name": "Uncaught script error on load",
|
||||
"description": "A script threw an uncaught exception or failed to parse while the page loaded. Broken JavaScript silently kills reveals, interactions, and dynamic content, and can leave most of a page invisible. Fix the error before judging anything else.",
|
||||
"group": "quality",
|
||||
"discipline": "Quality"
|
||||
},
|
||||
{
|
||||
"id": "content-hidden-at-rest",
|
||||
"name": "Content invisible at rest",
|
||||
"description": "A large share of the page text sits at opacity 0 or visibility hidden even after every reveal handler had a chance to run. This is the failed-reveal signature: the content shipped but never becomes visible. Make content visible by default and let JavaScript enhance its entrance instead of gating its existence.",
|
||||
"group": "quality",
|
||||
"discipline": "Layout & Space"
|
||||
},
|
||||
{
|
||||
"id": "edge-flush-cards",
|
||||
"name": "Cards flush against the scroller edge",
|
||||
"description": "Cards inside a horizontal scroller or tab panel sit flush against the container edge at rest while keeping a gutter on the other side, so their edges and rounded corners get cut off. Usually the panel is sized wider than its clip box. Keep a consistent inset on both sides.",
|
||||
"group": "quality",
|
||||
"discipline": "Layout & Space"
|
||||
},
|
||||
{
|
||||
"id": "text-occlusion",
|
||||
"name": "Text occluded by an overlapping element",
|
||||
"description": "Text is painted under an opaque element or a second text run, so part of it cannot be read. A decorative box, a stacked layer, or an inline element with leaked padding lands on the words instead of beside them. Give overlapping layers room, or move the text out from under the layer above it.",
|
||||
"group": "quality",
|
||||
"discipline": "Layout & Space"
|
||||
},
|
||||
{
|
||||
"id": "first-viewport-column-overflow",
|
||||
"name": "One column stretches the first viewport",
|
||||
"description": "A multi-column opening section lets one column run far past the fold while its sibling fits in a single viewport, so the short column floats in dead space and the fold falls deep inside one section. Balance the columns, cap the tall one, or let the long content flow below the opening row.",
|
||||
"group": "quality",
|
||||
"discipline": "Layout & Space"
|
||||
},
|
||||
{
|
||||
"id": "gray-on-color",
|
||||
"name": "Gray text on colored background",
|
||||
"description": "Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.",
|
||||
"group": "quality",
|
||||
"discipline": "Color & Contrast"
|
||||
},
|
||||
{
|
||||
"id": "low-contrast",
|
||||
"name": "Low contrast text",
|
||||
"description": "Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.",
|
||||
"group": "quality",
|
||||
"discipline": "Quality"
|
||||
},
|
||||
{
|
||||
"id": "layout-transition",
|
||||
"name": "Layout property animation",
|
||||
"description": "Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.",
|
||||
"group": "quality",
|
||||
"discipline": "Motion"
|
||||
},
|
||||
{
|
||||
"id": "line-length",
|
||||
"name": "Line length too long",
|
||||
"description": "Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.",
|
||||
"group": "quality",
|
||||
"discipline": "Layout & Space"
|
||||
},
|
||||
{
|
||||
"id": "cramped-padding",
|
||||
"name": "Cramped padding",
|
||||
"description": "Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.",
|
||||
"group": "quality",
|
||||
"discipline": "Layout & Space"
|
||||
},
|
||||
{
|
||||
"id": "body-text-viewport-edge",
|
||||
"name": "Body text touching viewport edge",
|
||||
"description": "Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.",
|
||||
"group": "quality",
|
||||
"discipline": "Layout & Space"
|
||||
},
|
||||
{
|
||||
"id": "tight-leading",
|
||||
"name": "Tight line height",
|
||||
"description": "Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.",
|
||||
"group": "quality",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "skipped-heading",
|
||||
"name": "Skipped heading level",
|
||||
"description": "Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.",
|
||||
"group": "quality",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "heading-rhythm",
|
||||
"name": "Heading crowded against the previous block",
|
||||
"description": "A heading binds to the content it introduces, so the rendered space above it should exceed the space below it. When headings across a page sit as close or closer to the block above than to their own content, every section reads as if it captions the previous one. Open up the space above each heading.",
|
||||
"group": "quality",
|
||||
"discipline": "Layout & Space"
|
||||
},
|
||||
{
|
||||
"id": "justified-text",
|
||||
"name": "Justified text",
|
||||
"description": "Justified text without hyphenation creates uneven word spacing (\"rivers of white\"). Use text-align: left for body text, or enable hyphens: auto if you must justify.",
|
||||
"group": "quality",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "tiny-text",
|
||||
"name": "Tiny body text",
|
||||
"description": "Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.",
|
||||
"group": "quality",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "undersized-ui-text",
|
||||
"name": "Undersized functional text",
|
||||
"description": "Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.",
|
||||
"group": "quality",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "all-caps-body",
|
||||
"name": "All-caps body text",
|
||||
"description": "Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.",
|
||||
"group": "quality",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "wide-tracking",
|
||||
"name": "Wide letter spacing on body text",
|
||||
"description": "Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.",
|
||||
"group": "quality",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "text-overflow",
|
||||
"name": "Content overflowing its container",
|
||||
"description": "Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.",
|
||||
"group": "quality",
|
||||
"discipline": "Layout & Space"
|
||||
},
|
||||
{
|
||||
"id": "repeated-container-text",
|
||||
"name": "Same text repeated inside one container",
|
||||
"description": "The same literal text rendered three or more times in structurally different spots inside a single card or panel is redundant messaging — usually a status or label wired into every slot of a template. Say it once, in the slot where it matters most.",
|
||||
"group": "quality",
|
||||
"discipline": "Quality"
|
||||
},
|
||||
{
|
||||
"id": "clipped-overflow-container",
|
||||
"name": "Positioned child clipped by overflow container",
|
||||
"description": "A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.",
|
||||
"group": "quality",
|
||||
"discipline": "Layout & Space"
|
||||
},
|
||||
{
|
||||
"id": "design-system-font",
|
||||
"name": "Font outside DESIGN.md",
|
||||
"description": "A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.",
|
||||
"group": "quality",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "design-system-color",
|
||||
"name": "Color outside DESIGN.md",
|
||||
"description": "A literal color is outside the DESIGN.md palette and sidecar tonal ramps. This may be legitimate, but it should be an intentional design-system addition rather than drift.",
|
||||
"group": "quality",
|
||||
"discipline": "Color & Contrast"
|
||||
},
|
||||
{
|
||||
"id": "design-system-radius",
|
||||
"name": "Radius outside DESIGN.md",
|
||||
"description": "A border-radius value is outside the DESIGN.md rounded scale. Use a documented radius token or update the design system if the new shape is intentional.",
|
||||
"group": "quality",
|
||||
"discipline": "Visual Details"
|
||||
},
|
||||
{
|
||||
"id": "design-system-font-size",
|
||||
"name": "Font size outside DESIGN.md",
|
||||
"description": "A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.",
|
||||
"group": "quality",
|
||||
"discipline": "Typography"
|
||||
},
|
||||
{
|
||||
"id": "gpt-thin-border-wide-shadow",
|
||||
"name": "Hairline border with wide shadow",
|
||||
"description": "A hairline border paired with a wide, diffuse shadow is a recurring generated-UI signature. Commit to one — a defined edge or a soft elevation — rather than both at once.",
|
||||
"group": "fingerprints",
|
||||
"discipline": "Visual Details"
|
||||
},
|
||||
{
|
||||
"id": "repeating-stripes-gradient",
|
||||
"name": "Repeating-gradient stripes",
|
||||
"description": "Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture or leave the surface plain.",
|
||||
"group": "fingerprints",
|
||||
"discipline": "Visual Details"
|
||||
},
|
||||
{
|
||||
"id": "codex-grid-background",
|
||||
"name": "Decorative grid-line background",
|
||||
"description": "A decorative grid or line-field background drawn with hairline linear-gradient layers tiled by a fixed pixel cell is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.",
|
||||
"group": "fingerprints",
|
||||
"discipline": "Visual Details"
|
||||
},
|
||||
{
|
||||
"id": "theater-slop-phrase",
|
||||
"name": "Theater framing copy",
|
||||
"description": "Dismissing something as \"theater\" is a recurring generated-copy tic. Say plainly what the thing does or does not do.",
|
||||
"group": "fingerprints",
|
||||
"discipline": "Copy"
|
||||
},
|
||||
{
|
||||
"id": "image-hover-transform",
|
||||
"name": "Image hover transform",
|
||||
"description": "Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.",
|
||||
"group": "slop",
|
||||
"discipline": "Motion"
|
||||
}
|
||||
]
|
||||
@@ -14,6 +14,18 @@ import '../styles/screens/depth.css';
|
||||
import '../styles/screens/layout-portfolio.css';
|
||||
// The design context document that replaces the picker after submission.
|
||||
import '../styles/design-context.css';
|
||||
// The lean design context document, ported from the standalone demo. Order
|
||||
// mirrors the demo page's stylesheet order so the cascade lands identically;
|
||||
// command and settings sit where dcx-document.css used to @import them.
|
||||
import '../styles/dcx/dcx-audience.css';
|
||||
import '../styles/dcx/dcx-detail.css';
|
||||
import '../styles/dcx/dcx-components.css';
|
||||
import '../styles/dcx/dcx-hierarchy.css';
|
||||
import '../styles/dcx/dcx-command.css';
|
||||
import '../styles/dcx/dcx-settings.css';
|
||||
import '../styles/dcx/dcx-document.css';
|
||||
import '../styles/dcx/dcx-rail.css';
|
||||
import '../styles/dcx/dcx-hooks.css';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
|
||||
@@ -1515,16 +1515,6 @@ const questions = [
|
||||
</dialog>
|
||||
|
||||
{/* Live edit tray: every queued request with its status, fed by polling. */}
|
||||
{/* Edits stage in the page and are written in one go, so the bar says what
|
||||
is waiting and where pressing it lands. Hidden until something is. */}
|
||||
<div class="dcx-savebar" data-dcx-savebar hidden>
|
||||
<button class="dcx-savebar-apply" type="button" data-dcx-apply>
|
||||
<span data-dcx-apply-label>Apply changes</span>
|
||||
<span class="dcx-savebar-count" data-dcx-apply-count>0</span>
|
||||
</button>
|
||||
<button class="dcx-savebar-discard" type="button" data-dcx-discard>Discard</button>
|
||||
</div>
|
||||
|
||||
<div class="dcx-tray" data-dcx-tray hidden aria-live="polite"></div>
|
||||
|
||||
<dialog class="picker-modal" data-font-modal aria-labelledby="picker-font-modal-title">
|
||||
@@ -1603,6 +1593,14 @@ const questions = [
|
||||
</aside>
|
||||
|
||||
<script>
|
||||
// The document engine loads first: it renames two category tiles at module
|
||||
// init and owns tile clicks in the capture phase, so it must be installed
|
||||
// before the data layer reads the tiles.
|
||||
import '../scripts/dcx/dcx-document.js';
|
||||
import '../scripts/dcx/dcx-audience.js';
|
||||
import '../scripts/dcx/dcx-hooks.js';
|
||||
import '../scripts/dcx/dcx-detail.js';
|
||||
import '../scripts/dcx/dcx-rail.js';
|
||||
import '../scripts/palette-picker.js';
|
||||
import '../scripts/design-context.js';
|
||||
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/* Audience detail treatment shared with demos/audience-scroll/docs.html. */
|
||||
|
||||
.dcx-audience {
|
||||
--dcx-audience-icon-size: 100px;
|
||||
}
|
||||
|
||||
.dcx-audience > .dcx-audience-hero {
|
||||
margin-bottom: clamp(48px, 6vw, 76px);
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-section {
|
||||
margin-top: 0;
|
||||
margin-bottom: clamp(48px, 6vw, 76px);
|
||||
scroll-margin-top: 18px;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-section-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--dcx-audience-icon-size);
|
||||
gap: clamp(24px, 4vw, 44px);
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-section-heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-section-title {
|
||||
margin: 0;
|
||||
color: var(--ks-champagne);
|
||||
font-family: var(--ks-font);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.25;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-section-lede {
|
||||
max-width: 52ch;
|
||||
margin: 10px 0 0;
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-section-icon {
|
||||
width: var(--dcx-audience-icon-size);
|
||||
height: var(--dcx-audience-icon-size);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-section-icon img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-section--people .dcx-defs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-section--people .dcx-def {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 18px 20px 20px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-bg);
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-section--people .dcx-def dt {
|
||||
color: var(--ks-champagne);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-section--people .dcx-def dd {
|
||||
margin-top: 9px;
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.58;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-journey {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 30px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-journey .dcx-callout {
|
||||
min-width: 0;
|
||||
padding: 18px 20px 20px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-bg);
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-journey .dcx-callout + .dcx-callout {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-journey-arrow {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--accent);
|
||||
font-family: var(--ks-mono);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-list {
|
||||
counter-reset: dcx-audience-item;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-list li {
|
||||
counter-increment: dcx-audience-item;
|
||||
position: static;
|
||||
display: grid;
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-bg);
|
||||
color: var(--ks-text);
|
||||
font-size: 0.96rem;
|
||||
line-height: 1.6;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-list li::before {
|
||||
content: counter(dcx-audience-item, decimal-leading-zero);
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font-family: var(--ks-mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.dcx-audience .dcx-audience-section-head {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-section--people .dcx-defs,
|
||||
.dcx-audience .dcx-audience-journey {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-journey-arrow {
|
||||
height: 22px;
|
||||
justify-content: start;
|
||||
padding-left: 20px;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.dcx-audience .dcx-audience-section-head {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-section-title {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-list li {
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dcx-audience *,
|
||||
.dcx-audience *::before,
|
||||
.dcx-audience *::after {
|
||||
transition-duration: 0.001ms !important;
|
||||
transition-delay: 0ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/* Compact command-copy control, matched to the homepage Get started treatment. */
|
||||
|
||||
.dcx-command-copy {
|
||||
--dcx-command-accent: var(--ks-kinpaku);
|
||||
--dcx-command-ground: var(--ks-code-block-bg, var(--ks-lacquer-deep));
|
||||
--dcx-command-ink: var(--ks-code-block-fg, var(--ks-text));
|
||||
--dcx-command-copy-border: var(--ks-rule);
|
||||
--dcx-command-copy-hover: oklch(77% 0.14 82 / 0.08);
|
||||
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 52px;
|
||||
padding: 9px 9px 9px 18px;
|
||||
border: 1px solid var(--dcx-command-accent);
|
||||
border-radius: 2px;
|
||||
background: var(--dcx-command-ground);
|
||||
}
|
||||
|
||||
.dcx-command-copy__prompt {
|
||||
flex: none;
|
||||
color: var(--dcx-command-accent);
|
||||
font: 500 1.125rem/1 var(--ks-mono);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dcx-command-copy > code {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--dcx-command-ink);
|
||||
font: 400 0.98rem/1.35 var(--ks-mono);
|
||||
letter-spacing: 0;
|
||||
scrollbar-width: thin;
|
||||
user-select: all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dcx-command-copy__button {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--dcx-command-copy-border);
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: var(--dcx-command-accent);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 180ms var(--ks-ease),
|
||||
border-color 180ms var(--ks-ease),
|
||||
color 180ms var(--ks-ease),
|
||||
transform 120ms var(--ks-ease);
|
||||
}
|
||||
|
||||
.dcx-command-copy__button:hover,
|
||||
.dcx-command-copy__button.copied {
|
||||
border-color: currentColor;
|
||||
background: var(--dcx-command-copy-hover);
|
||||
}
|
||||
|
||||
.dcx-command-copy__button:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.dcx-command-copy__button:focus-visible {
|
||||
outline: 2px solid var(--ks-patina);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.dcx-command-copy__button svg {
|
||||
grid-area: 1 / 1;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
transition:
|
||||
opacity 140ms var(--ks-ease),
|
||||
transform 180ms var(--ks-ease);
|
||||
}
|
||||
|
||||
.dcx-command-copy__copy-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
opacity: 1;
|
||||
stroke-width: 1.6;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.dcx-command-copy__check-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0;
|
||||
stroke-width: 2.1;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transform: scale(0.86);
|
||||
}
|
||||
|
||||
.dcx-command-copy__check-icon path {
|
||||
stroke-dasharray: 24;
|
||||
stroke-dashoffset: 24;
|
||||
}
|
||||
|
||||
.dcx-command-copy__button.copied .dcx-command-copy__copy-icon {
|
||||
opacity: 0;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
|
||||
.dcx-command-copy__button.copied .dcx-command-copy__check-icon {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.dcx-command-copy__button.copied .dcx-command-copy__check-icon path {
|
||||
animation: dcx-command-check-draw 260ms var(--ks-ease) forwards;
|
||||
}
|
||||
|
||||
.dcx-command-copy__label,
|
||||
.dcx-command-status {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
border: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes dcx-command-check-draw {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.dcx-command-copy {
|
||||
gap: 10px;
|
||||
padding: 9px 8px 9px 14px;
|
||||
}
|
||||
|
||||
.dcx-command-copy > code {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dcx-command-copy__button,
|
||||
.dcx-command-copy__button svg {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.dcx-command-copy__button.copied .dcx-command-copy__check-icon path {
|
||||
animation: none;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
/* A visual component inventory: specimens first, documentation chrome kept quiet. */
|
||||
|
||||
.dcx-detail-article--components .dcx-component-stack {
|
||||
display: grid;
|
||||
gap: clamp(42px, 5vw, 64px);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-example {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-example > figcaption {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-example h4 {
|
||||
margin: 0;
|
||||
color: var(--ks-champagne);
|
||||
font-family: var(--ks-font);
|
||||
font-size: 1.08rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-canvas {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: clamp(230px, 27vw, 300px);
|
||||
padding: clamp(30px, 5vw, 58px);
|
||||
border: 1px solid var(--ks-rule);
|
||||
border-radius: 8px;
|
||||
background: var(--ks-lacquer-deep);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-canvas--center {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-row--baseline {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-button {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
min-height: 44px;
|
||||
padding: 0 20px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
color: var(--ks-champagne);
|
||||
font-family: var(--ks-font);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-button svg,
|
||||
.dcx-detail-article--components .dcx-demo-icon-button svg,
|
||||
.dcx-detail-article--components .dcx-demo-input svg {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-button--primary {
|
||||
border-color: var(--ks-kinpaku);
|
||||
background: var(--ks-kinpaku);
|
||||
color: var(--ks-lacquer-deep);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-button--secondary {
|
||||
border-color: var(--ks-graphite-2);
|
||||
background: var(--ks-graphite-2);
|
||||
color: var(--ks-champagne);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-button--tertiary {
|
||||
border-color: color-mix(in oklch, var(--ks-patina) 55%, var(--ks-rule));
|
||||
background: color-mix(in oklch, var(--ks-patina) 13%, transparent);
|
||||
color: var(--ks-patina-pale);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-button--outline {
|
||||
border-color: var(--ks-rule);
|
||||
background: transparent;
|
||||
color: var(--ks-champagne);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-button--ghost {
|
||||
padding-inline: 12px;
|
||||
background: transparent;
|
||||
color: var(--ks-text);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-button--danger {
|
||||
border-color: var(--ks-vermilion);
|
||||
background: var(--ks-vermilion);
|
||||
color: var(--ks-champagne);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-button--danger-soft {
|
||||
border-color: transparent;
|
||||
background: color-mix(in oklch, var(--ks-vermilion) 20%, transparent);
|
||||
color: color-mix(in oklch, var(--ks-vermilion) 74%, var(--ks-champagne));
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-button--small {
|
||||
min-height: 36px;
|
||||
padding-inline: 16px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-button--large {
|
||||
min-height: 54px;
|
||||
padding-inline: 26px;
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-icon-button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 50%;
|
||||
color: var(--ks-champagne);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-icon-button--secondary {
|
||||
background: var(--ks-graphite-2);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-icon-button--outline {
|
||||
border-color: var(--ks-rule);
|
||||
color: var(--ks-patina);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-icon-button--danger {
|
||||
background: var(--ks-vermilion);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-button--loading {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid color-mix(in oklch, var(--ks-lacquer-deep) 35%, transparent);
|
||||
border-top-color: var(--ks-lacquer-deep);
|
||||
border-radius: 50%;
|
||||
animation: dcx-component-spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes dcx-component-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-canvas--fields,
|
||||
.dcx-detail-article--components .dcx-component-canvas--cards {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-demo-grid {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
width: min(100%, 900px);
|
||||
min-width: 0;
|
||||
gap: clamp(16px, 2.2vw, 22px);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-demo-grid--three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-demo-grid--two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-demo-grid--single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
width: min(100%, 680px);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-demo-grid--baseline {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-field {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-field-label {
|
||||
color: var(--ks-text-muted);
|
||||
font-family: var(--ks-font);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-input {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--ks-rule);
|
||||
border-radius: 3px;
|
||||
background: var(--ks-lacquer-raised);
|
||||
color: var(--ks-champagne);
|
||||
font-family: var(--ks-font);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-input.is-placeholder {
|
||||
color: var(--ks-text-mute-deep);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-field small {
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-field.is-invalid .dcx-demo-input {
|
||||
border-color: var(--ks-vermilion);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-field.is-invalid small {
|
||||
color: color-mix(in oklch, var(--ks-vermilion) 76%, var(--ks-champagne));
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-field--small .dcx-demo-input {
|
||||
min-height: 38px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-field--large .dcx-demo-input {
|
||||
min-height: 58px;
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-input--affix {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-input--affix > span {
|
||||
flex: none;
|
||||
color: var(--ks-patina);
|
||||
font-family: var(--ks-mono);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-input--suffix {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-demo-input--textarea {
|
||||
align-items: flex-start;
|
||||
min-height: 132px;
|
||||
padding-block: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 268px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ks-rule);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-bg);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card-body {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
padding: clamp(20px, 2.6vw, 28px);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card h5 {
|
||||
margin: 14px 0 8px;
|
||||
color: var(--ks-champagne);
|
||||
font-family: var(--ks-font);
|
||||
font-size: 1.04rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.38;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card p,
|
||||
.dcx-detail-article--components .dcx-showcase-card blockquote {
|
||||
margin: 0;
|
||||
color: var(--ks-text);
|
||||
font-family: var(--ks-font);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.62;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card blockquote {
|
||||
margin-top: 24px;
|
||||
color: var(--ks-champagne);
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card-meta {
|
||||
margin-top: auto;
|
||||
padding-top: 22px;
|
||||
color: var(--ks-text-muted);
|
||||
font-family: var(--ks-mono);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card dl {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card dl > div {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card dt {
|
||||
color: var(--ks-text-faint);
|
||||
font-family: var(--ks-mono);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card dd {
|
||||
margin: 0;
|
||||
color: var(--ks-text);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card-link {
|
||||
margin-top: auto;
|
||||
padding-top: 22px;
|
||||
color: var(--ks-patina);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card--media img {
|
||||
width: 100%;
|
||||
height: clamp(190px, 23vw, 240px);
|
||||
object-fit: cover;
|
||||
object-position: 50% 38%;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card--media-close img {
|
||||
object-position: 50% 66%;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-canvas--single-card {
|
||||
min-height: clamp(300px, 34vw, 380px);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card--horizontal {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.85fr) minmax(0, 1.15fr);
|
||||
width: min(100%, 780px);
|
||||
min-height: 290px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card--horizontal > img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 290px;
|
||||
object-fit: cover;
|
||||
object-position: 50% 42%;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card--action .dcx-showcase-card-body {
|
||||
min-height: 286px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card--action .dcx-demo-button {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.dcx-detail-article--components .dcx-component-demo-grid--three {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-demo-grid--three > :last-child:nth-child(odd) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.dcx-detail-article--components .dcx-component-stack {
|
||||
gap: 36px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-canvas {
|
||||
min-height: 210px;
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-demo-grid--three,
|
||||
.dcx-detail-article--components .dcx-component-demo-grid--two {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-demo-grid--three > :last-child:nth-child(odd) {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card--horizontal {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-showcase-card--horizontal > img {
|
||||
height: 230px;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-component-kind {
|
||||
color: var(--ks-text-faint);
|
||||
font-family: var(--ks-mono);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.14em;
|
||||
line-height: 1.45;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-components-command {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
width: min(100%, 620px);
|
||||
min-width: 0;
|
||||
margin-top: clamp(28px, 3.5vw, 40px);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-components-command-copy h4 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--ks-champagne);
|
||||
font-family: var(--ks-font);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dcx-detail-article--components .dcx-components-command-copy p {
|
||||
max-width: 54ch;
|
||||
margin: 0;
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
/* Audience-led editorial treatment for the remaining Design Context screens. */
|
||||
|
||||
.dcx-detail-article {
|
||||
--dcx-detail-icon-size: 100px;
|
||||
}
|
||||
|
||||
.dcx-detail-article > .dcx-detail-hero {
|
||||
margin-bottom: clamp(48px, 6vw, 76px);
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-section {
|
||||
margin-top: 0;
|
||||
margin-bottom: clamp(56px, 7vw, 88px);
|
||||
scroll-margin-top: 18px;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-section-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(80px, 150px);
|
||||
gap: clamp(24px, 4vw, 44px);
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-section-head--with-icon {
|
||||
grid-template-columns: minmax(0, 1fr) var(--dcx-detail-icon-size);
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-section-head:not(.dcx-detail-section-head--with-icon)::after {
|
||||
content: "";
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
border-inline-end: 46px solid var(--accent);
|
||||
background: color-mix(in oklch, var(--ks-patina) 72%, transparent);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-section-heading {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-section-title {
|
||||
margin: 0;
|
||||
color: var(--ks-champagne);
|
||||
font-family: var(--ks-font);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.25;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-section-lede {
|
||||
max-width: 54ch;
|
||||
margin: 10px 0 0;
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-section-icon {
|
||||
width: var(--dcx-detail-icon-size);
|
||||
height: var(--dcx-detail-icon-size);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-section-icon img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-section-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article :is(
|
||||
.dcx-callout,
|
||||
.dcx-voice-cell,
|
||||
.dcx-ref-card,
|
||||
.dcx-pick,
|
||||
.dcx-pair-card,
|
||||
.dcx-proof,
|
||||
.dcx-cue-card,
|
||||
.dcx-empty
|
||||
) {
|
||||
border-color: transparent;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-bg);
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-purpose {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-purpose-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-purpose .dcx-callout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
column-gap: 18px;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-purpose .dcx-callout > :not(.dcx-callout-name, .dcx-platform-pill) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-platform-pill {
|
||||
justify-self: end;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--ks-text-faint);
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-list {
|
||||
counter-reset: dcx-detail-item;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-list li {
|
||||
counter-increment: dcx-detail-item;
|
||||
position: static;
|
||||
display: grid;
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
padding: 18px 20px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-bg);
|
||||
color: var(--ks-text);
|
||||
font-size: 0.96rem;
|
||||
line-height: 1.6;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-list li::before {
|
||||
content: counter(dcx-detail-item, decimal-leading-zero);
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font-family: var(--ks-mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-principles,
|
||||
.dcx-detail-article .dcx-principles--cols {
|
||||
counter-reset: principle;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-principles--cols {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-principles li {
|
||||
display: grid;
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
padding: 18px 20px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-bg);
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.6;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-principles li::before {
|
||||
padding-top: 0.1em;
|
||||
color: var(--accent);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-principle-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-principle-copy strong {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--ks-champagne);
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-defs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-defs .dcx-def {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 18px 20px 20px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-bg);
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-defs .dcx-def dt {
|
||||
color: var(--ks-champagne);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-defs .dcx-def dd {
|
||||
margin-top: 8px;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-voice,
|
||||
.dcx-detail-article .dcx-voice-pair {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-ref-cards,
|
||||
.dcx-detail-article .dcx-picks,
|
||||
.dcx-detail-article .dcx-pair,
|
||||
.dcx-detail-article .dcx-surfaces {
|
||||
gap: clamp(16px, 2.5vw, 26px);
|
||||
}
|
||||
|
||||
.dcx-detail-article--brand .dcx-brand-assets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: clamp(18px, 2.5vw, 26px);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.dcx-detail-article--brand .dcx-brand-asset,
|
||||
.dcx-detail-article--brand .dcx-brand-asset figure {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--brand .dcx-brand-asset figure {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--brand .dcx-brand-asset-frame {
|
||||
position: relative;
|
||||
display: grid;
|
||||
aspect-ratio: 4 / 3;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
border: 1px solid var(--ks-rule);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-bg);
|
||||
}
|
||||
|
||||
.dcx-detail-article--brand .dcx-brand-asset[data-asset-kind="logo"] .dcx-brand-asset-frame {
|
||||
background-color: var(--panel-bg);
|
||||
background-image:
|
||||
linear-gradient(to right, var(--ks-rule) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--ks-rule) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--brand .dcx-brand-asset-frame img {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dcx-detail-article--brand .dcx-brand-asset[data-asset-kind="logo"] img {
|
||||
width: 72%;
|
||||
height: 72%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.dcx-detail-article--brand .dcx-brand-asset:not([data-asset-kind="logo"]) img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.dcx-detail-article--brand .dcx-brand-asset figcaption {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--brand .dcx-brand-asset figcaption strong {
|
||||
min-width: 0;
|
||||
color: var(--ks-text);
|
||||
font-family: var(--ks-font);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dcx-detail-article--brand .dcx-brand-asset figcaption span {
|
||||
flex: none;
|
||||
color: var(--ks-text-faint);
|
||||
font-family: var(--ks-mono);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.14em;
|
||||
line-height: 1.45;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-ref-card,
|
||||
.dcx-detail-article .dcx-pick,
|
||||
.dcx-detail-article .dcx-pair-card {
|
||||
padding: clamp(18px, 2.5vw, 26px);
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-pick {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(7rem, 0.65fr) minmax(0, 1.35fr);
|
||||
grid-template-areas:
|
||||
"surface title"
|
||||
"surface description";
|
||||
align-content: start;
|
||||
column-gap: clamp(18px, 2.5vw, 28px);
|
||||
row-gap: 7px;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-pick-surface {
|
||||
grid-area: surface;
|
||||
align-self: start;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-pick-title {
|
||||
grid-area: title;
|
||||
font-family: var(--ks-font);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-pick-desc {
|
||||
grid-area: description;
|
||||
color: var(--ks-text);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.58;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
@media (width <= 620px) {
|
||||
.dcx-detail-article .dcx-pick {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas:
|
||||
"surface"
|
||||
"title"
|
||||
"description";
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-pick-surface {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-surface-card {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-surface-copy {
|
||||
padding: 0 clamp(14px, 2vw, 22px) 4px;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-surface-name {
|
||||
color: var(--ks-champagne);
|
||||
font-family: var(--ks-font);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-proof--board,
|
||||
.dcx-detail-article .dcx-cue-frame {
|
||||
overflow: hidden;
|
||||
border-color: transparent;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-cue {
|
||||
gap: clamp(16px, 2.5vw, 26px);
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-cue-strip {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-fan {
|
||||
overflow: hidden;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--color .dcx-cue {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dcx-detail-article--color .dcx-cue-card {
|
||||
container-name: dcx-cue-palette;
|
||||
container-type: inline-size;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: var(--panel-bg);
|
||||
}
|
||||
|
||||
.dcx-detail-article--color .dcx-cue-bands {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--ks-rule);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.dcx-detail-article--color .dcx-cue-band-item {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) 46px;
|
||||
min-width: 0;
|
||||
background: var(--panel-bg);
|
||||
}
|
||||
|
||||
.dcx-detail-article--color .dcx-cue-band {
|
||||
background: var(--band-color);
|
||||
color: var(--band-ink);
|
||||
min-width: 0;
|
||||
min-height: 175px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dcx-detail-article--color .dcx-cue-band-value {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 14px;
|
||||
left: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: var(--ks-mono);
|
||||
font-size: clamp(0.58rem, 0.85vw, 0.7rem);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dcx-detail-article--color .dcx-cue-band-label {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 0;
|
||||
padding: 0 6px;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--ks-champagne);
|
||||
font-size: clamp(0.76rem, 1.1vw, 0.92rem);
|
||||
font-weight: 500;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@container dcx-cue-palette (max-width: 439px) {
|
||||
.dcx-detail-article--color .dcx-cue-bands {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dcx-detail-article--color .dcx-cue-band {
|
||||
min-height: 124px;
|
||||
}
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-swatches {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-swatch {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-bg);
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-swatch-chip {
|
||||
min-height: 140px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-swatch-meta h4 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--ks-text);
|
||||
font-family: var(--ks-font-display);
|
||||
font-size: 1.3rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-pair-name {
|
||||
color: var(--ks-champagne);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-proof--scale,
|
||||
.dcx-detail-article .dcx-proof--specimen,
|
||||
.dcx-detail-article .dcx-proof--icons {
|
||||
padding: clamp(18px, 2.5vw, 28px);
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-proof--scale > .picker-scale-sheet {
|
||||
row-gap: 0;
|
||||
padding-block: clamp(36px, 4vw, 52px);
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-proof--scale .ts-row {
|
||||
padding-block: clamp(14px, 2vw, 22px);
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-proof--specimen {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-proof--specimen > .picker-scale-specimen {
|
||||
gap: clamp(28px, 3vw, 36px);
|
||||
padding-block: clamp(36px, 4vw, 52px);
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-proof--specimen .sp-h1 {
|
||||
font-size: clamp(2.5rem, 4.25vw, 3.25rem);
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-proof--specimen .sp-h2 {
|
||||
font-size: clamp(2rem, 3vw, 2.5rem);
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-proof--specimen .sp-h3 {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-proof--specimen .sp-h4 {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-proof--specimen .sp-lede {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-proof--specimen :is(.sp-p, .sp-list) {
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-proof--specimen .sp-quote p {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-proof--specimen .sp-cite,
|
||||
.dcx-detail-article .dcx-proof--specimen :is(.sp-code, .sp-pre) {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-proof--specimen .sp-pre {
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.dcx-detail-article .sp-pre {
|
||||
max-width: 100%;
|
||||
overflow-x: visible;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.dcx-detail-article .sp-pre code {
|
||||
white-space: inherit;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-fan-note {
|
||||
max-width: 72ch;
|
||||
margin-top: 12px;
|
||||
color: var(--ks-text-faint);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-edit {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.dcx-detail-article .dcx-principles--cols,
|
||||
.dcx-detail-article .dcx-detail-defs,
|
||||
.dcx-detail-article--brand .dcx-brand-assets-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-swatch {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.dcx-detail-article .dcx-detail-section-head {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-section-head:not(.dcx-detail-section-head--with-icon) {
|
||||
grid-template-columns: minmax(0, 1fr) 72px;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-purpose {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.dcx-detail-article .dcx-detail-section-head {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-section-title {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-list li,
|
||||
.dcx-detail-article .dcx-principles li {
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--color .dcx-cue-band-value {
|
||||
right: 8px;
|
||||
bottom: 12px;
|
||||
left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dcx-detail-article *,
|
||||
.dcx-detail-article *::before,
|
||||
.dcx-detail-article *::after {
|
||||
transition-duration: 0.001ms !important;
|
||||
transition-delay: 0ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/* Continuous expanded document. The opening category grid is intentionally untouched. */
|
||||
|
||||
.dcx-main[data-dcx-continuous="true"] {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.dcx-document-title {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.dcx-main[data-dcx-continuous="true"] > .dcx-document-article {
|
||||
box-sizing: border-box;
|
||||
scroll-margin-top: 18px;
|
||||
}
|
||||
|
||||
.dcx-main[data-dcx-continuous="true"] > .dcx-document-article + .dcx-document-article {
|
||||
border-top: 1px solid var(--ks-rule);
|
||||
margin-top: clamp(48px, 6vw, 80px);
|
||||
padding-top: clamp(36px, 5vw, 64px);
|
||||
}
|
||||
|
||||
.dcx-expander[data-dcx-document="true"] .dcx-sub-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dcx-expander[data-dcx-document="true"] .dcx-nav-link[aria-expanded="true"] {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.dcx-main[data-dcx-continuous="true"] > .dcx-document-article + .dcx-document-article {
|
||||
margin-top: 42px;
|
||||
padding-top: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dcx-main[data-dcx-continuous="true"] {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/* Shared information hierarchy for every Design Context article.
|
||||
Labels identify a field; values carry the decision; supporting copy explains it. */
|
||||
|
||||
.dcx-expander {
|
||||
--dcx-hierarchy-title: var(--ks-champagne);
|
||||
--dcx-hierarchy-value: var(--ks-text);
|
||||
--dcx-hierarchy-support: var(--ks-text-muted);
|
||||
--dcx-hierarchy-label: var(--ks-text-faint);
|
||||
--dcx-hierarchy-label-size: 0.68rem;
|
||||
--dcx-hierarchy-value-size: 0.96rem;
|
||||
}
|
||||
|
||||
/* Definition lists: labels recede, committed values lead. */
|
||||
.dcx-detail-article .dcx-detail-defs .dcx-def dt,
|
||||
.dcx-audience .dcx-audience-section--people .dcx-def dt {
|
||||
color: var(--dcx-hierarchy-label);
|
||||
font-family: var(--ks-mono);
|
||||
font-size: var(--dcx-hierarchy-label-size);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.14em;
|
||||
line-height: 1.45;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-defs .dcx-def dd,
|
||||
.dcx-audience .dcx-audience-section--people .dcx-def dd {
|
||||
margin-top: 9px;
|
||||
color: var(--dcx-hierarchy-value);
|
||||
font-size: var(--dcx-hierarchy-value-size);
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-detail-defs .dcx-def dd strong {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* The prose inside a decision component is content, not metadata. */
|
||||
.dcx-detail-article .dcx-callout > p:not(.dcx-callout-name),
|
||||
.dcx-audience .dcx-callout > p:not(.dcx-callout-name),
|
||||
.dcx-detail-article .dcx-principles li,
|
||||
.dcx-detail-article .dcx-voice-cell > p,
|
||||
.dcx-detail-article .dcx-ref-takeaway,
|
||||
.dcx-detail-article .dcx-surface-goal,
|
||||
.dcx-detail-article .dcx-swatch-meta > p,
|
||||
.dcx-detail-article .dcx-scale-head,
|
||||
.dcx-detail-article .dcx-specimen-body,
|
||||
.dcx-detail-article .dcx-pick-desc,
|
||||
.dcx-detail-article .dcx-asset-caption > p,
|
||||
.dcx-detail-article .dcx-prose {
|
||||
color: var(--dcx-hierarchy-value);
|
||||
}
|
||||
|
||||
/* These callout headings describe the field; the following paragraph is the answer. */
|
||||
.dcx-audience .dcx-audience-journey .dcx-callout-name,
|
||||
.dcx-detail-article .dcx-detail-section--positioning .dcx-callout-name,
|
||||
.dcx-detail-article .dcx-detail-section--primary-conversion .dcx-callout-name,
|
||||
.dcx-detail-article .dcx-detail-section--anti-reference .dcx-callout-name {
|
||||
margin-bottom: 9px;
|
||||
color: var(--dcx-hierarchy-label);
|
||||
font-family: var(--ks-mono);
|
||||
font-size: var(--dcx-hierarchy-label-size);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.14em;
|
||||
line-height: 1.45;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dcx-audience .dcx-audience-journey .dcx-callout-name + p,
|
||||
.dcx-detail-article .dcx-detail-section--positioning .dcx-callout-name + p,
|
||||
.dcx-detail-article .dcx-detail-section--primary-conversion .dcx-callout-name + p,
|
||||
.dcx-detail-article .dcx-detail-section--anti-reference .dcx-callout-name + p {
|
||||
color: var(--dcx-hierarchy-value);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Preserve prominent answer titles inside components. */
|
||||
.dcx-detail-article :is(
|
||||
.dcx-detail-principle-copy strong,
|
||||
.dcx-ref-name,
|
||||
.dcx-surface-name,
|
||||
.dcx-pair-name,
|
||||
.dcx-pick-title,
|
||||
.dcx-empty-title
|
||||
) {
|
||||
color: var(--dcx-hierarchy-title);
|
||||
}
|
||||
|
||||
.dcx-detail-article .dcx-ref-name {
|
||||
font-family: var(--ks-font);
|
||||
}
|
||||
|
||||
/* In the palette, the role is the label and the hex is the committed value. */
|
||||
.dcx-detail-article--color .dcx-cue-band-label {
|
||||
color: var(--dcx-hierarchy-label);
|
||||
font-family: var(--ks-mono);
|
||||
font-size: var(--dcx-hierarchy-label-size);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dcx-detail-article--color .dcx-cue-band-value {
|
||||
font-size: clamp(0.7rem, 1vw, 0.78rem);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Type-scale rows follow the same label / value / supporting-value order. */
|
||||
.dcx-detail-article--typography .ts-sample {
|
||||
color: var(--ks-champagne) !important;
|
||||
}
|
||||
|
||||
.dcx-detail-article--typography .ts-tag {
|
||||
color: var(--dcx-hierarchy-label);
|
||||
font-family: var(--ks-mono);
|
||||
font-size: 16px !important;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.dcx-detail-article--typography .ts-values {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--typography .ts-values b {
|
||||
color: var(--dcx-hierarchy-value);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dcx-detail-article--typography .ts-values i {
|
||||
color: var(--dcx-hierarchy-support);
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
/* Quiet, readable Hooks controls for the continuous Design Context document. */
|
||||
|
||||
.dcx-detail-article--hooks {
|
||||
--dcx-hooks-rule: color-mix(in oklch, var(--ks-text) 18%, transparent);
|
||||
--dcx-hooks-rule-strong: color-mix(in oklch, var(--ks-text) 32%, transparent);
|
||||
--dcx-hooks-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-detail-section {
|
||||
margin-bottom: clamp(72px, 8vw, 104px);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-detail-section-head {
|
||||
margin-bottom: clamp(24px, 3vw, 32px);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-detail-section-lede {
|
||||
color: var(--ks-text-muted);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-definition {
|
||||
max-width: 58ch;
|
||||
margin: 0;
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 1rem;
|
||||
line-height: 1.72;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 28px;
|
||||
margin-top: 24px;
|
||||
padding: 20px 0;
|
||||
border-block: 1px solid var(--dcx-hooks-rule);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-status-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-status strong {
|
||||
display: block;
|
||||
color: var(--ks-champagne);
|
||||
font: 600 1rem/1.4 var(--ks-font);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-status p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-status-control {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-status-state {
|
||||
min-width: 22px;
|
||||
color: var(--ks-text);
|
||||
font: 500 0.7rem/1 var(--ks-mono);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-status.is-paused .dcx-hooks-status-state {
|
||||
color: var(--ks-text-muted);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-flow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-flow > div {
|
||||
min-width: 0;
|
||||
padding: 20px 0 2px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-flow > div + div {
|
||||
padding-left: 28px;
|
||||
border-left: 1px solid var(--dcx-hooks-rule);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-flow dt,
|
||||
.dcx-detail-article--hooks .dcx-hooks-search > span,
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form label > span,
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-discipline {
|
||||
color: var(--ks-text-muted);
|
||||
font: 500 0.68rem/1.4 var(--ks-mono);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-flow dd {
|
||||
margin: 7px 0 0;
|
||||
color: var(--ks-text);
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-switch {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
width: 46px;
|
||||
height: 44px;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-switch input {
|
||||
position: absolute;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-switch > span {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 38px;
|
||||
height: 20px;
|
||||
border: 1px solid var(--dcx-hooks-rule-strong);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
transition: border-color 180ms ease, background-color 180ms ease;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-switch > span::after {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--ks-text-muted);
|
||||
content: "";
|
||||
transition: transform 260ms var(--dcx-hooks-ease), background-color 180ms ease;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-switch input:checked + span {
|
||||
border-color: var(--ks-text-muted);
|
||||
background: var(--ks-graphite-2);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-switch input:checked + span::after {
|
||||
background: var(--ks-kinpaku);
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-switch input:focus-visible + span {
|
||||
outline: 2px solid var(--ks-kinpaku);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-browser {
|
||||
min-width: 0;
|
||||
border-block: 1px solid var(--dcx-hooks-rule);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-families {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: clamp(24px, 4vw, 42px);
|
||||
min-width: 0;
|
||||
padding-inline: 3px;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--dcx-hooks-rule);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-family {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
min-height: 62px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: var(--ks-text-muted);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-family::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-family:hover {
|
||||
color: var(--ks-patina);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-family.is-active {
|
||||
color: var(--ks-kinpaku);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-family.is-active::after {
|
||||
background: var(--ks-kinpaku);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-family:focus-visible {
|
||||
outline: 2px solid var(--ks-kinpaku);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-family-name {
|
||||
font: 600 0.92rem/1.35 var(--ks-font);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-family-count {
|
||||
color: var(--ks-text-muted);
|
||||
font: 500 0.68rem/1.35 var(--ks-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-rule-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-toolbar {
|
||||
display: flex;
|
||||
min-height: 68px;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 12px 0 14px;
|
||||
border-bottom: 1px solid var(--dcx-hooks-rule);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-search {
|
||||
display: grid;
|
||||
flex: 1 1 auto;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-search input,
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form input,
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form select,
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--dcx-hooks-rule-strong);
|
||||
border-radius: 0;
|
||||
color: var(--ks-text);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-search input {
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-search input::placeholder,
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form input::placeholder,
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form textarea::placeholder {
|
||||
color: var(--ks-text-muted);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-search input:focus,
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form input:focus,
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form select:focus,
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form textarea:focus {
|
||||
border-color: var(--ks-patina);
|
||||
outline: 1px solid var(--ks-patina);
|
||||
outline-offset: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-summary {
|
||||
flex: 0 0 auto;
|
||||
margin: 0 0 10px;
|
||||
color: var(--ks-text-muted);
|
||||
font: 500 0.68rem/1.4 var(--ks-mono);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-discipline {
|
||||
border-bottom: 1px solid var(--dcx-hooks-rule);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-discipline > summary {
|
||||
display: flex;
|
||||
min-height: 56px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
color: var(--ks-text);
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-discipline > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-discipline > summary::before {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 10px;
|
||||
border-right: 1px solid currentColor;
|
||||
border-bottom: 1px solid currentColor;
|
||||
content: "";
|
||||
transform: rotate(-45deg);
|
||||
transition: transform 360ms var(--dcx-hooks-ease);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-discipline[open]:not(.is-closing) > summary::before {
|
||||
color: var(--ks-patina);
|
||||
transform: rotate(45deg) translate(-2px, -2px);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-discipline > summary > span:first-child {
|
||||
flex: 1;
|
||||
font: 600 0.94rem/1.4 var(--ks-font);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-discipline > summary > span:last-child {
|
||||
color: var(--ks-text-muted);
|
||||
font: 500 0.68rem/1.4 var(--ks-mono);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-discipline > summary:focus-visible {
|
||||
outline: 2px solid var(--ks-kinpaku);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-disclosure {
|
||||
display: block;
|
||||
overflow: clip;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-disclosure-inner {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-rules {
|
||||
margin: 0;
|
||||
padding: 0 0 10px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-rule {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid var(--dcx-hooks-rule);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-rule-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-rule-copy code {
|
||||
overflow: hidden;
|
||||
color: var(--ks-text-muted);
|
||||
font: 500 0.68rem/1.45 var(--ks-mono);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-rule-copy strong {
|
||||
color: var(--ks-champagne);
|
||||
font: 600 0.96rem/1.4 var(--ks-font);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-rule-copy p {
|
||||
max-width: 64ch;
|
||||
margin: 5px 0 0;
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.58;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-rule > .dcx-hooks-switch {
|
||||
margin-inline: 2px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-empty {
|
||||
margin: 0;
|
||||
padding: 32px 0;
|
||||
color: var(--ks-text);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom {
|
||||
border-top: 1px solid var(--dcx-hooks-rule);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-toolbar {
|
||||
display: flex;
|
||||
min-height: 64px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
border-bottom: 1px solid var(--dcx-hooks-rule);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-toolbar p {
|
||||
margin: 0;
|
||||
color: var(--ks-text);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-button,
|
||||
.dcx-detail-article--hooks .dcx-hooks-remove {
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--dcx-hooks-rule-strong);
|
||||
border-radius: 0;
|
||||
color: var(--ks-text);
|
||||
background: transparent;
|
||||
font: 600 0.82rem/1 var(--ks-font);
|
||||
cursor: pointer;
|
||||
transition: border-color 180ms ease, background-color 180ms ease, color 180ms ease;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-button {
|
||||
padding: 0 17px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-button:hover {
|
||||
border-color: var(--ks-text);
|
||||
background: color-mix(in oklch, var(--ks-text) 8%, transparent);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-button:focus-visible,
|
||||
.dcx-detail-article--hooks .dcx-hooks-remove:focus-visible {
|
||||
outline: 2px solid var(--ks-kinpaku);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(170px, 0.75fr);
|
||||
gap: 20px;
|
||||
padding: 26px 0;
|
||||
border-bottom: 1px solid var(--dcx-hooks-rule);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form input,
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form select {
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form textarea {
|
||||
min-height: 104px;
|
||||
padding: 11px 12px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form-description,
|
||||
.dcx-detail-article--hooks .dcx-hooks-form-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-list {
|
||||
border-bottom: 1px solid var(--dcx-hooks-rule);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-list:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-rule {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 18px 0;
|
||||
border-bottom: 1px solid var(--dcx-hooks-rule);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-rule:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-discipline {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-remove {
|
||||
min-height: 36px;
|
||||
padding: 0 11px;
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-storage-note {
|
||||
margin: 14px 0 0;
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.dcx-detail-article--hooks .dcx-hooks-families {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-family {
|
||||
min-height: 58px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.dcx-detail-article--hooks .dcx-detail-section {
|
||||
margin-bottom: 64px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-status {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-flow {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-flow > div {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-flow > div + div {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-families {
|
||||
gap: 22px;
|
||||
overscroll-behavior-inline: contain;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-toolbar,
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
gap: 12px;
|
||||
padding-block: 16px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-summary {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-rule,
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-rule {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 22px 0;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form label,
|
||||
.dcx-detail-article--hooks .dcx-hooks-custom-form-description,
|
||||
.dcx-detail-article--hooks .dcx-hooks-form-actions {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-form-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.dcx-detail-article--hooks .dcx-hooks-form-actions .dcx-hooks-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dcx-detail-article--hooks .dcx-hooks-switch > span,
|
||||
.dcx-detail-article--hooks .dcx-hooks-switch > span::after,
|
||||
.dcx-detail-article--hooks .dcx-hooks-discipline > summary::before {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/* Shared material rail for every Design Context category. */
|
||||
|
||||
.dcx-nav.dcx-material-rail-host {
|
||||
--dcx-rail-text-gap: 18px;
|
||||
--dcx-rail-child-indent: 14px;
|
||||
--dcx-rail-item-padding-block: 8px;
|
||||
--dcx-rail-motion-duration: 520ms;
|
||||
--dcx-rail-motion-easing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--dcx-rail-category-duration: 520ms;
|
||||
--dcx-rail-category-easing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--dcx-rail-branch-duration: 320ms;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.dcx-material-rail-host .dcx-nav-label,
|
||||
.dcx-material-rail-host .dcx-nav-list {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dcx-material-rail-host .dcx-nav-link {
|
||||
padding-inline-start: var(--dcx-rail-text-gap);
|
||||
border-left-color: color-mix(in oklch, var(--ks-patina) 42%, transparent);
|
||||
}
|
||||
|
||||
.dcx-material-rail-host .dcx-subnav {
|
||||
padding: 2px 0 8px var(--dcx-rail-child-indent);
|
||||
}
|
||||
|
||||
@media (min-width: 921px) {
|
||||
.dcx-expander[data-dcx-document="true"] .dcx-material-rail-host .dcx-subnav {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translateY(-4px);
|
||||
animation: none;
|
||||
transition:
|
||||
max-height var(--dcx-rail-category-duration) var(--dcx-rail-category-easing),
|
||||
padding-top var(--dcx-rail-category-duration) var(--dcx-rail-category-easing),
|
||||
padding-bottom var(--dcx-rail-category-duration) var(--dcx-rail-category-easing),
|
||||
opacity 240ms var(--dcx-rail-category-easing),
|
||||
transform 360ms var(--dcx-rail-category-easing),
|
||||
visibility 0s linear var(--dcx-rail-category-duration);
|
||||
}
|
||||
|
||||
.dcx-expander[data-dcx-document="true"] .dcx-material-rail-host li.is-active > .dcx-subnav {
|
||||
max-height: var(--dcx-subnav-height, 24rem);
|
||||
padding-top: 2px;
|
||||
padding-bottom: 8px;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.dcx-expander[data-dcx-document="true"] .dcx-material-rail-host li.is-active > .dcx-subnav--summary {
|
||||
gap: 0;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.dcx-material-rail-host .dcx-sub-link {
|
||||
padding: var(--dcx-rail-item-padding-block) 0;
|
||||
padding-inline-start: var(--dcx-rail-text-gap);
|
||||
border: 0;
|
||||
border-left: 2px solid color-mix(in oklch, var(--ks-patina) 42%, transparent);
|
||||
line-height: 1.45;
|
||||
transition:
|
||||
color 160ms var(--ks-ease),
|
||||
border-color 160ms var(--ks-ease);
|
||||
}
|
||||
|
||||
.dcx-material-rail-host .dcx-sub-link[aria-current="location"] {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dcx-material-rail-host .dcx-subnav--summary {
|
||||
gap: 0;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.dcx-material-rail-host .dcx-subnav--summary .dcx-sub-link {
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dcx-material-rail-host .dcx-subnav--summary .dcx-sub-link[aria-current="location"] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.dcx-material-rail {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dcx-material-rail__patina-base {
|
||||
fill: var(--ks-patina);
|
||||
}
|
||||
|
||||
.dcx-material-rail__gold-base {
|
||||
fill: var(--ks-kinpaku);
|
||||
}
|
||||
|
||||
.dcx-material-rail__patina-image {
|
||||
opacity: 0.68;
|
||||
filter: saturate(1.18) contrast(1.06);
|
||||
}
|
||||
|
||||
.dcx-material-rail__gold-image {
|
||||
opacity: 0.7;
|
||||
filter: saturate(1.15) contrast(1.08);
|
||||
}
|
||||
|
||||
.dcx-material-rail__track,
|
||||
.dcx-material-rail__active,
|
||||
.dcx-material-rail__transition-track,
|
||||
.dcx-material-rail__transition-active {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.dcx-material-rail__track {
|
||||
stroke: var(--ks-patina);
|
||||
stroke: url("#dcx-material-rail-patina") var(--ks-patina);
|
||||
opacity: 0;
|
||||
transition: opacity var(--dcx-rail-category-duration) var(--dcx-rail-category-easing);
|
||||
}
|
||||
|
||||
.dcx-material-rail-host[data-rail-ready] .dcx-material-rail__track {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.dcx-material-rail__track[data-entering="true"] {
|
||||
opacity: 0 !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.dcx-material-rail__active {
|
||||
stroke: var(--accent);
|
||||
stroke: url("#dcx-material-rail-gold") var(--accent);
|
||||
opacity: 0;
|
||||
transition:
|
||||
stroke-dasharray var(--dcx-rail-motion-duration) var(--dcx-rail-motion-easing),
|
||||
stroke-dashoffset var(--dcx-rail-motion-duration) var(--dcx-rail-motion-easing),
|
||||
opacity 180ms ease;
|
||||
}
|
||||
|
||||
.dcx-material-rail__active[data-ready="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dcx-material-rail__active[data-initial="true"] {
|
||||
transition-property: opacity;
|
||||
}
|
||||
|
||||
.dcx-material-rail__active[data-entering="true"] {
|
||||
opacity: 0 !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.dcx-material-rail__transition-track {
|
||||
opacity: 0;
|
||||
transition: opacity var(--dcx-rail-branch-duration) var(--dcx-rail-category-easing);
|
||||
}
|
||||
|
||||
.dcx-material-rail__transition-active {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dcx-material-rail__transition-track[data-visible="true"] {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.dcx-material-rail__transition-active[data-visible="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dcx-material-rail__transition-track[data-initial="true"],
|
||||
.dcx-material-rail__transition-active[data-initial="true"] {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.dcx-material-rail__transition-active {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
transition:
|
||||
transform var(--dcx-rail-category-duration) var(--dcx-rail-category-easing),
|
||||
opacity 180ms ease;
|
||||
}
|
||||
|
||||
.dcx-material-rail__track[data-morphing="true"] {
|
||||
opacity: 0.58 !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.dcx-material-rail__transition-active[data-settling="true"] {
|
||||
transition:
|
||||
transform var(--dcx-rail-branch-duration) var(--dcx-rail-category-easing),
|
||||
opacity 180ms ease;
|
||||
}
|
||||
|
||||
.dcx-material-rail__active[data-handoff="true"],
|
||||
.dcx-material-rail__transition-active[data-handoff="true"] {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.dcx-material-rail-host[data-rail-ready] .dcx-nav-link,
|
||||
.dcx-material-rail-host[data-rail-ready] .dcx-sub-link,
|
||||
.dcx-material-rail-host[data-rail-ready] .dcx-nav-link:hover,
|
||||
.dcx-material-rail-host[data-rail-ready] .dcx-nav-link:focus-visible,
|
||||
.dcx-material-rail-host[data-rail-ready] .dcx-sub-link:hover,
|
||||
.dcx-material-rail-host[data-rail-ready] .dcx-sub-link:focus-visible {
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.dcx-material-rail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dcx-material-rail-host .dcx-nav-link {
|
||||
padding: 8px 10px;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.dcx-material-rail-host .dcx-subnav {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dcx-material-rail-host *,
|
||||
.dcx-material-rail-host *::before,
|
||||
.dcx-material-rail-host *::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.001ms !important;
|
||||
transition-delay: 0ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
.dcx-topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.dcx-topbar-actions .dcx-request {
|
||||
margin: 0 4px 0 0;
|
||||
}
|
||||
|
||||
.dcx-topbar-actions .dcx-close:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.dcx-settings-trigger,
|
||||
.dcx-settings-close {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--ks-rule);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--ks-text);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
transition:
|
||||
color 160ms var(--ks-ease),
|
||||
border-color 160ms var(--ks-ease),
|
||||
transform 220ms var(--ks-ease);
|
||||
}
|
||||
|
||||
.dcx-settings-trigger svg,
|
||||
.dcx-settings-close svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.45;
|
||||
}
|
||||
|
||||
.dcx-settings-trigger:hover,
|
||||
.dcx-settings-trigger:focus-visible,
|
||||
.dcx-settings-close:hover,
|
||||
.dcx-settings-close:focus-visible {
|
||||
border-color: var(--accent-line);
|
||||
color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dcx-settings-trigger:hover svg,
|
||||
.dcx-settings-trigger:focus-visible svg {
|
||||
transform: rotate(24deg);
|
||||
}
|
||||
|
||||
.dcx-settings-trigger svg {
|
||||
transition: transform 260ms var(--ks-ease);
|
||||
}
|
||||
|
||||
.dcx-settings-close:hover,
|
||||
.dcx-settings-close:focus-visible {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.dcx-settings-trigger:focus-visible,
|
||||
.dcx-settings-close:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.dcx-settings-modal {
|
||||
width: min(680px, calc(100vw - 24px));
|
||||
max-width: none;
|
||||
max-height: calc(100svh - 24px);
|
||||
margin: auto;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ks-rule);
|
||||
border-radius: 6px;
|
||||
background: var(--ks-lacquer);
|
||||
color: var(--ks-text);
|
||||
box-shadow: 0 32px 90px oklch(0% 0 0 / 0.64);
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.985);
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.dcx-settings-modal::backdrop {
|
||||
background: oklch(0% 0 0 / 0);
|
||||
backdrop-filter: blur(0);
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
backdrop-filter 240ms ease;
|
||||
}
|
||||
|
||||
.dcx-settings-modal.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.dcx-settings-modal.is-visible::backdrop {
|
||||
background: oklch(0% 0 0 / 0.76);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.dcx-settings-panel {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
padding: clamp(24px, 3vw, 30px);
|
||||
}
|
||||
|
||||
.dcx-settings-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 24px;
|
||||
padding-bottom: clamp(20px, 2.6vw, 26px);
|
||||
}
|
||||
|
||||
.dcx-settings-head h2 {
|
||||
margin: 0;
|
||||
color: var(--ks-champagne);
|
||||
font-family: var(--ks-font);
|
||||
font-size: clamp(1.45rem, 2.6vw, 1.75rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.dcx-settings-head p {
|
||||
max-width: 52ch;
|
||||
margin: 10px 0 0;
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dcx-settings-commands {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
margin: 0;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--ks-rule);
|
||||
}
|
||||
|
||||
.dcx-settings-modal .dcx-command-copy {
|
||||
border-color: var(--ks-rule);
|
||||
}
|
||||
|
||||
.dcx-settings-command {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 0 0 20px;
|
||||
border-bottom: 1px solid var(--ks-rule);
|
||||
}
|
||||
|
||||
.dcx-settings-command-copy {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dcx-settings-command h3 {
|
||||
margin: 0;
|
||||
color: var(--ks-champagne);
|
||||
font-family: var(--ks-font);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.dcx-settings-command p {
|
||||
margin: 0;
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.dcx-settings-command:last-child {
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-height: 820px) {
|
||||
.dcx-settings-panel {
|
||||
padding-block: 18px;
|
||||
}
|
||||
|
||||
.dcx-settings-head {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.dcx-settings-head p {
|
||||
margin-top: 6px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.dcx-settings-commands {
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.dcx-settings-command {
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.dcx-settings-command-copy {
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.dcx-topbar-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dcx-settings-modal {
|
||||
width: calc(100vw - 24px);
|
||||
max-height: calc(100svh - 24px);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dcx-settings-panel {
|
||||
padding: 22px 20px 18px;
|
||||
}
|
||||
|
||||
.dcx-settings-head {
|
||||
gap: 18px;
|
||||
padding-bottom: 22px;
|
||||
}
|
||||
|
||||
.dcx-settings-head h2 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.dcx-settings-head p {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.dcx-settings-command {
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.dcx-settings-command-copy {
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dcx-settings-modal,
|
||||
.dcx-settings-modal::backdrop,
|
||||
.dcx-settings-trigger,
|
||||
.dcx-settings-trigger svg,
|
||||
.dcx-settings-close {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -2202,25 +2202,6 @@ body.dcx-live .dcx-request { display: inline-flex; }
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dcx-swatch-actions {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Same trick as the palette screen's custom-color input: present for the
|
||||
picker dialog, invisible to layout. */
|
||||
.dcx-native-color {
|
||||
position: absolute;
|
||||
inset: auto auto 0 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dcx-pair-why + .dcx-edit { margin-top: 18px; }
|
||||
|
||||
.dcx-topbar .dcx-request { margin-left: auto; margin-right: 14px; }
|
||||
@@ -2233,110 +2214,11 @@ body.dcx-live .dcx-request { display: inline-flex; }
|
||||
/* ============================================================
|
||||
Request tray — queued work and its status, bottom right.
|
||||
============================================================ */
|
||||
/* Editable values read as text until the pointer is over them, so an article
|
||||
stays a document rather than a form. A staged one keeps a mark, because the
|
||||
count on the bar has to be attributable to something on the page. */
|
||||
.dcx-editable {
|
||||
border-radius: 2px;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
body.dcx-live .dcx-editable:hover {
|
||||
box-shadow: inset 0 -1px 0 var(--ks-rule);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
body.dcx-live .dcx-editable:focus {
|
||||
outline: 1px solid var(--ks-kinpaku);
|
||||
background: oklch(78% 0.12 82 / 0.1);
|
||||
}
|
||||
|
||||
body.dcx-live .dcx-editable[data-dcx-dirty] {
|
||||
box-shadow: inset 0 -1px 0 var(--ks-kinpaku);
|
||||
}
|
||||
|
||||
/* Sits above the tray, sharing its column so the two never overlap. */
|
||||
.dcx-savebar {
|
||||
position: fixed;
|
||||
right: 22px;
|
||||
bottom: 22px;
|
||||
/* Above the fullscreen expander: editing happens inside an open article, so
|
||||
the control that saves it cannot sit behind the article. */
|
||||
z-index: 620;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: min(340px, calc(100vw - 44px));
|
||||
}
|
||||
|
||||
.dcx-savebar[hidden] { display: none; }
|
||||
|
||||
/* The bar owns the corner and the tray stacks above it. A plain sibling
|
||||
selector does the arithmetic, so neither has to know the other is there. */
|
||||
.dcx-savebar:not([hidden]) ~ .dcx-tray {
|
||||
bottom: 78px;
|
||||
}
|
||||
|
||||
.dcx-savebar-apply {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--ks-kinpaku);
|
||||
background: var(--ks-kinpaku);
|
||||
color: var(--ks-kinpaku-ink);
|
||||
font-family: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dcx-savebar-apply:hover { filter: brightness(1.08); }
|
||||
.dcx-savebar-apply:disabled { cursor: wait; filter: brightness(0.94); }
|
||||
|
||||
.dcx-savebar-count {
|
||||
padding: 2px 8px;
|
||||
background: oklch(4% 0.004 95 / 0.18);
|
||||
font-family: var(--ks-font-mono, ui-monospace, monospace);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dcx-savebar-count[hidden] { display: none; }
|
||||
|
||||
.dcx-savebar-discard {
|
||||
padding: 12px 12px;
|
||||
border: 1px solid var(--ks-rule);
|
||||
background: var(--ks-lacquer);
|
||||
color: var(--ks-text-muted);
|
||||
font-family: inherit;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dcx-savebar-discard:hover { color: var(--ks-text); }
|
||||
.dcx-savebar-discard:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* The bar arriving is the one moment it has to be noticed. */
|
||||
@keyframes dcx-savebar-in {
|
||||
0% { transform: scale(0.9); opacity: 0; }
|
||||
60% { transform: scale(1.04); opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.dcx-savebar[data-just-appeared] {
|
||||
animation: dcx-savebar-in 460ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.dcx-tray {
|
||||
position: fixed;
|
||||
right: 22px;
|
||||
bottom: 22px;
|
||||
/* Same reason as the save bar: what the agent is doing has to stay readable
|
||||
/* Above the open document: what the agent is doing has to stay readable
|
||||
while an article is open, which is when the asking happens. */
|
||||
z-index: 600;
|
||||
display: grid;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { cp, copyFile, mkdir, rm } from 'node:fs/promises';
|
||||
import { cp, copyFile, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ANTIPATTERNS } from '../cli/engine/registry/antipatterns.mjs';
|
||||
import { composeHookRules } from './lib/hook-rule-presentation.js';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const buildDir = path.join(root, 'build-picker');
|
||||
@@ -16,6 +18,10 @@ const outputDir = path.join(root, 'skill/scripts/picker');
|
||||
const assetsSource = path.join(root, 'picker/assets');
|
||||
const assetsOutput = path.join(outputDir, 'assets');
|
||||
const runtimeAssets = ['hero-dark.jpg', 'kinpaku-gold-leaf.jpg'];
|
||||
// The design context document's images, vendored from the standalone demo:
|
||||
// per-section foil icons, the rail textures, the brand-asset placeholders, and
|
||||
// the components photo. Whole directories, so a demo re-sync stays a plain copy.
|
||||
const runtimeAssetDirs = ['audience', 'product', 'brand', 'color', 'typography', 'material', 'components'];
|
||||
// The page links ./favicon.svg, so it is also served from the output root.
|
||||
const faviconSource = path.join(assetsSource, 'favicon.svg');
|
||||
const faviconOutput = path.join(outputDir, 'favicon.svg');
|
||||
@@ -26,6 +32,13 @@ const iconDataSource = path.join(root, 'picker/data/icon-packs.json');
|
||||
const iconDataOutput = path.join(outputDir, 'icon-packs.json');
|
||||
|
||||
await rm(buildDir, { recursive: true, force: true });
|
||||
// The Hooks page's rule list, composed from the canonical detector registry so
|
||||
// the document never drifts from what the hook actually enforces. Committed and
|
||||
// regenerated every build; tests/hook-rule-presentation.test.js guards the sync.
|
||||
await writeFile(
|
||||
path.join(root, 'picker/data/hook-rules.json'),
|
||||
`${JSON.stringify(composeHookRules(ANTIPATTERNS), null, 2)}\n`,
|
||||
);
|
||||
execFileSync(
|
||||
'bun',
|
||||
['x', 'astro', 'build', '--config', 'picker/astro.config.mjs'],
|
||||
@@ -40,6 +53,9 @@ await copyFile(iconDataSource, iconDataOutput);
|
||||
for (const asset of runtimeAssets) {
|
||||
await copyFile(path.join(assetsSource, asset), path.join(assetsOutput, asset));
|
||||
}
|
||||
for (const dir of runtimeAssetDirs) {
|
||||
await cp(path.join(assetsSource, dir), path.join(assetsOutput, dir), { recursive: true });
|
||||
}
|
||||
await rm(buildDir, { recursive: true, force: true });
|
||||
|
||||
console.log(`Built ${path.relative(root, outputDir)}/`);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
Presentation metadata for the Hooks page of the design context document.
|
||||
|
||||
The rule ids, names, and descriptions come from the canonical registry
|
||||
(cli/engine/registry/antipatterns.mjs); this module adds only how the document
|
||||
groups and labels them. `FINGERPRINT_IDS` promotes a handful of slop rules into
|
||||
their own "Fingerprints" family (model-specific signatures); `DISCIPLINES` gives
|
||||
every rule a discipline label for the collapsible groups. Both must cover the
|
||||
registry exactly — tests/hook-rule-presentation.test.js fails the suite when a
|
||||
rule is added without a discipline, so the document can never silently miss one.
|
||||
*/
|
||||
|
||||
export const FINGERPRINT_IDS = new Set([
|
||||
'gpt-thin-border-wide-shadow',
|
||||
'repeating-stripes-gradient',
|
||||
'codex-grid-background',
|
||||
'theater-slop-phrase',
|
||||
]);
|
||||
|
||||
export const DISCIPLINES = {
|
||||
'side-tab': 'Visual Details',
|
||||
'border-accent-on-rounded': 'Visual Details',
|
||||
'overused-font': 'Typography',
|
||||
'flat-type-hierarchy': 'Typography',
|
||||
'gradient-text': 'Color & Contrast',
|
||||
'ai-color-palette': 'Color & Contrast',
|
||||
'cream-palette': 'Color & Contrast',
|
||||
'nested-cards': 'Layout & Space',
|
||||
'monotonous-spacing': 'Layout & Space',
|
||||
'bounce-easing': 'Motion',
|
||||
'pulsing-dot': 'Motion',
|
||||
'blinking-cursor': 'Motion',
|
||||
'shape-assembled-illustration': 'Imagery',
|
||||
'dark-glow': 'Color & Contrast',
|
||||
'radial-halo': 'Color & Contrast',
|
||||
'radial-spotlight-glow': 'Color & Contrast',
|
||||
'marquee': 'Motion',
|
||||
'icon-tile-stack': 'Typography',
|
||||
'italic-serif-display': 'Typography',
|
||||
'hero-eyebrow-chip': 'Typography',
|
||||
'kicker-above-heading': 'Typography',
|
||||
'numbered-section-labels': 'Layout & Space',
|
||||
'em-dash-overuse': 'Copy',
|
||||
'marketing-buzzword': 'Copy',
|
||||
'aphoristic-cadence': 'Copy',
|
||||
'oversized-h1': 'Typography',
|
||||
'extreme-negative-tracking': 'Typography',
|
||||
'broken-image': 'Imagery',
|
||||
'script-error': 'Quality',
|
||||
'content-hidden-at-rest': 'Layout & Space',
|
||||
'edge-flush-cards': 'Layout & Space',
|
||||
'text-occlusion': 'Layout & Space',
|
||||
'first-viewport-column-overflow': 'Layout & Space',
|
||||
'gray-on-color': 'Color & Contrast',
|
||||
'low-contrast': 'Quality',
|
||||
'layout-transition': 'Motion',
|
||||
'line-length': 'Layout & Space',
|
||||
'cramped-padding': 'Layout & Space',
|
||||
'body-text-viewport-edge': 'Layout & Space',
|
||||
'tight-leading': 'Typography',
|
||||
'skipped-heading': 'Typography',
|
||||
'heading-rhythm': 'Layout & Space',
|
||||
'justified-text': 'Typography',
|
||||
'tiny-text': 'Typography',
|
||||
'undersized-ui-text': 'Typography',
|
||||
'all-caps-body': 'Typography',
|
||||
'wide-tracking': 'Typography',
|
||||
'text-overflow': 'Layout & Space',
|
||||
'repeated-container-text': 'Quality',
|
||||
'clipped-overflow-container': 'Layout & Space',
|
||||
'design-system-font': 'Typography',
|
||||
'design-system-color': 'Color & Contrast',
|
||||
'design-system-radius': 'Visual Details',
|
||||
'design-system-font-size': 'Typography',
|
||||
'gpt-thin-border-wide-shadow': 'Visual Details',
|
||||
'repeating-stripes-gradient': 'Visual Details',
|
||||
'codex-grid-background': 'Visual Details',
|
||||
'theater-slop-phrase': 'Copy',
|
||||
'image-hover-transform': 'Motion',
|
||||
};
|
||||
|
||||
/* One entry per registry rule, in registry order, in the exact shape the
|
||||
document's Hooks module renders. */
|
||||
export function composeHookRules(antipatterns) {
|
||||
return antipatterns.map((rule) => ({
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
description: rule.description,
|
||||
group: FINGERPRINT_IDS.has(rule.id) ? 'fingerprints' : rule.category,
|
||||
discipline: DISCIPLINES[rule.id],
|
||||
}));
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export const SUITES = {
|
||||
'tests/validate-plugin-versions.test.js',
|
||||
'tests/validate-plugin-manifest.test.js',
|
||||
'tests/plugin-paths.test.js',
|
||||
'tests/hook-rule-presentation.test.js',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
* agent --POST /doc/reply---------> queue status + version bump
|
||||
* browser --GET /doc/state (poll)--> { version, requests, batch } -> re-read
|
||||
*
|
||||
* It also serves the document's own images: GET /brand-assets/* for what the
|
||||
* user supplied, GET /assets/* for the ones the picker ships. Both are
|
||||
* token-gated and read-only. They are here rather than on the picker server
|
||||
* because article images load when a view opens, which is always after the
|
||||
* picker has exited.
|
||||
*
|
||||
* Edits made in the document stage in the browser and arrive here as one batch.
|
||||
* Applying them is deterministic and belongs to this process: each change names
|
||||
* a field, the field names a place in the store, and the value is written
|
||||
@@ -38,6 +44,7 @@
|
||||
import http from 'node:http';
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fontRelativePath, migrate, paths, readJsonSoft, writeJsonAtomic } from './design-context/store.mjs';
|
||||
import { createSaveRoutes } from './design-context/session-routes.mjs';
|
||||
|
||||
@@ -47,6 +54,10 @@ const contextPath = store.contextJson;
|
||||
const sessionPath = store.sessionJson;
|
||||
const fontsDir = store.fontsDir;
|
||||
const brandAssetsDir = store.assetsDir;
|
||||
/* The built picker beside this script: the document's own images (section
|
||||
foils, rail textures, placeholder brand assets) are served from here after
|
||||
the submit-flow picker server has exited. Read-only, image types only. */
|
||||
const pickerAssetsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'picker', 'assets');
|
||||
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
|
||||
@@ -58,6 +69,13 @@ const BRAND_ASSET_MIME = new Map([
|
||||
['.webp', 'image/webp'],
|
||||
['.gif', 'image/gif'],
|
||||
]);
|
||||
const PICKER_ASSET_MIME = new Map([
|
||||
['.png', 'image/png'],
|
||||
['.jpg', 'image/jpeg'],
|
||||
['.jpeg', 'image/jpeg'],
|
||||
['.webp', 'image/webp'],
|
||||
['.svg', 'image/svg+xml'],
|
||||
]);
|
||||
const REQUEST_KINDS = new Set(['font', 'freeform']);
|
||||
/* Long polls are sliced under common proxy/undici header timeouts, the same
|
||||
270s ceiling live-poll uses. */
|
||||
@@ -273,6 +291,45 @@ async function handleRequest(request, response) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* The document's own static images, for the tab that outlives the picker
|
||||
server: the submit flow exits on /submit, and article images only load when
|
||||
a view opens, which is always after that. Same trust model as the route
|
||||
above, token-gated and read-only, but contained rather than flat, because
|
||||
the vendored files sit in per-category subdirectories. */
|
||||
if (request.method === 'GET' && requestPath.startsWith('/assets/')) {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
let assetPath;
|
||||
try {
|
||||
assetPath = decodeURIComponent(requestPath.slice('/assets/'.length));
|
||||
} catch {
|
||||
throw httpError(400, 'Invalid path');
|
||||
}
|
||||
const extension = path.extname(assetPath).toLowerCase();
|
||||
const filePath = path.resolve(pickerAssetsDir, assetPath);
|
||||
const contained = path.relative(pickerAssetsDir, filePath);
|
||||
if (!assetPath
|
||||
|| assetPath.includes('\0')
|
||||
|| !PICKER_ASSET_MIME.has(extension)
|
||||
|| contained.startsWith('..')
|
||||
|| path.isAbsolute(contained)) {
|
||||
throw httpError(404, 'Not found');
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = await readFile(filePath);
|
||||
} catch {
|
||||
throw httpError(404, 'Not found');
|
||||
}
|
||||
response.writeHead(200, {
|
||||
'Content-Type': PICKER_ASSET_MIME.get(extension),
|
||||
'Content-Length': body.length,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'max-age=86400',
|
||||
});
|
||||
response.end(body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && requestPath === '/doc/state') {
|
||||
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
|
||||
lastBrowserSeen = Date.now();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Sync guard for the design context document's Hooks page.
|
||||
*
|
||||
* The document lists the detector's rules from picker/data/hook-rules.json,
|
||||
* which build:picker regenerates from the canonical registry plus the
|
||||
* presentation mapping in scripts/lib/hook-rule-presentation.js. This pins
|
||||
* both directions: a rule added to the registry without a discipline fails
|
||||
* here (so the document can never silently miss one), and a stale committed
|
||||
* JSON fails against a fresh composition.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ANTIPATTERNS } from '../cli/engine/registry/antipatterns.mjs';
|
||||
import { FINGERPRINT_IDS, DISCIPLINES, composeHookRules } from '../scripts/lib/hook-rule-presentation.js';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('hook rule presentation', () => {
|
||||
test('gives every registry rule a discipline, and maps nothing else', () => {
|
||||
const registryIds = new Set(ANTIPATTERNS.map((rule) => rule.id));
|
||||
for (const rule of ANTIPATTERNS) {
|
||||
if (!DISCIPLINES[rule.id]) {
|
||||
throw new Error(`rule ${rule.id} has no discipline; add it to scripts/lib/hook-rule-presentation.js`);
|
||||
}
|
||||
}
|
||||
for (const id of Object.keys(DISCIPLINES)) {
|
||||
if (!registryIds.has(id)) throw new Error(`mapped id ${id} is not in the registry`);
|
||||
}
|
||||
for (const id of FINGERPRINT_IDS) {
|
||||
const rule = ANTIPATTERNS.find((entry) => entry.id === id);
|
||||
if (!rule) throw new Error(`fingerprint id ${id} is not in the registry`);
|
||||
expect(rule.category).toBe('slop');
|
||||
}
|
||||
expect(Object.keys(DISCIPLINES).length).toBe(ANTIPATTERNS.length);
|
||||
});
|
||||
|
||||
test('matches the committed picker/data/hook-rules.json', () => {
|
||||
const committed = JSON.parse(readFileSync(path.join(here, '../picker/data/hook-rules.json'), 'utf8'));
|
||||
expect(committed).toEqual(composeHookRules(ANTIPATTERNS));
|
||||
});
|
||||
});
|
||||
@@ -552,3 +552,63 @@ test('timeout exits 2 with one stderr line', async (t) => {
|
||||
assert.equal((await waitForExit(server.processHandle))[0], 2);
|
||||
assert.equal(server.stderr().trim(), 'Picker timed out without a submission.');
|
||||
});
|
||||
|
||||
/* The doc session serves the design context document's own images after the
|
||||
picker server has exited on submit. The route is token-gated, read-only, and
|
||||
contained, but unlike /brand-assets/ it must reach into subdirectories,
|
||||
because the vendored files sit per category. */
|
||||
test('doc session serves picker assets, token-gated and contained', async (t) => {
|
||||
const fixture = await createFixture();
|
||||
const sessionScript = path.join(root, 'skill/scripts/picker-doc-session.mjs');
|
||||
const builtAsset = path.join(root, 'skill/scripts/picker/assets/audience/needs-foil.png');
|
||||
/* skill/scripts/picker/ is gitignored build output, so a fresh clone has no
|
||||
assets to serve. The gate for this work always builds first; here the
|
||||
happy-path reads skip rather than fail on a tree that never built. */
|
||||
const built = existsSync(builtAsset);
|
||||
|
||||
const child = spawn(process.execPath, [sessionScript, '--port', String(portBase + 60)], {
|
||||
cwd: fixture.cwd,
|
||||
env: { ...process.env, IMPECCABLE_DOC_TOKEN: 't-assets' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
try {
|
||||
const sessionFile = path.join(fixture.cwd, '.impeccable/design-context/runtime/session.json');
|
||||
let session = null;
|
||||
for (let attempt = 0; attempt < 100 && !session; attempt += 1) {
|
||||
if (existsSync(sessionFile)) session = JSON.parse(await readFile(sessionFile, 'utf8'));
|
||||
else await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
assert.ok(session?.port, 'the session never recorded its port');
|
||||
const base = `http://127.0.0.1:${session.port}`;
|
||||
|
||||
if (built) {
|
||||
const ok = await fetch(`${base}/assets/audience/needs-foil.png?token=t-assets`);
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal(ok.headers.get('content-type'), 'image/png');
|
||||
assert.ok((await ok.arrayBuffer()).byteLength > 0, 'served an empty body');
|
||||
|
||||
// Subdirectories deeper than one level resolve too.
|
||||
const nested = await fetch(`${base}/assets/brand/placeholders/hanazono-primary-mark.png?token=t-assets`);
|
||||
assert.equal(nested.status, 200);
|
||||
} else {
|
||||
t.diagnostic('skipping the served-file assertions: run `bun run build:picker` to cover them');
|
||||
}
|
||||
|
||||
// The gate and the containment hold whether or not the tree has been built.
|
||||
assert.equal((await fetch(`${base}/assets/audience/needs-foil.png?token=wrong`)).status, 403);
|
||||
assert.equal((await fetch(`${base}/assets/audience/needs-foil.png`)).status, 403);
|
||||
assert.equal((await fetch(`${base}/assets/..%2Fpicker-server.mjs?token=t-assets`)).status, 404);
|
||||
assert.equal((await fetch(`${base}/assets/../picker-server.mjs?token=t-assets`)).status, 404);
|
||||
/* An extension the route DOES allow, on a real file outside the assets
|
||||
directory: only the containment check can turn this one away, so it is
|
||||
the case that actually tests it. */
|
||||
assert.equal((await fetch(`${base}/assets/../favicon.svg?token=t-assets`)).status, 404);
|
||||
assert.equal((await fetch(`${base}/assets/..%2F..%2F..%2F..%2Fpicker/assets/favicon.svg?token=t-assets`)).status, 404);
|
||||
assert.equal((await fetch(`${base}/assets/audience/needs-foil.txt?token=t-assets`)).status, 404);
|
||||
assert.equal((await fetch(`${base}/assets/?token=t-assets`)).status, 404);
|
||||
} finally {
|
||||
child.kill('SIGTERM');
|
||||
await rm(fixture.cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||