Skip to content
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ jobs:
name: Jira
runs-on: ubuntu-latest
steps:
- uses: openstax/jira-linked-action@v0.1.14
- uses: openstax/jira-linked-action@v0.1.16
with:
jira_site: openstax
jira_project: DISCO
Expand All @@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
module: [cutie]
module: [cutie-core, cutie-client, cutie-editor, cutie-example]
env:
CI: true
steps:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on: [pull_request]
jobs:
cfn:
timeout-minutes: 30
runs-on: ubuntu-20.04
runs-on: ubuntu-latest

steps:
- name: Checkout repo
Expand Down
14 changes: 8 additions & 6 deletions packages/cutie-client/src/errors/validationDisplay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@ import type { StyleManager } from '../transformer/types';

const SVG_NS = 'http://www.w3.org/2000/svg';

// SVG path from Material Symbols Outlined "warning"
// https://raw.githubusercontent.com/google/material-design-icons/master/symbols/web/warning/materialsymbolsoutlined/warning_24px.svg
// SVG path from Material Symbols Outlined "error" based on
// https://raw.githubusercontent.com/google/material-design-icons/refs/heads/master/symbols/web/error/materialsymbolsoutlined/error_24px.svg
// 24-unit grid, inset by 0.75 per side to match the ink scale of the feedback icons
const WARNING_ICON_PATH =
'm40-120 440-760 440 760H40Zm138-80h604L480-720 178-200Zm302-40q17 0 28.5-11.5T520-280q0-17-11.5-28.5T480-320q-17 0-28.5 11.5T440-280q0 17 11.5 28.5T480-240Zm-40-120h80v-200h-80v200Zm40-100Z';
'M12.713 16.713C12.9043 16.521 13 16.2833 13 16C13 15.7167 12.904 15.4793 12.712 15.288C12.52 15.0967 12.2827 15.0007 12 15C11.7173 14.9993 11.48 15.0953 11.288 15.288C11.096 15.4807 11 15.718 11 16C11 16.282 11.096 16.5197 11.288 16.713C11.48 16.9063 11.7173 17.002 12 17C12.2827 16.998 12.5203 16.903 12.713 16.713ZM12.713 12.712C12.9043 12.5207 13 12.2833 13 12V8C13 7.71667 12.904 7.47933 12.712 7.288C12.52 7.09667 12.2827 7.00067 12 7C11.7173 6.99933 11.48 7.09533 11.288 7.288C11.096 7.48067 11 7.718 11 8V12C11 12.2833 11.096 12.521 11.288 12.713C11.48 12.905 11.7173 13.0007 12 13C12.2827 12.9993 12.5203 12.9033 12.713 12.712ZM12 22C10.6167 22 9.31667 21.7373 8.1 21.212C6.88334 20.6867 5.825 19.9743 4.925 19.075C4.025 18.1757 3.31267 17.1173 2.788 15.9C2.26333 14.6827 2.00067 13.3827 2 12C1.99933 10.6173 2.262 9.31733 2.788 8.1C3.314 6.88267 4.02633 5.82433 4.925 4.925C5.82367 4.02567 6.882 3.31333 8.1 2.788C9.318 2.26267 10.618 2 12 2C13.382 2 14.682 2.26267 15.9 2.788C17.118 3.31333 18.1763 4.02567 19.075 4.925C19.9737 5.82433 20.6863 6.88267 21.213 8.1C21.7397 9.31733 22.002 10.6173 22 12C21.998 13.3827 21.7353 14.6827 21.212 15.9C20.6887 17.1173 19.9763 18.1757 19.075 19.075C18.1737 19.9743 17.1153 20.687 15.9 21.213C14.6847 21.739 13.3847 22.0013 12 22ZM12 20C14.2333 20 16.125 19.225 17.675 17.675C19.225 16.125 20 14.2333 20 12C20 9.76667 19.225 7.875 17.675 6.325C16.125 4.775 14.2333 4 12 4C9.76667 4 7.875 4.775 6.325 6.325C4.775 7.875 4 9.76667 4 12C4 14.2333 4.775 16.125 6.325 17.675C7.875 19.225 9.76667 20 12 20Z';

const CONSTRAINT_ERROR_CLASS = 'cutie-constraint-error';

const INLINE_REQUIRED_INDICATOR_STYLE_ID = 'cutie-inline-required-indicator';

