Skip to content
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
255841a
chore: upgrade DoenetML engine chain to 0.7.21 (dark mode support)
dqnykamp Jul 17, 2026
e6cecf9
feat(theme): site-wide dark mode with System/Light/Dark preference
dqnykamp Jul 17, 2026
52218d5
feat(theme): dark-mode Doenet logo variant with light wordmark
dqnykamp Jul 17, 2026
49888dc
fix(theme): make Alert readable in dark mode
dqnykamp Jul 17, 2026
8868657
fix(theme): make orange.100 warning strips readable in dark mode
dqnykamp Jul 17, 2026
43e8978
fix(theme): dark-mode legibility on editor Settings tab and shared sc…
dqnykamp Jul 17, 2026
ff86a91
test(a11y): scan every page accessibility check in dark mode too
dqnykamp Jul 18, 2026
bc615a7
fix(a11y): dark-mode nav-button contrast + deterministic mode in a11y…
dqnykamp Jul 18, 2026
089b823
fix(a11y): theme-aware card hover; add hovered-row a11y tests
dqnykamp Jul 18, 2026
9a7450d
fix: dark-mode a11y follow-ups + embed version guard + lint
dqnykamp Jul 18, 2026
3a1967b
fix(a11y): dark-mode contrast for form errors and status boxes
dqnykamp Jul 18, 2026
6012a8a
fix(a11y): dark-mode contrast for library status labels
dqnykamp Jul 18, 2026
436881c
fix(a11y): dark-mode contrast + theming in ShareModal and ShareButton
dqnykamp Jul 18, 2026
1b1407f
fix(a11y): dark-mode contrast for classification/license selected cards
dqnykamp Jul 18, 2026
6881469
fix(a11y): dark-mode theming for compound editor, shared activities, …
dqnykamp Jul 18, 2026
8efdb32
fix(a11y): dark-mode visibility for folder divider and root activitie…
dqnykamp Jul 18, 2026
9dfb58c
fix(a11y): dark-mode theming for side-banner default and Home video b…
dqnykamp Jul 18, 2026
a5ec420
fix(lint): rename harness local 'props' to satisfy react/prop-types
dqnykamp Jul 18, 2026
b26622b
ci: run component tests in dark mode + fix dark failures it surfaces
dqnykamp Jul 18, 2026
25d211f
test(a11y): wait for hover tooltip to settle before axe scan
dqnykamp Jul 18, 2026
48cdffd
ci: run dark component tests in a separate job to preserve required c…
dqnykamp Jul 18, 2026
a0dfbd6
ci: use color-mode matrix for component tests (light + dark)
dqnykamp Jul 18, 2026
fdb9044
fix(a11y): flip viewer page-frame background in dark mode
dqnykamp Jul 18, 2026
548153c
fix(a11y): dark-mode content-card visibility pills + first Card test
dqnykamp Jul 18, 2026
5eee8af
fix(a11y): flip WelcomeBanner + widget borders/icons for dark mode
dqnykamp Jul 18, 2026
23c1407
feat(theme): make solid blue buttons mode-aware
dqnykamp Jul 18, 2026
70cd4b6
Revert "feat(theme): make solid blue buttons mode-aware"
dqnykamp Jul 18, 2026
0525849
fix(a11y): dark-mode colors for score chart + compound-editor empty s…
dqnykamp Jul 18, 2026
89cb68f
test(a11y): component-level dark+light contrast coverage for uncovere…
dqnykamp Jul 18, 2026
35fb95c
refactor(db): make users.theme a Theme enum, matching ContentType/Vis…
dqnykamp Jul 18, 2026
586d9f3
fix(theme): treat DoenetML `dev` version like `latest` in supportsDar…
dqnykamp Jul 18, 2026
b7272e5
refactor(widgets): move VisibilityPill into its own file beside its test
dqnykamp Jul 18, 2026
148acd3
chore(types): mirror users.theme onto server-side UserInfoWithEmail
dqnykamp Jul 18, 2026
c003477
refactor(theme): define ThemeSetting locally instead of importing fro…
dqnykamp Jul 19, 2026
42195f5
refactor(theme): drive Chakra color mode via a doenet-theme-setting m…
dqnykamp Jul 19, 2026
c4caa01
feat(navbar): inline icon theme selector that keeps the account menu …
dqnykamp Jul 20, 2026
6ae0f07
feat(theme): soften dark-mode palette (off-gray surfaces, gentler acc…
cqnykamp Jul 20, 2026
9b355e0
fix(a11y): keep active mode tab ≥AA on softened dark surfaces
dqnykamp Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -295,10 +295,18 @@ jobs:
component-tests:
runs-on: ubuntu-latest

# Each group runs in both light and dark color modes so accessibility
# (contrast) regressions are caught in dark mode too. The two modes run as
# independent matrix jobs (in parallel), so wall-clock time is unaffected.
# NOTE: this renames the checks to `component-tests (groupN, light|dark)`;
# branch-protection required checks must use those names.
strategy:
fail-fast: false
matrix:
test-group: [group1, group2, group3, group4]
color-mode: [light, dark]

name: component-tests (${{ matrix.test-group }}, ${{ matrix.color-mode }})

steps:
# Checkout repo
Expand All @@ -316,16 +324,17 @@ jobs:
- name: Build shared package
run: npm run build --workspace @doenet-tools/shared

# Run component tests (grouped)
# Run component tests (grouped). The `:dark` script variants pass
# `--env colorMode=dark`; light uses the plain script.
- name: Run component tests
run: npm run test:${{ matrix.test-group }} --workspace @doenet-tools/app
run: npm run test:${{ matrix.test-group }}${{ matrix.color-mode == 'dark' && ':dark' || '' }} --workspace @doenet-tools/app

# Upload artifacts on failure
- name: Upload test artifacts
if: failure()
uses: actions/upload-artifact@v7
with:
name: component-test-artifacts
name: component-test-artifacts-${{ matrix.test-group }}-${{ matrix.color-mode }}
path: |
apps/app/cypress/screenshots/
apps/app/cypress/videos/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `users` ADD COLUMN `theme` ENUM('system', 'light', 'dark') NOT NULL DEFAULT 'system';
7 changes: 7 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ enum Visibility {
public
}

enum Theme {
system
light
dark
}

model recentContent {
id Int @id @default(autoincrement())
userId Bytes @db.Binary(16)
Expand Down Expand Up @@ -385,6 +391,7 @@ model users {
isLibrary Boolean @default(false)
isEditor Boolean @default(false)
isAuthor Boolean @default(false)
theme Theme @default(system)
// Early-access flag for the experimental image-upload feature. Flip with
// `apps/api/scripts/enable-image-upload.ts`.
canUploadImages Boolean @default(false)
Expand Down
16 changes: 15 additions & 1 deletion apps/api/src/query/user.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { prisma } from "../model";
import { Prisma } from "@prisma/client";
import { Prisma, Theme } from "@prisma/client";
import { UserInfo, UserInfoWithEmail } from "../types";
import { generateHandle, generateUnusedHandle } from "../utils/names";
import { filterEditableContent } from "../utils/permissions";
Expand Down Expand Up @@ -94,6 +94,7 @@ export async function getMyUserInfo({
isEditor: true,
isAuthor: true,
canUploadImages: true,
theme: true,
},
});
return { user };
Expand Down Expand Up @@ -191,6 +192,19 @@ export async function setIsAuthor({
});
}

export async function setTheme({
loggedInUserId,
theme,
}: {
loggedInUserId: Uint8Array;
theme: Theme;
}) {
await prisma.users.update({
where: { userId: loggedInUserId },
data: { theme },
});
}

export async function createStudentHandleAccounts({
loggedInUserId,
folderId,
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/routes/userRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import {
getUser,
getUserInfoIfLoggedIn,
setIsAuthor,
setTheme,
updateUser,
} from "../query/user";
import {
createHandleSchema,
setIsAuthorSchema,
setThemeSchema,
userIdSchema,
userNamesSchema,
} from "../schemas/userSchemas";
Expand All @@ -34,6 +36,8 @@ userRouter.get(

userRouter.post("/setIsAuthor", queryLoggedIn(setIsAuthor, setIsAuthorSchema));

userRouter.post("/setTheme", queryLoggedIn(setTheme, setThemeSchema));

userRouter.post(
"/handles",
queryLoggedIn(createStudentHandleAccounts, createHandleSchema),
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/schemas/userSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ export const setIsAuthorSchema = z.object({
isAuthor: z.boolean(),
});

export const setThemeSchema = z.object({
theme: z.enum(["system", "light", "dark"]),
});

export const userIdSchema = z.object({
userId: uuidSchema,
});
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/test/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getMyUserInfo,
getUserInfoIfLoggedIn,
setIsAuthor,
setTheme,
updateUser,
upgradeAnonymousUser,
} from "../query/user";
Expand Down Expand Up @@ -106,6 +107,21 @@ test("turn author mode on and off", async () => {
expect(userInfo.user.isAuthor).eq(false);
});

test("set and read theme preference", async () => {
const { userId } = await createTestUser();

let userInfo = await getMyUserInfo({ loggedInUserId: userId });
expect(userInfo.user.theme).eq("system");

await setTheme({ loggedInUserId: userId, theme: "dark" });
userInfo = await getMyUserInfo({ loggedInUserId: userId });
expect(userInfo.user.theme).eq("dark");

await setTheme({ loggedInUserId: userId, theme: "light" });
userInfo = await getMyUserInfo({ loggedInUserId: userId });
expect(userInfo.user.theme).eq("light");
});

test("a logged-in user can see their own email", async () => {
const { userId: loggedInUserId, email } = await createTestUser();

Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export type UserInfoWithEmail = UserInfo & {
isAuthor?: boolean;
isEditor?: boolean;
canUploadImages?: boolean;
theme?: Theme;
};

export type ContentClassification = {
Expand Down Expand Up @@ -178,6 +179,9 @@ export type AssignmentMode = "formative" | "summative";

export type Visibility = "private" | "unlisted" | "public";

/** This type must match the Prisma-defined enum `Theme` */
export type Theme = "system" | "light" | "dark";

export type ContentBase = {
contentId: Uuid;
ownerId: Uuid;
Expand Down
16 changes: 16 additions & 0 deletions apps/app/cypress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ export default defineConfig({
"better-react-mathjax",
"luxon",
"axios",
// ScoreSummaryChart.cy.tsx is the first component spec to import
// recharts; pre-bundle it so Vite doesn't re-optimize mid-run and
// full-reload the page (issue #2957).
"recharts",
// react-icons subpaths: each bundles thousands of icon modules and
// is a classic mid-run re-optimize trigger. The page specs under
// paths/ (Curate/SharedWithMe/Trash, via CardList/Card) sort ahead
// of any widget spec that would otherwise warm these first, so
// pre-bundle them at startup to keep those specs from flaking
// (issue #2957).
"react-icons/fa6",
"react-icons/fi",
"react-icons/io5",
"react-icons/md",
"react-icons/lu",
"react-icons/bs",
],
},
},
Expand Down
100 changes: 100 additions & 0 deletions apps/app/cypress/support/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,103 @@
// }
// }
// }

// -- checkContrast ------------------------------------------------------------
// axe's color-contrast rule returns "incomplete" (and so wick-a11y silently
// passes) for text rendered inside a transformed/portaled container such as a
// Chakra <Modal>: it cannot resolve the effective background through the stack.
// This command fills that gap with a direct WCAG 2 AA contrast computation. For
// every element under `selector` that owns a visible text node, it reads the
// computed text color, walks up to the first non-transparent background, and
// asserts the ratio meets the AA threshold (4.5:1 normal text, 3:1 for >=18pt
// or >=14pt bold). Use it for dark-mode contrast on modal-nested content that
// checkAccessibility cannot see.
function parseRGB(str: string): [number, number, number, number] {
const m = str.match(/rgba?\(([^)]+)\)/);
if (!m) return [0, 0, 0, 0];
const parts = m[1].split(",").map((p) => parseFloat(p.trim()));
return [parts[0], parts[1], parts[2], parts[3] ?? 1];
}

function relLum([r, g, b]: [number, number, number, number]): number {
const f = (c: number) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
}

function contrastRatio(
fg: [number, number, number, number],
bg: [number, number, number, number],
): number {
const l1 = relLum(fg);
const l2 = relLum(bg);
const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1];
return (hi + 0.05) / (lo + 0.05);
}

function composite(
over: [number, number, number, number],
under: [number, number, number, number],
): [number, number, number, number] {
const a = over[3];
return [
over[0] * a + under[0] * (1 - a),
over[1] * a + under[1] * (1 - a),
over[2] * a + under[2] * (1 - a),
1,
];
}

// Resolve an element's *effective* opaque background, compositing any
// semi-transparent layers (e.g. Chakra's whiteAlpha button fills) over their
// ancestors. Without this, a translucent whiteAlpha.200 fill would be read as
// pure white and flagged as a false contrast failure in dark mode.
function resolveBg(
el: Element | null,
fallback: [number, number, number, number] = [255, 255, 255, 1],
): [number, number, number, number] {
if (!el) return fallback;
const bg = parseRGB(window.getComputedStyle(el).backgroundColor);
if (bg[3] === 1) return bg;
const under = resolveBg(el.parentElement, fallback);
if (bg[3] === 0) return under;
return composite(bg, under);
}

Cypress.Commands.add("checkContrast", (selector: string) => {
cy.get(selector).then(($root) => {
const failures: string[] = [];
const all = [
...($root.get(0)?.matches?.("*") ? [$root.get(0)] : []),
...Array.from($root.get(0).querySelectorAll("*")),
];
for (const el of all) {
const ownText = Array.from(el.childNodes)
.filter((n) => n.nodeType === 3)
.map((n) => n.textContent || "")
.join("")
.trim();
if (!ownText) continue;
const cs = window.getComputedStyle(el);
if (cs.visibility === "hidden" || cs.display === "none") continue;
const bg = resolveBg(el);
const rawFg = parseRGB(cs.color);
// Text colors are often whiteAlpha/blackAlpha (e.g. Chakra body text is
// rgba(255,255,255,0.92)); composite over the background for a true ratio.
const fg = rawFg[3] < 1 ? composite(rawFg, bg) : rawFg;
const ratio = contrastRatio(fg, bg);
const sizePx = parseFloat(cs.fontSize) || 16;
const bold = parseInt(cs.fontWeight, 10) >= 700;
const large = sizePx >= 24 || (bold && sizePx >= 18.66);
const threshold = large ? 3 : 4.5;
if (ratio < threshold) {
failures.push(
`"${ownText.slice(0, 40)}" ratio ${ratio.toFixed(2)} < ${threshold} (color ${cs.color} on bg rgb(${bg[0]},${bg[1]},${bg[2]}))`,
);
}
}
expect(failures, `contrast failures under ${selector}`).to.deep.equal([]);
});
});
20 changes: 20 additions & 0 deletions apps/app/cypress/support/component.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,35 @@ declare global {
* @param options.routerProps Props for the memory router
* @param options.action Optional action handler for route actions (e.g., fetcher.submit())
* @param options.routes Optional additional routes to add to the router
* @param options.colorMode Force the Chakra color mode ("light" | "dark").
* Defaults to the `colorMode` Cypress env value, else "light". Set the
* env for a whole run (e.g. `--env colorMode=dark`) to exercise every
* spec's accessibility checks in dark mode.
* @param options.outletContext Value provided to `useOutletContext()` — the
* component is mounted as an index child of a route rendering
* `<Outlet context={outletContext}>`. Use for pages that read the site
* context (e.g. `{ user }`).
* @param options.loaderData Value returned from the route `loader`, for
* pages that read `useLoaderData()`.
*/
mount(
component: React.ReactNode,
options?: MountOptions & {
routerProps?: MemoryRouterProps;
action?: (data: { request: Request }) => Promise<any>;
routes?: any[];
colorMode?: "light" | "dark";
outletContext?: unknown;
loaderData?: unknown;
},
): Cypress.Chainable<MountReturn>;
/**
* Assert WCAG 2 AA text contrast for every text-bearing element under
* `selector`, computing the ratio directly against the resolved
* background. Fills the gap where axe returns "incomplete" for text
* inside a transformed/portaled Chakra <Modal>. See commands.ts.
*/
checkContrast(selector: string): Cypress.Chainable<void>;
}
}
}
Loading