Skip to content

Commit e74f206

Browse files
author
DavidQ
committed
Add Palette Manager color harmony schemes - PR_26140_059-add-palette-color-harmony-schemes
1 parent c561ea3 commit e74f206

7 files changed

Lines changed: 878 additions & 3 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Palette Color Harmony Schemes Report
2+
3+
PR: PR_26140_059-add-palette-color-harmony-schemes
4+
5+
## Summary
6+
7+
Added a Color Harmony Schemes accordion to Palette Manager V2 under Selected Swatch. The tool now generates harmony colors from the selected user/source swatch, can map calculated colors to closest matches from the current source palette or all loaded source palettes, and can add one or all generated colors to the active user palette without duplicating existing hex values.
8+
9+
## Implementation
10+
11+
- Added `tools/palette-manager-v2/modules/paletteHarmonyUtils.js` for harmony scheme definitions, HSL/RGB conversion, mathematical harmony generation, closest palette matching, parameter normalization, and harmony symbol selection.
12+
- Added `tools/palette-manager-v2/controls/PaletteHarmonyControl.js` for the accordion UI, match-source selector, scheme selector, scheme-specific numeric inputs, generated color list, and Add Selected/Add All actions.
13+
- Updated `tools/palette-manager-v2/modules/PaletteManagerApp.js` to track selected source/user swatches for harmony generation, recalculate harmony colors on swatch/scheme/source/parameter changes, and add generated colors with OK/WARN/FAIL status messages.
14+
- Updated `tools/palette-manager-v2/index.html` with the new accordion directly beneath Selected Swatch.
15+
- Updated `tools/palette-manager-v2/paletteManagerV2.css` so the selected swatch preview is 30px by 30px and harmony colors render as selectable generated swatches.
16+
- Added targeted Playwright coverage in `tests/playwright/tools/WorkspaceManagerV2.spec.mjs` for empty state, calculated colors, closest-match modes, duplicate avoidance, and Add Selected/Add All behavior.
17+
18+
## Harmony Schemes
19+
20+
The scheme dropdown is sorted A-Z and includes: Achromatic, Accented Analogous, Analogous, Complementary, Diadic, Double-Complementary, Double-Split-Complementary, Hexadic, Monochromatic, Near-Complementary, Polychromatic, Side-Complementary, Split-Complementary, Square, Tetradic, and Triadic.
21+
22+
Numeric controls are rendered only for schemes with configurable values: accented/standard analogous angle, double-complementary/tetradic pair angle, double-split/split/side complementary angle, monochromatic lightness step, and near-complementary offset.
23+
24+
## Match Sources
25+
26+
- Calculated: uses mathematically generated colors from the selected swatch.
27+
- Source Palette Closest Match: maps generated colors to the closest color in the currently selected source palette only.
28+
- All Palettes Closest Match: maps generated colors to the closest color across all loaded source palettes.
29+
30+
No hidden or fallback palettes were added.
31+
32+
## Validation
33+
34+
Passed:
35+
36+
- `node --check tools/palette-manager-v2/modules/paletteHarmonyUtils.js; node --check tools/palette-manager-v2/controls/PaletteHarmonyControl.js; node --check tools/palette-manager-v2/modules/PaletteManagerApp.js; node --check tools/palette-manager-v2/controls/PaletteEditorControl.js; node --check tests/playwright/tools/WorkspaceManagerV2.spec.mjs`
37+
- Targeted harmony math validation via Node stdin: complementary red -> cyan, triadic red -> green/blue, closest cyan match -> nearest source swatch.
38+
- `npx playwright test tests/playwright/tools/WorkspaceManagerV2.spec.mjs --project=playwright --workers=1 --reporter=list --grep "generates Palette Manager V2 harmony schemes"`
39+
- `npm run test:workspace-v2` (59 passed)
40+
- `git diff --check` (passed; Git reported line-ending normalization warnings only)
41+
42+
A first attempt at the standalone Node calculation validation failed because PowerShell interpreted a JavaScript template literal in the inline command. The corrected stdin-based command passed.
43+
44+
## Manual Test Notes
45+
46+
1. Open Palette Manager V2.
47+
2. Confirm the Color Harmony Schemes accordion appears directly under Selected Swatch.
48+
3. Select or add a swatch and confirm generated harmony colors update immediately.
49+
4. Confirm Calculated, Source Palette Closest Match, and All Palettes Closest Match modes update the preview list.
50+
5. Use Add Selected and Add All; existing duplicate hexes should produce WARN messages and should not be added again.
51+
52+
## Scope Notes
53+
54+
- No sample JSON was touched.
55+
- No unrelated Palette Manager V2 behavior was intentionally changed.
56+
- No commit was made by Codex.

