From 9eeec013a1ed947ecdb015fe27601019b71e37a6 Mon Sep 17 00:00:00 2001 From: obsidian <150540200+AJBcoding@users.noreply.github.com> Date: Tue, 5 May 2026 04:26:26 -0700 Subject: [PATCH 1/2] cp-j0gw.11: interactive M01 cohort-floor demo widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone, mobile-readable widget that lets readers drag a slider between 16 and 30 and watch which illustrative programs flip between MEASURED and NOT MEASURED. Demonstrates M01 (cohort-visibility cascade) without any institutional data. New files - web/widgets/cohort-floor-demo.html — standalone page; mirrors the top-disclaimer + public-data-badge patterns from web/learn.html on cp-0on-ext-senate-plain-lang. - web/widgets/cohort-floor-demo.js — vanilla ES module. Exports computeVerdict / summarizeAt / detectFlips for unit testing; auto-wires the slider on DOMContentLoaded. - web/widgets/cohort-floor-demo.d.ts — type declarations matching the existing web/js/*.d.ts pattern. - web/widgets/cohort-floor-demo.css — page-specific styling, including inline copies of the public-data-badge + top-disclaimer rules so the page stays self-contained on main (where css/learn.css does not yet exist). - tests/ui/cohort-floor-demo.test.ts — 12 unit tests for the verdict-flip computation, including the cp-wssr AHEAD-published-flag override. Modified - web/index.html — adds a third persona-card linking to the demo. Spec hooks - Slider 16↔30 (cp-wssr methodology range). - 12 illustrative programs, synthetic completer counts, labeled "illustrative" on the page. - Caption cites M01 + cp-wssr + NPRM § 668.403(d)(1) + HEA § 454(c)(4)(A). - No external requests, no analytics, no LLM at request time. - Mobile viewport meta tag + touch/keyboard slider. Tests: 209/209 passing (197 baseline on main + 12 new). typecheck clean. Note: the bead requested a link from web/learn.html; that file lives only on cp-0on-ext-senate-plain-lang, not on main. Chair to add the learn.html link when that branch merges. Refs: cp-j0gw.11 --- tests/ui/cohort-floor-demo.test.ts | 113 ++++++++++++++++++++ web/index.html | 11 ++ web/widgets/cohort-floor-demo.css | 161 +++++++++++++++++++++++++++++ web/widgets/cohort-floor-demo.d.ts | 50 +++++++++ web/widgets/cohort-floor-demo.html | 111 ++++++++++++++++++++ web/widgets/cohort-floor-demo.js | 152 +++++++++++++++++++++++++++ 6 files changed, 598 insertions(+) create mode 100644 tests/ui/cohort-floor-demo.test.ts create mode 100644 web/widgets/cohort-floor-demo.css create mode 100644 web/widgets/cohort-floor-demo.d.ts create mode 100644 web/widgets/cohort-floor-demo.html create mode 100644 web/widgets/cohort-floor-demo.js diff --git a/tests/ui/cohort-floor-demo.test.ts b/tests/ui/cohort-floor-demo.test.ts new file mode 100644 index 0000000..0472f53 --- /dev/null +++ b/tests/ui/cohort-floor-demo.test.ts @@ -0,0 +1,113 @@ +// M01 cohort-floor demo widget — verdict-flip computation tests (cp-j0gw.11). + +import { describe, expect, it } from 'vitest'; +import { + FLOOR_MIN, + FLOOR_MAX, + FLOOR_DEFAULT, + SAMPLE_PROGRAMS, + computeVerdict, + summarizeAt, + detectFlips, +} from '../../web/widgets/cohort-floor-demo.js'; + +describe('cohort-floor-demo: constants', () => { + it('floor range is 16–30 inclusive', () => { + expect(FLOOR_MIN).toBe(16); + expect(FLOOR_MAX).toBe(30); + expect(FLOOR_DEFAULT).toBe(30); + }); + + it('illustrative dataset has at least one program at every interesting boundary', () => { + const ns = SAMPLE_PROGRAMS.map((p) => p.n); + expect(Math.max(...ns)).toBeGreaterThanOrEqual(30); + expect(Math.min(...ns)).toBeLessThan(16); + expect(ns.some((n) => n >= 16 && n < 30)).toBe(true); + }); +}); + +describe('cohort-floor-demo: computeVerdict', () => { + it('MEASURED when n >= floor', () => { + expect(computeVerdict({ n: 30, ahead_published: false }, 30)).toBe('MEASURED'); + expect(computeVerdict({ n: 100, ahead_published: false }, 30)).toBe('MEASURED'); + expect(computeVerdict({ n: 16, ahead_published: false }, 16)).toBe('MEASURED'); + }); + + it('NOT_MEASURED when n < floor and no AHEAD override', () => { + expect(computeVerdict({ n: 22, ahead_published: false }, 30)).toBe('NOT_MEASURED'); + expect(computeVerdict({ n: 15, ahead_published: false }, 16)).toBe('NOT_MEASURED'); + }); + + it('MEASURED_OVERRIDE when n in [16, floor) and AHEAD published', () => { + expect(computeVerdict({ n: 22, ahead_published: true }, 30)).toBe('MEASURED_OVERRIDE'); + expect(computeVerdict({ n: 16, ahead_published: true }, 30)).toBe('MEASURED_OVERRIDE'); + }); + + it('AHEAD override does not apply below n=16', () => { + expect(computeVerdict({ n: 15, ahead_published: true }, 30)).toBe('NOT_MEASURED'); + expect(computeVerdict({ n: 7, ahead_published: true }, 30)).toBe('NOT_MEASURED'); + }); + + it('NOT_MEASURED for invalid n', () => { + expect(computeVerdict({ ahead_published: true }, 30)).toBe('NOT_MEASURED'); + expect(computeVerdict({ n: NaN, ahead_published: true }, 30)).toBe('NOT_MEASURED'); + }); +}); + +describe('cohort-floor-demo: summarizeAt', () => { + const programs = [ + { id: 'a', n: 38, ahead_published: true }, + { id: 'b', n: 22, ahead_published: true }, + { id: 'c', n: 22, ahead_published: false }, + { id: 'd', n: 7, ahead_published: false }, + ]; + + it('partitions programs at floor=30', () => { + const s = summarizeAt(programs, 30); + expect(s.measured.map((p) => p.id)).toEqual(['a']); + expect(s.override.map((p) => p.id)).toEqual(['b']); + expect(s.notMeasured.map((p) => p.id)).toEqual(['c', 'd']); + }); + + it('partitions programs at floor=16', () => { + const s = summarizeAt(programs, 16); + expect(s.measured.map((p) => p.id)).toEqual(['a', 'b', 'c']); + expect(s.override).toEqual([]); + expect(s.notMeasured.map((p) => p.id)).toEqual(['d']); + }); +}); + +describe('cohort-floor-demo: detectFlips', () => { + it('detects programs that flip MEASURED → NOT_MEASURED as the floor rises', () => { + const programs = [ + { id: 'stable-high', n: 38, ahead_published: false }, + { id: 'flips', n: 22, ahead_published: false }, + { id: 'override-stable', n: 22, ahead_published: true }, + { id: 'stable-low', n: 7, ahead_published: false }, + ]; + const flips = detectFlips(programs, 16, 30); + expect(flips).toHaveLength(2); + const idsByFlip = new Map(flips.map((f) => [f.program.id, f])); + expect(idsByFlip.get('flips')).toMatchObject({ from: 'MEASURED', to: 'NOT_MEASURED' }); + expect(idsByFlip.get('override-stable')).toMatchObject({ + from: 'MEASURED', + to: 'MEASURED_OVERRIDE', + }); + }); + + it('returns empty when no programs flip', () => { + const programs = [ + { id: 'high', n: 38, ahead_published: false }, + { id: 'low', n: 5, ahead_published: false }, + ]; + expect(detectFlips(programs, 20, 25)).toEqual([]); + }); + + it('flip set on the SAMPLE_PROGRAMS between floor=16 and floor=30 is non-empty', () => { + const flips = detectFlips(SAMPLE_PROGRAMS, 16, 30); + expect(flips.length).toBeGreaterThan(0); + for (const f of flips) { + expect(f.from).not.toBe(f.to); + } + }); +}); diff --git a/web/index.html b/web/index.html index a1f6dac..94515c6 100644 --- a/web/index.html +++ b/web/index.html @@ -42,6 +42,17 @@

Check specific programs

Check specific programs → + +
+

See how the 30-completer floor works

+

+ Drag a slider between 16 and 30 and watch which illustrative + programs flip between MEASURED and NOT MEASURED. Demonstrates + M01 — the cohort-visibility cascade — with synthetic completer + counts; no institutional data leaves your browser. +

+ Try the cohort-floor demo → +
diff --git a/web/widgets/cohort-floor-demo.css b/web/widgets/cohort-floor-demo.css new file mode 100644 index 0000000..fb2349d --- /dev/null +++ b/web/widgets/cohort-floor-demo.css @@ -0,0 +1,161 @@ +/* M01 cohort-floor demo widget — page-specific styling. */ + +.public-data-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: #e6f4ea; + color: #14532d; + padding: 0.35rem 0.75rem; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 600; + margin-top: 0.75rem; +} + +.public-data-badge .badge-dot { + color: #16a34a; + font-size: 0.65rem; +} + +.top-disclaimer { + background: #fff8e6; + border-left: 4px solid #c08a00; + padding: var(--space-sm) var(--space-md); + margin: 0 var(--space-md) var(--space-md); + border-radius: 4px; + font-size: 0.95rem; + line-height: 1.45; + max-width: var(--max-content-width); +} + +.top-disclaimer p { margin: 0; } +.top-disclaimer strong { font-weight: 700; } + +.cf-page { + max-width: var(--max-content-width); + margin: 0 auto; + padding: var(--space-md); +} + +.cf-control, +.cf-results, +.cf-caption { + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--space-md); + margin-bottom: var(--space-md); +} + +.cf-control h2 { + margin-top: 0; +} + +.cf-slider-label { + display: block; + font-size: 0.9rem; + color: var(--color-text-muted); + margin-bottom: var(--space-sm); +} + +.cf-slider { + width: 100%; + height: 2.25rem; + cursor: pointer; +} + +.cf-scale { + display: flex; + justify-content: space-between; + font-size: 0.8rem; + color: var(--color-text-muted); + margin-top: var(--space-xs); +} + +.cf-counts { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); + margin: var(--space-md) 0 0; + font-size: 0.9rem; +} + +.cf-count { + padding: 0.15rem 0.5rem; + border-radius: 999px; + font-weight: 600; +} + +.cf-count-measured { + background: var(--color-pass-bg); + color: var(--color-pass-band); +} + +.cf-count-override { + background: var(--color-noise-band-bg); + color: var(--color-noise-band-band); +} + +.cf-count-not-measured { + background: var(--color-not-measured-bg); + color: var(--color-not-measured-band); +} + +.cf-list { + list-style: none; + padding: 0; + margin: 0; +} + +.cf-row { + display: grid; + grid-template-columns: 1fr auto auto; + gap: var(--space-sm); + align-items: baseline; + padding: var(--space-sm) var(--space-sm); + border-bottom: 1px solid var(--color-border); + font-size: 0.95rem; +} + +.cf-row:last-child { + border-bottom: none; +} + +.cf-row-label { font-weight: 500; } +.cf-row-n { color: var(--color-text-muted); font-variant-numeric: tabular-nums; } +.cf-row-verdict { + font-weight: 700; + font-size: 0.85rem; + letter-spacing: 0.02em; + padding: 0.1rem 0.5rem; + border-radius: var(--radius); +} + +.cf-measured .cf-row-verdict { + background: var(--color-pass-bg); + color: var(--color-pass-band); +} + +.cf-measured-override .cf-row-verdict { + background: var(--color-noise-band-bg); + color: var(--color-noise-band-band); +} + +.cf-not-measured .cf-row-verdict { + background: var(--color-not-measured-bg); + color: var(--color-not-measured-band); +} + +.cf-caption h2 { margin-top: 0; } + +.cf-footer { + font-size: 0.9rem; + color: var(--color-text-muted); + margin-top: var(--space-md); +} + +@media (prefers-color-scheme: dark) { + .public-data-badge { background: #14321b; color: #b6e8c4; } + .top-disclaimer { background: #2a2410; border-left-color: #d6a400; color: #f4e8c2; } +} diff --git a/web/widgets/cohort-floor-demo.d.ts b/web/widgets/cohort-floor-demo.d.ts new file mode 100644 index 0000000..ae227d6 --- /dev/null +++ b/web/widgets/cohort-floor-demo.d.ts @@ -0,0 +1,50 @@ +// Type declarations for web/widgets/cohort-floor-demo.js (cp-j0gw.11). + +export const FLOOR_MIN: 16; +export const FLOOR_MAX: 30; +export const FLOOR_DEFAULT: 30; + +export interface SampleProgram { + id: string; + label: string; + n: number; + ahead_published: boolean; +} + +export const SAMPLE_PROGRAMS: ReadonlyArray; + +export type Verdict = 'MEASURED' | 'MEASURED_OVERRIDE' | 'NOT_MEASURED'; + +export function computeVerdict( + program: { n?: number; ahead_published?: boolean } | null | undefined, + floor: number, +): Verdict; + +export interface FloorSummary