const INLINE_REQUIRED_INDICATOR_STYLES = `
.cutie-required-indicator {
font-size: 0.75em;
font-size: 1.15em;
vertical-align: super;
color: var(--cutie-text-muted);
cursor: default;
Expand All @@ -31,7 +32,7 @@ const VALIDATION_DISPLAY_STYLES = `
display: flex;
align-items: center;
gap: 0.3em;
font-size: 0.85em;
font-size: 1em;
color: var(--cutie-text-muted);
margin-top: 0.5em;
}
Expand All @@ -41,6 +42,7 @@ const VALIDATION_DISPLAY_STYLES = `
width: 1em;
height: 1em;
flex-shrink: 0;
transform: translateY(0.05em);
}

/* Error state — icon provides non-color signal per WCAG 1.4.1 */
Expand Down Expand Up @@ -88,7 +90,7 @@ export function createConstraintMessage(
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('class', 'cutie-constraint-icon');
svg.setAttribute('viewBox', '0 -960 960 960');
svg.setAttribute('viewBox', '0.75 0.75 22.5 22.5');
svg.setAttribute('fill', 'currentColor');

const path = document.createElementNS(SVG_NS, 'path');
Expand Down
3 changes: 2 additions & 1 deletion packages/cutie-client/src/styles/baseStyles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ const BASE_STYLES = `
--cutie-feedback-correct: #22c55e;
--cutie-feedback-incorrect: #d32f2f;
--cutie-feedback-info: #4a90e2;
--cutie-line-height: 1.5;
color: var(--cutie-text);
background-color: var(--cutie-bg);
line-height: 1.5;
line-height: var(--cutie-line-height);
}

