mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 01:26:29 +03:00
Add strategy-band contrast alert and expand design context fields.
Mirror the screen 02 neutral contrast badge on the strategy strip, share alert painting between both strips, and render richer PRODUCT.md context in the design document with matching visual-cues schema guidance. AI-assisted commit. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -805,6 +805,14 @@ const questions = [
|
||||
</svg>
|
||||
</button>
|
||||
<output data-band-hex={role}>#000000</output>
|
||||
{role === 'neutral' && (
|
||||
<>
|
||||
<span class="picker-band-alert" data-contrast-alert role="img" tabindex="0" aria-label="Contrast warning" aria-describedby="picker-strategy-contrast-tip" hidden>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M12 2.5 22.5 20.5H1.5L12 2.5Z M11 9h2l-.3 5.4h-1.4L11 9Z M12 16.2a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Z"></path></svg>
|
||||
</span>
|
||||
<span class="picker-band-alert-tip" role="tooltip" id="picker-strategy-contrast-tip" data-contrast-tip></span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div class="picker-band-foot">
|
||||
<h2>{label}</h2>
|
||||
|
||||
@@ -168,10 +168,10 @@ const defs = (items) => `
|
||||
<div class="dcx-def"><dt>${dt}</dt><dd>${dd}</dd></div>`).join('')}
|
||||
</dl>`;
|
||||
|
||||
const callout = (name, body, accent = false) => `
|
||||
const callout = (name, body, accent = false, extra = '') => `
|
||||
<div class="dcx-callout${accent ? ' dcx-callout--accent' : ''}">
|
||||
<p class="dcx-callout-name">${escapeHtml(name)}</p>
|
||||
<p>${body}</p>
|
||||
<p>${body}</p>${extra}
|
||||
</div>`;
|
||||
|
||||
const list = (items) => `
|
||||
@@ -278,11 +278,28 @@ function buildAudience(s, name) {
|
||||
parts.push(block('Who they are', who.length
|
||||
? defs(who)
|
||||
: fromChat('The primary and secondary user read was confirmed', '<code>PRODUCT.md · Users</code>')));
|
||||
if (audience.emotion) {
|
||||
parts.push(block('Emotional state', callout('On arrival', escapeHtml(audience.emotion), true)));
|
||||
/* 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', 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 {
|
||||
parts.push(block(arrival ? 'Emotional state' : 'Emotional journey', arrival || leaving));
|
||||
}
|
||||
}
|
||||
if (Array.isArray(audience.needs) && audience.needs.length) {
|
||||
parts.push(block('Needs', list(audience.needs.map(escapeHtml))));
|
||||
/* Needs and trust triggers share a two-column row when both exist; either
|
||||
alone keeps the full measure. The nested blocks keep their data-label,
|
||||
which is what the expander subnav is built from. */
|
||||
const needsBlock = Array.isArray(audience.needs) && audience.needs.length
|
||||
? block('Needs', list(audience.needs.map(escapeHtml))) : '';
|
||||
const trustBlock = Array.isArray(audience.trust) && audience.trust.length
|
||||
? block('Trust triggers', list(audience.trust.map(escapeHtml))) : '';
|
||||
if (needsBlock && trustBlock) parts.push(`<div class="dcx-cols">${needsBlock}${trustBlock}</div>`);
|
||||
else if (needsBlock || trustBlock) parts.push(needsBlock || trustBlock);
|
||||
if (Array.isArray(audience.inclusion) && audience.inclusion.length) {
|
||||
parts.push(block('Who must not be excluded', list(audience.inclusion.map(escapeHtml))));
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
@@ -301,12 +318,36 @@ function surfaceCards(s, body) {
|
||||
</article>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
/* Display labels for the PRODUCT.md platform value; an unrecognized value
|
||||
renders as written rather than being dropped. */
|
||||
const PLATFORM_LABELS = { web: 'Web', ios: 'iOS', android: 'Android', adaptive: 'Adaptive' };
|
||||
|
||||
function buildProduct(s, name) {
|
||||
const product = s.context?.product || {};
|
||||
const parts = [heading(2, 'Product', 'Purpose, surfaces, use cases, what must be clear first.', name)];
|
||||
parts.push(block('Purpose', product.purpose
|
||||
? callout(product.name || name || 'This product', escapeHtml(product.purpose))
|
||||
: fromChat('The purpose and success definition were confirmed', '<code>PRODUCT.md · Product Purpose</code>')));
|
||||
const purposeCallout = product.purpose
|
||||
? 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()
|
||||
? `<span class="dcx-chip dcx-platform-pill">${escapeHtml(PLATFORM_LABELS[product.platform.trim()] || product.platform.trim())}</span>`
|
||||
: '';
|
||||
parts.push(block('Purpose', platform
|
||||
? `<div class="dcx-purpose"><div class="dcx-purpose-main">${purposeCallout}</div>${platform}</div>`
|
||||
: purposeCallout));
|
||||
if (product.positioning && (product.positioning.not || product.positioning.this)) {
|
||||
const cells = [
|
||||
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>`));
|
||||
}
|
||||
if (Array.isArray(product.clarities) && product.clarities.length) {
|
||||
parts.push(block('What must be clear first', list(product.clarities.map(escapeHtml))));
|
||||
}
|
||||
if (product.operatingContext) {
|
||||
parts.push(block('Operating context', `<p class="dcx-prose">${escapeHtml(product.operatingContext)}</p>`));
|
||||
}
|
||||
parts.push(block('Surfaces', surfaceCards(s, (surface) => `
|
||||
<p class="dcx-surface-goal">${surface.goal ? escapeHtml(surface.goal) : ''}</p>
|
||||
${surface.examples.length ? chips(surface.examples) : ''}`)
|
||||
@@ -318,15 +359,38 @@ function buildBrand(s, name) {
|
||||
const brand = s.context?.brand || {};
|
||||
const interview = s.context?.interview || {};
|
||||
const parts = [heading(3, 'Brand', 'Identity, voice, references, taste boundaries.', name)];
|
||||
/* Personality: the confirmed sentence when the agent passed it; the three
|
||||
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', escapeHtml(brand.personality), true)
|
||||
: fromChat('Three words, voice, and tone were confirmed', '<code>PRODUCT.md · Brand Personality</code>')));
|
||||
: (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>'))));
|
||||
if (Array.isArray(brand.commitments) && brand.commitments.length) {
|
||||
parts.push(block('Commitments', list(brand.commitments.map(escapeHtml))));
|
||||
}
|
||||
if (Array.isArray(interview.references) && interview.references.length) {
|
||||
parts.push(block('Named references', chips(interview.references)
|
||||
/* Q4 references arrive as plain strings from old cues.json files and as
|
||||
{ name, takeaway } objects from new ones; a mixed list renders each
|
||||
entry in its own form. Strings stay the bare pills they were. */
|
||||
const cards = interview.references.filter((ref) => ref && typeof ref === 'object' && ref.name);
|
||||
const plain = interview.references.filter((ref) => typeof ref === 'string');
|
||||
const inner = (cards.length ? `<div class="dcx-ref-cards">${cards.map((ref) => `
|
||||
<article class="dcx-ref-card">
|
||||
<h3 class="dcx-ref-name">${escapeHtml(ref.name)}</h3>${ref.takeaway ? `
|
||||
<p class="dcx-ref-takeaway">${escapeHtml(ref.takeaway)}</p>` : ''}
|
||||
</article>`).join('')}</div>` : '') + (plain.length ? chips(plain) : '');
|
||||
parts.push(block('Named references', inner
|
||||
+ note('Q4 of the seed interview: brands, products, printed objects — not adjectives.')));
|
||||
}
|
||||
if (interview.antiReference) {
|
||||
parts.push(block('Anti-reference', callout('Not this', escapeHtml(interview.antiReference))
|
||||
/* Q5 stays a bare string in old files and gains a why as { name, why }. */
|
||||
const anti = typeof interview.antiReference === 'object'
|
||||
? callout('Not this', `<strong>${escapeHtml(interview.antiReference.name || '')}</strong>${
|
||||
interview.antiReference.why ? ` · ${escapeHtml(interview.antiReference.why)}` : ''}`)
|
||||
: callout('Not this', escapeHtml(interview.antiReference));
|
||||
parts.push(block('Anti-reference', anti
|
||||
+ note('Q5 of the seed interview. A hard constraint on every palette and pair that followed.')));
|
||||
}
|
||||
if (Array.isArray(s.context?.assets) && s.context.assets.length) {
|
||||
|
||||
@@ -1186,23 +1186,29 @@ function setActiveRole(role) {
|
||||
}
|
||||
|
||||
/* The contrast alert: a danger badge on the neutral swatch, with its
|
||||
explanation in the sibling tooltip. Re-read from renderPreview() on
|
||||
every render, so every path that can change which color sits in the
|
||||
neutral slot (inputs, tints, rings, card switch, reset, reorder)
|
||||
lands here. One block-level line per failed check, mid-tone first. */
|
||||
explanation in the sibling tooltip. Both strips carry one. This strip's
|
||||
badge is re-read from renderPreview() on every render, so every path
|
||||
that can change which color sits in the neutral slot (inputs, tints,
|
||||
rings, card switch, reset, reorder) lands here; the strategy strip's
|
||||
badge is re-read by paintStrategyBands() against the committed fields
|
||||
that strip is painted from. One block-level line per failed check,
|
||||
mid-tone first. */
|
||||
const alertBadge = $('[data-contrast-alert]', panel);
|
||||
const alertTip = $('[data-contrast-tip]', panel);
|
||||
|
||||
function syncNeutralAlert() {
|
||||
const issues = cards.length ? neutralContrastIssue(state().colors) : [];
|
||||
alertBadge.hidden = issues.length === 0;
|
||||
alertTip.replaceChildren(...issues.map((text) => {
|
||||
function paintContrastAlert(badge, tip, issues) {
|
||||
badge.hidden = issues.length === 0;
|
||||
tip.replaceChildren(...issues.map((text) => {
|
||||
const line = document.createElement('span');
|
||||
line.textContent = text;
|
||||
return line;
|
||||
}));
|
||||
}
|
||||
|
||||
function syncNeutralAlert() {
|
||||
paintContrastAlert(alertBadge, alertTip, cards.length ? neutralContrastIssue(state().colors) : []);
|
||||
}
|
||||
|
||||
function setColor(role, hex, detached = true) {
|
||||
const itemState = state();
|
||||
itemState.colors[role] = hex.toUpperCase();
|
||||
@@ -1526,7 +1532,14 @@ function recommitPalette() {
|
||||
|
||||
/* The strategy screen's band is a reading of the committed palette rather than
|
||||
of the deck, which is also what makes it correct after a reorder on either
|
||||
screen: both end in the fields this paints from. */
|
||||
screen: both end in the fields this paints from. Its neutral carries the
|
||||
same contrast alert the editable strip does, judged against the same
|
||||
committed colors this strip is painted from, so a reorder made here that
|
||||
drags a mid-tone into the neutral slot is reported here, on the swatch
|
||||
the visitor just dropped. */
|
||||
const strategyAlertBadge = $('[data-contrast-alert]', strategyBands);
|
||||
const strategyAlertTip = $('[data-contrast-tip]', strategyBands);
|
||||
|
||||
function paintStrategyBands() {
|
||||
const committed = roleMap((role) => $(`[name="palette-${role}"]`).value);
|
||||
if (Object.values(committed).some((hex) => !hex)) return;
|
||||
@@ -1536,6 +1549,7 @@ function paintStrategyBands() {
|
||||
band.style.setProperty('--band-ink', contrastInk(committed[role]));
|
||||
$('output', band).textContent = committed[role];
|
||||
}
|
||||
paintContrastAlert(strategyAlertBadge, strategyAlertTip, neutralContrastIssue(committed));
|
||||
}
|
||||
|
||||
/* Everything screen 03 spends on something other than the answer: the block's
|
||||
|
||||
@@ -679,6 +679,90 @@ body.dcx-open { overflow: hidden; background: linear-gradient(180deg, var(--ks-l
|
||||
|
||||
.dcx-callout--accent { border-color: var(--accent-line); background: var(--accent-wash); }
|
||||
|
||||
/* ============================================================
|
||||
Context enrichment: the success line, paired callouts, the
|
||||
needs/trust columns, the platform pill, reference cards, and
|
||||
the operating-context prose. Each rule exists for a block
|
||||
that renders only when the agent passed the matching context
|
||||
field; every value is a dcx or ks token already in this file.
|
||||
============================================================ */
|
||||
.dcx-callout p.dcx-callout-success {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--ks-rule);
|
||||
}
|
||||
|
||||
/* Two callouts sharing a row: the emotional journey's arrival and
|
||||
leaving beats, and positioning's not-this / this cells. */
|
||||
.dcx-callout-pair {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Two sibling blocks sharing a row (needs beside trust triggers). The
|
||||
children stay real .dcx-block elements so the subnav still finds them. */
|
||||
.dcx-cols {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
|
||||
gap: 0 clamp(24px, 4vw, 48px);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* The purpose callout with the platform pill at its shoulder. */
|
||||
.dcx-purpose {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dcx-platform-pill {
|
||||
font-family: var(--ks-mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Named references as cards, the .dcx-pick anatomy with a takeaway. */
|
||||
.dcx-ref-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
|
||||
gap: clamp(14px, 2vw, 22px);
|
||||
}
|
||||
|
||||
.dcx-ref-cards + .dcx-chips { margin-top: 12px; }
|
||||
|
||||
.dcx-ref-card {
|
||||
border: 1px solid var(--ks-rule);
|
||||
background: var(--panel-bg);
|
||||
padding: clamp(18px, 2.5vw, 26px);
|
||||
}
|
||||
|
||||
.dcx-ref-name {
|
||||
margin: 0 0 8px;
|
||||
font-family: var(--ks-font-display);
|
||||
font-weight: 300;
|
||||
font-size: 1.3rem;
|
||||
color: var(--ks-text);
|
||||
}
|
||||
|
||||
.dcx-ref-takeaway {
|
||||
margin: 0;
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Operating context: one paragraph at reading measure. */
|
||||
.dcx-prose {
|
||||
margin: 0;
|
||||
max-width: 62ch;
|
||||
color: var(--ks-text-muted);
|
||||
font-size: 0.96rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Numbered principle list. */
|
||||
.dcx-principles {
|
||||
list-style: none;
|
||||
|
||||
@@ -1235,6 +1235,26 @@ body.picker-page {
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
/* The compact band's contrast alert. The editable band parks the badge in
|
||||
its free lower right corner; here that corner belongs to the hex readout,
|
||||
so the badge mirrors the grip in the free upper right one. It ducks under
|
||||
the grip (z-index 1 against the grip's 2) so the transient "Drag to
|
||||
reorder" tip, which reads to the right of the grip, is not punched
|
||||
through by the glyph while it shows. The explanation floats above the
|
||||
strip: inside it the short band could not clear the badge, and the strip
|
||||
is its block's last row, so the room is above. */
|
||||
.picker-bands--compact .picker-band-alert {
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
bottom: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.picker-bands--compact .picker-band-alert-tip {
|
||||
right: 3px;
|
||||
bottom: calc(100% + 9px);
|
||||
}
|
||||
|
||||
.picker-bands--compact .picker-band-foot {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user