{ + floor: number; + measured: P[]; + override: P[]; + notMeasured: P[]; +} + +export function summarizeAt

( + programs: ReadonlyArray

, + floor: number, +): FloorSummary

; + +export interface FlipRecord

{ + program: P; + from: Verdict; + to: Verdict; +} + +export function detectFlips

( + programs: ReadonlyArray

, + fromFloor: number, + toFloor: number, +): Array>; + +export function initCohortFloorDemo( + rootEl: HTMLElement | null, + programs?: ReadonlyArray, +): void; diff --git a/web/widgets/cohort-floor-demo.html b/web/widgets/cohort-floor-demo.html new file mode 100644 index 0000000..2a2ce8f --- /dev/null +++ b/web/widgets/cohort-floor-demo.html @@ -0,0 +1,111 @@ + + + + + + M01 Cohort-floor demo — CSU Deans EP Tool + + + + + +

+ + + +
+
+

Cohort floor: 30

+ + + +

+
+ +
+

Programs at this floor

+
    +
    + +
    +

    What this is showing

    +

    + AHEAD's count_wne_p4 is single-window — a count of + completers in one award year matched to year-4 earnings. OBBBA's + statutory 30-floor (HEA § 454(c)(4)(A)) is for the + pooled measurement cohort assembled under NPRM § IX.GD + (raw.txt 2272–2344), one award year added at a time across years + 5–8 prior to the earnings year. +

    +

    + The AHEAD-published-flag override (cp-wssr) honors + verdicts in the 16 ≤ n < 30 gap when AHEAD already published a + flag for the program. That override is why some programs in this + demo stay MEASURED at floor=30 even though their pooled cohort + sits below 30. +

    +
    + + +
    + +
    +

    + Re-derive against primary sources before any external submission. +

    +
    + + + + diff --git a/web/widgets/cohort-floor-demo.js b/web/widgets/cohort-floor-demo.js new file mode 100644 index 0000000..c9e00e8 --- /dev/null +++ b/web/widgets/cohort-floor-demo.js @@ -0,0 +1,152 @@ +// M01 cohort-floor demo widget — verdict-flip logic + DOM wiring. +// +// Model (cp-j0gw.11 spec): +// - Slider chooses a cohort floor in the range [16, 30]. +// - 16 = the AHEAD count_wne_p4 single-window publication threshold. +// - 30 = the OBBBA statutory floor for the pooled measurement cohort +// (NPRM § 668.403(d)(1); HEA § 454(c)(4)(A)). +// - For each illustrative program, the verdict at a given floor is: +// MEASURED if n >= floor +// MEASURED if n < floor AND ahead_published === true AND n >= 16 +// (the cp-wssr AHEAD-published-flag override: +// when AHEAD already published a verdict in the +// 16 <= n < 30 gap, the override honors it) +// NOT MEASURED otherwise +// +// The data set is illustrative and labeled as such on the page — no +// institution-specific completer counts. + +export const FLOOR_MIN = 16; +export const FLOOR_MAX = 30; +export const FLOOR_DEFAULT = 30; + +/** Illustrative programs (synthetic, labeled "illustrative" on the page). */ +export const SAMPLE_PROGRAMS = Object.freeze([ + { id: 'p01', label: 'Program A — 4-yr, CIP 50.07', n: 38, ahead_published: true }, + { id: 'p02', label: 'Program B — 4-yr, CIP 50.05', n: 32, ahead_published: true }, + { id: 'p03', label: 'Program C — 4-yr, CIP 50.06', n: 28, ahead_published: true }, + { id: 'p04', label: 'Program D — 4-yr, CIP 50.07', n: 25, ahead_published: true }, + { id: 'p05', label: 'Program E — 4-yr, CIP 50.09', n: 22, ahead_published: true }, + { id: 'p06', label: 'Program F — 4-yr, CIP 50.04', n: 20, ahead_published: false }, + { id: 'p07', label: 'Program G — 4-yr, CIP 50.0102', n: 18, ahead_published: true }, + { id: 'p08', label: 'Program H — 4-yr, CIP 50.10', n: 17, ahead_published: false }, + { id: 'p09', label: 'Program I — 4-yr, CIP 50.0901', n: 16, ahead_published: true }, + { id: 'p10', label: 'Program J — 4-yr, CIP 50.06', n: 14, ahead_published: false }, + { id: 'p11', label: 'Program K — 4-yr, CIP 50.07', n: 11, ahead_published: false }, + { id: 'p12', label: 'Program L — 4-yr, CIP 50.05', n: 7, ahead_published: false }, +]); + +/** + * Compute the verdict for one program at a given cohort floor. + * + * @param {{n:number, ahead_published:boolean}} program + * @param {number} floor integer in [FLOOR_MIN, FLOOR_MAX] + * @returns {'MEASURED'|'MEASURED_OVERRIDE'|'NOT_MEASURED'} + */ +export function computeVerdict(program, floor) { + if (!Number.isFinite(program?.n)) return 'NOT_MEASURED'; + if (program.n >= floor) return 'MEASURED'; + if (program.ahead_published === true && program.n >= FLOOR_MIN) { + return 'MEASURED_OVERRIDE'; + } + return 'NOT_MEASURED'; +} + +/** + * Categorize all programs at a given floor into measured / override / not-measured. + * @param {ReadonlyArray<{id:string,label:string,n:number,ahead_published:boolean}>} programs + * @param {number} floor + */ +export function summarizeAt(programs, floor) { + const measured = []; + const override = []; + const notMeasured = []; + for (const p of programs) { + const v = computeVerdict(p, floor); + if (v === 'MEASURED') measured.push(p); + else if (v === 'MEASURED_OVERRIDE') override.push(p); + else notMeasured.push(p); + } + return { floor, measured, override, notMeasured }; +} + +/** + * Detect programs whose verdict differs between two floor settings. + * Returns an array of { program, from, to } records, ordered as in the input. + */ +export function detectFlips(programs, fromFloor, toFloor) { + const flips = []; + for (const p of programs) { + const from = computeVerdict(p, fromFloor); + const to = computeVerdict(p, toFloor); + if (from !== to) flips.push({ program: p, from, to }); + } + return flips; +} + +/* ─── DOM wiring ───────────────────────────────────────────────────── */ + +const VERDICT_LABELS = { + MEASURED: 'MEASURED', + MEASURED_OVERRIDE: 'MEASURED (AHEAD override)', + NOT_MEASURED: 'NOT MEASURED', +}; + +function renderProgramRow(program, verdict) { + const row = document.createElement('li'); + row.className = `cf-row cf-${verdict.toLowerCase().replace(/_/g, '-')}`; + row.innerHTML = ` + ${program.label} + n=${program.n} + ${VERDICT_LABELS[verdict]} + `; + return row; +} + +function render(rootEl, summary) { + const slot = rootEl.querySelector('[data-cf-list]'); + if (!slot) return; + slot.innerHTML = ''; + for (const p of [...summary.measured, ...summary.override, ...summary.notMeasured]) { + const v = computeVerdict(p, summary.floor); + slot.appendChild(renderProgramRow(p, v)); + } + const counters = rootEl.querySelector('[data-cf-counts]'); + if (counters) { + counters.innerHTML = ` + ${summary.measured.length} measured + ${summary.override.length} AHEAD override + ${summary.notMeasured.length} not measured + `; + } + const floorLabel = rootEl.querySelector('[data-cf-floor]'); + if (floorLabel) floorLabel.textContent = String(summary.floor); +} + +/** + * Initialize the widget against a root element. Idempotent; safe to call once. + * @param {HTMLElement} rootEl + * @param {ReadonlyArray} programs + */ +export function initCohortFloorDemo(rootEl, programs = SAMPLE_PROGRAMS) { + if (!rootEl) return; + const slider = rootEl.querySelector('[data-cf-slider]'); + if (!slider) return; + const apply = () => { + const raw = Number(slider.value); + const floor = Number.isFinite(raw) + ? Math.min(FLOOR_MAX, Math.max(FLOOR_MIN, Math.round(raw))) + : FLOOR_DEFAULT; + render(rootEl, summarizeAt(programs, floor)); + }; + slider.addEventListener('input', apply); + slider.addEventListener('change', apply); + apply(); +} + +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + document.addEventListener('DOMContentLoaded', () => { + const rootEl = document.querySelector('[data-cf-root]'); + if (rootEl) initCohortFloorDemo(rootEl); + }); +} From 38e236cbfd7f458c05a4e25f12104a207ea148bc Mon Sep 17 00:00:00 2001 From: obsidian <150540200+AJBcoding@users.noreply.github.com> Date: Tue, 5 May 2026 05:30:55 -0700 Subject: [PATCH 2/2] lint: replace cascade literal in widget link text (cp-j0gw.17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design.v6 §13 forbids the literal "cascade" in dean-facing UI surfaces. Two surfaces in the cp-j0gw.11 widget commit tripped the linter: - web/widgets/cohort-floor-demo.html footer link text "M01 — Cohort visibility cascade" → "M01 — The cohort-expansion rule" (matches the existing M01 panel header in web/learn.html on cp-0on-ext-senate-plain-lang). - web/index.html persona-card body "the cohort-visibility cascade" → "the cohort-expansion rule" (same substitute). Verification: - npx tsx scripts/lint-glossary.ts content web → no violations - npm test → 209/209 passing on this branch --- web/index.html | 2 +- web/widgets/cohort-floor-demo.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/index.html b/web/index.html index 94515c6..15b1b6f 100644 --- a/web/index.html +++ b/web/index.html @@ -48,7 +48,7 @@

    See how the 30-completer floor works

    Drag a slider between 16 and 30 and watch which illustrative programs flip between MEASURED and NOT MEASURED. Demonstrates - M01 — the cohort-visibility cascade — with synthetic completer + M01 — the cohort-expansion rule — with synthetic completer counts; no institutional data leaves your browser.

    Try the cohort-floor demo → diff --git a/web/widgets/cohort-floor-demo.html b/web/widgets/cohort-floor-demo.html index 2a2ce8f..7effe5c 100644 --- a/web/widgets/cohort-floor-demo.html +++ b/web/widgets/cohort-floor-demo.html @@ -92,7 +92,7 @@

    What this is showing