diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 082856166..ca2de7d94 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 e968b8021..ce239527c 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 f5d0675bc..e51d9c3cc 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 fbd312ea3..ca405c3c8 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 92640ad9e..759c179fe 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 && (