.cutie-item-container img {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,5 +241,29 @@ describe('formulaInteraction', () => {
const constraintEl = container.querySelector('.cutie-constraint-text')!;
expect(constraintEl.classList.contains('cutie-constraint-error')).toBe(true);
});

it('clears error state on input once the value becomes valid', async () => {
const doc = createQtiDocument(`
<qti-extended-text-interaction response-identifier="R1" min-strings="1">
</qti-extended-text-interaction>
`);

const fragment = transformInteraction(doc, itemState);
const container = document.createElement('div');
container.appendChild(fragment);
await waitForMathField();

const mathField = container.querySelector('.cutie-formula-field') as HTMLElement & { value: string };
const constraintEl = container.querySelector('.cutie-constraint-text')!;

itemState.collectAll();
expect(mathField.getAttribute('aria-invalid')).toBe('true');

mathField.value = 'x+1';
mathField.dispatchEvent(new Event('input'));

expect(mathField.hasAttribute('aria-invalid')).toBe(false);
expect(constraintEl.classList.contains('cutie-constraint-error')).toBe(false);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -109,21 +109,24 @@ class FormulaInteractionHandler implements ElementHandler {
// Track active input element for aria-describedby/aria-invalid
let activeInputElement: HTMLElement | null = null;

// Check the required (min-strings) constraint and decorate the error state.
const validate = (): boolean => {
const isValid = constraints.minStrings <= 0 || currentValue.trim().length > 0;
if (isValid) {
activeInputElement?.removeAttribute('aria-invalid');
} else {
activeInputElement?.setAttribute('aria-invalid', 'true');
}
constraintResult?.constraint.setError(!isValid);
return isValid;
};

// Register response accessor immediately (returns current value)
if (context.itemState) {
context.itemState.registerResponse(responseIdentifier, () => {
const trimmed = currentValue.trim();
const isValid = constraints.minStrings <= 0 || trimmed.length > 0;

if (!isValid) {
activeInputElement?.setAttribute('aria-invalid', 'true');
constraintResult?.constraint.setError(true);
return { value: trimmed === '' ? null : trimmed, valid: false };
}

activeInputElement?.removeAttribute('aria-invalid');
constraintResult?.constraint.setError(false);
return { value: trimmed === '' ? null : trimmed, valid: true };
const valid = validate();
return { value: trimmed === '' ? null : trimmed, valid };
});
}

Expand Down Expand Up @@ -157,6 +160,8 @@ class FormulaInteractionHandler implements ElementHandler {
// Listen for input events to update current value
mathField.addEventListener('input', () => {
currentValue = mathField.value;
// Clear the error in real time once already in an error state.
if (activeInputElement?.hasAttribute('aria-invalid')) validate();
});

// Handle interaction state
Expand Down Expand Up @@ -228,6 +233,11 @@ const FORMULA_INTERACTION_STYLES = `
box-shadow: none;
}

.cutie-formula-field[aria-invalid="true"],
.cutie-formula-field[aria-invalid="true"]:focus-within {
border-color: var(--cutie-feedback-incorrect);
}

.cutie-formula-field[disabled] {
background-color: var(--cutie-bg-alt);
cursor: not-allowed;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,15 @@ const EXTENDED_TEXT_INTERACTION_STYLES = `
border-color: var(--cutie-primary);
}

.cutie-extended-text-interaction textarea[aria-invalid="true"] {
border-color: var(--cutie-feedback-incorrect);
}

.cutie-extended-text-interaction textarea[aria-invalid="true"]:focus {
outline-color: var(--cutie-feedback-incorrect);
border-color: var(--cutie-feedback-incorrect);
}

.cutie-extended-text-interaction textarea:disabled {
background-color: var(--cutie-bg-alt);
cursor: not-allowed;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,16 @@ const RICH_TEXT_INTERACTION_STYLES = `
border-color: var(--cutie-primary);
}

/* aria-invalid is set on the inner .ql-editor (quill.root) */
.cutie-rich-text-wrapper:has([aria-invalid="true"]) {
border-color: var(--cutie-feedback-incorrect);
}

.cutie-rich-text-wrapper:has([aria-invalid="true"]):focus-within {
outline-color: var(--cutie-feedback-incorrect);
border-color: var(--cutie-feedback-incorrect);
}

.cutie-rich-text-interaction .ql-toolbar {
border: none;
border-bottom: 1px solid var(--cutie-border);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,26 @@ const FEEDBACK_BLOCK_STYLES = `
.cutie-feedback-block[data-feedback-type="correct"],
.cutie-feedback-block[data-feedback-type="incorrect"],
.cutie-feedback-block[data-feedback-type="info"] {
--cutie-feedback-block-padding-y: 1.75em;
display: block;
position: relative;
margin: 0.75em 0;
padding: 0.75em 1em 0.75em 2.25em;
padding: var(--cutie-feedback-block-padding-y) 1em var(--cutie-feedback-block-padding-y) 2.25em;
background-color: var(--cutie-bg-alt);
color: var(--cutie-text);
font-style: italic;
}

/* align icon to the first line of text */
.cutie-feedback-block .cutie-feedback-icon {
position: absolute;
left: 0.625em;
top: 0.75em;
top: var(--cutie-feedback-block-padding-y);
height: calc(1em * var(--cutie-line-height, 1.5));
}

.cutie-feedback-block .cutie-feedback-icon__svg {
transform: none;
}

.cutie-feedback-block[data-feedback-type="correct"] {
Expand All @@ -91,4 +98,12 @@ const FEEDBACK_BLOCK_STYLES = `
.cutie-feedback-block[data-feedback-type="info"] {
border-left: 0.5em solid var(--cutie-feedback-info);
}

.cutie-feedback-block > .cutie-feedback-icon + * {
margin-top: 0;
}

.cutie-feedback-block > :last-child {
margin-bottom: 0;
}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ describe('isFeedbackType', () => {

describe('createFeedbackIcon', () => {
it.each([
['correct', 'Correct:'],
['incorrect', 'Incorrect:'],
['info', 'Information:'],
] as [FeedbackType, string][])(
['correct', 'Correct:', '0.75 0.75 22.5 22.5'],
['incorrect', 'Incorrect:', '0.75 0.75 22.5 22.5'],
['info', 'Information:', '0 -960 960 960'],
] as [FeedbackType, string, string][])(
'should create icon for "%s" with currentColor fill and label "%s"',
(type, label) => {
(type, label, viewBox) => {
const icon = createFeedbackIcon(type);

expect(icon.tagName.toLowerCase()).toBe('span');
Expand All @@ -30,7 +30,7 @@ describe('createFeedbackIcon', () => {
expect(svg?.getAttribute('aria-hidden')).toBe('true');
expect(svg?.getAttribute('class')).toBe('cutie-feedback-icon__svg');
expect(svg?.getAttribute('fill')).toBe('currentColor');
expect(svg?.getAttribute('viewBox')).toBe('0 -960 960 960');
expect(svg?.getAttribute('viewBox')).toBe(viewBox);

const path = svg?.querySelector('path');
expect(path).not.toBeNull();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,27 @@ export function isFeedbackType(value: string): value is FeedbackType {
return FEEDBACK_TYPES.includes(value as FeedbackType);
}

// SVG paths from Material Symbols Outlined (viewBox="0 -960 960 960")
// check and close paths match packages/cutie-editor/src/components/icons.tsx
const ICON_CONFIG: Record<FeedbackType, { path: string; label: string }> = {
// SVG paths from Material Symbols. viewBox is tracked per icon so
// each path renders in its own coordinate system. The 24-grid icons are
// inset by 0.75 units per side so their ink fills the same share of the
// box as the 960-grid info icon.
const ICON_CONFIG: Record<
FeedbackType,
{ path: string; viewBox: string; label: string }
> = {
correct: {
path: 'M382-240 154-468l57-57 171 171 367-367 57 57-424 424Z',
path: 'M9.55006 15.15L18.0251 6.675C18.2251 6.475 18.4584 6.375 18.7251 6.375C18.9917 6.375 19.2251 6.475 19.4251 6.675C19.6251 6.875 19.7251 7.11267 19.7251 7.388C19.7251 7.66333 19.6251 7.90067 19.4251 8.1L10.2501 17.3C10.0501 17.5 9.81673 17.6 9.55006 17.6C9.28339 17.6 9.05006 17.5 8.85006 17.3L4.55006 13C4.35006 12.8 4.25406 12.5627 4.26206 12.288C4.27006 12.0133 4.37439 11.7757 4.57506 11.575C4.77572 11.3743 5.01339 11.2743 5.28806 11.275C5.56272 11.2757 5.80006 11.3757 6.00006 11.575L9.55006 15.15Z',
viewBox: '0.75 0.75 22.5 22.5',
label: 'Correct:',
},
incorrect: {
path: 'm256-200-56-56 224-224-224-224 56-56 224 224 224-224 56 56-224 224 224 224-56 56-224-224-224 224Z',
path: 'M12 13.4L7.10005 18.3C6.91672 18.4834 6.68338 18.575 6.40005 18.575C6.11672 18.575 5.88338 18.4834 5.70005 18.3C5.51672 18.1167 5.42505 17.8834 5.42505 17.6C5.42505 17.3167 5.51672 17.0834 5.70005 16.9L10.6 12L5.70005 7.10005C5.51672 6.91672 5.42505 6.68338 5.42505 6.40005C5.42505 6.11672 5.51672 5.88338 5.70005 5.70005C5.88338 5.51672 6.11672 5.42505 6.40005 5.42505C6.68338 5.42505 6.91672 5.51672 7.10005 5.70005L12 10.6L16.9 5.70005C17.0834 5.51672 17.3167 5.42505 17.6 5.42505C17.8834 5.42505 18.1167 5.51672 18.3 5.70005C18.4834 5.88338 18.575 6.11672 18.575 6.40005C18.575 6.68338 18.4834 6.91672 18.3 7.10005L13.4 12L18.3 16.9C18.4834 17.0834 18.575 17.3167 18.575 17.6C18.575 17.8834 18.4834 18.1167 18.3 18.3C18.1167 18.4834 17.8834 18.575 17.6 18.575C17.3167 18.575 17.0834 18.4834 16.9 18.3L12 13.4Z',
viewBox: '0.75 0.75 22.5 22.5',
label: 'Incorrect:',
},
info: {
path: 'M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z',
viewBox: '0 -960 960 960',
label: 'Information:',
},
};
Expand All @@ -35,7 +43,7 @@ export function createFeedbackIcon(type: FeedbackType): HTMLSpanElement {
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('class', 'cutie-feedback-icon__svg');
svg.setAttribute('fill', 'currentColor');
svg.setAttribute('viewBox', '0 -960 960 960');
svg.setAttribute('viewBox', config.viewBox);

const path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('d', config.path);
Expand Down Expand Up @@ -72,6 +80,7 @@ export const FEEDBACK_ICON_STYLES = `
.cutie-feedback-icon__svg {
width: 1em;
height: 1em;
transform: translateY(0.06em);
}

.cutie-feedback-sr-text {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,5 +77,6 @@ const FEEDBACK_INLINE_STYLES = `
.cutie-feedback-inline .cutie-feedback-icon__svg {
width: 1.25em;
height: 1.25em;
transform: translateY(-0.06em);
}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,12 @@ export const GAP_MATCH_INTERACTION_STYLES = `
opacity: 0.6;
cursor: not-allowed;
background-color: var(--cutie-bg-alt);
border-color: var(--cutie-border);
}

.cutie-gap-text[disabled]:hover {
background-color: var(--cutie-bg-alt);
border-color: var(--cutie-primary);
border-color: var(--cutie-border);
}

.cutie-gap-text[disabled]:focus {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,15 @@ const INLINE_CHOICE_INTERACTION_STYLES = `
border-color: var(--cutie-primary);
}

.cutie-inline-choice-interaction[aria-invalid="true"] {
border-color: var(--cutie-feedback-incorrect);
}

.cutie-inline-choice-interaction[aria-invalid="true"]:focus {
outline-color: var(--cutie-feedback-incorrect);
border-color: var(--cutie-feedback-incorrect);
}

.cutie-inline-choice-interaction:disabled {
background-color: var(--cutie-bg-alt);
cursor: not-allowed;
Expand Down
Loading
Loading