From 29107801cc71e848e0577827ad11290e7b19cbf1 Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Thu, 5 Nov 2026 09:49:28 -0500 Subject: [PATCH] feat: add instance-wide card themes for light and dark mode Put in-app pages on a card an administrator can color per mode from the application settings, and shrink page submit buttons that read as bars once the card around them is invisible. Co-Authored-By: Claude Opus 5.5 --- apps/api/prisma/schema.prisma | 26 + .../src/setup/__tests__/setup.service.test.ts | 37 ++ apps/api/src/setup/setup.service.ts | 4 + apps/web/AGENTS.md | 20 + .../BulkRemoteAssignmentWizard/StepLayout.tsx | 11 +- .../DeleteRemoteAssignments.tsx | 15 +- .../InstanceCard/InstanceCard.stories.tsx | 32 ++ .../components/InstanceCard/InstanceCard.tsx | 19 + .../__tests__/InstanceCard.test.tsx | 27 + apps/web/src/components/InstanceCard/index.ts | 1 + .../InstrumentCard/InstrumentCard.tsx | 8 +- apps/web/src/components/Layout/Layout.tsx | 10 +- .../Layout/__tests__/Layout.test.tsx | 27 + .../LoginPageEditor/__tests__/hooks.test.tsx | 2 + .../LoginPageEditor/fields/SwatchButton.tsx | 3 +- .../fields/__tests__/SectionHeader.test.tsx | 2 + .../fields/__tests__/SwatchButton.test.tsx | 5 + .../sections/EnableBrandingCard.tsx | 24 +- .../LoginPageEditor/sections/FooterCard.tsx | 18 +- .../sections/LeftPanelThemeCard.tsx | 22 +- .../sections/RightPanelThemeCard.tsx | 22 +- .../LoginPageEditor/sections/SectionCards.tsx | 44 +- .../sections/TextColorCard.tsx | 23 +- .../__tests__/SectionCards.harness.tsx | 2 + .../PermissionSetForm/PermissionSetForm.tsx | 2 + .../components/SectionCard/SectionCard.tsx | 6 +- .../StartSessionForm/StartSessionForm.tsx | 2 + .../UserPermissionsEditor.tsx | 35 +- .../routes/_app/__tests__/dashboard.test.tsx | 5 + apps/web/src/routes/_app/about.tsx | 25 +- .../_app/admin/__tests__/settings.test.tsx | 96 +++- apps/web/src/routes/_app/admin/audit/logs.tsx | 9 +- .../src/routes/_app/admin/branding/index.tsx | 5 +- .../src/routes/_app/admin/groups/create.tsx | 45 +- .../src/routes/_app/admin/groups/index.tsx | 117 +++-- .../instrument-repos/__tests__/index.test.tsx | 10 +- .../_app/admin/instrument-repos/index.tsx | 15 +- .../web/src/routes/_app/admin/instruments.tsx | 13 +- .../permission-sets/$permissionSetId.tsx | 83 +-- .../_app/admin/permission-sets/create.tsx | 43 +- .../_app/admin/permission-sets/index.tsx | 141 ++--- apps/web/src/routes/_app/admin/settings.tsx | 135 ++++- .../src/routes/_app/admin/users/$userId.tsx | 36 +- .../admin/users/__tests__/$userId.test.tsx | 5 + .../src/routes/_app/admin/users/create.tsx | 491 +++++++++--------- .../web/src/routes/_app/admin/users/index.tsx | 209 ++++---- apps/web/src/routes/_app/contact.tsx | 7 +- .../instruments/$instrumentId/graph.tsx | 7 +- .../instruments/$instrumentId/route.tsx | 55 +- .../routes/_app/datahub/instruments/index.tsx | 5 +- .../subjects/$subjectId/assignments.tsx | 4 +- .../datahub/subjects/$subjectId/graph.tsx | 7 +- .../datahub/subjects/$subjectId/route.tsx | 53 +- .../routes/_app/datahub/subjects/index.tsx | 5 +- .../src/routes/_app/group/email-templates.tsx | 5 +- apps/web/src/routes/_app/group/manage.tsx | 31 +- .../routes/_app/group/remote-assignments.tsx | 9 +- .../instruments/accessible-instruments.tsx | 29 +- .../routes/_app/session/remote-assignment.tsx | 167 +++--- .../src/routes/_app/session/start-session.tsx | 26 +- .../src/routes/_app/upload/$instrumentId.tsx | 179 ++++--- apps/web/src/routes/_app/upload/index.tsx | 35 +- apps/web/src/routes/_app/user.tsx | 289 ++++++----- .../src/utils/__tests__/card-color.test.ts | 72 +++ apps/web/src/utils/card-color.ts | 113 ++++ .../schemas/src/setup/__tests__/setup.test.ts | 33 +- packages/schemas/src/setup/setup.ts | 35 ++ testing/src/pages/_app/admin/settings.page.ts | 4 + .../src/specs/admin-instrument-repos.spec.ts | 13 +- testing/src/specs/instance-card-color.spec.ts | 183 +++++++ 70 files changed, 2133 insertions(+), 1165 deletions(-) create mode 100644 apps/web/src/components/InstanceCard/InstanceCard.stories.tsx create mode 100644 apps/web/src/components/InstanceCard/InstanceCard.tsx create mode 100644 apps/web/src/components/InstanceCard/__tests__/InstanceCard.test.tsx create mode 100644 apps/web/src/components/InstanceCard/index.ts create mode 100644 apps/web/src/utils/__tests__/card-color.test.ts create mode 100644 apps/web/src/utils/card-color.ts create mode 100644 testing/src/specs/instance-card-color.spec.ts diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 0828561668..ca2de7d947 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -444,6 +444,28 @@ enum TableColorMode { // Mirrors LANGUAGES in @opendatacapture/schemas/core. Values are the language codes themselves, // so a document read back through Prisma is already a `Language` with nothing to narrow. +enum LightCardTheme { + invisible + paper + ivory + fog + silver + mist + heather + custom +} + +enum DarkCardTheme { + invisible + midnight + graphite + charcoal + steel + mist + heather + custom +} + enum Language { en es @@ -523,6 +545,10 @@ model SetupState { id String @id @default(auto()) @map("_id") @db.ObjectId activeLanguages Language[] @default([en, fr]) branding BrandingConfig? + darkCardTheme DarkCardTheme? + lightCardTheme LightCardTheme? + customCardColorDark String? + customCardColorLight String? defaultAssignmentDurationDays Int? isBulkRemoteAssignmentsEnabled Boolean? isDemo Boolean diff --git a/apps/api/src/setup/__tests__/setup.service.test.ts b/apps/api/src/setup/__tests__/setup.service.test.ts index e968b80219..ce239527c3 100644 --- a/apps/api/src/setup/__tests__/setup.service.test.ts +++ b/apps/api/src/setup/__tests__/setup.service.test.ts @@ -100,6 +100,43 @@ describe('SetupService', () => { }); }); + describe('cardColor', () => { + it("should persist each mode's color", async () => { + setupStateModel.findFirst.mockResolvedValue({ id: 'setup-1', isSetup: true }); + await setupService.updateState({ darkCardTheme: 'invisible', lightCardTheme: 'mist' }); + expect(setupStateModel.update.mock.lastCall?.[0]).toMatchObject({ + data: { darkCardTheme: 'invisible', lightCardTheme: 'mist' }, + where: { id: 'setup-1' } + }); + }); + + it("should return each mode's saved color", async () => { + setupStateModel.findFirst.mockResolvedValue({ + darkCardTheme: 'midnight', + isDemo: false, + isSetup: true, + lightCardTheme: 'mist' + }); + await expect(setupService.getState()).resolves.toMatchObject({ + darkCardTheme: 'midnight', + lightCardTheme: 'mist' + }); + }); + + it("should return each mode's saved custom color", async () => { + setupStateModel.findFirst.mockResolvedValue({ customCardColorDark: '#111111', isDemo: false, isSetup: true }); + await expect(setupService.getState()).resolves.toMatchObject({ customCardColorDark: '#111111' }); + }); + + it('should report invisible cards for an instance that never chose a color', async () => { + setupStateModel.findFirst.mockResolvedValue({ isDemo: false, isSetup: true }); + await expect(setupService.getState()).resolves.toMatchObject({ + darkCardTheme: 'invisible', + lightCardTheme: 'invisible' + }); + }); + }); + describe('isBulkRemoteAssignmentsEnabled', () => { it('should persist isBulkRemoteAssignmentsEnabled', async () => { configService.get.mockReturnValue(true); diff --git a/apps/api/src/setup/setup.service.ts b/apps/api/src/setup/setup.service.ts index f5d0675bc5..e51d9c3cc4 100644 --- a/apps/api/src/setup/setup.service.ts +++ b/apps/api/src/setup/setup.service.ts @@ -66,6 +66,9 @@ export class SetupService { return { activeLanguages: fallbackLanguage ? [fallbackLanguage, ...otherLanguages] : DEFAULT_ACTIVE_LANGUAGES, branding: branding.success ? branding.data : null, + customCardColorDark: savedOptions?.customCardColorDark, + customCardColorLight: savedOptions?.customCardColorLight, + darkCardTheme: savedOptions?.darkCardTheme ?? 'invisible', defaultAssignmentDurationDays: savedOptions?.defaultAssignmentDurationDays ?? null, // Derived from the gateway rather than reported as stored: a bulk assignment is only a link // the gateway serves, so an instance that later dropped the gateway must report this off @@ -78,6 +81,7 @@ export class SetupService { // configuration itself is never exposed here (this is a public route). isMailEnabled: isMailEnabled(savedOptions?.mailConfig), isSetup: Boolean(savedOptions?.isSetup), + lightCardTheme: savedOptions?.lightCardTheme ?? 'invisible', release: __RELEASE__, tableColorMode: savedOptions?.tableColorMode ?? DEFAULT_TABLE_COLOR_MODE, uptime: Math.round(process.uptime()) diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index fbd312ea37..ca405c3c80 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -181,6 +181,26 @@ Put something in `packages/react-core` instead **only if `apps/gateway` also use `data-testid` attributes are the Playwright suite's selectors. Don't remove them; add one when you add UI that an e2e test will drive. +## Cards + +A card on an in-app page is `@/components/InstanceCard`, not libui's `Card`: every in-app page sits on +one, below its `PageHeader`, and tiles inside a page use it too. It draws nothing until an admin picks a +theme per mode in the application settings (`SetupState.lightCardTheme`, `darkCardTheme`, and a +`customCardColor*` hex for the `custom` theme). `Layout` sets each mode's CSS variables with +`getInstanceCardStyle`, and `INSTANCE_CARD_SURFACE` reads them; pass that constant to a libui component +that wraps its own `Card`. A new theme is one entry in `LIGHT_CARD_THEMES` or `DARK_CARD_THEMES` in `packages/schemas`, the matching +enum in the Prisma schema, and the maps in `utils/card-color.ts`, which fail to compile until it is added. +Removing one needs a data migration, since Prisma throws on a stored value its enum no longer has. + +A page's submit button takes `PAGE_SUBMIT_BUTTON`, through `customStyles.submitBtn` on libui's `Form`, since +a full-width one reads as a bar with no card around it. A form in a dialog or sheet keeps the full width. + +Libui's `Card` stays on what is not a page surface: floating UI (the walkthrough), a preview of the login +page, and pages outside `Layout` (login, password reset, setup). Two in-app pages opt out of the color: the +dashboard keeps its own tiles, and start session keeps the page's color inside a thin libui `Card`. +`/instruments/render/$id` has no card, since it is where a form is administered. Nothing here applies to +`apps/gateway`. + ## Tables A table listing records is the libui `DataTable`. libui's plain `Table` is for a small table the diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/StepLayout.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/StepLayout.tsx index 92640ad9e8..759c179fe8 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/StepLayout.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/StepLayout.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import { Card } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { cn } from '@douglasneuroinformatics/libui/utils'; import { CheckIcon } from 'lucide-react'; +import { InstanceCard } from '@/components/InstanceCard'; + import type { WizardStep } from './types'; const WIZARD_STEPS = ['SUBJECTS', 'INSTRUMENTS', 'REVIEW'] as const; @@ -50,7 +51,7 @@ const StepLayout = ({ aside, children, description, footer, onStepChange, step, const currentIndex = step ? WIZARD_STEPS.indexOf(step) : -1; return ( - + {currentIndex !== -1 && (