Your DoenetML
<p>Number of through points: <mathInput name="n" prefill="3" /></p>
<sequence name="s" from="-4" to="4" length="$n" />
<repeat name="r" for="$s" valueName="i">
<point>($i, sin($i))</point>
</repeat>
<graph>
<curve name="c" through="$r">
<bezierControls alwaysVisible="true" />
</curve>
</graph>
What happened?
Change the number of through points from 3 to 7. The three original through points keep their handles, but the four newly added ones are drawn with no fill and no stroke — they are invisible, even though alwaysVisible="true" is set. Moving the pointer over the curve makes them all appear, and they stay visible afterwards.
Reading the JSXGraph state after the change confirms it (fillcolor on the through points):
| x |
−4 |
−2.67 |
−1.33 |
0 |
1.33 |
2.67 |
4 |
fillcolor |
#404040 |
#404040 |
#404040 |
none |
none |
none |
none |
The first three are the pre-existing points; the last four were added by the update.
Expected: every through point of a curve with <bezierControls alwaysVisible="true" /> is drawn as a handle, whether it was there from the start or added later.
The same code path runs when the pointer is over a curve without alwaysVisible (hovering the curve calls the same "make the through points always visible" helper), so a curve that gains through points while hovered should be affected too — I only verified the alwaysVisible case.
Cause
packages/doenetml/src/Viewer/renderers/curve.tsx (~L964, in the branch that adds points when numericalThroughPoints grows):
let attributesForNewThroughPoints = Object.assign(
{},
throughPointAttributes.current, // { fillColor: "none", strokeColor: "none", ... }
);
if (/* the existing points are currently always-visible */) {
Object.assign(
attributesForNewThroughPoints,
throughPointAlwaysVisible.current, // { fillcolor: handleColor, strokecolor: handleColor }
);
}
throughPointAlwaysVisible is spelled in lowercase because its other two uses (makeThroughPointsAlwaysVisible, and the comparison just above this) write straight to visProp, where JSXGraph stores keys lowercased. Merged into an attribute object it is a different key from fillColor, so the object ends up carrying both spellings.
JSXGraph then normalizes the attribute object with JXG.keysToLowerCase, which walks the keys with while (n--) — reverse insertion order — so of two spellings the first-written one wins. fillColor: "none" was written first, so the always-visible override is silently dropped and the new points come out transparent.
This is the same failure mode as #1621 (a labeled <vector>/<polyline> drawing a label on each drag handle), in the opposite direction: there a camelCase override was dropped in favor of an earlier lowercase builder key. That PR documents the rule in buildGraphicalAttributes.ts and adds a Vitest guard for the attribute builders, but this call site is hand-rolled and not covered by it.
Suggested fix
Translate to the attribute spelling at the merge site rather than reusing the visProp object, e.g.
Object.assign(attributesForNewThroughPoints, {
fillColor: throughPointAlwaysVisible.current.fillcolor,
strokeColor: throughPointAlwaysVisible.current.strokecolor,
});
I confirmed locally that this makes every newly added through point render as a handle.
Worth pairing with e2e coverage: packages/test-cypress/cypress/e2e/tagSpecific/curve.bezier.cy.js is entirely it.skip'd, and nothing else in the repo exercises bezierControlsAlwaysVisible, so this path currently has no test at all.
What version of DoenetML are you using?
main at fd3ee74 (viewer 0.7.21)
What browsers are you seeing the problem on?
Chrome
🤖 Generated with Claude Code
Your DoenetML
What happened?
Change the number of through points from
3to7. The three original through points keep their handles, but the four newly added ones are drawn with no fill and no stroke — they are invisible, even thoughalwaysVisible="true"is set. Moving the pointer over the curve makes them all appear, and they stay visible afterwards.Reading the JSXGraph state after the change confirms it (
fillcoloron the through points):fillcolor#404040#404040#404040nonenonenonenoneThe first three are the pre-existing points; the last four were added by the update.
Expected: every through point of a curve with
<bezierControls alwaysVisible="true" />is drawn as a handle, whether it was there from the start or added later.The same code path runs when the pointer is over a curve without
alwaysVisible(hovering the curve calls the same "make the through points always visible" helper), so a curve that gains through points while hovered should be affected too — I only verified thealwaysVisiblecase.Cause
packages/doenetml/src/Viewer/renderers/curve.tsx(~L964, in the branch that adds points whennumericalThroughPointsgrows):throughPointAlwaysVisibleis spelled in lowercase because its other two uses (makeThroughPointsAlwaysVisible, and the comparison just above this) write straight tovisProp, where JSXGraph stores keys lowercased. Merged into an attribute object it is a different key fromfillColor, so the object ends up carrying both spellings.JSXGraph then normalizes the attribute object with
JXG.keysToLowerCase, which walks the keys withwhile (n--)— reverse insertion order — so of two spellings the first-written one wins.fillColor: "none"was written first, so the always-visible override is silently dropped and the new points come out transparent.This is the same failure mode as #1621 (a labeled
<vector>/<polyline>drawing a label on each drag handle), in the opposite direction: there a camelCase override was dropped in favor of an earlier lowercase builder key. That PR documents the rule inbuildGraphicalAttributes.tsand adds a Vitest guard for the attribute builders, but this call site is hand-rolled and not covered by it.Suggested fix
Translate to the attribute spelling at the merge site rather than reusing the
visPropobject, e.g.I confirmed locally that this makes every newly added through point render as a handle.
Worth pairing with e2e coverage:
packages/test-cypress/cypress/e2e/tagSpecific/curve.bezier.cy.jsis entirelyit.skip'd, and nothing else in the repo exercisesbezierControlsAlwaysVisible, so this path currently has no test at all.What version of DoenetML are you using?
mainat fd3ee74 (viewer 0.7.21)What browsers are you seeing the problem on?
Chrome
🤖 Generated with Claude Code