fix(spa): chip pills, derivation $var dropdown, constraint subcat axes

This commit is contained in:
DumiFlex
2026-04-28 23:38:15 +03:00
parent c9d893e969
commit bab0fe9966
4 changed files with 74 additions and 34 deletions
@@ -78,9 +78,12 @@ describe("ConstraintEditor.vue", () => {
});
it("loads existing constraint payload when id is given", async () => {
// Both axes are now sub_categories (matrix is subcat × subcat, not
// value × subcat) — give source its own sub_categories so the row
// matches the matrix payload key.
apiMod.list.mockResolvedValue({
items: [
makeWildcardRow("wc_src", "Outfit", { values: ["jeans", "tux"] }),
makeWildcardRow("wc_src", "Outfit", { subs: ["jeans", "tux"] }),
makeWildcardRow("wc_tgt", "HairColor", { subs: ["warm", "cool"] }),
],
total: 2,
@@ -110,7 +113,7 @@ describe("ConstraintEditor.vue", () => {
it("save calls api.modules.create with type 'constraint'", async () => {
apiMod.list.mockResolvedValue({
items: [
makeWildcardRow("wc_src", "Outfit", { values: ["jeans"] }),
makeWildcardRow("wc_src", "Outfit", { subs: ["jeans"] }),
makeWildcardRow("wc_tgt", "HairColor", { subs: ["warm"] }),
],
total: 2,
+17 -6
View File
@@ -5,6 +5,7 @@ import Button from "./ui/Button.vue";
import Input from "./ui/Input.vue";
import Select from "./ui/Select.vue";
import Chip from "./ui/Chip.vue";
import RichTextInput from "./RichTextInput.vue";
import type {
DerivationAction,
DerivationBranch,
@@ -223,12 +224,18 @@ const branchCount = computed(() => rule.value.branches.length);
:aria-label="`Condition operator for rule ${ruleNumber} branch ${bi + 1}`"
@update:model-value="(v) => onConditionOp(bi, v as DerivationOp)"
/>
<Input
<!-- RichTextInput so users get $-autocomplete on condition
values (e.g. compare against `$mood` resolved upstream).
surface=derivation gates @{} refs to muted styling — they
don't resolve in derivation conditions per spec §2.7. -->
<RichTextInput
:model-value="branch.condition.value"
surface="derivation"
:var-suggestions="varSuggestions"
placeholder="value"
class="field-value"
:aria-label="`Condition value for rule ${ruleNumber} branch ${bi + 1}`"
@update:model-value="(v) => onConditionValue(bi, String(v ?? ''))"
@update:model-value="(v) => onConditionValue(bi, v)"
/>
</div>
</div>
@@ -255,12 +262,14 @@ const branchCount = computed(() => rule.value.branches.length);
</div>
<div class="row">
<span class="row-label">Value</span>
<Input
<RichTextInput
:model-value="branch.action.value"
surface="derivation"
:var-suggestions="varSuggestions"
class="field-value-full"
placeholder="The new / appended / prepended value"
:aria-label="`Action value for rule ${ruleNumber} branch ${bi + 1}`"
@update:model-value="(v) => onActionValue(bi, String(v ?? ''))"
@update:model-value="(v) => onActionValue(bi, v)"
/>
</div>
</div>
@@ -300,12 +309,14 @@ const branchCount = computed(() => rule.value.branches.length);
</div>
<div class="row">
<span class="row-label">Value</span>
<Input
<RichTextInput
:model-value="rule.else.action.value"
surface="derivation"
:var-suggestions="varSuggestions"
class="field-value-full"
placeholder="The new / appended / prepended value"
:aria-label="`ELSE action value for rule ${ruleNumber}`"
@update:model-value="(v) => onElseValue(String(v ?? ''))"
@update:model-value="(v) => onElseValue(v)"
/>
</div>
</div>
+26 -16
View File
@@ -33,10 +33,10 @@
.wp-rt .wp-rt-var,
.wp-rt-preview .wp-rt-var {
color: var(--wp-accent-text-strong, var(--wp-accent-text, #c4b5fd));
background: rgba(139, 92, 246, 0.16);
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 16%, transparent);
box-shadow: inset 0 0 0 1px rgba(139, 92, 246, 0.32);
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 32%, transparent);
background: rgba(139, 92, 246, 0.24);
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 24%, transparent);
box-shadow: inset 0 0 0 1px rgba(139, 92, 246, 0.50);
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 50%, transparent);
border-radius: 4px;
font-weight: 500;
}
@@ -44,10 +44,10 @@
.wp-rt .wp-rt-ref,
.wp-rt-preview .wp-rt-ref {
color: var(--wp-kind-wildcard, #f0abfc);
background: rgba(192, 38, 211, 0.15);
background: color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 15%, transparent);
box-shadow: inset 0 0 0 1px rgba(192, 38, 211, 0.30);
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 30%, transparent);
background: rgba(192, 38, 211, 0.24);
background: color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 24%, transparent);
box-shadow: inset 0 0 0 1px rgba(192, 38, 211, 0.50);
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 50%, transparent);
border-radius: 4px;
font-weight: 500;
}
@@ -75,24 +75,34 @@
}
/* Focus / rest dual-mode chip chrome (input only).
While editing, chips are zero-width visual decorations so the
mirror text width matches the textarea text width exactly and
the native caret tracks correctly. When blurred, chips get a
hair of horizontal padding so they read as proper pills. */
While editing, chips stay tightly aligned with the textarea text
beneath the mirror so the native caret stays close to the right
character. We accept a small visual margin (negative-offset shadow)
to make the chip read as a pill — caret can drift up to 2-3px
inside a chip but the contrast win is worth it.
When blurred, chips get a hair of horizontal padding so they read
as proper pills with breathing room from adjacent text. */
.wp-rt--focused .wp-rt-var,
.wp-rt--focused .wp-rt-ref {
/* Zero in-flow padding keeps width identical to the textarea, so
* caret position stays correct character-for-character. The visible
* pill comes from the chip background + box-shadow alone. */
padding: 0;
}
.wp-rt--rest .wp-rt-var,
.wp-rt--rest .wp-rt-ref {
padding: 0 4px;
margin: 0 0.5px;
/* Pill chrome at rest — bigger horizontal padding + a breathing
* margin so chips don't merge with adjacent text. The width drift
* vs the (now-blurred) textarea is invisible because no caret is
* showing. */
padding: 1px 5px;
margin: 0 1.5px;
border-radius: 4px;
}
.wp-rt--rest .wp-rt-dp-multi,
.wp-rt--rest .wp-rt-dp-weight {
padding: 0 3px;
margin: 0 0.5px;
padding: 0 4px;
margin: 0 1px;
}
/* Read-only preview always renders chips at rest. Match the
+26 -10
View File
@@ -83,6 +83,29 @@ function wildcardById(id: string | null): ModuleRow | undefined {
const sourceWildcard = computed(() => wildcardById(sourceWildcardId.value));
const targetWildcard = computed(() => wildcardById(targetWildcardId.value));
// Matrix axes are BOTH sub-categories — source's on the rows, target's
// on the cols. Source-value-keyed cells (the prior shape) confused the
// "rule" semantics: a constraint matrix expresses category-level
// boost/exclude rules, not per-option overrides. Per-option overrides
// belong in the Exceptions table beneath the matrix.
const sourceSubCategories = computed<string[]>(() => {
const wc = sourceWildcard.value;
if (!wc) return [];
const payload = wc.payload as WildcardPayloadShape;
return payload.sub_categories ?? [];
});
const targetSubCategories = computed<string[]>(() => {
const wc = targetWildcard.value;
if (!wc) return [];
const payload = wc.payload as WildcardPayloadShape;
return payload.sub_categories ?? [];
});
// Per-option pickers used by the Exceptions editor below the matrix.
// Exceptions DO operate at value granularity (override one specific
// option-pair regardless of what the sub-category matrix says) so we
// keep these computed for that table only.
const sourceValues = computed<string[]>(() => {
const wc = sourceWildcard.value;
if (!wc) return [];
@@ -93,13 +116,6 @@ const sourceValues = computed<string[]>(() => {
return Array.from(new Set(values));
});
const targetSubCategories = computed<string[]>(() => {
const wc = targetWildcard.value;
if (!wc) return [];
const payload = wc.payload as WildcardPayloadShape;
return payload.sub_categories ?? [];
});
const targetValues = computed<string[]>(() => {
const wc = targetWildcard.value;
if (!wc) return [];
@@ -330,18 +346,18 @@ defineExpose({ sourceWildcardId, targetWildcardId, matrix, exceptions, applyRest
Pick a source and target wildcard to populate the matrix.
</div>
<div
v-else-if="sourceValues.length === 0 || targetSubCategories.length === 0"
v-else-if="sourceSubCategories.length === 0 || targetSubCategories.length === 0"
class="wp-empty-card"
data-test="matrix-need-subs"
>
<i class="pi pi-info-circle" />
<span v-if="sourceValues.length === 0">Source wildcard needs at least one option value. </span>
<span v-if="sourceSubCategories.length === 0">Source wildcard needs at least one sub-category. </span>
<span v-if="targetSubCategories.length === 0">Target wildcard needs at least one sub-category. </span>
Add them on the wildcard editor to define rules.
</div>
<ConstraintMatrixGrid
v-else
:rows="sourceValues"
:rows="sourceSubCategories"
:cols="targetSubCategories"
:model-value="matrix"
data-test="matrix-grid"