diff --git a/VueApp/src/CTS/__tests__/assessment-bubble.test.ts b/VueApp/src/CTS/__tests__/assessment-bubble.test.ts new file mode 100644 index 000000000..a15f0daed --- /dev/null +++ b/VueApp/src/CTS/__tests__/assessment-bubble.test.ts @@ -0,0 +1,160 @@ +import { mount } from "@vue/test-utils" +import { Quasar } from "quasar" + +import AssessmentBubble from "../components/AssessmentBubble.vue" + +/** + * Tests for AssessmentBubble — the rating dot rendered on CTS assessment lists. + * + * Focus areas: + * 1. Privacy: aria-label must surface the descriptive rating label, never the + * numeric value. Students should not hear "Rating 1 of 5" from a screen + * reader when they are low-rated. + * 2. Class mapping: value/maxValue drive the bubbleClass contract consumed + * by cts.css. + * 3. Click contract: clickable variant (id prop set) emits bubble-click with + * the id; non-clickable variant renders as a non-interactive span. + */ + +function createWrapper(props: Record) { + return mount(AssessmentBubble, { + props: props as never, + global: { + plugins: [[Quasar, {}]], + }, + }) +} + +describe(AssessmentBubble, () => { + describe("aria-label privacy", () => { + it("uses levelName on the clickable button and does not expose the numeric value", () => { + const wrapper = createWrapper({ + maxValue: 5, + value: 1, + levelName: "Trust with indirect supervision", + id: 42, + }) + + const label = wrapper.get("button").attributes("aria-label")! + expect(label).toContain("Trust with indirect supervision") + expect(label).not.toMatch(/\b1 of 5\b/i) + expect(label).not.toMatch(/rating\s+\d/i) + }) + + it("uses levelName on the standalone span and does not expose the numeric value", () => { + const wrapper = createWrapper({ + maxValue: 5, + value: 2, + levelName: "Trust with direct supervision", + }) + + const label = wrapper.get('span[role="img"]').attributes("aria-label")! + expect(label).toBe("Trust with direct supervision") + expect(label).not.toMatch(/\b2 of 5\b/i) + }) + + it("appends open-details hint on the clickable variant", () => { + const wrapper = createWrapper({ + maxValue: 5, + value: 3, + levelName: "Independent remote supervision", + id: 7, + }) + + expect(wrapper.get("button").attributes("aria-label")).toBe( + "Independent remote supervision, open assessment details", + ) + }) + + it("falls back to a generic hint when levelName is missing on a clickable bubble", () => { + const wrapper = createWrapper({ + maxValue: 5, + value: 3, + id: 7, + }) + + expect(wrapper.get("button").attributes("aria-label")).toBe("Open assessment details") + }) + }) + + describe("bubbleClass contract", () => { + it.each([ + [1, "assessmentBubble5_1"], + [2, "assessmentBubble5_2"], + [3, "assessmentBubble5_3"], + [4, "assessmentBubble5_4"], + [5, "assessmentBubble5_5"], + ])("maps value=%i to %s", (value, expected) => { + const wrapper = createWrapper({ + maxValue: 5, + value, + levelName: "Label", + }) + + expect(wrapper.get('span[role="img"]').classes()).toContain(expected) + }) + + it.each([0, 6])("yields no level class for out-of-range value=%i", (value) => { + const wrapper = createWrapper({ + maxValue: 5, + value, + levelName: "Label", + }) + + const classes = wrapper.get('span[role="img"]').classes() + expect(classes.some((c) => c.startsWith("assessmentBubble5_"))).toBeFalsy() + }) + + it("yields no level class when maxValue is not 5", () => { + const wrapper = createWrapper({ + maxValue: 3, + value: 2, + levelName: "Label", + }) + + const classes = wrapper.get('span[role="img"]').classes() + expect(classes.some((c) => c.startsWith("assessmentBubble5_"))).toBeFalsy() + }) + }) + + describe("click behaviour", () => { + it("renders a button and emits bubble-click with the id when clicked", async () => { + const wrapper = createWrapper({ + maxValue: 5, + value: 3, + levelName: "Label", + id: 99, + }) + + await wrapper.get("button").trigger("click") + + expect(wrapper.emitted("bubble-click")).toEqual([[99]]) + }) + + it("renders a non-interactive span and does not emit when id is omitted", () => { + const wrapper = createWrapper({ + maxValue: 5, + value: 3, + levelName: "Label", + }) + + expect(wrapper.find("button").exists()).toBeFalsy() + expect(wrapper.find('span[role="img"]').exists()).toBeTruthy() + expect(wrapper.emitted("bubble-click")).toBeUndefined() + }) + }) + + describe("bubble content", () => { + it("does not render the numeric value inside the bubble", () => { + const wrapper = createWrapper({ + maxValue: 5, + value: 4, + levelName: "Label", + id: 1, + }) + + const bubble = wrapper.get("span.assessmentBubble") + expect(bubble.text()).toBe("") + }) + }) +}) diff --git a/VueApp/src/CTS/assets/cts.css b/VueApp/src/CTS/assets/cts.css index 35d9d7a77..41c22753c 100644 --- a/VueApp/src/CTS/assets/cts.css +++ b/VueApp/src/CTS/assets/cts.css @@ -1,6 +1,12 @@ .assessmentGroup { border-top: 1px solid silver; } + +.expandToggleCol { + flex: 0 0 2.5rem; + max-width: 2.5rem; + text-align: right; +} /* .assessmentbubble { width: 15px; @@ -51,24 +57,99 @@ color: rgba(11,3,139,1); } */ +.assessmentBubbleTrigger { + background: none; + border: 0; + padding: 0; + margin: 0 0.25rem 0 0; + line-height: 0; + color: inherit; + cursor: pointer; +} + +.assessmentBubble[role="img"] { + margin-right: 0.25rem; +} + +.assessmentBubbleTooltipText { + white-space: pre-wrap; +} + +.assessmentBubbleTrigger:focus-visible { + outline: 2px solid var(--q-primary); + outline-offset: 2px; + border-radius: 50%; +} + +.assessmentBubble { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + font-size: 0.75rem; + font-weight: 600; + line-height: 1; +} + .assessmentBubble5_1 { - color: rgba(62, 127, 238, 0.3); + background-color: rgba(62, 127, 238, 0.3); + color: #212529; } .assessmentBubble5_2 { - color: rgba(62, 127, 238, 0.7); + background-color: rgba(62, 127, 238, 0.7); + color: #212529; } .assessmentBubble5_3 { - color: rgba(62, 127, 238, 1); + background-color: rgba(62, 127, 238, 1); + color: #000; } .assessmentBubble5_4 { - color: rgba(0, 44, 175, 0.8); + background-color: rgba(0, 44, 175, 0.8); + color: #fff; } .assessmentBubble5_5 { - color: rgba(11, 3, 139, 1); + background-color: rgba(11, 3, 139, 1); + color: #fff; +} + +/* Style 1 — legacy palette (size matches current bubble) */ +.assessmentBubble--legacy.assessmentBubble5_1 { + background-color: rgba(174, 235, 255, 1); + color: #212529; +} +.assessmentBubble--legacy.assessmentBubble5_2 { + background-color: rgba(134, 198, 255, 1); + color: #212529; +} +.assessmentBubble--legacy.assessmentBubble5_3 { + background-color: rgba(62, 127, 238, 1); + color: #000; +} +.assessmentBubble--legacy.assessmentBubble5_4 { + background-color: rgba(0, 44, 219, 1); + color: #fff; +} +.assessmentBubble--legacy.assessmentBubble5_5 { + background-color: rgba(11, 3, 139, 1); + color: #fff; +} + +/* Style 3 — abbreviation pill (keeps current palette, stretches shape) */ +.assessmentBubble--abbrev { + width: auto; + min-width: 1.5rem; + padding: 0 0.45rem; + border-radius: 0.75rem; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.04em; + white-space: nowrap; } .assessmentBubbleCloser5_1 { @@ -91,6 +172,426 @@ color: rgba(2, 40, 150, 1); } +.levelChip { + display: inline-block; + padding: 0.125rem 0.625rem; + border-radius: 0.75rem; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.25; + white-space: nowrap; + background-color: #adb5bd; + color: #212529; +} +.levelChip--1 { + background-color: rgba(62, 127, 238, 0.3); + color: #212529; +} +.levelChip--2 { + background-color: rgba(62, 127, 238, 0.7); + color: #212529; +} +.levelChip--3 { + background-color: rgba(62, 127, 238, 1); + color: #000; +} +.levelChip--4 { + background-color: rgba(0, 44, 175, 0.8); + color: #fff; +} +.levelChip--5 { + background-color: rgba(11, 3, 139, 1); + color: #fff; +} + +.assessmentComment { + font-style: italic; +} + +/* Shared dot colors (used by EpaProgressionChart SVG circles and inline byline dots) */ +.cts-dot-1 { + fill: rgb(62 127 238 / 30%); + background-color: rgb(62 127 238 / 30%); +} +.cts-dot-2 { + fill: rgb(62 127 238 / 70%); + background-color: rgb(62 127 238 / 70%); +} +.cts-dot-3 { + fill: rgb(62 127 238 / 100%); + background-color: rgb(62 127 238 / 100%); +} +.cts-dot-4 { + fill: rgb(0 44 175 / 80%); + background-color: rgb(0 44 175 / 80%); +} +.cts-dot-5 { + fill: rgb(11 3 139 / 100%); + background-color: rgb(11 3 139 / 100%); +} + +/* ── Style 4: Voices in the Margin ── */ +.epaThread { + background: #ffffff; + border: 1px solid #e8e4dc; + border-radius: 0.5rem; + padding: 1.375rem 1.75rem; + margin-bottom: 1.125rem; +} +.epaThreadHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid #e8e4dc; + padding-bottom: 0.75rem; + margin-bottom: 1.125rem; +} +.epaThreadTitle { + font-family: Georgia, "Times New Roman", serif; + font-style: italic; + font-weight: 500; + font-size: 1.125rem; + color: var(--ucdavis-blue-100, #022851); + line-height: 1.3; +} +.epaThreadCount { + font-size: 0.72rem; + font-weight: 500; + color: #6c757d; + text-transform: lowercase; + letter-spacing: 0.04em; + white-space: nowrap; + flex-shrink: 0; +} +.epaThreadEmpty { + font-style: italic; + font-size: 0.85rem; + color: #616161; +} +.epaThreadContent { + display: flex; + align-items: flex-start; + gap: 1.5rem; +} +.epaThreadBody { + flex: 1; + min-width: 0; + display: grid; + grid-template-columns: 2rem 1fr; + position: relative; +} +.epaThreadGutter { + grid-column: 1; + grid-row: 1 / -1; + position: relative; + width: 2rem; +} +.epaThreadGutter svg { + position: absolute; + top: 0; + left: 0; + width: 2rem; + overflow: visible; + pointer-events: none; +} +.epaThread--sparse .epaThreadGutter::before { + content: ""; + position: absolute; + left: 0.8125rem; + top: 1rem; + bottom: 1rem; + width: 0.1875rem; + background: #ffc519; + opacity: 0.45; + border-radius: 0.125rem; +} +.epaVoice { + grid-column: 2; + padding: 0.875rem 0 0.875rem 1.25rem; + border-radius: 0.25rem; + transition: background-color 0.12s; +} +.epaVoice + .epaVoice { + border-top: 1px solid #f0ece4; +} +.epaVoice--active { + background-color: rgba(2, 40, 81, 0.05); +} +.epaVoice:focus-visible { + outline: 2px solid var(--q-primary); + outline-offset: 2px; +} +.epaVoiceQuote { + font-family: Georgia, "Times New Roman", serif; + font-style: italic; + font-size: 1rem; + line-height: 1.6; + color: #1a1a1a; + margin: 0 0 0.5rem; +} +.epaVoiceNoComment { + font-size: 0.85rem; + color: #adb5bd; + font-style: italic; + margin-bottom: 0.5rem; +} +.epaVoiceByline { + font-size: 0.8rem; + color: #6c757d; + line-height: 1.55; +} +.epaVoiceByline strong { + color: var(--ucdavis-blue-100, #022851); + font-weight: 600; +} +.epaVoiceSupervision { + font-style: italic; + display: block; +} +.epaVoiceLevelDot { + display: inline-block; + width: 0.7em; + height: 0.7em; + border-radius: 50%; + vertical-align: -0.05em; + margin-right: 0.4em; + border: 1px solid rgba(2, 40, 81, 0.15); +} +.epaThreadMore { + grid-column: 2; + margin: 0.5rem 0 0 1.25rem; + font-size: 0.8rem; + color: var(--ucdavis-blue-100, #022851); + background: none; + border: none; + border-bottom: 1px dotted var(--ucdavis-blue-100, #022851); + padding: 0 0 1px; + cursor: pointer; + text-align: left; + opacity: 0.75; +} +.epaThreadMore:hover, +.epaThreadMore:focus-visible { + opacity: 1; +} +.epaThreadMore:focus-visible { + outline: 2px solid var(--q-primary); + outline-offset: 2px; +} +.epaChartPanel { + flex-shrink: 0; + padding-top: 0.125rem; +} +.epaChartPanel svg { + display: block; + overflow: visible; +} +.epaChartButton { + flex-shrink: 0; + padding: 0.25rem; + background: none; + border: 1px solid transparent; + border-radius: 0.375rem; + cursor: pointer; + transition: + border-color 0.12s, + background-color 0.12s; +} +.epaChartButton:hover, +.epaChartButton:focus-visible { + border-color: var(--ucdavis-blue-100, #022851); + background-color: rgba(2, 40, 81, 0.03); +} +.epaChartButton:focus-visible { + outline: 2px solid var(--q-primary); + outline-offset: 2px; +} +.epaChartExpandHint { + display: block; + font-size: 0.7rem; + color: #6c757d; + text-align: center; + margin-top: 0.25rem; + letter-spacing: 0.03em; + transition: color 0.12s; +} +.epaChartButton:hover .epaChartExpandHint, +.epaChartButton:focus-visible .epaChartExpandHint { + color: var(--ucdavis-blue-100, #022851); +} +@media (max-width: 599px) { + .epaThreadContent { + flex-direction: column; + gap: 1rem; + } + .epaChartButton { + align-self: center; + order: -1; + } +} + +/* Style toggle (segmented control on MyAssessments) */ +.assessmentStyleToggle { + display: inline-flex; + flex-wrap: wrap; + gap: 0.25rem; +} + +/* ── Style 5: Supervision Blend ── */ +.epaBlend { + background: #ffffff; + border: 1px solid #e8e4dc; + border-radius: 0.5rem; + padding: 1.375rem 1.75rem; + margin-bottom: 1.125rem; +} +.epaBlendHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid #e8e4dc; + padding-bottom: 0.75rem; + margin-bottom: 1.125rem; +} +.epaBlendTitle { + font-family: Georgia, "Times New Roman", serif; + font-style: italic; + font-weight: 500; + font-size: 1.125rem; + color: var(--ucdavis-blue-100, #022851); + line-height: 1.3; +} +.epaBlendCount { + font-size: 0.72rem; + font-weight: 500; + color: #6c757d; + text-transform: lowercase; + letter-spacing: 0.04em; + white-space: nowrap; + flex-shrink: 0; +} +.epaBlendEmpty { + font-style: italic; + font-size: 0.85rem; + color: #616161; +} +.epaBlendBar { + display: flex; + width: 100%; + height: 1.5rem; + border-radius: 0.5rem; + overflow: hidden; + background: #f0ece4; + margin-bottom: 0.875rem; +} +.epaBlendSegment { + height: 100%; + box-shadow: inset -1px 0 0 #ffffff; + transition: width 0.2s; +} +.epaBlendSegment:last-child { + box-shadow: none; +} +.epaBlendSegment--lv-1 { + background-color: rgba(62, 127, 238, 0.3); +} +.epaBlendSegment--lv-2 { + background-color: rgba(62, 127, 238, 0.7); +} +.epaBlendSegment--lv-3 { + background-color: rgba(62, 127, 238, 1); +} +.epaBlendSegment--lv-4 { + background-color: rgba(0, 44, 175, 0.8); +} +.epaBlendSegment--lv-5 { + background-color: rgba(11, 3, 139, 1); +} +.epaBlendLegend { + list-style: none; + margin: 0 0 1.25rem; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.25rem; +} +.epaBlendLegendItem { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: #1a1d2e; +} +.epaBlendSwatch { + width: 0.75rem; + height: 0.75rem; + border-radius: 0.1875rem; + flex-shrink: 0; +} +.epaBlendLegendLabel { + color: #1a1d2e; +} +.epaBlendLegendCount { + color: #6c757d; + font-variant-numeric: tabular-nums; +} +.epaBlendComments--withBar { + border-top: 1px solid #f0ece4; + padding-top: 0.875rem; +} +.epaBlendComment { + padding: 0.625rem 0; + border-top: 1px solid #f0ece4; +} +.epaBlendComment:first-child { + border-top: none; + padding-top: 0; +} +.epaBlendCommentText { + font-family: Georgia, "Times New Roman", serif; + font-style: italic; + font-size: 1rem; + line-height: 1.55; + color: #1a1a1a; + margin: 0 0 0.375rem; +} +.epaBlendCommentMissing { + font-size: 0.85rem; + color: #adb5bd; + font-style: italic; + margin-bottom: 0.375rem; +} +.epaBlendCommentMeta { + font-size: 0.8rem; + color: #6c757d; +} +.epaBlendCommentMeta strong { + color: var(--ucdavis-blue-100, #022851); + font-weight: 600; +} +.epaBlendMore { + margin: 0.5rem 0 0; + font-size: 0.8rem; + color: var(--ucdavis-blue-100, #022851); + background: none; + border: none; + border-bottom: 1px dotted var(--ucdavis-blue-100, #022851); + padding: 0 0 1px; + cursor: pointer; + text-align: left; + opacity: 0.75; +} +.epaBlendMore:hover, +.epaBlendMore:focus-visible { + opacity: 1; +} +.epaBlendMore:focus-visible { + outline: 2px solid var(--q-primary); + outline-offset: 2px; +} + /* .assessmentbubble.ab5_1 { background-color: rgb(169, 208, 255) @@ -105,6 +606,6 @@ background-color: rgb(42, 60, 152) } .assessmentbubble.ab5_5 { - background-color: rgb(0, 11, 113) + background-color: rgb(0, 11, 113) } */ diff --git a/VueApp/src/CTS/components/AssessmentBubble.vue b/VueApp/src/CTS/components/AssessmentBubble.vue index 7a7c700e6..ce44eb75c 100644 --- a/VueApp/src/CTS/components/AssessmentBubble.vue +++ b/VueApp/src/CTS/components/AssessmentBubble.vue @@ -1,22 +1,40 @@ diff --git a/VueApp/src/CTS/components/EpaProgressionChart.vue b/VueApp/src/CTS/components/EpaProgressionChart.vue new file mode 100644 index 000000000..ca744838d --- /dev/null +++ b/VueApp/src/CTS/components/EpaProgressionChart.vue @@ -0,0 +1,299 @@ + + + + diff --git a/VueApp/src/CTS/components/EpaSupervisionBlend.vue b/VueApp/src/CTS/components/EpaSupervisionBlend.vue new file mode 100644 index 000000000..9631b94c4 --- /dev/null +++ b/VueApp/src/CTS/components/EpaSupervisionBlend.vue @@ -0,0 +1,122 @@ + + diff --git a/VueApp/src/CTS/components/EpaVoiceThread.vue b/VueApp/src/CTS/components/EpaVoiceThread.vue new file mode 100644 index 000000000..f1a822bef --- /dev/null +++ b/VueApp/src/CTS/components/EpaVoiceThread.vue @@ -0,0 +1,264 @@ + + diff --git a/VueApp/src/CTS/pages/MyAssessments.vue b/VueApp/src/CTS/pages/MyAssessments.vue index 11aad2771..8ed57b5c6 100644 --- a/VueApp/src/CTS/pages/MyAssessments.vue +++ b/VueApp/src/CTS/pages/MyAssessments.vue @@ -1,6 +1,6 @@