diff --git a/docs/superpowers/plans/2026-08-21-student-visibility-preview-hotfix.md b/docs/superpowers/plans/2026-08-21-student-visibility-preview-hotfix.md
new file mode 100644
index 0000000..799084a
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-21-student-visibility-preview-hotfix.md
@@ -0,0 +1,343 @@
+# Student Visibility Preview Hotfix Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add an authenticated staff-only student preview that enforces logged-out resource and link visibility without changing production authentication, backend behavior, or workflow exposure.
+
+**Architecture:** Keep the real session intact and derive a separate `canBypassVisibility` boolean from authentication plus the `preview=student` query parameter. `CourseRecords` owns preview state and passes visibility authority explicitly to `RecordCard` and `RecordTable`; pure URL and policy helpers provide unit-test coverage without adding dependencies.
+
+**Tech Stack:** React 18, React Router, Reactstrap, Vitest 3, ESLint 9, Vite 5
+
+**Spec:** `docs/superpowers/specs/2026-08-21-student-visibility-preview-hotfix-design.md`
+
+## Global Constraints
+
+- Base all work on production commit `eacfc14d8fdff51030b989f6fd640c8357d44973`.
+- Do not change `AuthContext`, cookies, tokens, permissions, backend APIs, or persisted visibility data.
+- Do not modify `src/pages/Admin.jsx`, `src/components/layout/AppRoutes.jsx`, `src/components/layout/Header.jsx`, `src/config/api.config.js`, `.env.production`, `.env.staging`, or `server/workflow-admin/`.
+- `preview=student` may only make a view more restrictive; it must never grant visibility.
+- Preserve all unrelated URL query parameters when entering or leaving preview.
+- Do not add runtime or development dependencies.
+
+---
+
+### Task 1: Add tested student-preview policy helpers
+
+**Files:**
+- Create: `src/util/studentPreview.js`
+- Create: `src/util/studentPreview.test.js`
+- Modify: `package.json`
+
+**Interfaces:**
+- Produces: `isStudentPreview(search: string): boolean`
+- Produces: `withStudentPreview(search: string, enabled: boolean): string`
+- Produces: `canBypassVisibility(isAuthenticated: boolean, studentPreview: boolean): boolean`
+
+- [ ] **Step 1: Add the test command and failing helper tests**
+
+Add `"test": "vitest run"` to `package.json` scripts and create:
+
+```js
+import { describe, expect, it } from 'vitest';
+import {
+ canBypassVisibility,
+ isStudentPreview,
+ withStudentPreview,
+} from './studentPreview';
+
+describe('student preview policy', () => {
+ it('recognizes only the explicit student preview value', () => {
+ expect(isStudentPreview('?preview=student')).toBe(true);
+ expect(isStudentPreview('?preview=staff')).toBe(false);
+ expect(isStudentPreview('')).toBe(false);
+ });
+
+ it('allows only non-preview authenticated sessions to bypass visibility', () => {
+ expect(canBypassVisibility(true, false)).toBe(true);
+ expect(canBypassVisibility(true, true)).toBe(false);
+ expect(canBypassVisibility(false, false)).toBe(false);
+ expect(canBypassVisibility(false, true)).toBe(false);
+ });
+
+ it('adds preview without losing existing parameters', () => {
+ expect(withStudentPreview('?college=smith§ion=01', true))
+ .toBe('?college=smith§ion=01&preview=student');
+ });
+
+ it('removes only preview when exiting', () => {
+ expect(withStudentPreview('?college=smith&preview=student§ion=01', false))
+ .toBe('?college=smith§ion=01');
+ });
+});
+```
+
+- [ ] **Step 2: Run the test and verify it fails**
+
+Run:
+
+```bash
+npm test -- src/util/studentPreview.test.js
+```
+
+Expected: FAIL because `src/util/studentPreview.js` does not exist.
+
+- [ ] **Step 3: Implement the pure helpers**
+
+Create `src/util/studentPreview.js`:
+
+```js
+const PREVIEW_PARAM = 'preview';
+const STUDENT_PREVIEW_VALUE = 'student';
+
+export const isStudentPreview = (search = '') => {
+ return new URLSearchParams(search).get(PREVIEW_PARAM) === STUDENT_PREVIEW_VALUE;
+};
+
+export const withStudentPreview = (search = '', enabled) => {
+ const params = new URLSearchParams(search);
+
+ if (enabled) {
+ params.set(PREVIEW_PARAM, STUDENT_PREVIEW_VALUE);
+ } else {
+ params.delete(PREVIEW_PARAM);
+ }
+
+ const query = params.toString();
+ return query ? `?${query}` : '';
+};
+
+export const canBypassVisibility = (isAuthenticated, studentPreview) => {
+ return Boolean(isAuthenticated && !studentPreview);
+};
+```
+
+- [ ] **Step 4: Run the helper tests**
+
+Run:
+
+```bash
+npm test -- src/util/studentPreview.test.js
+```
+
+Expected: 4 tests PASS.
+
+- [ ] **Step 5: Commit the policy helper**
+
+```bash
+git add package.json src/util/studentPreview.js src/util/studentPreview.test.js
+git commit -m "test: define student preview visibility policy"
+```
+
+---
+
+### Task 2: Add preview state and controls to the course page
+
+**Files:**
+- Modify: `src/pages/CourseRecords.jsx:1-50, 517-543, 756-843, 1045-1130, 1177-1338`
+
+**Interfaces:**
+- Consumes: `isStudentPreview(location.search)`
+- Consumes: `withStudentPreview(location.search, enabled)`
+- Consumes: `canBypassVisibility(isAuthenticated, studentPreview)`
+- Produces: `canBypassResourceVisibility: boolean` passed to record views
+
+- [ ] **Step 1: Import the policy helpers and derive page state**
+
+Import the three helpers from `../util/studentPreview`. Immediately after reading authentication state, derive:
+
+```js
+const studentPreview = isStudentPreview(location.search);
+const canBypassResourceVisibility = canBypassVisibility(
+ isAuthenticated,
+ studentPreview
+);
+
+const setStudentPreview = useCallback((enabled) => {
+ navigate({
+ pathname: location.pathname,
+ search: withStudentPreview(location.search, enabled),
+ hash: location.hash,
+ }, { replace: true });
+}, [location.hash, location.pathname, location.search, navigate]);
+```
+
+- [ ] **Step 2: Replace page-level visibility authority**
+
+Within resource filtering, hidden-resource counts, and the “not currently available” summary, replace visibility uses of `isAuthenticated` with `canBypassResourceVisibility`. Keep `isAuthenticated` only for deciding whether to show staff preview controls.
+
+Update hook dependency arrays accordingly. Do not replace authentication used for unrelated session behavior.
+
+- [ ] **Step 3: Add the preview action and active banner**
+
+Immediately after the course-information block and before the display-mode control (currently between lines 1086 and 1088), render **Preview as student** only when `isAuthenticated && !studentPreview`. When `studentPreview` is true, render:
+
+```jsx
+
+
+ Student preview is active.{' '}
+ Resources and links outside their visibility dates are hidden as they are for students.
+
+
+
+```
+
+The enter action calls `setStudentPreview(true)`. Reuse Reactstrap components already imported by the page; add no new package.
+
+- [ ] **Step 4: Pass visibility authority to every record view**
+
+Pass the same prop through every render path:
+
+```jsx
+
+
+```
+
+Cover grouped cards, ungrouped cards, split-view physical cards, split-view electronic cards, and the table view. Remove the currently ineffective `isAuthenticated={isAuthenticated}` props passed to `RecordCard`.
+
+- [ ] **Step 5: Run targeted lint**
+
+Run:
+
+```bash
+npx eslint src/pages/CourseRecords.jsx src/util/studentPreview.js src/util/studentPreview.test.js
+```
+
+Expected: exit 0.
+
+- [ ] **Step 6: Commit the course-page controls**
+
+```bash
+git add src/pages/CourseRecords.jsx
+git commit -m "feat: add student preview controls"
+```
+
+---
+
+### Task 3: Make card and table views honor explicit visibility authority
+
+**Files:**
+- Modify: `src/components/page-sections/course-record/RecordCard.jsx:18, 40-56, 171-214, 250, 522-625, 758-899`
+- Modify: `src/components/page-sections/course-record/RecordTable.jsx:14, 40-56, 96-224, 355-373, 585-632, 746, 1061-1082`
+
+**Interfaces:**
+- Consumes: `canBypassVisibility: boolean` from `CourseRecords`
+- Produces: matching resource and link visibility behavior in card and table views
+
+- [ ] **Step 1: Update `RecordCard`**
+
+Add `canBypassVisibility = false` to its props, remove the direct `useAuth` import and hook, and use the prop everywhere the component currently uses `isAuthenticated` for:
+
+- whole-resource visibility;
+- primary-link visibility;
+- additional-link visibility;
+- staff-only visibility date text and popovers;
+- decisions to render unavailable resources.
+
+Add `canBypassVisibility: PropTypes.bool` and its default value. Do not change tracking behavior or URLs.
+
+- [ ] **Step 2: Update `RecordTable`**
+
+Add `canBypassVisibility = false` to its props, remove the direct `useAuth` import and hook, and use the prop everywhere the table currently uses `isAuthenticated` for filtering, primary links, additional links, and staff visibility information.
+
+Add `canBypassVisibility: PropTypes.bool` and its default value. Ensure both combined and split table paths use the same authority.
+
+- [ ] **Step 3: Run tests and targeted lint**
+
+Run:
+
+```bash
+npm test -- src/util/studentPreview.test.js
+npx eslint src/pages/CourseRecords.jsx src/components/page-sections/course-record/RecordCard.jsx src/components/page-sections/course-record/RecordTable.jsx src/util/studentPreview.js src/util/studentPreview.test.js
+```
+
+Expected: 4 tests PASS and ESLint exits 0.
+
+- [ ] **Step 4: Build the production artifact**
+
+Run:
+
+```bash
+npm run build
+```
+
+Expected: Vite completes a production build and writes assets under `/course-reserves/`. Existing bundle-size or Browserslist warnings are acceptable; compilation errors are not.
+
+- [ ] **Step 5: Commit the record-view integration**
+
+```bash
+git add src/components/page-sections/course-record/RecordCard.jsx src/components/page-sections/course-record/RecordTable.jsx
+git commit -m "fix: enforce student visibility during preview"
+```
+
+---
+
+### Task 4: Verify production isolation and the original bug scenario
+
+**Files:**
+- No source changes expected
+- Record results in the pull-request description
+
+**Interfaces:**
+- Consumes: completed hotfix branch
+- Produces: release evidence and a go/no-go decision
+
+- [ ] **Step 1: Confirm protected production files are unchanged**
+
+Run:
+
+```bash
+git diff --exit-code eacfc14d8fdff51030b989f6fd640c8357d44973 -- \
+ .env.production \
+ .env.staging \
+ src/pages/Admin.jsx \
+ src/components/layout/AppRoutes.jsx \
+ src/components/layout/Header.jsx \
+ src/config/api.config.js \
+ server/workflow-admin
+```
+
+Expected: no diff and exit 0.
+
+- [ ] **Step 2: Run the complete automated verification**
+
+Run:
+
+```bash
+npm test
+npm run build
+```
+
+Expected: all tests PASS and production build exits 0.
+
+- [ ] **Step 3: Smoke-test visibility in staging or a production-equivalent environment**
+
+Use one course containing:
+
+- a whole electronic resource outside its visibility window;
+- a primary link outside its visibility window;
+- an additional link outside its visibility window;
+- a currently visible resource and link as controls.
+
+Verify this matrix:
+
+| Session/view | Out-of-window content | In-window content | Staff date annotations |
+| --- | --- | --- | --- |
+| Fully logged out | Hidden | Visible | Hidden |
+| Staff, default view | Visible | Visible | Visible |
+| Staff, `preview=student` | Hidden | Visible | Hidden |
+
+Repeat in card/table and combined/split views. Enter and exit preview and confirm the course, college, and section remain unchanged.
+
+- [ ] **Step 4: Verify workflow behavior has not regressed**
+
+Using the production build, confirm the Workflow dropdown, workflow-template tab, mention badge, and workflow notification bell remain absent from normal production navigation. Do not treat the already-known direct-URL exposure as part of this hotfix; document it for the separate workflow-hardening issue.
+
+- [ ] **Step 5: Make the release decision**
+
+Proceed only if the logged-out and staff-preview rows match exactly. If the logged-out row exposes an out-of-window resource or link, stop the release and open a separate persistence/normalization hotfix before deploying student preview.
+
+- [ ] **Step 6: Prepare rollback information**
+
+Record production baseline `eacfc14d8fdff51030b989f6fd640c8357d44973` and its previously deployed artifact in the release notes. Because this branch has no schema or API changes, rollback is redeployment of that artifact.
diff --git a/docs/superpowers/specs/2026-08-21-student-visibility-preview-hotfix-design.md b/docs/superpowers/specs/2026-08-21-student-visibility-preview-hotfix-design.md
new file mode 100644
index 0000000..26baf13
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-21-student-visibility-preview-hotfix-design.md
@@ -0,0 +1,84 @@
+# Student Visibility Preview Hotfix Design
+
+**Status:** Approved in conversation on 2026-08-21
+
+**Production baseline:** `eacfc14d8fdff51030b989f6fd640c8357d44973` (`main`)
+
+## Problem
+
+Authenticated staff always bypass resource and link visibility windows on the public course-records page. That behavior lets staff manage and verify future or expired resources, but it also makes the visibility toggles appear ineffective during training.
+
+The hotfix must give staff an accurate student-facing preview without logging them out, changing authentication, changing stored visibility settings, or exposing workflow features that production intentionally keeps out of navigation.
+
+## Goals
+
+- Add a clearly labeled **Preview as student** action for authenticated users on the public course-records page.
+- In preview mode, apply the same resource, primary-link, and additional-link visibility decisions used for logged-out visitors.
+- Make preview mode unmistakable with a persistent banner and an **Exit student preview** action.
+- Preserve the current URL and all unrelated query parameters when entering or leaving preview mode.
+- Preserve the current production build mode, authentication, API endpoints, workflow navigation behavior, and backend behavior.
+
+## Non-goals
+
+- Do not change `AuthContext`, authentication cookies, tokens, roles, or permissions.
+- Do not change resource visibility values in the database.
+- Do not change `VisibilityDates.jsx`, `UnifiedVisibilityControl.jsx`, resource persistence, or backend APIs in this hotfix.
+- Do not modify workflow components, routes, polling, environment files, or production feature gates.
+- Do not attempt to fix the existing direct-URL workflow exposure in the same branch; track that as a separate production-hardening change.
+
+## Design
+
+### Preview state
+
+The URL query parameter `preview=student` is the source of truth. It survives a refresh, can be exited without losing course-identifying parameters, and is safe if shared: it can only make the view more restrictive.
+
+Only authenticated users see the preview control. If a logged-out user opens a URL containing `preview=student`, normal student restrictions still apply.
+
+### Visibility authority
+
+Authentication and visibility authority must be separate concepts:
+
+```js
+const canBypassVisibility = isAuthenticated && !isStudentPreview;
+```
+
+`isAuthenticated` continues to describe the real session. `canBypassVisibility` is passed to every visibility decision in `CourseRecords`, `RecordCard`, and `RecordTable`. Preview mode must also suppress staff-only visibility dates and unavailable-link annotations so that the result matches a logged-out view.
+
+The helper that interprets and updates the preview query parameter will live in `src/util/studentPreview.js`. Keeping URL manipulation pure makes it testable without adding a component-testing dependency to this production hotfix.
+
+### User interface
+
+Authenticated users see **Preview as student** near the course-page controls. When enabled, the page displays a warning banner:
+
+> Student preview is active. Resources and links outside their visibility dates are hidden as they are for students.
+
+The banner includes **Exit student preview**. Both actions preserve `college`, `courseListingId`, `id`, `section`, and any future query parameters.
+
+### Failure behavior
+
+Preview has no API dependency. Invalid preview parameter values are ignored. If visibility data is absent, existing visibility behavior remains unchanged.
+
+If a logged-out production smoke test shows that a saved toggle is not honored, release is blocked. That would confirm a separate persistence or normalization defect and must not be hidden by the preview UX.
+
+## Files
+
+- Create `src/util/studentPreview.js` — preview query parsing, query updates, and visibility-bypass calculation.
+- Create `src/util/studentPreview.test.js` — pure unit tests for preview semantics and parameter preservation.
+- Modify `package.json` — expose the existing Vitest dependency through `npm test`.
+- Modify `src/pages/CourseRecords.jsx` — render the control/banner and supply visibility authority.
+- Modify `src/components/page-sections/course-record/RecordCard.jsx` — consume explicit visibility authority.
+- Modify `src/components/page-sections/course-record/RecordTable.jsx` — consume explicit visibility authority.
+
+## Acceptance criteria
+
+- Logged-in staff default view remains unchanged.
+- Logged-in staff student preview matches a logged-out view for whole resources, primary links, and additional links.
+- Preview works in card, table, combined, and split views.
+- Entering and exiting preview preserves all unrelated query parameters.
+- No authentication, backend, workflow, route, environment, or deployment files change.
+- Unit tests, targeted lint, and the production Vite build pass.
+- A manual smoke test confirms saved visibility settings work while fully logged out before release.
+
+## Rollout and rollback
+
+Build from this hotfix branch using the existing production command, deploy through the current production process, and smoke-test both staff and logged-out sessions. Rollback consists of redeploying the artifact from production baseline `eacfc14d` because this hotfix has no schema, API, or persistent-data changes.
diff --git a/package.json b/package.json
index 9605a25..74868d1 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
"version": "1.1.1",
"type": "module",
"scripts": {
+ "test": "vitest run --workspace vitest.unit.workspace.js",
"dev": "vite",
"build": "vite build",
"build:staging": "vite build --mode staging",
diff --git a/src/components/page-sections/course-record/RecordCard.jsx b/src/components/page-sections/course-record/RecordCard.jsx
index 564a24f..c20ba74 100644
--- a/src/components/page-sections/course-record/RecordCard.jsx
+++ b/src/components/page-sections/course-record/RecordCard.jsx
@@ -18,9 +18,17 @@ import {
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExternalLinkAlt, faBook, faClock, faInfoCircle} from '@fortawesome/free-solid-svg-icons';
import { trackingService } from '../../../services/trackingService';
-import { useAuth } from '../../../contexts/AuthContext';
import { sanitizeHtml, containsHtml } from '../../../util/htmlUtils';
-import { isPrimaryLinkVisible, isLinkVisible, getVisibilityInfo } from '../../../util/resourceVisibility';
+import {
+ getVisibilityInfo,
+ isLinkVisible,
+ isPrimaryLinkVisible,
+ parseVisibilityDate,
+} from '../../../util/resourceVisibility';
+
+const formatVisibilityDate = (value, boundary = 'start') => (
+ parseVisibilityDate(value, boundary)?.toLocaleDateString()
+);
/**
* RecordCard component
@@ -39,6 +47,7 @@ import { isPrimaryLinkVisible, isLinkVisible, getVisibilityInfo } from '../../..
* @param {Object} props.courseInfo - Information about the associated course
* @param {string} props.collegeParam - College parameter for tracking
* @param {boolean} props.showVisibilityMessages - Whether to show visibility messages instead of hiding items
+ * @param {boolean} props.canBypassVisibility - Whether visibility restrictions can be bypassed
* @returns {JSX.Element|null} The record card or null if visibility conditions aren't met
*/
const RecordCard = ({
@@ -51,10 +60,8 @@ const RecordCard = ({
courseInfo,
collegeParam,
showVisibilityMessages = true,
+ canBypassVisibility = false,
}) => {
- // Get authentication state directly from context
- const { isAuthenticated } = useAuth();
-
// State for managing popover visibility
const [activePopover, setActivePopover] = useState(null);
@@ -170,8 +177,8 @@ const RecordCard = ({
*/
const checkVisibility = () => {
if (isElectronic && resource) {
- // Authenticated users can see all resources regardless of visibility window
- if (isAuthenticated) {
+ // Users authorized to bypass visibility can see all resources regardless of visibility window
+ if (canBypassVisibility) {
return { isVisible: true };
}
@@ -179,12 +186,8 @@ const RecordCard = ({
// Use resource-level visibility dates
- const startVisibility = resource.start_visibility
- ? new Date(resource.start_visibility)
- : null;
- const endVisibility = resource.end_visibility
- ? new Date(resource.end_visibility)
- : null;
+ const startVisibility = parseVisibilityDate(resource.start_visibility);
+ const endVisibility = parseVisibilityDate(resource.end_visibility, 'end');
// If current time is before the start of the visibility window
if (startVisibility && now < startVisibility) {
@@ -210,8 +213,8 @@ const RecordCard = ({
// Check visibility
const { isVisible, message } = checkVisibility();
- // For non-authenticated users, completely skip rendering resources that aren't visible
- if (!isVisible && !isAuthenticated && !showVisibilityMessages) {
+ // For users without visibility-bypass authority, skip resources that aren't visible
+ if (!isVisible && !canBypassVisibility && !showVisibilityMessages) {
return null; // Simply return null for invisible resources, let parent component show the message
}
@@ -247,7 +250,7 @@ const RecordCard = ({
const reserveMaterialTypes = getReserveMaterialTypes();
// Get visibility information for electronic resources
- const visibilityInfo = isElectronic && resource ? getVisibilityInfo(resource, isAuthenticated) : { showVisibilityDates: false };
+ const visibilityInfo = isElectronic && resource ? getVisibilityInfo(resource, canBypassVisibility) : { showVisibilityDates: false };
const showVisibilityDates = visibilityInfo.showVisibilityDates;
/**
@@ -515,11 +518,13 @@ const RecordCard = ({
});
};
- // Check if resource has additional links
- const hasAdditionalLinks = resource.links && resource.links.length > 0;
+ const visibleAdditionalLinks = Array.isArray(resource.links)
+ ? resource.links.filter(link => isLinkVisible(link, canBypassVisibility))
+ : [];
+ const hasAdditionalLinks = visibleAdditionalLinks.length > 0;
// Check if the primary resource link is visible based on visibility settings
- const isPrimaryLinkVisibleCheck = isPrimaryLinkVisible(resource, isAuthenticated);
+ const isPrimaryLinkVisibleCheck = isPrimaryLinkVisible(resource, canBypassVisibility);
return (
@@ -540,25 +545,18 @@ const RecordCard = ({
)}
- {/* Show message when primary link is not available */}
- {resource.item_url && !isPrimaryLinkVisibleCheck && (
-
- Access Resource: The link to this resource is not currently available due to visibility restrictions.
-
- {resource.links.map((link, index) => {
- // Check if this individual link is visible
- const isCurrentLinkVisible = isLinkVisible(link, isAuthenticated);
-
- // Always render the link item, but conditionally show the actual link
- return (
-
+ )}
+
+ {studentPreview && (
+
+
+ Student preview is active.{' '}
+ Resources and links outside their visibility dates are hidden as they are for students.
+
+
+
+ )}
+