tests/playwright/tools/WorkspaceManagerV2.spec.mjs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8075,6 +8075,79 @@ test.describe("Workspace Manager V2 bootstrap", () => {
80758075
}
80768076
});
80778077

8078+
test("generates Palette Manager V2 harmony schemes and adds non-duplicate colors", async ({ page }) => {
8079+
const server = await startRepoServer();
8080+
const pageErrors = [];
8081+
8082+
page.on("pageerror", (error) => {
8083+
pageErrors.push(error.message);
8084+
});
8085+
8086+
try {
8087+
await workspaceV2CoverageReporter.start(page);
8088+
await page.goto(`${server.baseUrl}/tools/palette-manager-v2/index.html`, { waitUntil: "networkidle" });
8089+
await expect(page.locator(".palette-manager-v2.app-shell")).toBeVisible();
8090+
await expect(page.locator("#harmonyColorList")).toContainText("Select a user or source swatch.");
8091+
await expect(page.locator("#addSelectedHarmonyButton")).toBeDisabled();
8092+
await expect(page.locator("#addAllHarmonyButton")).toBeDisabled();
8093+
const selectedPreviewBox = await page.locator("#selectedSwatchPreview").boundingBox();
8094+
expect(Math.round(selectedPreviewBox.width)).toBe(30);
8095+
expect(Math.round(selectedPreviewBox.height)).toBe(30);
8096+
8097+
await page.locator("#swatchSymbolInput").fill("R");
8098+
await page.locator("#swatchHexInput").fill("#FF0000");
8099+
await page.locator("#swatchNameInput").fill("Harmony Base Red");
8100+
await page.locator("#addSwatchButton").click();
8101+
await expect(page.locator("#userPaletteCount")).toHaveText("1 user swatches");
8102+
await expect(page.locator("#harmonySchemeSelect")).toHaveValue("achromatic");
8103+
8104+
await page.locator("#harmonyMatchSourceSelect").selectOption("calculated");
8105+
await page.locator("#harmonySchemeSelect").selectOption("complementary");
8106+
await expect(page.locator("#harmonyColorList [data-harmony-index='0']")).toHaveAttribute("data-harmony-hex", "#00FFFF");
8107+
await page.locator("#addSelectedHarmonyButton").click();
8108+
await expect(page.locator("#userPaletteCount")).toHaveText("2 user swatches");
8109+
await expect(page.locator("#paletteStatus")).toHaveText(/OK Added selected harmony color/);
8110+
8111+
await page.locator('#userSwatchList [aria-label="Edit Harmony Base Red"]').click();
8112+
await expect(page.locator("#harmonyColorList [data-harmony-index='0']")).toHaveAttribute("data-harmony-hex", "#00FFFF");
8113+
await page.locator("#addSelectedHarmonyButton").click();
8114+
await expect(page.locator("#userPaletteCount")).toHaveText("2 user swatches");
8115+
await expect(page.locator("#paletteStatus")).toHaveText(/WARN Harmony color already exists/);
8116+
8117+
await page.locator("#harmonySchemeSelect").selectOption("triadic");
8118+
await page.locator("#addAllHarmonyButton").click();
8119+
await expect(page.locator("#userPaletteCount")).toHaveText("4 user swatches");
8120+
await expect(page.locator("#paletteStatus")).toHaveText(/OK Added 2 harmony colors\. Skipped 0 duplicates\./);
8121+
const paletteHexes = await page.evaluate(() => window.paletteManagerV2App.getPaletteValue().swatches.map((swatch) => swatch.hex));
8122+
expect(new Set(paletteHexes).size).toBe(paletteHexes.length);
8123+
expect(paletteHexes).toEqual(expect.arrayContaining(["#FF0000", "#00FFFF", "#00FF00", "#0000FF"]));
8124+
8125+
await page.locator('#userSwatchList [aria-label="Edit Harmony Base Red"]').click();
8126+
await page.locator("#harmonySchemeSelect").selectOption("complementary");
8127+
await page.locator("#harmonyMatchSourceSelect").selectOption("source-palette");
8128+
const sourceMatchState = await page.evaluate(() => {
8129+
const sourceId = document.querySelector("#sourcePaletteSelect").value;
8130+
const harmonyHex = document.querySelector("#harmonyColorList [data-harmony-index='0']").dataset.harmonyHex;
8131+
const sourceHexes = (window.paletteList.SOURCE_PALETTES[sourceId] || []).map((swatch) => swatch.hex.toUpperCase());
8132+
return { harmonyHex, isFromCurrentSource: sourceHexes.includes(harmonyHex) };
8133+
});
8134+
expect(sourceMatchState.isFromCurrentSource).toBe(true);
8135+
8136+
await page.locator("#harmonyMatchSourceSelect").selectOption("all-palettes");
8137+
const allMatchState = await page.evaluate(() => {
8138+
const harmonyHex = document.querySelector("#harmonyColorList [data-harmony-index='0']").dataset.harmonyHex;
8139+
const allHexes = Object.values(window.paletteList.SOURCE_PALETTES)
8140+
.flatMap((swatches) => swatches.map((swatch) => swatch.hex.toUpperCase()));
8141+
return { harmonyHex, isFromAnySource: allHexes.includes(harmonyHex) };
8142+
});
8143+
expect(allMatchState.isFromAnySource).toBe(true);
8144+
expect(pageErrors).toEqual([]);
8145+
} finally {
8146+
await workspaceV2CoverageReporter.stop(page);
8147+
await server.close();
8148+
}
8149+
});
8150+
80788151
test("does not redirect legacy Text to Speech V2 path, sample, or schema references", async ({ page }) => {
80798152
const server = await startRepoServer();
80808153
await workspaceV2CoverageReporter.start(page);
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { cloneSwatch, normalizeHex } from "../modules/paletteUtils.js";
2+
3+
export class PaletteHarmonyControl {
4+
constructor({ documentRef, refs, app }) {
5+
this.document = documentRef;
6+
this.refs = refs;
7+
this.app = app;
8+
}
9+
10+
bind() {
11+
this.renderOptions();
12+
this.refs.harmonyMatchSourceSelect.addEventListener("change", () => {
13+
this.app.setHarmonyMatchSource(this.refs.harmonyMatchSourceSelect.value);
14+
});
15+
this.refs.harmonySchemeSelect.addEventListener("change", () => {
16+
this.app.setHarmonyScheme(this.refs.harmonySchemeSelect.value);
17+
});
18+
this.refs.addSelectedHarmonyButton.addEventListener("click", () => {
19+
this.app.addSelectedHarmonyColor();
20+
});
21+
this.refs.addAllHarmonyButton.addEventListener("click", () => {
22+
this.app.addAllHarmonyColors();
23+
});
24+
}
25+
26+
renderOptions() {
27+
this.refs.harmonyMatchSourceSelect.replaceChildren();
28+
this.app.getHarmonyMatchSourceOptions().forEach((option) => {
29+
const element = this.document.createElement("option");
30+
element.value = option.value;
31+
element.textContent = option.label;
32+
this.refs.harmonyMatchSourceSelect.appendChild(element);
33+
});
34+
35+
this.refs.harmonySchemeSelect.replaceChildren();
36+
this.app.getHarmonySchemeOptions().forEach((option) => {
37+
const element = this.document.createElement("option");
38+
element.value = option.value;
39+
element.textContent = option.label;
40+
this.refs.harmonySchemeSelect.appendChild(element);
41+
});
42+
}
43+
44+
render() {
45+
this.refs.harmonyMatchSourceSelect.value = this.app.getHarmonyMatchSource();
46+
this.refs.harmonySchemeSelect.value = this.app.getHarmonyScheme();
47+
this.renderParameterControls();
48+
this.renderHarmonyColors();
49+
}
50+
51+
renderParameterControls() {
52+
this.refs.harmonyParameterControls.replaceChildren();
53+
const parameter = this.app.getHarmonySchemeParameter();
54+
if (!parameter) {
55+
return;
56+
}
57+
const label = this.document.createElement("label");
58+
label.className = "palette-manager-v2__field palette-manager-v2__field--compact";
59+
label.textContent = parameter.label;
60+
61+
const input = this.document.createElement("input");
62+
input.id = "harmonyParameterInput";
63+
input.type = "number";
64+
input.min = String(parameter.min);
65+
input.max = String(parameter.max);
66+
input.step = String(parameter.step);
67+
input.value = String(this.app.getHarmonyParameterValue());
68+
input.addEventListener("input", () => {
69+
this.app.setHarmonyParameterValue(input.value);
70+
});
71+
72+
label.appendChild(input);
73+
this.refs.harmonyParameterControls.appendChild(label);
74+
}
75+
76+
renderHarmonyColors() {
77+
this.refs.harmonyColorList.replaceChildren();
78+
const colors = this.app.getHarmonyColors();
79+
this.refs.addSelectedHarmonyButton.disabled = colors.length === 0;
80+
this.refs.addAllHarmonyButton.disabled = colors.length === 0;
81+
82+
if (!this.app.getSelectedSwatch()) {
83+
const empty = this.document.createElement("p");
84+
empty.className = "palette-manager-v2__meta";
85+
empty.textContent = "Select a user or source swatch.";
86+
this.refs.harmonyColorList.appendChild(empty);
87+
return;
88+
}
89+
90+
if (colors.length === 0) {
91+
const empty = this.document.createElement("p");
92+
empty.className = "palette-manager-v2__meta";
93+
empty.textContent = "No harmony colors could be generated.";
94+
this.refs.harmonyColorList.appendChild(empty);
95+
return;
96+
}
97+
98+
colors.forEach((color, index) => {
99+
this.refs.harmonyColorList.appendChild(this.createHarmonyColorButton(color, index));
100+
});
101+
}
102+
103+
createHarmonyColorButton(color, index) {
104+
const cleanSwatch = cloneSwatch(color.swatch || {});
105+
const button = this.document.createElement("button");
106+
button.type = "button";
107+
button.className = "palette-manager-v2__harmony-color";
108+
button.classList.toggle("is-selected", index === this.app.getSelectedHarmonyColorIndex());
109+
button.setAttribute("aria-pressed", String(index === this.app.getSelectedHarmonyColorIndex()));
110+
button.dataset.harmonyIndex = String(index);
111+
button.dataset.harmonyHex = normalizeHex(color.hex);
112+
button.title = `${color.name}: ${normalizeHex(color.hex)}`;
113+
button.addEventListener("click", () => {
114+
this.app.setSelectedHarmonyColorIndex(index);
115+
});
116+
117+
const chip = this.document.createElement("span");
118+
chip.className = "palette-manager-v2__harmony-chip";
119+
chip.style.background = normalizeHex(color.hex);
120+
chip.setAttribute("aria-hidden", "true");
121+
122+
const text = this.document.createElement("span");
123+
text.className = "palette-manager-v2__harmony-text";
124+
text.textContent = cleanSwatch.name || color.name || normalizeHex(color.hex);
125+
126+
const meta = this.document.createElement("span");
127+
meta.className = "palette-manager-v2__harmony-meta";
128+
meta.textContent = normalizeHex(color.hex);
129+
130+
button.append(chip, text, meta);
131+
return button;
132+
}
133+
}

tools/palette-manager-v2/index.html

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,31 @@
6767
</div>
6868
</section>
6969

70+
<section class="accordion-v2 palette-manager-v2__left-accordion is-open" data-accordion-v2-open="true">
71+
<button class="accordion-v2__header" type="button" aria-expanded="true" aria-controls="colorHarmonyAccordionContent">
72+
<span>Color Harmony Schemes</span>
73+
<span class="accordion-v2__icon" aria-hidden="true">+</span>
74+
</button>
75+
<div id="colorHarmonyAccordionContent" class="accordion-v2__content">
76+
<div class="palette-manager-v2__form-grid palette-manager-v2__form-grid--stacked palette-manager-v2__form-grid--compact">
77+
<label class="palette-manager-v2__field palette-manager-v2__field--compact">
78+
Match source
79+
<select id="harmonyMatchSourceSelect"></select>
80+
</label>
81+
<label class="palette-manager-v2__field palette-manager-v2__field--compact">
82+
Scheme
83+
<select id="harmonySchemeSelect"></select>
84+
</label>
85+
</div>
86+
<div id="harmonyParameterControls" class="palette-manager-v2__harmony-parameters" aria-label="Harmony scheme parameters"></div>
87+
<div id="harmonyColorList" class="palette-manager-v2__harmony-list" aria-label="Generated harmony colors"></div>
88+
<div class="palette-manager-v2__controls palette-manager-v2__harmony-actions">
89+
<button id="addSelectedHarmonyButton" type="button">Add Selected</button>
90+
<button id="addAllHarmonyButton" type="button">Add All</button>
91+
</div>
92+
</div>
93+
</section>
94+
7095
<section class="accordion-v2 palette-manager-v2__left-accordion is-open" data-accordion-v2-open="true">
7196
<button class="accordion-v2__header" type="button" aria-expanded="true" aria-controls="userDefinedSwatchAccordionContent">
7297
<span>User Defined Swatch</span>

0 commit comments

Comments
 (0)