feat(constraints): mark accepts axes in the matrix

The constraint matrix showed an accepts group (SHOES) exactly like an ordinary
tag group — the editor tells them apart via its accepts button, but the matrix
couldn't. Plumb tag_group_kinds through to the grid (SPA ConstraintMatrix + the
canvas MatrixSection, both wired from their editor/modal) and mark an accepts
band with a small amber pi-sync after the axis name, keeping the group's own hue
so it never collides with an amber group. A hover tooltip and a MatrixLegend row
(shown only when the matrix has an accepts axis) explain the mark. Verified live:
mark on SHOES, none on a classify-only source, legend row appears/​hides with it.
This commit is contained in:
DumiFlex
2026-08-19 23:30:00 +03:00
parent 1cb59b2159
commit fd6642b258
9 changed files with 187 additions and 13 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -101,6 +101,7 @@ interface WildcardPayload {
sub_categories?: string[];
options?: Array<{ value?: string }>;
tag_groups?: Record<string, string[]>;
tag_group_kinds?: Record<string, string>;
}
/** Normalised module shape the resolution lookups consume. Unifies the
@@ -307,6 +308,14 @@ const targetGroups = computed<Record<string, string[]>>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return findWildcard(pl.target_wildcard_id)?.tag_groups ?? {};
});
const sourceGroupKinds = computed<Record<string, string>>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return findWildcard(pl.source_wildcard_id)?.tag_group_kinds ?? {};
});
const targetGroupKinds = computed<Record<string, string>>(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
return findWildcard(pl.target_wildcard_id)?.tag_group_kinds ?? {};
});
const sourceName = computed(() => {
const pl = (props.module.payload ?? {}) as ConstraintPayload;
@@ -715,6 +724,8 @@ function onSpaClick(): void {
:target-name="targetName"
:source-groups="sourceGroups"
:target-groups="targetGroups"
:source-group-kinds="sourceGroupKinds"
:target-group-kinds="targetGroupKinds"
:stranded="hasDangling"
@update="onUpdate"
/>
@@ -6,6 +6,12 @@
*/
import { ref } from "vue";
defineProps<{
/** Show the accepts-axis row — only when this matrix actually has one, so the
* legend doesn't explain a mark that isn't on screen. */
showAccepts?: boolean;
}>();
const expanded = ref(false);
function toggle(): void {
@@ -46,12 +52,28 @@ function toggle(): void {
<span class="legend-name n-reduce">Reduce</span>
<span class="legend-desc">When the source sub-cat is picked, target values in this column are N times less likely.</span>
</div>
<div v-if="showAccepts" class="legend-row">
<span class="legend-sample s-axis"><i class="pi pi-sync glyph" aria-hidden="true" /></span>
<span class="legend-name n-axis">Accepts axis</span>
<span class="legend-desc">This group rolls one tag per pick and is readable as <code>$var.NAME</code>. Marked <i class="pi pi-sync" aria-hidden="true" /> on its band.</span>
</div>
</div>
</div>
</template>
<style scoped>
.legend { margin-top: 14px; }
.legend-sample.s-axis {
color: var(--wp-axis, #fbbf24);
background: color-mix(in srgb, var(--wp-axis, #fbbf24) 14%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--wp-axis, #fbbf24) 40%, transparent);
}
.legend-name.n-axis { color: var(--wp-axis, #fbbf24); }
.legend-desc code {
font-family: var(--wp-font-mono, monospace);
color: var(--wp-text-muted, #a1a1ad);
}
.legend-desc .pi-sync { color: var(--wp-axis, #fbbf24); font-size: 10px; }
.legend-toggle {
display: inline-flex;
align-items: center;
@@ -46,6 +46,10 @@ const props = withDefaults(
* ConstraintMatrix. Empty → flat source/target-tinted headers. */
sourceGroups?: Record<string, string[]>;
targetGroups?: Record<string, string[]>;
/** axis name → kind. `"accepts"` axes get a marked band (see SPA
* ConstraintMatrix) — the matrix otherwise can't show accepts-ness. */
sourceGroupKinds?: Record<string, string>;
targetGroupKinds?: Record<string, string>;
/** Read-only recovery view: the source/target wildcard was deleted, so
* the configured rules are shown for understanding only. Cells don't
* open the rule popover; the grid frame + cells mute to a snapshot.
@@ -53,7 +57,8 @@ const props = withDefaults(
* ConstraintMatrix `readonly` prop / `wp-mx--readonly` treatment. */
stranded?: boolean;
}>(),
{ sourceName: "", targetName: "", sourceGroups: () => ({}), targetGroups: () => ({}), stranded: false },
{ sourceName: "", targetName: "", sourceGroups: () => ({}), targetGroups: () => ({}),
sourceGroupKinds: () => ({}), targetGroupKinds: () => ({}), stranded: false },
);
const emit = defineEmits<{ "update": [patch: Partial<ModuleEntry>] }>();
@@ -80,6 +85,22 @@ const orderedSources = computed(() => orderByGroups([...props.sourceSubs], props
const orderedTargets = computed(() => orderByGroups([...props.targetSubs], props.targetGroups));
const rowGroups = computed(() => toGroups(orderedSources.value));
const colGroups = computed(() => toGroups(orderedTargets.value));
const sourceAcceptsAxes = computed(() => acceptsSet(props.sourceGroupKinds));
const targetAcceptsAxes = computed(() => acceptsSet(props.targetGroupKinds));
function acceptsSet(kinds: Record<string, string>): Set<string> {
return new Set(Object.entries(kinds).filter(([, k]) => k === "accepts").map(([a]) => a));
}
function isAcceptsBand(grp: { axisName: string }, which: "source" | "target"): boolean {
const set = which === "source" ? sourceAcceptsAxes.value : targetAcceptsAxes.value;
return set.has(grp.axisName);
}
function acceptsTip(grp: { axisName: string }): string {
return `Accepts axis — rolls one tag per pick, readable as $var.${grp.axisName}`;
}
const hasAcceptsAxis = computed(
() => sourceAcceptsAxes.value.size > 0 || targetAcceptsAxes.value.size > 0,
);
const hasColBands = computed(() => colGroups.value.some((g) => !isBucket(g)));
const hasRowGroups = computed(() => rowGroups.value.some((g) => !isBucket(g)));
@@ -388,8 +409,9 @@ onBeforeUnmount(() => {
:class="{ 'mx-th-band--bucket': isBucket(grp) }"
:colspan="grp.tags.length"
:style="{ '--ax': tagHue(grp, 'target', true) }"
:title="groupLabel(grp)"
><span class="chip">{{ groupLabel(grp) }}</span></th>
:title="isAcceptsBand(grp, 'target') ? acceptsTip(grp) : groupLabel(grp)"
><span class="chip">{{ groupLabel(grp)
}}<i v-if="isAcceptsBand(grp, 'target')" class="pi pi-sync mx-axis-mark" aria-hidden="true" /></span></th>
</template>
<!-- Flat cols: tag headers sit directly in the single header row. -->
<template v-else>
@@ -427,8 +449,9 @@ onBeforeUnmount(() => {
:class="{ 'mx-grp-head--bucket': isBucket(grp) }"
:colspan="orderedTargets.length + 1"
:style="{ '--ax': tagHue(grp, 'source', true) }"
:title="groupLabel(grp)"
><span class="chip">{{ groupLabel(grp) }}</span></th>
:title="isAcceptsBand(grp, 'source') ? acceptsTip(grp) : groupLabel(grp)"
><span class="chip">{{ groupLabel(grp)
}}<i v-if="isAcceptsBand(grp, 'source')" class="pi pi-sync mx-axis-mark" aria-hidden="true" /></span></th>
</tr>
<tr v-for="s in grp.tags" :key="s">
<th
@@ -440,7 +463,8 @@ onBeforeUnmount(() => {
:style="{ '--ax': tagHue(grp, 'source', hasRowGroups) }"
>
<span v-if="!isBucket(grp) && grp.tags.length === 1" class="chip">
<span class="eye">{{ grp.axisName }}</span>
<span class="eye">{{ grp.axisName
}}<i v-if="isAcceptsBand(grp, 'source')" class="pi pi-sync mx-axis-mark" :title="acceptsTip(grp)" aria-hidden="true" /></span>
<span class="v">{{ s }}</span>
</span>
<span v-else class="chip">{{ s }}</span>
@@ -474,7 +498,7 @@ onBeforeUnmount(() => {
Reattach a live wildcard to edit these rules.
</p>
<MatrixLegend />
<MatrixLegend :show-accepts="hasAcceptsAxis" />
<!-- Popover lives in a body-level portal so its hover state stays
isolated from the source cell, the cells in the row below
@@ -509,6 +533,7 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.mx-axis-mark { color: var(--wp-axis, #fbbf24); font-size: 9px; margin-left: 5px; vertical-align: baseline; }
.mx {
padding: 12px 16px;
background: var(--wp-bg2);
@@ -290,3 +290,56 @@ describe("ConstraintMatrix.vue", () => {
wrap.unmount();
});
});
describe("ConstraintMatrix.vue — accepts-axis mark", () => {
const groups = { SHOES: ["sneakers", "heels"], REGISTER: ["casual", "formal"] };
it("marks an accepts source band with the amber pi-sync, classify bands unmarked", () => {
const wrap = mount(ConstraintMatrix, {
props: {
rows: ["sneakers", "heels", "casual", "formal"],
cols: ["warm", "cool"],
modelValue: {},
sourceGroups: groups,
sourceGroupKinds: { SHOES: "accepts" },
},
attachTo: document.body,
});
const marks = wrap.findAll(".wp-mx-axis-mark");
expect(marks.length).toBe(1);
// it sits on the SHOES band, not REGISTER
const shoesHead = wrap.findAll(".wp-mx-grp-head").find((h) => h.text().includes("SHOES"));
expect(shoesHead?.find(".wp-mx-axis-mark").exists()).toBe(true);
const registerHead = wrap.findAll(".wp-mx-grp-head").find((h) => h.text().includes("REGISTER"));
expect(registerHead?.find(".wp-mx-axis-mark").exists()).toBe(false);
wrap.unmount();
});
it("marks an accepts TARGET column band too", () => {
const wrap = mount(ConstraintMatrix, {
props: {
rows: ["warm", "cool"],
cols: ["sneakers", "heels"],
modelValue: {},
targetGroups: { SHOES: ["sneakers", "heels"] },
targetGroupKinds: { SHOES: "accepts" },
},
attachTo: document.body,
});
expect(wrap.find(".wp-mx-th-band .wp-mx-axis-mark").exists()).toBe(true);
wrap.unmount();
});
it("shows nothing when no group is accepts", () => {
const wrap = mount(ConstraintMatrix, {
props: {
rows: ["sneakers", "heels"], cols: ["warm", "cool"], modelValue: {},
sourceGroups: { SHOES: ["sneakers", "heels"] },
sourceGroupKinds: { SHOES: "classify" },
},
attachTo: document.body,
});
expect(wrap.findAll(".wp-mx-axis-mark").length).toBe(0);
wrap.unmount();
});
});
+60 -6
View File
@@ -46,6 +46,12 @@ interface Props {
* tinted headers as before. */
sourceGroups?: Record<string, string[]>;
targetGroups?: Record<string, string[]>;
/** axis name → kind for each wildcard. An `"accepts"` axis rolls one tag per
* pick and is readable as `$var.AXIS`; the matrix marks those bands so a
* reader can tell an axis from an ordinary tag group (which the editor shows
* via its accepts button, but the matrix otherwise cannot). */
sourceGroupKinds?: Record<string, string>;
targetGroupKinds?: Record<string, string>;
// Read-only recovery view: the source/target wildcard was deleted, so the
// configured rules are shown for understanding only. Cells don't open the
// rule popover; reattach a live wildcard to edit.
@@ -56,6 +62,8 @@ const props = withDefaults(defineProps<Props>(), {
targetName: "",
sourceGroups: () => ({}),
targetGroups: () => ({}),
sourceGroupKinds: () => ({}),
targetGroupKinds: () => ({}),
readonly: false,
});
const emit = defineEmits<{ "update:modelValue": [value: ConstraintMatrix] }>();
@@ -139,6 +147,26 @@ const orderedRows = computed(() => orderByGroups(props.rows, props.sourceGroups)
const orderedCols = computed(() => orderByGroups(props.cols, props.targetGroups));
const rowGroups = computed(() => toGroups(orderedRows.value));
const colGroups = computed(() => toGroups(orderedCols.value));
/** Axis names marked `accepts` on each side. */
const sourceAcceptsAxes = computed(() => acceptsSet(props.sourceGroupKinds));
const targetAcceptsAxes = computed(() => acceptsSet(props.targetGroupKinds));
function acceptsSet(kinds: Record<string, string>): Set<string> {
return new Set(Object.entries(kinds).filter(([, k]) => k === "accepts").map(([a]) => a));
}
/** Whether a band's axis is an accepts axis on the given side. */
function isAcceptsBand(grp: { axisName: string }, which: "source" | "target"): boolean {
const set = which === "source" ? sourceAcceptsAxes.value : targetAcceptsAxes.value;
return set.has(grp.axisName);
}
/** Hover text for the accepts mark. */
function acceptsTip(grp: { axisName: string }): string {
return `Accepts axis — rolls one tag per pick, readable as $var.${grp.axisName}`;
}
/** Any accepts axis at all, so the legend can hide its row otherwise. */
const hasAcceptsAxis = computed(
() => sourceAcceptsAxes.value.size > 0 || targetAcceptsAxes.value.size > 0,
);
/** True when at least one column / row axis is a NAMED group — then the header
* grows a band/chip and the leftover bucket is worth labelling. A fully-flat
* matrix keeps the legacy source/target tint and grows no band/chip. */
@@ -318,9 +346,14 @@ defineExpose({ cellAt });
:class="{ 'wp-mx-th-band--bucket': isBucket(grp) }"
:colspan="grp.tags.length"
:style="{ '--ax': tagHue(grp, 'target', true) }"
:title="groupLabel(grp)"
:title="isAcceptsBand(grp, 'target') ? acceptsTip(grp) : groupLabel(grp)"
>
<span class="chip">{{ groupLabel(grp) }}</span>
<span class="chip">{{ groupLabel(grp)
}}<i
v-if="isAcceptsBand(grp, 'target')"
class="pi pi-sync wp-mx-axis-mark"
aria-hidden="true"
/></span>
</th>
</template>
<!-- Flat cols: tag headers sit directly in the single header row. -->
@@ -359,8 +392,13 @@ defineExpose({ cellAt });
:class="{ 'wp-mx-grp-head--bucket': isBucket(grp) }"
:colspan="orderedCols.length + 1"
:style="{ '--ax': tagHue(grp, 'source', true) }"
:title="groupLabel(grp)"
><span class="chip">{{ groupLabel(grp) }}</span></th>
:title="isAcceptsBand(grp, 'source') ? acceptsTip(grp) : groupLabel(grp)"
><span class="chip">{{ groupLabel(grp)
}}<i
v-if="isAcceptsBand(grp, 'source')"
class="pi pi-sync wp-mx-axis-mark"
aria-hidden="true"
/></span></th>
</tr>
<tr v-for="tag in grp.tags" :key="tag">
<th
@@ -372,7 +410,13 @@ defineExpose({ cellAt });
:style="{ '--ax': tagHue(grp, 'source', hasRowGroups) }"
>
<span v-if="!isBucket(grp) && grp.tags.length === 1" class="chip">
<span class="eye">{{ grp.axisName }}</span>
<span class="eye">{{ grp.axisName
}}<i
v-if="isAcceptsBand(grp, 'source')"
class="pi pi-sync wp-mx-axis-mark"
:title="acceptsTip(grp)"
aria-hidden="true"
/></span>
<span class="v">{{ tag }}</span>
</span>
<span v-else class="chip">{{ tag }}</span>
@@ -419,7 +463,7 @@ defineExpose({ cellAt });
Reattach the source to edit these rules.
</p>
<MatrixLegend />
<MatrixLegend :show-accepts="hasAcceptsAxis" />
<!-- Popover teleports to body so its hover state stays isolated
from the source cell + any table-level clipping. -->
@@ -567,6 +611,16 @@ defineExpose({ cellAt });
background: color-mix(in srgb, var(--ax) 15%, var(--wp-bg-1));
border-bottom: 2px solid color-mix(in srgb, var(--ax) 55%, transparent);
}
/* Accepts-axis mark — a small amber pi-sync after the band/eyebrow label. The
band keeps its own group hue (so it never collides with an amber group); the
mark is a distinct glyph. Meaning lives in the tooltip + the legend row. */
.wp-mx-axis-mark {
color: var(--wp-axis, #fbbf24);
font-size: 9px;
margin-left: 5px;
vertical-align: baseline;
}
/* The uncategorized bucket reads as a catch-all, not a real axis: same neutral
* tint (via --ax) but a dashed edge instead of the solid axis accent. */
.wp-mx-th-band--bucket .chip { border-bottom-style: dashed; }
+9
View File
@@ -135,6 +135,7 @@ interface WildcardPayloadShape {
options?: WildcardOption[];
sub_categories?: string[];
tag_groups?: Record<string, string[]>;
tag_group_kinds?: Record<string, string>;
}
const name = ref("");
@@ -561,6 +562,12 @@ const sourceGroups = computed<Record<string, string[]>>(
const targetGroups = computed<Record<string, string[]>>(
() => (targetWildcard.value?.payload as WildcardPayloadShape)?.tag_groups ?? {},
);
const sourceGroupKinds = computed<Record<string, string>>(
() => (sourceWildcard.value?.payload as WildcardPayloadShape)?.tag_group_kinds ?? {},
);
const targetGroupKinds = computed<Record<string, string>>(
() => (targetWildcard.value?.payload as WildcardPayloadShape)?.tag_group_kinds ?? {},
);
const targetSubCategories = computed<string[]>(() => {
const wc = targetWildcard.value;
@@ -1428,6 +1435,8 @@ defineExpose({ sourceWildcardId, targetWildcardId, sourceWildcardName, targetWil
:target-name="targetWildcard?.name ?? (targetWildcardName ?? '')"
:source-groups="sourceGroups"
:target-groups="targetGroups"
:source-group-kinds="sourceGroupKinds"
:target-group-kinds="targetGroupKinds"
:readonly="refMissing"
data-test="matrix-grid"
@update:model-value="onMatrixUpdate"