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 && (
- {title}
- {description}
+ {title}
+ {description}
{aside}
{children}
{footer && {footer}
}
-
+
);
};
diff --git a/apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx b/apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx
index 01c6dc6c9..8f007fb37 100644
--- a/apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx
+++ b/apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx
@@ -1,13 +1,14 @@
import React, { useState } from 'react';
import { toBasicISOString } from '@douglasneuroinformatics/libjs';
-import { Button, Card, Checkbox, DataTable, Dialog } from '@douglasneuroinformatics/libui/components';
+import { Button, Checkbox, DataTable, Dialog } from '@douglasneuroinformatics/libui/components';
import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks';
import type { Assignment } from '@opendatacapture/schemas/assignment';
import type { UnilingualInstrumentInfo } from '@opendatacapture/schemas/instrument';
import { removeSubjectIdScope } from '@opendatacapture/subject-utils';
import { ColorTagCell } from '@/components/ColorTagCell';
+import { InstanceCard } from '@/components/InstanceCard';
import { SortableHeader } from '@/components/SortableHeader';
import { useAssignmentsQuery } from '@/hooks/useAssignmentsQuery';
import { useDeleteBulkAssignmentsMutation } from '@/hooks/useDeleteBulkAssignmentsMutation';
@@ -109,19 +110,19 @@ export const DeleteRemoteAssignments = ({ groupId, onBack, subjectIdDisplayLengt
};
return (
-
+
-
+
{t({ en: 'Delete Assignments', es: 'Eliminar tareas', fr: 'Supprimer des tâches' })}
-
-
+
+
{t({
en: 'Select outstanding or expired assignments to delete.',
es: 'Seleccione las tareas pendientes o expiradas para eliminar.',
fr: 'Sélectionnez les tâches en attente ou expirées à supprimer.'
})}
-
+
{rows.length === 0 ? (
@@ -277,6 +278,6 @@ export const DeleteRemoteAssignments = ({ groupId, onBack, subjectIdDisplayLengt
-
+
);
};
diff --git a/apps/web/src/components/InstanceCard/InstanceCard.stories.tsx b/apps/web/src/components/InstanceCard/InstanceCard.stories.tsx
new file mode 100644
index 000000000..506ddf52d
--- /dev/null
+++ b/apps/web/src/components/InstanceCard/InstanceCard.stories.tsx
@@ -0,0 +1,32 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { getInstanceCardStyle, INSTANCE_CARD_VARIABLES } from '@/utils/card-color';
+
+import { InstanceCard } from './InstanceCard';
+
+type Story = StoryObj
;
+
+const content = (
+
+ Card Title
+ Card description
+
+);
+
+export default { component: InstanceCard } as Meta;
+
+export const Invisible: Story = { args: { children: content } };
+
+export const Colored: Story = {
+ args: { children: content },
+ decorators: [
+ (Story) => (
+
+
+
+ )
+ ]
+};
diff --git a/apps/web/src/components/InstanceCard/InstanceCard.tsx b/apps/web/src/components/InstanceCard/InstanceCard.tsx
new file mode 100644
index 000000000..12b0095c9
--- /dev/null
+++ b/apps/web/src/components/InstanceCard/InstanceCard.tsx
@@ -0,0 +1,19 @@
+import type { ComponentProps } from 'react';
+
+import { Card } from '@douglasneuroinformatics/libui/components';
+import { cn } from '@douglasneuroinformatics/libui/utils';
+
+import { INSTANCE_CARD_SURFACE } from '@/utils/card-color';
+
+const InstanceCardRoot = ({ className, ...props }: ComponentProps) => (
+
+);
+
+/** A card in the color chosen for the whole instance in the application settings. */
+export const InstanceCard = Object.assign(InstanceCardRoot, {
+ Content: Card.Content,
+ Description: Card.Description,
+ Footer: Card.Footer,
+ Header: Card.Header,
+ Title: Card.Title
+});
diff --git a/apps/web/src/components/InstanceCard/__tests__/InstanceCard.test.tsx b/apps/web/src/components/InstanceCard/__tests__/InstanceCard.test.tsx
new file mode 100644
index 000000000..abad3d0cb
--- /dev/null
+++ b/apps/web/src/components/InstanceCard/__tests__/InstanceCard.test.tsx
@@ -0,0 +1,27 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it } from 'vitest';
+
+import { InstanceCard } from '../InstanceCard';
+
+describe('InstanceCard', () => {
+ afterEach(cleanup);
+
+ it("should replace libui's own surface, so every card in the instance shares one color", () => {
+ render( );
+ const { className } = screen.getByTestId('instance-card');
+ expect(className).toContain('bg-[color:var(--instance-card-fill,transparent)]');
+ expect(className).toContain('border-[color:var(--instance-card-border,transparent)]');
+ expect(className).not.toContain('bg-card');
+ expect(className).not.toContain('shadow-sm');
+ });
+
+ it('should keep the layout a caller asks for', () => {
+ render( );
+ expect(screen.getByTestId('instance-card').className).toContain('flex p-6');
+ });
+
+ it('should mark itself, so a page can be checked for having one', () => {
+ render( );
+ expect(screen.getByTestId('instance-card').hasAttribute('data-instance-card')).toBe(true);
+ });
+});
diff --git a/apps/web/src/components/InstanceCard/index.ts b/apps/web/src/components/InstanceCard/index.ts
new file mode 100644
index 000000000..c9b1b712e
--- /dev/null
+++ b/apps/web/src/components/InstanceCard/index.ts
@@ -0,0 +1 @@
+export * from './InstanceCard';
diff --git a/apps/web/src/components/InstrumentCard/InstrumentCard.tsx b/apps/web/src/components/InstrumentCard/InstrumentCard.tsx
index 8800daa34..b224a59de 100644
--- a/apps/web/src/components/InstrumentCard/InstrumentCard.tsx
+++ b/apps/web/src/components/InstrumentCard/InstrumentCard.tsx
@@ -1,6 +1,6 @@
import React from 'react';
-import { Card, Heading, Tooltip } from '@douglasneuroinformatics/libui/components';
+import { Heading, Tooltip } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { cn } from '@douglasneuroinformatics/libui/utils';
import { licenses } from '@opendatacapture/licenses';
@@ -8,6 +8,8 @@ import { InstrumentIcon } from '@opendatacapture/react-core';
import type { TranslatedInstrumentInfo } from '@opendatacapture/schemas/instrument';
import { BadgeAlertIcon, BadgeCheckIcon } from 'lucide-react';
+import { InstanceCard } from '@/components/InstanceCard';
+
type BaseCardItem = { label: string; tooltip?: React.ReactNode };
type LinkCardItem = BaseCardItem & { href?: null | string; kind: 'link' };
@@ -134,7 +136,7 @@ export const InstrumentCard = ({ highlighted, instrument, onClick }: InstrumentC
];
return (
-
-
+
);
};
diff --git a/apps/web/src/components/Layout/Layout.tsx b/apps/web/src/components/Layout/Layout.tsx
index 9a3f3737e..75a0a8786 100644
--- a/apps/web/src/components/Layout/Layout.tsx
+++ b/apps/web/src/components/Layout/Layout.tsx
@@ -1,6 +1,9 @@
+import { cn } from '@douglasneuroinformatics/libui/utils';
import { Outlet } from '@tanstack/react-router';
+import { useSetupStateQuery } from '@/hooks/useSetupStateQuery';
import { useAppStore } from '@/store';
+import { getInstanceCardStyle, INSTANCE_CARD_VARIABLES } from '@/utils/card-color';
import { Footer } from '../Footer';
import { GroupSwitcher, useIsGroupSwitcherVisible } from '../GroupSwitcher';
@@ -10,9 +13,14 @@ import { Sidebar } from '../Sidebar';
export const Layout = () => {
const groupSwitcherPosition = useAppStore((store) => store.groupSwitcherPosition);
const isGroupSwitcherVisible = useIsGroupSwitcherVisible();
+ const setupState = useSetupStateQuery().data;
return (
-
+
diff --git a/apps/web/src/components/Layout/__tests__/Layout.test.tsx b/apps/web/src/components/Layout/__tests__/Layout.test.tsx
index a3c4e671e..9fc217f17 100644
--- a/apps/web/src/components/Layout/__tests__/Layout.test.tsx
+++ b/apps/web/src/components/Layout/__tests__/Layout.test.tsx
@@ -1,7 +1,9 @@
+import type { DarkCardTheme, LightCardTheme } from '@opendatacapture/schemas/setup';
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GroupSwitcherPosition } from '@/store/types';
+import { getDarkCardShades, getLightCardShades } from '@/utils/card-color';
import { Layout } from '../Layout';
@@ -9,11 +11,19 @@ const store = vi.hoisted((): { groupSwitcherPosition: GroupSwitcherPosition } =>
groupSwitcherPosition: 'sidebar'
}));
const groupSwitcher = vi.hoisted(() => ({ isVisible: true }));
+const setupState = vi.hoisted((): { darkCardTheme: DarkCardTheme; lightCardTheme: LightCardTheme } => ({
+ darkCardTheme: 'invisible',
+ lightCardTheme: 'invisible'
+}));
vi.mock('@/store', () => ({
useAppStore: vi.fn((selector: (state: typeof store) => unknown) => selector(store))
}));
+vi.mock('@/hooks/useSetupStateQuery', () => ({
+ useSetupStateQuery: () => ({ data: setupState })
+}));
+
vi.mock('@tanstack/react-router', () => ({
Outlet: () =>
}));
@@ -30,6 +40,8 @@ describe('Layout', () => {
beforeEach(() => {
store.groupSwitcherPosition = 'sidebar';
groupSwitcher.isVisible = true;
+ setupState.darkCardTheme = 'invisible';
+ setupState.lightCardTheme = 'invisible';
});
afterEach(cleanup);
@@ -39,6 +51,21 @@ describe('Layout', () => {
expect(screen.getByRole('main').contains(screen.getByTestId('outlet'))).toBe(true);
});
+ it("should set both modes' card colors around the page, so every card below it reads the same ones", () => {
+ setupState.darkCardTheme = 'midnight';
+ setupState.lightCardTheme = 'mist';
+ render(
);
+ const { style } = screen.getByTestId('layout');
+ expect(style.getPropertyValue('--instance-card-fill-light')).toBe(getLightCardShades().mist.fill);
+ expect(style.getPropertyValue('--instance-card-fill-dark')).toBe(getDarkCardShades().midnight.fill);
+ });
+
+ it('should clear dark mode on an instance with no dark color, so the light color does not carry over', () => {
+ setupState.lightCardTheme = 'mist';
+ render(
);
+ expect(screen.getByTestId('layout').style.getPropertyValue('--instance-card-fill-dark')).toBe('transparent');
+ });
+
it('should render the navbar, the sidebar and the footer around the route', () => {
render(
);
expect(screen.getByTestId('navbar')).toBeTruthy();
diff --git a/apps/web/src/components/LoginPageEditor/__tests__/hooks.test.tsx b/apps/web/src/components/LoginPageEditor/__tests__/hooks.test.tsx
index 34e0463f2..fc734a40f 100644
--- a/apps/web/src/components/LoginPageEditor/__tests__/hooks.test.tsx
+++ b/apps/web/src/components/LoginPageEditor/__tests__/hooks.test.tsx
@@ -82,10 +82,12 @@ function createSetupState(branding: BrandingConfig | null): SetupState {
return {
activeLanguages: ['en'],
branding,
+ darkCardTheme: 'invisible',
isDemo: false,
isGatewayEnabled: false,
isMailEnabled: false,
isSetup: true,
+ lightCardTheme: 'invisible',
release: { buildTime: 0, type: 'production', version: '1.0.0' },
tableColorMode: 'tags',
uptime: 0
diff --git a/apps/web/src/components/LoginPageEditor/fields/SwatchButton.tsx b/apps/web/src/components/LoginPageEditor/fields/SwatchButton.tsx
index 97eab5ca3..57cf9880e 100644
--- a/apps/web/src/components/LoginPageEditor/fields/SwatchButton.tsx
+++ b/apps/web/src/components/LoginPageEditor/fields/SwatchButton.tsx
@@ -9,9 +9,10 @@ type SwatchButtonProps = {
style: React.CSSProperties;
};
-/** A selectable gradient swatch used by the left- and right-panel theme grids. */
+/** A selectable color swatch, used by the login page's theme grids and the card color settings. */
export const SwatchButton = ({ isSelected, label, onClick, style }: SwatchButtonProps) => (
, 'editor'
const SETUP_STATE: SetupState = {
activeLanguages: ['en'],
branding: null,
+ darkCardTheme: 'invisible',
isDemo: false,
isGatewayEnabled: false,
isMailEnabled: false,
isSetup: true,
+ lightCardTheme: 'invisible',
release: { buildTime: 0, type: 'production', version: '1.0.0' },
tableColorMode: 'tags',
uptime: 0
diff --git a/apps/web/src/components/LoginPageEditor/fields/__tests__/SwatchButton.test.tsx b/apps/web/src/components/LoginPageEditor/fields/__tests__/SwatchButton.test.tsx
index 51027f612..a2d7d31f3 100644
--- a/apps/web/src/components/LoginPageEditor/fields/__tests__/SwatchButton.test.tsx
+++ b/apps/web/src/components/LoginPageEditor/fields/__tests__/SwatchButton.test.tsx
@@ -42,4 +42,9 @@ describe('SwatchButton', () => {
render( );
expect(screen.getByRole('button').querySelector('span')!.style.background).toBe('red');
});
+
+ it('should expose its selection to assistive technology', () => {
+ render( );
+ expect(screen.getByRole('button', { name: 'Sage' }).getAttribute('aria-pressed')).toBe('true');
+ });
});
diff --git a/apps/web/src/components/LoginPageEditor/sections/EnableBrandingCard.tsx b/apps/web/src/components/LoginPageEditor/sections/EnableBrandingCard.tsx
index 401e7cff4..690ece317 100644
--- a/apps/web/src/components/LoginPageEditor/sections/EnableBrandingCard.tsx
+++ b/apps/web/src/components/LoginPageEditor/sections/EnableBrandingCard.tsx
@@ -1,6 +1,8 @@
-import { Card, Checkbox, Label } from '@douglasneuroinformatics/libui/components';
+import { Checkbox, Label } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
+import { InstanceCard } from '@/components/InstanceCard';
+
import type { BrandingEditor } from '../hooks';
/** Top-level toggle: when off, the classic (unbranded) login page is shown. */
@@ -8,24 +10,24 @@ export const EnableBrandingCard = ({ editor }: { editor: BrandingEditor }) => {
const { t } = useTranslation();
const { form, update } = editor;
return (
-
-
-
+
+
+
{t({
en: 'Enable Custom Login Page',
es: 'Activar la página de inicio de sesión personalizada',
fr: 'Activer la page de connexion personnalisée'
})}
-
-
+
+
{t({
en: 'When disabled, the classic Open Data Capture login page is shown instead.',
es: 'Cuando está desactivada, se muestra la página de inicio de sesión clásica de Open Data Capture.',
fr: "Lorsque désactivé, la page de connexion classique d'Open Data Capture est affichée."
})}
-
-
-
+
+
+
{
{t({ en: 'Enable', es: 'Activar', fr: 'Activer' })}
-
-
+
+
);
};
diff --git a/apps/web/src/components/LoginPageEditor/sections/FooterCard.tsx b/apps/web/src/components/LoginPageEditor/sections/FooterCard.tsx
index 0a1a825e2..d10001457 100644
--- a/apps/web/src/components/LoginPageEditor/sections/FooterCard.tsx
+++ b/apps/web/src/components/LoginPageEditor/sections/FooterCard.tsx
@@ -1,6 +1,8 @@
-import { Card, Checkbox, Label } from '@douglasneuroinformatics/libui/components';
+import { Checkbox, Label } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
+import { InstanceCard } from '@/components/InstanceCard';
+
import type { BrandingEditor } from '../hooks';
/** Toggles the GitHub / documentation links shown in the login page footer. */
@@ -8,11 +10,11 @@ export const FooterCard = ({ editor }: { editor: BrandingEditor }) => {
const { t } = useTranslation();
const { form, update } = editor;
return (
-
-
- {t({ en: 'Footer', es: 'Pie de página', fr: 'Pied de page' })}
-
-
+
+
+ {t({ en: 'Footer', es: 'Pie de página', fr: 'Pied de page' })}
+
+
{
})}
-
-
+
+
);
};
diff --git a/apps/web/src/components/LoginPageEditor/sections/LeftPanelThemeCard.tsx b/apps/web/src/components/LoginPageEditor/sections/LeftPanelThemeCard.tsx
index 60af4993a..17d99f5fd 100644
--- a/apps/web/src/components/LoginPageEditor/sections/LeftPanelThemeCard.tsx
+++ b/apps/web/src/components/LoginPageEditor/sections/LeftPanelThemeCard.tsx
@@ -1,8 +1,8 @@
-import { Card } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { LOGIN_THEMES } from '@opendatacapture/schemas/setup';
import type { BrandingConfig } from '@opendatacapture/schemas/setup';
+import { InstanceCard } from '@/components/InstanceCard';
import { getLoginGradient } from '@/utils/branding';
import { THEME_LABELS } from '../constants';
@@ -16,24 +16,24 @@ export const LeftPanelThemeCard = ({ editor }: { editor: BrandingEditor }) => {
const { t } = useTranslation();
const { form, update } = editor;
return (
-
-
-
+
+
+
{t({
en: 'Background Theme - Left Panel',
es: 'Tema de fondo: panel izquierdo',
fr: "Thème d'arrière-plan - Panneau gauche"
})}
-
-
+
+
{t({
en: 'The gradient shown behind the branding panel.',
es: 'El degradado que se muestra detrás del panel de marca.',
fr: 'Le dégradé affiché derrière le panneau.'
})}
-
-
-
+
+
+
{LOGIN_THEMES.map((theme) => {
const swatchBranding: BrandingConfig =
@@ -73,7 +73,7 @@ export const LeftPanelThemeCard = ({ editor }: { editor: BrandingEditor }) => {
/>
)}
-
-
+
+
);
};
diff --git a/apps/web/src/components/LoginPageEditor/sections/RightPanelThemeCard.tsx b/apps/web/src/components/LoginPageEditor/sections/RightPanelThemeCard.tsx
index 1786a801f..99e8c42c6 100644
--- a/apps/web/src/components/LoginPageEditor/sections/RightPanelThemeCard.tsx
+++ b/apps/web/src/components/LoginPageEditor/sections/RightPanelThemeCard.tsx
@@ -1,6 +1,6 @@
-import { Card } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
+import { InstanceCard } from '@/components/InstanceCard';
import { getRightPanelGradient } from '@/utils/branding';
import { RIGHT_PANEL_LABELS, RIGHT_PANEL_OPTIONS } from '../constants';
@@ -14,24 +14,24 @@ export const RightPanelThemeCard = ({ editor }: { editor: BrandingEditor }) => {
const { t } = useTranslation();
const { form, update } = editor;
return (
-
-
-
+
+
+
{t({
en: 'Background Theme - Right Panel',
es: 'Tema de fondo: panel derecho',
fr: "Thème d'arrière-plan - Panneau droit"
})}
-
-
+
+
{t({
en: 'The gradient applied behind the login form.',
es: 'El degradado aplicado detrás del formulario de inicio de sesión.',
fr: 'Le dégradé appliqué derrière le formulaire de connexion.'
})}
-
-
-
+
+
+
{RIGHT_PANEL_OPTIONS.map((option) => {
const swatchStyle =
@@ -77,7 +77,7 @@ export const RightPanelThemeCard = ({ editor }: { editor: BrandingEditor }) => {
/>
)}
-
-
+
+
);
};
diff --git a/apps/web/src/components/LoginPageEditor/sections/SectionCards.tsx b/apps/web/src/components/LoginPageEditor/sections/SectionCards.tsx
index b9b471ce6..782d6df9d 100644
--- a/apps/web/src/components/LoginPageEditor/sections/SectionCards.tsx
+++ b/apps/web/src/components/LoginPageEditor/sections/SectionCards.tsx
@@ -1,12 +1,14 @@
import React from 'react';
-import { Button, Card, Input, Label, RadioGroup, Select, TextArea } from '@douglasneuroinformatics/libui/components';
+import { Button, Input, Label, RadioGroup, Select, TextArea } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { cn } from '@douglasneuroinformatics/libui/utils';
import { LOGO_ALIGNMENTS, LOGO_SIZES } from '@opendatacapture/schemas/setup';
import type { LogoAlignment, LogoSize, LogoSource, PanelSection } from '@opendatacapture/schemas/setup';
import { PlusIcon, TrashIcon, UploadIcon, XIcon } from 'lucide-react';
+import { InstanceCard } from '@/components/InstanceCard';
+
import {
ACCEPTED_LOGO_MIME_TYPES,
LOGO_ALIGNMENT_LABELS,
@@ -38,7 +40,7 @@ const DetailsCard = ({ editor }: CardProps) => {
const { t } = useTranslation();
const { form, update, updateText } = editor;
return (
-
+
update('boldDetails', b) }}
description={t({
@@ -52,7 +54,7 @@ const DetailsCard = ({ editor }: CardProps) => {
title={t(SECTION_TITLES.details)}
/>
{form.showDetails && (
-
+
{t({ en: 'English', es: 'Inglés', fr: 'Anglais' })}
@@ -80,9 +82,9 @@ const DetailsCard = ({ editor }: CardProps) => {
value={form.detailsFontSize}
onChange={(v) => update('detailsFontSize', v)}
/>
-
+
)}
-
+
);
};
@@ -90,7 +92,7 @@ const LogoCard = ({ editor }: CardProps) => {
const { t } = useTranslation();
const { fileInputRef, form, handleLogoFile, isCustomSizeInvalid, update } = editor;
return (
-
+
{
title={t(SECTION_TITLES.logo)}
/>
{form.showLogo && (
-
+
{/*
An uploaded image and a URL can both be saved at once; the radio
selects which one the login page actually renders. The inactive
@@ -270,9 +272,9 @@ const LogoCard = ({ editor }: CardProps) => {
)}
)}
-
+
)}
-
+
);
};
@@ -280,7 +282,7 @@ const NameCard = ({ editor }: CardProps) => {
const { t } = useTranslation();
const { form, update, updateText } = editor;
return (
-
+
update('boldName', b) }}
description={t({
@@ -292,7 +294,7 @@ const NameCard = ({ editor }: CardProps) => {
section="name"
title={t(SECTION_TITLES.name)}
/>
-
+
{t({ en: 'English', es: 'Inglés', fr: 'Anglais' })}
@@ -335,8 +337,8 @@ const NameCard = ({ editor }: CardProps) => {
update('nameFontSize', v)} />
-
-
+
+
);
};
@@ -344,7 +346,7 @@ const ResourcesCard = ({ editor }: CardProps) => {
const { t } = useTranslation();
const { addResourceLink, form, removeResourceLink, update, updateResourceLinkHref, updateResourceLinkLabel } = editor;
return (
-
+
{
title={t(SECTION_TITLES.resources)}
/>
{form.showResourceLinks && (
-
+
{form.resourceLinks.length === 0 && (
{t({
@@ -439,9 +441,9 @@ const ResourcesCard = ({ editor }: CardProps) => {
value={form.resourceLinksFontSize}
onChange={(v) => update('resourceLinksFontSize', v)}
/>
-
+
)}
-
+
);
};
@@ -449,7 +451,7 @@ const TaglineCard = ({ editor }: CardProps) => {
const { t } = useTranslation();
const { form, update, updateText } = editor;
return (
-
+
update('boldTagline', b) }}
description={t({
@@ -463,7 +465,7 @@ const TaglineCard = ({ editor }: CardProps) => {
title={t(SECTION_TITLES.tagline)}
/>
{form.showTagline && (
-
+
{t({ en: 'English', es: 'Inglés', fr: 'Anglais' })}
@@ -491,9 +493,9 @@ const TaglineCard = ({ editor }: CardProps) => {
value={form.taglineFontSize}
onChange={(v) => update('taglineFontSize', v)}
/>
-
+
)}
-
+
);
};
diff --git a/apps/web/src/components/LoginPageEditor/sections/TextColorCard.tsx b/apps/web/src/components/LoginPageEditor/sections/TextColorCard.tsx
index 16eedcc9f..fbc0ce2c3 100644
--- a/apps/web/src/components/LoginPageEditor/sections/TextColorCard.tsx
+++ b/apps/web/src/components/LoginPageEditor/sections/TextColorCard.tsx
@@ -1,6 +1,7 @@
-import { Card } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
+import { InstanceCard } from '@/components/InstanceCard';
+
import { DEFAULT_PANEL_TEXT_COLOR } from '../constants';
import { ColorField } from '../fields/ColorField';
@@ -15,24 +16,24 @@ export const TextColorCard = ({ editor }: { editor: BrandingEditor }) => {
const { t } = useTranslation();
const { form, update } = editor;
return (
-
-
-
+
+
+
{t({
en: 'Text Color - Left Panel',
es: 'Color del texto: panel izquierdo',
fr: 'Couleur du texte - Panneau gauche'
})}
-
-
+
+
{t({
en: 'The color applied to all text on the branding panel.',
es: 'El color aplicado a todo el texto del panel de marca.',
fr: 'La couleur appliquée à tout le texte du panneau.'
})}
-
-
-
+
+
+
{
onChange={(v) => update('panelTextColor', v)}
/>
-
-
+
+
);
};
diff --git a/apps/web/src/components/LoginPageEditor/sections/__tests__/SectionCards.harness.tsx b/apps/web/src/components/LoginPageEditor/sections/__tests__/SectionCards.harness.tsx
index c3adddf0b..505991cd3 100644
--- a/apps/web/src/components/LoginPageEditor/sections/__tests__/SectionCards.harness.tsx
+++ b/apps/web/src/components/LoginPageEditor/sections/__tests__/SectionCards.harness.tsx
@@ -37,10 +37,12 @@ async function renderWithBrandingEditor(
const setupState: SetupState = {
activeLanguages: ['en', 'fr'],
branding,
+ darkCardTheme: 'invisible',
isDemo: false,
isGatewayEnabled: false,
isMailEnabled: false,
isSetup: true,
+ lightCardTheme: 'invisible',
release: { buildTime: 0, type: 'production', version: '1.0.0' },
tableColorMode: 'tags',
uptime: 0
diff --git a/apps/web/src/components/PermissionSetForm/PermissionSetForm.tsx b/apps/web/src/components/PermissionSetForm/PermissionSetForm.tsx
index 2d760fd3c..7e7b450de 100644
--- a/apps/web/src/components/PermissionSetForm/PermissionSetForm.tsx
+++ b/apps/web/src/components/PermissionSetForm/PermissionSetForm.tsx
@@ -7,6 +7,7 @@ import { $CreatePermissionSetData } from '@opendatacapture/schemas/permission-se
import type { PermissionSet } from '@opendatacapture/schemas/permission-set';
import { PermissionSetRulesEditor } from '@/components/PermissionSetRulesEditor';
+import { PAGE_SUBMIT_BUTTON } from '@/utils/card-color';
import { createRuleDraft, isIncompleteRuleDraft, withRuleDrafts } from '@/utils/permission-set-rules';
import type { PermissionSetRuleDraft } from '@/utils/permission-set-rules';
@@ -44,6 +45,7 @@ export const PermissionSetForm = ({
variant: 'input'
}
}}
+ customStyles={{ submitBtn: PAGE_SUBMIT_BUTTON }}
data-testid="permission-set-form"
fieldsFooter={
diff --git a/apps/web/src/components/SectionCard/SectionCard.tsx b/apps/web/src/components/SectionCard/SectionCard.tsx
index 8a3bc4a51..a64c5baad 100644
--- a/apps/web/src/components/SectionCard/SectionCard.tsx
+++ b/apps/web/src/components/SectionCard/SectionCard.tsx
@@ -1,12 +1,14 @@
import type React from 'react';
+import { InstanceCard } from '@/components/InstanceCard';
+
export type SectionCardProps = {
children: React.ReactNode;
'data-testid'?: string;
};
export const SectionCard = ({ children, 'data-testid': testId }: SectionCardProps) => (
-
+
{children}
-
+
);
diff --git a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx
index a001937f2..e3c0ef4c2 100644
--- a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx
+++ b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx
@@ -17,6 +17,7 @@ import type { Promisable } from 'type-fest';
import { z } from 'zod/v4';
import { useSubjectDemographicsLookup } from '@/hooks/useSubjectDemographicsLookup';
+import { PAGE_SUBMIT_BUTTON } from '@/utils/card-color';
import { getValueForLanguage } from '@/utils/language';
const currentDate = new Date();
@@ -207,6 +208,7 @@ export const StartSessionForm = ({
}
}
]}
+ customStyles={{ submitBtn: PAGE_SUBMIT_BUTTON }}
data-testid="start-session-form"
initialValues={initialValues}
readOnly={readOnly}
diff --git a/apps/web/src/components/UserPermissionsEditor/UserPermissionsEditor.tsx b/apps/web/src/components/UserPermissionsEditor/UserPermissionsEditor.tsx
index 83e4dc242..db3766a35 100644
--- a/apps/web/src/components/UserPermissionsEditor/UserPermissionsEditor.tsx
+++ b/apps/web/src/components/UserPermissionsEditor/UserPermissionsEditor.tsx
@@ -1,4 +1,4 @@
-import { Button, Card, Table, Tabs } from '@douglasneuroinformatics/libui/components';
+import { Button, Table, Tabs } from '@douglasneuroinformatics/libui/components';
import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { cn } from '@douglasneuroinformatics/libui/utils';
import {
@@ -16,6 +16,7 @@ import { PlusIcon, Trash2Icon } from 'lucide-react';
import { CellSelect } from '@/components/CellSelect';
import { Chip } from '@/components/Chip';
import { ColorTagCell } from '@/components/ColorTagCell';
+import { InstanceCard } from '@/components/InstanceCard';
import { usePermissionLabels } from '@/hooks/usePermissionLabels';
import { resolveTheme } from '@/utils/chart-theme';
import { ALL_GROUPS, grantableActions, grantableSubjects, isIncompleteDraft, withoutIndex } from '@/utils/permissions';
@@ -83,34 +84,34 @@ export const UserPermissionsEditor = ({
if (user.basePermissionLevel === 'ADMIN') {
return (
-
-
- {t({ en: 'Permissions', es: 'Permisos', fr: 'Autorisations' })}
-
+
+
+ {t({ en: 'Permissions', es: 'Permisos', fr: 'Autorisations' })}
+
{t({
en: 'Administrators hold every permission, so there is nothing further to grant.',
es: 'Los administradores tienen todos los permisos, por lo que no hay nada más que conceder.',
fr: "Les administrateurs détiennent toutes les autorisations, il n'y a donc rien de plus à accorder."
})}
-
-
-
+
+
+
);
}
return (
-
-
- {t({ en: 'Permissions', es: 'Permisos', fr: 'Autorisations' })}
-
+
+
+ {t({ en: 'Permissions', es: 'Permisos', fr: 'Autorisations' })}
+
{t({
en: 'A permission set or custom grants come on top of the base permission level, never both. Changes take effect the next time this user signs in.',
es: 'Un conjunto de permisos o permisos personalizados se suman al nivel de permisos de base, nunca ambos. Los cambios se aplican la próxima vez que este usuario inicie sesión.',
fr: "Un ensemble d'autorisations ou des autorisations personnalisées s'ajoutent au niveau de base, jamais les deux. Les modifications prennent effet à la prochaine connexion de cet utilisateur."
})}
-
-
-
+
+
+
)}
-
-
+
+
);
};
diff --git a/apps/web/src/routes/_app/__tests__/dashboard.test.tsx b/apps/web/src/routes/_app/__tests__/dashboard.test.tsx
index 979e2c8a7..72403f198 100644
--- a/apps/web/src/routes/_app/__tests__/dashboard.test.tsx
+++ b/apps/web/src/routes/_app/__tests__/dashboard.test.tsx
@@ -212,6 +212,11 @@ describe('Route', () => {
expect(mocks.useInstrumentRecords).toHaveBeenCalledWith({ enabled: true, params: { groupId: 'group-1' } });
});
+ it('should not put the page on a card, so only its tiles take the instance color', () => {
+ renderPage();
+ expect(document.querySelector('[data-instance-card]')).toBeNull();
+ });
+
it.each([
['CLINICAL', 'Overview of Your Clinic'],
['RESEARCH', 'Overview of Your Research Group']
diff --git a/apps/web/src/routes/_app/about.tsx b/apps/web/src/routes/_app/about.tsx
index b7fd7cbd9..e8bacbb3a 100644
--- a/apps/web/src/routes/_app/about.tsx
+++ b/apps/web/src/routes/_app/about.tsx
@@ -2,13 +2,14 @@ import type React from 'react';
import { useCallback, useRef, useState } from 'react';
import { parseDuration } from '@douglasneuroinformatics/libjs';
-import { Card, Heading } from '@douglasneuroinformatics/libui/components';
+import { Heading } from '@douglasneuroinformatics/libui/components';
import { useInterval, useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { $GatewayHealthcheckResult } from '@opendatacapture/schemas/gateway';
import type { ReleaseInfo } from '@opendatacapture/schemas/setup';
import { createFileRoute } from '@tanstack/react-router';
import axios from 'axios';
+import { InstanceCard } from '@/components/InstanceCard';
import { PageHeader } from '@/components/PageHeader';
import { setupStateQueryOptions, useSetupStateQuery } from '@/hooks/useSetupStateQuery';
@@ -171,18 +172,18 @@ const RouteComponent = () => {
})}
-
-
- Open Data Capture
-
+
+
+ Open Data Capture
+
{t({
en: "This page provides technical information that you can share with your platform administrator if you encounter any issues. Don't worry if you don't understand the content; it's intended for technical support.",
es: 'Esta página ofrece información técnica que puede compartir con el administrador de su plataforma si tiene algún problema. No se preocupe si no entiende el contenido; está destinado al soporte técnico.',
fr: "Cette page fournit des informations techniques que vous pouvez partager avec l'administrateur de votre plateforme si vous rencontrez des problèmes. Ne vous inquiétez pas si vous ne comprenez pas le contenu ; il est destiné à l'assistance technique."
})}
-
-
-
+
+
+
{
})}
testId="about-gateway-info"
/>
-
-
+
+
{t({
en: `Generated on ${currentDateString}`,
@@ -218,8 +219,8 @@ const RouteComponent = () => {
fr: `Généré le ${currentDateString}`
})}
-
-
+
+
);
};
diff --git a/apps/web/src/routes/_app/admin/__tests__/settings.test.tsx b/apps/web/src/routes/_app/admin/__tests__/settings.test.tsx
index 8dab30549..55024c313 100644
--- a/apps/web/src/routes/_app/admin/__tests__/settings.test.tsx
+++ b/apps/web/src/routes/_app/admin/__tests__/settings.test.tsx
@@ -1,6 +1,12 @@
import type { ActiveLanguages } from '@opendatacapture/schemas/core';
-import type { $UpdateSetupStateData, TableColorMode } from '@opendatacapture/schemas/setup';
-import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { DEFAULT_TABLE_COLOR_MODE } from '@opendatacapture/schemas/setup';
+import type {
+ $UpdateSetupStateData,
+ DarkCardTheme,
+ LightCardTheme,
+ TableColorMode
+} from '@opendatacapture/schemas/setup';
+import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Route } from '@/routes/_app/admin/settings';
@@ -10,9 +16,13 @@ import '@/services/i18n';
type SetupStateData = {
activeLanguages: ActiveLanguages;
+ customCardColorDark?: string;
+ customCardColorLight?: string;
+ darkCardTheme?: DarkCardTheme;
defaultAssignmentDurationDays?: null | number;
isBulkRemoteAssignmentsEnabled?: boolean | null;
isExperimentalFeaturesEnabled?: boolean | null;
+ lightCardTheme?: LightCardTheme;
tableColorMode: TableColorMode;
};
@@ -370,4 +380,86 @@ describe('Route', () => {
expect(mocks.setGroupSwitcherPosition).toHaveBeenCalledWith('topbar');
});
});
+
+ describe('card color', () => {
+ const NO_COLORS: SetupStateData = {
+ activeLanguages: ['en'],
+ darkCardTheme: 'invisible',
+ lightCardTheme: 'invisible',
+ tableColorMode: DEFAULT_TABLE_COLOR_MODE
+ };
+ const picker = (mode: string) => within(screen.getByTestId(`card-theme-${mode}`));
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should mark the theme the instance has stored for each mode', () => {
+ mocks.state.setupState = { ...NO_COLORS, lightCardTheme: 'mist' };
+ renderPage();
+ expect(picker('light').getByRole('button', { name: 'Mist' }).getAttribute('aria-pressed')).toBe('true');
+ expect(picker('dark').getByRole('button', { name: 'No Color' }).getAttribute('aria-pressed')).toBe('true');
+ });
+
+ it('should offer no color, six themes and custom', () => {
+ mocks.state.setupState = NO_COLORS;
+ renderPage();
+ expect(picker('light').getAllByRole('button')).toHaveLength(8);
+ });
+
+ it.each([
+ ['light', 'Silver', { lightCardTheme: 'silver' }],
+ ['dark', 'Midnight', { darkCardTheme: 'midnight' }]
+ ])('should save a theme chosen for %s mode without touching the other mode', (mode, name, saved) => {
+ mocks.state.setupState = NO_COLORS;
+ renderPage();
+ fireEvent.click(picker(mode).getByRole('button', { name }));
+ expect(savedData()).toEqual([saved]);
+ });
+
+ it('should offer each mode its own themes', () => {
+ mocks.state.setupState = NO_COLORS;
+ renderPage();
+ expect(picker('dark').queryByRole('button', { name: 'Paper' })).toBeNull();
+ expect(picker('light').queryByRole('button', { name: 'Midnight' })).toBeNull();
+ });
+
+ it('should hide the hex field until custom is chosen', () => {
+ mocks.state.setupState = NO_COLORS;
+ renderPage();
+ expect(screen.queryByLabelText('Custom Color')).toBeNull();
+ });
+
+ it.each([
+ ['light', 'lightCardTheme', { customCardColorLight: '#123456' }],
+ ['dark', 'darkCardTheme', { customCardColorDark: '#123456' }]
+ ])('should save a custom %s color once typing settles', (mode, themeKey, saved) => {
+ vi.useFakeTimers();
+ mocks.state.setupState = { ...NO_COLORS, [themeKey]: 'custom' };
+ renderPage();
+ fireEvent.change(picker(mode).getByLabelText('Custom Color'), { target: { value: '#123456' } });
+ expect(savedData()).toEqual([]);
+ act(() => {
+ vi.advanceTimersByTime(700);
+ });
+ expect(savedData()).toEqual([saved]);
+ });
+
+ it('should not save a custom color that is not yet a valid hex', () => {
+ vi.useFakeTimers();
+ mocks.state.setupState = { ...NO_COLORS, lightCardTheme: 'custom' };
+ renderPage();
+ fireEvent.change(picker('light').getByLabelText('Custom Color'), { target: { value: '#12' } });
+ act(() => {
+ vi.advanceTimersByTime(700);
+ });
+ expect(savedData()).toEqual([]);
+ });
+
+ it('should start the hex field from the stored custom color', () => {
+ mocks.state.setupState = { ...NO_COLORS, customCardColorLight: '#abcdef', lightCardTheme: 'custom' };
+ renderPage();
+ expect(picker('light').getByLabelText
('Custom Color').value).toBe('#abcdef');
+ });
+ });
});
diff --git a/apps/web/src/routes/_app/admin/audit/logs.tsx b/apps/web/src/routes/_app/admin/audit/logs.tsx
index de78ff129..cdeee2841 100644
--- a/apps/web/src/routes/_app/admin/audit/logs.tsx
+++ b/apps/web/src/routes/_app/admin/audit/logs.tsx
@@ -15,6 +15,7 @@ import { ArrowDownIcon, ArrowUpIcon, CalendarIcon, DownloadIcon, XIcon } from 'l
import { ColorTagCell } from '@/components/ColorTagCell';
import { FilterMenu } from '@/components/FilterMenu';
+import { InstanceCard } from '@/components/InstanceCard';
import { PageHeader } from '@/components/PageHeader';
import { auditLogsQueryOptions, fetchAllAuditLogs, useAuditLogsQuery } from '@/hooks/useAuditLogsQuery';
import { groupsQueryOptions, useGroupsQuery } from '@/hooks/useGroupsQuery';
@@ -309,10 +310,12 @@ const RouteComponent = () => {
{t('common.auditLogs')}
-
- {/* Remounted when the filters change: in server mode the table owns its page index and offers no
+
+
+ {/* Remounted when the filters change: in server mode the table owns its page index and offers no
way to set it, so a fresh store is the only way to return it to the first page. */}
-
+
+
);
};
diff --git a/apps/web/src/routes/_app/admin/branding/index.tsx b/apps/web/src/routes/_app/admin/branding/index.tsx
index 9fc615aaf..add8e57b3 100644
--- a/apps/web/src/routes/_app/admin/branding/index.tsx
+++ b/apps/web/src/routes/_app/admin/branding/index.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { Logo } from '@opendatacapture/react-core';
import { createFileRoute, Link } from '@tanstack/react-router';
+import { InstanceCard } from '@/components/InstanceCard';
import { PageHeader } from '@/components/PageHeader';
const LoginPagePreview = () => (
@@ -37,7 +38,7 @@ const RouteComponent = () => {
-
+
@@ -51,7 +52,7 @@ const RouteComponent = () => {
})}
-
+
diff --git a/apps/web/src/routes/_app/admin/groups/create.tsx b/apps/web/src/routes/_app/admin/groups/create.tsx
index 21527d070..83b35f5d1 100644
--- a/apps/web/src/routes/_app/admin/groups/create.tsx
+++ b/apps/web/src/routes/_app/admin/groups/create.tsx
@@ -3,8 +3,10 @@ import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { $CreateGroupData } from '@opendatacapture/schemas/group';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
+import { InstanceCard } from '@/components/InstanceCard';
import { PageHeader } from '@/components/PageHeader';
import { useCreateGroupMutation } from '@/hooks/useCreateGroupMutation';
+import { PAGE_SUBMIT_BUTTON } from '@/utils/card-color';
import { getApiErrorMessage } from '@/utils/error';
const RouteComponent = () => {
@@ -41,27 +43,30 @@ const RouteComponent = () => {
})}
-
+ type: {
+ kind: 'string',
+ label: t('common.groupType'),
+ options: {
+ CLINICAL: t('common.clinical'),
+ RESEARCH: t('common.research')
+ },
+ variant: 'select'
+ }
+ }}
+ customStyles={{ submitBtn: PAGE_SUBMIT_BUTTON }}
+ validationSchema={$CreateGroupData.omit({ settings: true })}
+ onSubmit={handleSubmit}
+ />
+
);
};
diff --git a/apps/web/src/routes/_app/admin/groups/index.tsx b/apps/web/src/routes/_app/admin/groups/index.tsx
index 7f8fa33e3..e76364eb0 100644
--- a/apps/web/src/routes/_app/admin/groups/index.tsx
+++ b/apps/web/src/routes/_app/admin/groups/index.tsx
@@ -9,6 +9,7 @@ import { createFileRoute, Link } from '@tanstack/react-router';
import axios from 'axios';
import { ColorTagCell } from '@/components/ColorTagCell';
+import { InstanceCard } from '@/components/InstanceCard';
import { PageHeader } from '@/components/PageHeader';
import { useDeleteGroupMutation } from '@/hooks/useDeleteGroupMutation';
import { GROUPS_QUERY_KEY, groupsQueryOptions, useGroupsQuery } from '@/hooks/useGroupsQuery';
@@ -106,64 +107,66 @@ const RouteComponent = () => {
})}
- {
- const group = ctx.row.original;
- return (
-
- {group.name}
-
-
- );
+
+ {
+ const group = ctx.row.original;
+ return (
+
+ {group.name}
+
+
+ );
+ },
+ header: t('common.groupName')
},
- header: t('common.groupName')
- },
- {
- accessorKey: 'type',
- cell: (ctx) => {
- const type = ctx.getValue() as Group['type'];
- const typeLabels: { [K in Group['type']]: string } = {
- CLINICAL: t('common.clinical'),
- RESEARCH: t('common.research')
- };
- return (
-
- );
- },
- header: t('common.groupType')
- }
- ]}
- data={groupsQuery.data}
- rowActions={[
- {
- label: t('common.manage'),
- onSelect: setSelectedGroup
- }
- ]}
- togglesComponent={() => (
-
-
- {t({
- en: 'Add Group',
- es: 'Agregar grupo',
- fr: 'Ajouter un groupe'
- })}
-
-
- )}
- onRowClick={(group) => setHighlightedRowId(group.id)}
- onRowDoubleClick={(group) => {
- setHighlightedRowId(group.id);
- setSelectedGroup(group);
- }}
- />
+ {
+ accessorKey: 'type',
+ cell: (ctx) => {
+ const type = ctx.getValue() as Group['type'];
+ const typeLabels: { [K in Group['type']]: string } = {
+ CLINICAL: t('common.clinical'),
+ RESEARCH: t('common.research')
+ };
+ return (
+
+ );
+ },
+ header: t('common.groupType')
+ }
+ ]}
+ data={groupsQuery.data}
+ rowActions={[
+ {
+ label: t('common.manage'),
+ onSelect: setSelectedGroup
+ }
+ ]}
+ togglesComponent={() => (
+
+
+ {t({
+ en: 'Add Group',
+ es: 'Agregar grupo',
+ fr: 'Ajouter un groupe'
+ })}
+
+
+ )}
+ onRowClick={(group) => setHighlightedRowId(group.id)}
+ onRowDoubleClick={(group) => {
+ setHighlightedRowId(group.id);
+ setSelectedGroup(group);
+ }}
+ />
+
diff --git a/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx b/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx
index b025effbf..43a979f7e 100644
--- a/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx
+++ b/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx
@@ -284,17 +284,13 @@ describe('Route', () => {
expect(nameCell.style.position).toBe('');
});
- it('should draw no card behind either tab, so the tables sit directly on the page', () => {
+ it('should sit both tabs on the instance card, so they take the color chosen for the instance', () => {
renderPage();
- const repositoriesCard = screen.getByTestId('instrument-repos-card').className;
+ expect(screen.getByTestId('instrument-repos-card').hasAttribute('data-instance-card')).toBe(true);
openActivityLog();
- for (const className of [repositoriesCard, screen.getByTestId('instrument-repos-card').className]) {
- expect(className).toContain('bg-transparent');
- expect(className).toContain('border-transparent');
- expect(className).toContain('shadow-none');
- }
+ expect(screen.getByTestId('instrument-repos-card').hasAttribute('data-instance-card')).toBe(true);
});
it('should pad the table in its own colour above and beside it, so Sync All and the rows share one edge', () => {
diff --git a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx
index 293b3c657..e4c56fd82 100644
--- a/apps/web/src/routes/_app/admin/instrument-repos/index.tsx
+++ b/apps/web/src/routes/_app/admin/instrument-repos/index.tsx
@@ -3,7 +3,6 @@ import type { FormEvent } from 'react';
import {
Button,
- Card,
DataTable,
Dialog,
Heading,
@@ -20,6 +19,7 @@ import type { InstrumentRepo, InstrumentRepoSyncLog } from '@opendatacapture/sch
import { createFileRoute } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'motion/react';
+import { InstanceCard } from '@/components/InstanceCard';
import { InstrumentRepoSyncLogTable } from '@/components/InstrumentRepoSyncLogTable';
import { PageHeader } from '@/components/PageHeader';
import { SectionTabs } from '@/components/SectionTabs';
@@ -291,17 +291,16 @@ const RouteComponent = () => {
})}
-
-
+
{sectionHeadings[section].title}
-
- {sectionHeadings[section].description}
+
+ {sectionHeadings[section].description}
{
)}
-
+
{isConfirmClearHistoryOpen && (
diff --git a/apps/web/src/routes/_app/admin/instruments.tsx b/apps/web/src/routes/_app/admin/instruments.tsx
index 541a99881..2f2e00ae2 100644
--- a/apps/web/src/routes/_app/admin/instruments.tsx
+++ b/apps/web/src/routes/_app/admin/instruments.tsx
@@ -9,6 +9,7 @@ import type { InstrumentInfo, TranslatedInstrumentInfo } from '@opendatacapture/
import { createFileRoute } from '@tanstack/react-router';
import { ColorTagCell } from '@/components/ColorTagCell';
+import { InstanceCard } from '@/components/InstanceCard';
import { InstrumentKindCell } from '@/components/InstrumentKindCell';
import { InstrumentPreviewDialog } from '@/components/InstrumentPreviewDialog';
import type { InstrumentPreviewItem, InstrumentSource } from '@/components/InstrumentPreviewDialog';
@@ -381,11 +382,13 @@ const RouteComponent = () => {
})}
- {view === 'series' ? (
-
- ) : (
-
- )}
+
+ {view === 'series' ? (
+
+ ) : (
+
+ )}
+
);
};
diff --git a/apps/web/src/routes/_app/admin/permission-sets/$permissionSetId.tsx b/apps/web/src/routes/_app/admin/permission-sets/$permissionSetId.tsx
index a7f69af3c..0a7189bbc 100644
--- a/apps/web/src/routes/_app/admin/permission-sets/$permissionSetId.tsx
+++ b/apps/web/src/routes/_app/admin/permission-sets/$permissionSetId.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { ChevronLeftIcon } from '@heroicons/react/24/solid';
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
+import { InstanceCard } from '@/components/InstanceCard';
import { PageHeader } from '@/components/PageHeader';
import { PermissionSetForm } from '@/components/PermissionSetForm';
import { useConfirmPermissionSetDeletion } from '@/hooks/useConfirmPermissionSetDeletion';
@@ -29,47 +30,49 @@ const PermissionSetEditor = ({ permissionSetId }: { permissionSetId: string }) =
})}
-
-
-
- {t({ en: 'Return', es: 'Volver', fr: 'Retour' })}
-
-
confirmDeletion(permissionSet, { onDeleted: () => void navigate({ to: '..' }) })}
- >
- {t('core.delete')}
-
- }
- submitLabel={t({ en: 'Save Changes', es: 'Guardar los cambios', fr: 'Enregistrer les modifications' })}
- onSubmit={async (data) => {
- try {
- await updatePermissionSetMutation.mutateAsync({ data, id: permissionSet.id });
- } catch (err) {
- const errorMessage = getApiErrorMessage(
- err,
- t({
- en: 'Failed to save permission set',
- es: 'Error al guardar el conjunto de permisos',
- fr: "Échec de l'enregistrement de l'ensemble d'autorisations"
- })
- );
- return { errorMessage, success: false };
+
+
+
+
+ {t({ en: 'Return', es: 'Volver', fr: 'Retour' })}
+
+
confirmDeletion(permissionSet, { onDeleted: () => void navigate({ to: '..' }) })}
+ >
+ {t('core.delete')}
+
}
- return undefined;
- }}
- />
-
+ submitLabel={t({ en: 'Save Changes', es: 'Guardar los cambios', fr: 'Enregistrer les modifications' })}
+ onSubmit={async (data) => {
+ try {
+ await updatePermissionSetMutation.mutateAsync({ data, id: permissionSet.id });
+ } catch (err) {
+ const errorMessage = getApiErrorMessage(
+ err,
+ t({
+ en: 'Failed to save permission set',
+ es: 'Error al guardar el conjunto de permisos',
+ fr: "Échec de l'enregistrement de l'ensemble d'autorisations"
+ })
+ );
+ return { errorMessage, success: false };
+ }
+ return undefined;
+ }}
+ />
+
+
);
};
diff --git a/apps/web/src/routes/_app/admin/permission-sets/create.tsx b/apps/web/src/routes/_app/admin/permission-sets/create.tsx
index 5a5463d0c..9b92767af 100644
--- a/apps/web/src/routes/_app/admin/permission-sets/create.tsx
+++ b/apps/web/src/routes/_app/admin/permission-sets/create.tsx
@@ -2,6 +2,7 @@ import { Heading } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
+import { InstanceCard } from '@/components/InstanceCard';
import { PageHeader } from '@/components/PageHeader';
import { PermissionSetForm } from '@/components/PermissionSetForm';
import { useCreatePermissionSetMutation } from '@/hooks/useCreatePermissionSetMutation';
@@ -23,26 +24,28 @@ const RouteComponent = () => {
})}
- {
- try {
- await createPermissionSetMutation.mutateAsync({ data });
- } catch (err) {
- const errorMessage = getApiErrorMessage(
- err,
- t({
- en: 'Failed to create permission set',
- es: 'Error al crear el conjunto de permisos',
- fr: "Échec de la création de l'ensemble d'autorisations"
- })
- );
- return { errorMessage, success: false };
- }
- void navigate({ to: '..' });
- return undefined;
- }}
- />
+
+ {
+ try {
+ await createPermissionSetMutation.mutateAsync({ data });
+ } catch (err) {
+ const errorMessage = getApiErrorMessage(
+ err,
+ t({
+ en: 'Failed to create permission set',
+ es: 'Error al crear el conjunto de permisos',
+ fr: "Échec de la création de l'ensemble d'autorisations"
+ })
+ );
+ return { errorMessage, success: false };
+ }
+ void navigate({ to: '..' });
+ return undefined;
+ }}
+ />
+
);
};
diff --git a/apps/web/src/routes/_app/admin/permission-sets/index.tsx b/apps/web/src/routes/_app/admin/permission-sets/index.tsx
index b1781556f..c324fb53c 100644
--- a/apps/web/src/routes/_app/admin/permission-sets/index.tsx
+++ b/apps/web/src/routes/_app/admin/permission-sets/index.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import type { PermissionSetSummary } from '@opendatacapture/schemas/permission-set';
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
+import { InstanceCard } from '@/components/InstanceCard';
import { PageHeader } from '@/components/PageHeader';
import { TruncatedCell } from '@/components/TruncatedCell';
import { useConfirmPermissionSetDeletion } from '@/hooks/useConfirmPermissionSetDeletion';
@@ -33,76 +34,78 @@ const RouteComponent = () => {
})}
- {
- const permissionSet = ctx.row.original;
- return (
-
-
-
-
- );
+
+ {
+ const permissionSet = ctx.row.original;
+ return (
+
+
+
+
+ );
+ },
+ header: t({ en: 'Name', es: 'Nombre', fr: 'Nom' })
},
- header: t({ en: 'Name', es: 'Nombre', fr: 'Nom' })
- },
- {
- accessorFn: (permissionSet) => permissionSet.rules.length,
- header: t({ en: 'Rules', es: 'Reglas', fr: 'Règles' }),
- id: 'ruleCount'
- },
- {
- accessorKey: 'holderCount',
- cell: ({ row }) => {row.original.holderCount} ,
- header: t({ en: 'Holders', es: 'Titulares', fr: 'Titulaires' })
- }
- ]}
- data={permissionSetsQuery.data}
- emptyStateProps={{
- description: t({
- en: 'A permission set bundles grants an administrator can then give users in one step.',
- es: 'Un conjunto de permisos agrupa permisos que un administrador puede luego dar a los usuarios en un solo paso.',
- fr: "Un ensemble d'autorisations regroupe des autorisations qu'un administrateur peut ensuite donner aux utilisateurs en une seule étape."
- }),
- title: t({
- en: 'No permission sets yet',
- es: 'Todavía no hay conjuntos de permisos',
- fr: "Aucun ensemble d'autorisations pour l'instant"
- })
- }}
- rowActions={[
- {
- label: t('common.manage'),
- onSelect: handleOpenPermissionSet
- },
- {
- label: t('core.delete'),
- onSelect: (permissionSet) => confirmDeletion(permissionSet)
- }
- ]}
- togglesComponent={() => (
-
-
- {t({
- en: 'Add Permission Set',
- es: 'Agregar un conjunto de permisos',
- fr: "Ajouter un ensemble d'autorisations"
- })}
-
-
- )}
- onRowClick={(permissionSet) => setHighlightedRowId(permissionSet.id)}
- onRowDoubleClick={handleOpenPermissionSet}
- />
+ {
+ accessorFn: (permissionSet) => permissionSet.rules.length,
+ header: t({ en: 'Rules', es: 'Reglas', fr: 'Règles' }),
+ id: 'ruleCount'
+ },
+ {
+ accessorKey: 'holderCount',
+ cell: ({ row }) => {row.original.holderCount} ,
+ header: t({ en: 'Holders', es: 'Titulares', fr: 'Titulaires' })
+ }
+ ]}
+ data={permissionSetsQuery.data}
+ emptyStateProps={{
+ description: t({
+ en: 'A permission set bundles grants an administrator can then give users in one step.',
+ es: 'Un conjunto de permisos agrupa permisos que un administrador puede luego dar a los usuarios en un solo paso.',
+ fr: "Un ensemble d'autorisations regroupe des autorisations qu'un administrateur peut ensuite donner aux utilisateurs en une seule étape."
+ }),
+ title: t({
+ en: 'No permission sets yet',
+ es: 'Todavía no hay conjuntos de permisos',
+ fr: "Aucun ensemble d'autorisations pour l'instant"
+ })
+ }}
+ rowActions={[
+ {
+ label: t('common.manage'),
+ onSelect: handleOpenPermissionSet
+ },
+ {
+ label: t('core.delete'),
+ onSelect: (permissionSet) => confirmDeletion(permissionSet)
+ }
+ ]}
+ togglesComponent={() => (
+
+
+ {t({
+ en: 'Add Permission Set',
+ es: 'Agregar un conjunto de permisos',
+ fr: "Ajouter un ensemble d'autorisations"
+ })}
+
+
+ )}
+ onRowClick={(permissionSet) => setHighlightedRowId(permissionSet.id)}
+ onRowDoubleClick={handleOpenPermissionSet}
+ />
+
);
};
diff --git a/apps/web/src/routes/_app/admin/settings.tsx b/apps/web/src/routes/_app/admin/settings.tsx
index cd4517951..368c1d3ce 100644
--- a/apps/web/src/routes/_app/admin/settings.tsx
+++ b/apps/web/src/routes/_app/admin/settings.tsx
@@ -1,22 +1,19 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
-import {
- Card,
- Checkbox,
- Heading,
- HoverCard,
- Input,
- Select,
- Separator
-} from '@douglasneuroinformatics/libui/components';
+import { Checkbox, Heading, HoverCard, Input, Select, Separator } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { cn } from '@douglasneuroinformatics/libui/utils';
import { DEFAULT_ASSIGNMENT_DURATION_DAYS } from '@opendatacapture/schemas/assignment';
import type { Language } from '@opendatacapture/schemas/core';
-import { MAX_ASSIGNMENT_DURATION_DAYS } from '@opendatacapture/schemas/setup';
+import { DARK_CARD_THEMES, LIGHT_CARD_THEMES, MAX_ASSIGNMENT_DURATION_DAYS } from '@opendatacapture/schemas/setup';
+import type { DarkCardTheme, LightCardTheme } from '@opendatacapture/schemas/setup';
import { createFileRoute } from '@tanstack/react-router';
import { CircleHelpIcon } from 'lucide-react';
+import { InstanceCard } from '@/components/InstanceCard';
+import { HEX_PATTERN } from '@/components/LoginPageEditor/constants';
+import { ColorField } from '@/components/LoginPageEditor/fields/ColorField';
+import { SwatchButton } from '@/components/LoginPageEditor/fields/SwatchButton';
import { PageHeader } from '@/components/PageHeader';
import { SaveStatus } from '@/components/SaveStatus';
import { SelectTableColorMode } from '@/components/SelectTableColorMode';
@@ -26,6 +23,13 @@ import { useUpdateSetupStateMutation } from '@/hooks/useUpdateSetupStateMutation
import { useAppStore } from '@/store';
import type { GroupSwitcherPosition } from '@/store/types';
import { parseDurationDays } from '@/utils/assignment-duration';
+import {
+ CARD_THEME_LABELS,
+ DEFAULT_CUSTOM_CARD_COLOR,
+ getDarkCardShades,
+ getLightCardShades
+} from '@/utils/card-color';
+import type { CardMode, CardShades } from '@/utils/card-color';
import { LANGUAGE_LABELS, LANGUAGES } from '@/utils/language';
/** libui ships no Switch, so this is hand-rolled — `label` names it for assistive technology. */
@@ -58,6 +62,76 @@ const Toggle = ({
);
+const CUSTOM_COLOR_AUTOSAVE_DELAY = 700;
+
+/** The login page's picker: a square per theme, and a hex field once Custom is chosen. */
+const CardThemePicker = ({
+ customColor,
+ label,
+ mode,
+ onCustomColorChange,
+ onThemeChange,
+ shades,
+ theme,
+ themes
+}: {
+ customColor: null | string | undefined;
+ label: string;
+ mode: CardMode;
+ onCustomColorChange: (color: string) => void;
+ onThemeChange: (theme: TTheme) => void;
+ shades: CardShades;
+ theme: TTheme;
+ themes: readonly TTheme[];
+}) => {
+ const { t } = useTranslation();
+ const [customDraft, setCustomDraft] = useState(customColor ?? DEFAULT_CUSTOM_CARD_COLOR[mode]);
+ const debounceRef = useRef>(undefined);
+
+ useEffect(() => () => clearTimeout(debounceRef.current), []);
+
+ const handleCustomDraftChange = (color: string) => {
+ setCustomDraft(color);
+ clearTimeout(debounceRef.current);
+ if (HEX_PATTERN.test(color)) {
+ debounceRef.current = setTimeout(() => onCustomColorChange(color), CUSTOM_COLOR_AUTOSAVE_DELAY);
+ }
+ };
+
+ return (
+
+
{label}
+
+ {themes.map((option) => {
+ const { border, fill } = shades[option];
+ return (
+ onThemeChange(option)}
+ />
+ );
+ })}
+
+ {theme === 'custom' && (
+
+ )}
+
+ );
+};
+
const SettingSection = ({ children, title }: { children: React.ReactNode; title: string }) => (
{title}
@@ -176,9 +250,9 @@ const RouteComponent = () => {
})}
-
-
-
+
+
+
@@ -320,6 +394,37 @@ const RouteComponent = () => {
+
+ autosave({ customCardColorLight })}
+ onThemeChange={(lightCardTheme) => autosave({ lightCardTheme })}
+ />
+ autosave({ customCardColorDark })}
+ onThemeChange={(darkCardTheme) => autosave({ darkCardTheme })}
+ />
+
+
@@ -364,8 +469,8 @@ const RouteComponent = () => {
/>
-
-
+
+
diff --git a/apps/web/src/routes/_app/admin/users/$userId.tsx b/apps/web/src/routes/_app/admin/users/$userId.tsx
index 6bf7ec8a5..f38f36125 100644
--- a/apps/web/src/routes/_app/admin/users/$userId.tsx
+++ b/apps/web/src/routes/_app/admin/users/$userId.tsx
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import { snakeToCamelCase } from '@douglasneuroinformatics/libjs';
-import { Button, Card, Dialog, Heading } from '@douglasneuroinformatics/libui/components';
+import { Button, Dialog, Heading } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { ArchiveBoxArrowDownIcon, ArchiveBoxXMarkIcon, ChevronLeftIcon } from '@heroicons/react/24/solid';
import { isGrantablePermission } from '@opendatacapture/schemas/core';
@@ -10,6 +10,7 @@ import type { $UpdateUserPermissionsData } from '@opendatacapture/schemas/user';
import { createFileRoute, Link } from '@tanstack/react-router';
import { Chip } from '@/components/Chip';
+import { InstanceCard } from '@/components/InstanceCard';
import { PageHeader } from '@/components/PageHeader';
import { UpdateUserForm } from '@/components/UpdateUserForm';
import type { UpdateUserFormInputData } from '@/components/UpdateUserForm';
@@ -23,6 +24,7 @@ import { permissionSetsQueryOptions, usePermissionSetsQuery } from '@/hooks/useP
import { useUnarchiveUserMutation } from '@/hooks/useUnarchiveUserMutation';
import { useUpdateUserMutation } from '@/hooks/useUpdateUserMutation';
import { useAppStore } from '@/store';
+import { PAGE_SUBMIT_BUTTON } from '@/utils/card-color';
import { isIncompleteDraft, withPermissionDrafts } from '@/utils/permissions';
import type { PermissionDraft } from '@/utils/permissions';
import { clearedIfBlank, omittedIfUnchanged, validationSummary } from '@/utils/validation';
@@ -97,14 +99,14 @@ const UserEditor = ({ userId }: { userId: string }) => {
{t({ en: 'Return', es: 'Volver', fr: 'Retour' })}
-
-
+
+
-
+
{user.username}
-
+
{identityLine}
@@ -176,21 +178,21 @@ const UserEditor = ({ userId }: { userId: string }) => {
)}
-
-
+
+
-
-
- {t({ en: 'Account', es: 'Cuenta', fr: 'Compte' })}
-
+
+
+ {t({ en: 'Account', es: 'Cuenta', fr: 'Compte' })}
+
{t({
en: 'Contact details, group membership, status and password.',
es: 'Información de contacto, pertenencia a grupos, estado y contraseña.',
fr: 'Coordonnées, appartenance aux groupes, statut et mot de passe.'
})}
-
-
-
+
+
+
{submitErrorMessage && (
{
}
}}
/>
-
-
+
+
{
onSourceChange={setPermissionSource}
/>
{
});
describe('saving', () => {
+ it('should not stretch the save button, which reads as a bar with no card around it', () => {
+ renderPage();
+ expect(saveButton().className.split(' ')).not.toContain('w-full');
+ });
+
it('should refuse to save while a permission row is half filled in', async () => {
renderPage();
act(() => editorProps().onDraftsChange([{ action: 'create' }]));
diff --git a/apps/web/src/routes/_app/admin/users/create.tsx b/apps/web/src/routes/_app/admin/users/create.tsx
index 4f6d35893..99017856e 100644
--- a/apps/web/src/routes/_app/admin/users/create.tsx
+++ b/apps/web/src/routes/_app/admin/users/create.tsx
@@ -10,6 +10,7 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router';
import axios from 'axios';
import { z } from 'zod/v4';
+import { InstanceCard } from '@/components/InstanceCard';
import { LanguageSelect } from '@/components/LanguageSelect';
import { PageHeader } from '@/components/PageHeader';
import { useCreateUserMutation } from '@/hooks/useCreateUserMutation';
@@ -20,6 +21,7 @@ import { usePasswordGenerator } from '@/hooks/usePasswordGenerator';
import type { PasswordFormValues } from '@/hooks/usePasswordGenerator';
import { permissionSetsQueryOptions, usePermissionSetsQuery } from '@/hooks/usePermissionSetsQuery';
import { useSetupStateQuery } from '@/hooks/useSetupStateQuery';
+import { PAGE_SUBMIT_BUTTON } from '@/utils/card-color';
import { $Email, $PhoneNumber, omittedIfBlank, requiresGroup } from '@/utils/validation';
/** The option standing for no permission set. A set id is an ObjectId, so the two cannot collide. */
@@ -128,263 +130,266 @@ const RouteComponent = () => {
})}
- {setupStateQuery.data.isMailEnabled && (
-
-
- {t({
- en: 'Welcome email language',
- es: 'Idioma del correo de bienvenida',
- fr: 'Langue du courriel de bienvenue'
- })}
-
-
-
- )}
-
);
};
diff --git a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/graph.tsx b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/graph.tsx
index a57972616..5650b050d 100644
--- a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/graph.tsx
+++ b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/graph.tsx
@@ -4,6 +4,7 @@ import { ActionDropdown, Select, Tabs } from '@douglasneuroinformatics/libui/com
import { useDownload, useNotificationsStore, useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { createFileRoute } from '@tanstack/react-router';
+import { InstanceCard } from '@/components/InstanceCard';
import { RecordDistributionChart } from '@/components/RecordDistributionChart';
import { RecordScatterChart } from '@/components/RecordScatterChart';
import { SelectChartPalette } from '@/components/SelectChartPalette';
@@ -200,13 +201,13 @@ const RouteComponent = () => {
/>
-
{renderChart()}
-
+
);
};
diff --git a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx
index 0f30c241d..390705d45 100644
--- a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx
+++ b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/route.tsx
@@ -7,6 +7,7 @@ import { $SessionType } from '@opendatacapture/schemas/session';
import { createFileRoute, Link, Outlet } from '@tanstack/react-router';
import { z } from 'zod/v4';
+import { InstanceCard } from '@/components/InstanceCard';
import { LoadingFallback } from '@/components/LoadingFallback';
import { PageHeader } from '@/components/PageHeader';
import { TabLink } from '@/components/TabLink';
@@ -30,32 +31,34 @@ const RouteComponent = () => {
{title ?? t({ en: 'Instrument', es: 'Instrumento', fr: 'Instrument' })}
-
-
- {t({ en: 'Return', es: 'Volver', fr: 'Retour' })}
-
- {!isSeries && (
-
-
-
-
- )}
- }>
-
-
+
+
+
+ {t({ en: 'Return', es: 'Volver', fr: 'Retour' })}
+
+ {!isSeries && (
+
+
+
+
+ )}
+ }>
+
+
+
);
};
diff --git a/apps/web/src/routes/_app/datahub/instruments/index.tsx b/apps/web/src/routes/_app/datahub/instruments/index.tsx
index fc4eb90a8..f5dddb906 100644
--- a/apps/web/src/routes/_app/datahub/instruments/index.tsx
+++ b/apps/web/src/routes/_app/datahub/instruments/index.tsx
@@ -4,6 +4,7 @@ import { Heading } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
+import { InstanceCard } from '@/components/InstanceCard';
import { InstrumentTable } from '@/components/InstrumentTable';
import type { InstrumentRow } from '@/components/InstrumentTable';
import { PageHeader } from '@/components/PageHeader';
@@ -63,7 +64,9 @@ const RouteComponent = () => {
})}
-
+
+
+
);
};
diff --git a/apps/web/src/routes/_app/datahub/subjects/$subjectId/assignments.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/assignments.tsx
index bbc26503c..c5c947e8d 100644
--- a/apps/web/src/routes/_app/datahub/subjects/$subjectId/assignments.tsx
+++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/assignments.tsx
@@ -6,6 +6,7 @@ import React, { useEffect, useState } from 'react';
import { toBasicISOString } from '@douglasneuroinformatics/libjs';
import { Button, Heading, Input, Label, Sheet, Table } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
+import { cn } from '@douglasneuroinformatics/libui/utils';
import { CopyButton } from '@opendatacapture/react-core';
import type { Assignment, AssignmentStatus } from '@opendatacapture/schemas/assignment';
import type { UnilingualInstrumentInfo } from '@opendatacapture/schemas/instrument';
@@ -18,6 +19,7 @@ import { useAssignmentsQuery } from '@/hooks/useAssignmentsQuery';
import { useInstrument } from '@/hooks/useInstrument';
import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery';
import { useUpdateAssignment } from '@/hooks/useUpdateAssignment';
+import { INSTANCE_CARD_SURFACE } from '@/utils/card-color';
const AssignmentSlider: React.FC<{
assignment: Assignment | null;
@@ -113,7 +115,7 @@ const AssignmentsTable: React.FC<{
};
return (
-
+
diff --git a/apps/web/src/routes/_app/datahub/subjects/$subjectId/graph.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/graph.tsx
index 0d458020d..8b5978363 100644
--- a/apps/web/src/routes/_app/datahub/subjects/$subjectId/graph.tsx
+++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/graph.tsx
@@ -7,6 +7,7 @@ import { useDownload, useNotificationsStore, useTheme, useTranslation } from '@d
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod/v4';
+import { InstanceCard } from '@/components/InstanceCard';
import { LineGraph } from '@/components/LineGraph';
import { SelectChartPalette } from '@/components/SelectChartPalette';
import { SelectEdition } from '@/components/SelectEdition';
@@ -165,8 +166,8 @@ const RouteComponent = () => {
-
@@ -178,7 +179,7 @@ const RouteComponent = () => {
label: t('datahub.visualization.xLabel')
}}
/>
-
+
);
};
diff --git a/apps/web/src/routes/_app/datahub/subjects/$subjectId/route.tsx b/apps/web/src/routes/_app/datahub/subjects/$subjectId/route.tsx
index 3011d679b..2114cd691 100644
--- a/apps/web/src/routes/_app/datahub/subjects/$subjectId/route.tsx
+++ b/apps/web/src/routes/_app/datahub/subjects/$subjectId/route.tsx
@@ -7,6 +7,7 @@ import { removeSubjectIdScope } from '@opendatacapture/subject-utils';
import { createFileRoute, Link, Outlet } from '@tanstack/react-router';
import { z } from 'zod/v4';
+import { InstanceCard } from '@/components/InstanceCard';
import { LoadingFallback } from '@/components/LoadingFallback';
import { PageHeader } from '@/components/PageHeader';
import { TabLink } from '@/components/TabLink';
@@ -37,32 +38,34 @@ const RouteComponent = () => {
)}
- {/* A subject reached from an instrument's record table belongs to that listing, not to the
+
+ {/* A subject reached from an instrument's record table belongs to that listing, not to the
subject index — so the return goes back to where the reader actually was. */}
-
-
- {t({ en: 'Return', es: 'Volver', fr: 'Retour' })}
-
-
-
-
- {config.setup.isGatewayEnabled && (
-
- )}
-
- }>
-
-
+
+
+ {t({ en: 'Return', es: 'Volver', fr: 'Retour' })}
+
+
+
+
+ {config.setup.isGatewayEnabled && (
+
+ )}
+
+ }>
+
+
+
);
};
diff --git a/apps/web/src/routes/_app/datahub/subjects/index.tsx b/apps/web/src/routes/_app/datahub/subjects/index.tsx
index 62cf69b63..dd5cab9de 100644
--- a/apps/web/src/routes/_app/datahub/subjects/index.tsx
+++ b/apps/web/src/routes/_app/datahub/subjects/index.tsx
@@ -23,6 +23,7 @@ import { unparse } from 'papaparse';
import { CollectedFilterMenu } from '@/components/CollectedFilterMenu';
import { ColorTagCell } from '@/components/ColorTagCell';
import { IdentificationForm } from '@/components/IdentificationForm';
+import { InstanceCard } from '@/components/InstanceCard';
import { PageHeader } from '@/components/PageHeader';
import { SearchSubmitGuard } from '@/components/SearchSubmitGuard';
import { SortableHeader } from '@/components/SortableHeader';
@@ -570,7 +571,7 @@ const RouteComponent = () => {
{t('datahub.index.title')}
-
+
{
@@ -580,7 +581,7 @@ const RouteComponent = () => {
void navigate({ to: `./${subject.id}/table` });
}}
/>
-
+
);
};
diff --git a/apps/web/src/routes/_app/group/email-templates.tsx b/apps/web/src/routes/_app/group/email-templates.tsx
index 0daf94142..607c82964 100644
--- a/apps/web/src/routes/_app/group/email-templates.tsx
+++ b/apps/web/src/routes/_app/group/email-templates.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { GroupEmailTemplates } from '@/components/GroupEmailTemplates';
+import { InstanceCard } from '@/components/InstanceCard';
import { PageHeader } from '@/components/PageHeader';
import { config } from '@/config';
@@ -18,7 +19,9 @@ const RouteComponent = () => {
{t({ en: 'Email Templates', es: 'Plantillas de correo', fr: 'Modèles de courriel' })}
-
+
+
+
);
};
diff --git a/apps/web/src/routes/_app/group/manage.tsx b/apps/web/src/routes/_app/group/manage.tsx
index e5cc5c3e3..7e1356c09 100644
--- a/apps/web/src/routes/_app/group/manage.tsx
+++ b/apps/web/src/routes/_app/group/manage.tsx
@@ -24,6 +24,7 @@ import { EyeIcon, TrashIcon } from 'lucide-react';
import type { Promisable } from 'type-fest';
import { z } from 'zod/v4';
+import { InstanceCard } from '@/components/InstanceCard';
import { InstrumentPreviewDialog } from '@/components/InstrumentPreviewDialog';
import type { InstrumentPreviewItem, InstrumentSource } from '@/components/InstrumentPreviewDialog';
import { PageHeader } from '@/components/PageHeader';
@@ -34,6 +35,7 @@ import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery';
import { useSetupStateQuery } from '@/hooks/useSetupStateQuery';
import { useUpdateGroupMutation } from '@/hooks/useUpdateGroupMutation';
import { useAppStore } from '@/store';
+import { PAGE_SUBMIT_BUTTON } from '@/utils/card-color';
import { buildSeriesAvailability } from '@/utils/series-availability';
/**
@@ -797,6 +799,7 @@ const ManageGroupForm = ({ data, onSubmit, readOnly }: ManageGroupFormProps) =>
title: t('group.manage.groupSettings')
}
]}
+ customStyles={{ submitBtn: PAGE_SUBMIT_BUTTON }}
initialValues={settingsInitialValues}
readOnly={readOnly}
validationSchema={z
@@ -971,20 +974,22 @@ const RouteComponent = () => {
{t('manage.pageTitle')}
- {/* The key remounts the form on a group switch, the only time its selection may be reseeded:
+
+ {/* The key remounts the form on a group switch, the only time its selection may be reseeded:
`initialSelectedIds` changes identity on every refetch, and reseeding then would drop unsaved edits. */}
- {
- const updatedGroup = await updateGroupMutation.mutateAsync(data);
- changeGroup(updatedGroup);
- },
- readOnly: Boolean(setupState.data?.isDemo && import.meta.env.PROD)
- }}
- />
+ {
+ const updatedGroup = await updateGroupMutation.mutateAsync(data);
+ changeGroup(updatedGroup);
+ },
+ readOnly: Boolean(setupState.data?.isDemo && import.meta.env.PROD)
+ }}
+ />
+
);
};
diff --git a/apps/web/src/routes/_app/group/remote-assignments.tsx b/apps/web/src/routes/_app/group/remote-assignments.tsx
index 0f115060a..1650cc53a 100644
--- a/apps/web/src/routes/_app/group/remote-assignments.tsx
+++ b/apps/web/src/routes/_app/group/remote-assignments.tsx
@@ -1,13 +1,14 @@
import React, { useEffect, useState } from 'react';
import { toBasicISOString } from '@douglasneuroinformatics/libjs';
-import { Card, Heading } from '@douglasneuroinformatics/libui/components';
+import { Heading } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { createFileRoute, redirect } from '@tanstack/react-router';
import { PlusIcon, SendIcon, Trash2Icon } from 'lucide-react';
import { BulkRemoteAssignmentWizard } from '@/components/BulkRemoteAssignmentWizard';
import { DeleteRemoteAssignments } from '@/components/DeleteRemoteAssignments';
+import { InstanceCard } from '@/components/InstanceCard';
import { PageHeader } from '@/components/PageHeader';
import { config } from '@/config';
import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery';
@@ -27,7 +28,7 @@ type ActionCardProps = {
};
const ActionCard = ({ icon, onClick, testId, title }: ActionCardProps) => (
- (
}}
>
{icon}
- {title}
-
+ {title}
+
);
const CompositeIcon = ({ Badge }: { Badge: React.FC<{ className?: string }> }) => (
diff --git a/apps/web/src/routes/_app/instruments/accessible-instruments.tsx b/apps/web/src/routes/_app/instruments/accessible-instruments.tsx
index b486bb7ba..0bc7751f5 100644
--- a/apps/web/src/routes/_app/instruments/accessible-instruments.tsx
+++ b/apps/web/src/routes/_app/instruments/accessible-instruments.tsx
@@ -4,6 +4,7 @@ import { Heading } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
+import { InstanceCard } from '@/components/InstanceCard';
import { InstrumentShowcase } from '@/components/InstrumentShowcase';
import { PageHeader } from '@/components/PageHeader';
import { WithFallback } from '@/components/WithFallback';
@@ -29,19 +30,21 @@ const RouteComponent = () => {
{t('instruments.accessible.title')}
- {
- void navigate({
- params: { id: instrument.id },
- state: { info: instrument },
- to: `/instruments/render/$id`
- });
- }
- }}
- />
+
+ {
+ void navigate({
+ params: { id: instrument.id },
+ state: { info: instrument },
+ to: `/instruments/render/$id`
+ });
+ }
+ }}
+ />
+
);
};
diff --git a/apps/web/src/routes/_app/session/remote-assignment.tsx b/apps/web/src/routes/_app/session/remote-assignment.tsx
index 4db7d4b32..23640ae4c 100644
--- a/apps/web/src/routes/_app/session/remote-assignment.tsx
+++ b/apps/web/src/routes/_app/session/remote-assignment.tsx
@@ -9,6 +9,7 @@ import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router';
import { z } from 'zod/v4';
import { AssignmentEmailForm } from '@/components/AssignmentEmailForm';
+import { InstanceCard } from '@/components/InstanceCard';
import { InstrumentShowcase } from '@/components/InstrumentShowcase';
import { PageHeader } from '@/components/PageHeader';
import { QRCode } from '@/components/QRCode';
@@ -125,92 +126,94 @@ const RouteComponent = () => {
})}
- {
- setSelectedInstrument(instrument);
- setIsCreateModalOpen(true);
- }
- }}
- />
-
- {
- event.preventDefault();
- createDialogRef.current?.querySelector('button[type="submit"]')?.focus();
+
+ {
+ setSelectedInstrument(instrument);
+ setIsCreateModalOpen(true);
+ }
}}
- >
-
-
- {t({
- en: 'Create Remote Assignment',
- es: 'Crear tarea remota',
- fr: 'Créer une tâche à distance'
- })}
-
-
- {t(
- {
- en: 'Assign "{}" to the current subject for remote completion.',
- es: 'Asignar "{}" al sujeto actual para que la complete de forma remota.',
- fr: 'Assigner « {} » au client actuel pour complétion à distance.'
- },
- { args: [selectedInstrument?.details.title ?? ''] }
- )}
-
-
-
+
+ {
+ event.preventDefault();
+ createDialogRef.current?.querySelector('button[type="submit"]')?.focus();
}}
- validationSchema={
- z.object({
- expiresAt: z.date().min(new Date(), {
- message: t({
- en: 'Expiry date must be in the future',
- es: 'La fecha de vencimiento debe ser futura',
- fr: "La date d'expiration doit être dans le futur"
+ >
+
+
+ {t({
+ en: 'Create Remote Assignment',
+ es: 'Crear tarea remota',
+ fr: 'Créer une tâche à distance'
+ })}
+
+
+ {t(
+ {
+ en: 'Assign "{}" to the current subject for remote completion.',
+ es: 'Asignar "{}" al sujeto actual para que la complete de forma remota.',
+ fr: 'Assigner « {} » au client actuel pour complétion à distance.'
+ },
+ { args: [selectedInstrument?.details.title ?? ''] }
+ )}
+
+
+ >
- }
- onSubmit={async ({ expiresAt }) => {
- const created = await createAssignmentMutation.mutateAsync({
- data: {
- expiresAt,
- groupId: currentGroup?.id,
- instrumentId: selectedInstrument!.id,
- subjectId: currentSession.subjectId
}
- });
- setAssignment(created);
- setIsCreateModalOpen(false);
- setIsResultSliderOpen(true);
- }}
- />
-
-
-
+ }}
+ initialValues={{
+ expiresAt: getDefaultAssignmentExpiry(setupStateQuery.data.defaultAssignmentDurationDays)
+ }}
+ validationSchema={
+ z.object({
+ expiresAt: z.date().min(new Date(), {
+ message: t({
+ en: 'Expiry date must be in the future',
+ es: 'La fecha de vencimiento debe ser futura',
+ fr: "La date d'expiration doit être dans le futur"
+ })
+ })
+ }) satisfies z.ZodType>
+ }
+ onSubmit={async ({ expiresAt }) => {
+ const created = await createAssignmentMutation.mutateAsync({
+ data: {
+ expiresAt,
+ groupId: currentGroup?.id,
+ instrumentId: selectedInstrument!.id,
+ subjectId: currentSession.subjectId
+ }
+ });
+ setAssignment(created);
+ setIsCreateModalOpen(false);
+ setIsResultSliderOpen(true);
+ }}
+ />
+
+
+
+
);
};
diff --git a/apps/web/src/routes/_app/session/start-session.tsx b/apps/web/src/routes/_app/session/start-session.tsx
index 284850028..db423c39a 100644
--- a/apps/web/src/routes/_app/session/start-session.tsx
+++ b/apps/web/src/routes/_app/session/start-session.tsx
@@ -48,17 +48,19 @@ const RouteComponent = () => {
{currentSession === null && (
- {
- const session = await createSessionMutation.mutateAsync(formData);
- startSession({ ...session, type: formData.type });
- }}
- />
+
+ {
+ const session = await createSessionMutation.mutateAsync(formData);
+ startSession({ ...session, type: formData.type });
+ }}
+ />
+
)}
{currentSession !== null && (
@@ -71,7 +73,7 @@ const RouteComponent = () => {
transition={{ duration: 0.3 }}
>
-
+
{
})}
- {!isLoading ? (
-
-
-
-
-
-
- {(currentUser?.groups.length ?? 0) > 0 && (
-
+
+ {!isLoading ? (
+
+
+
+
+
+
+ {(currentUser?.groups.length ?? 0) > 0 && (
+
+
+ {t({
+ en: 'Filter users by group.',
+ es: 'Filtrar los usuarios por grupo.',
+ fr: 'Filtrer les utilisateurs par groupe.'
+ })}
+
+
{
+ setSelectedGroupId(id);
+ setSelectedUsername(undefined);
+ }}
+ >
+
+
+
+
+
+ {currentUser?.groups.map((group) => (
+
+ {group.name}
+
+ ))}
+
+
+
+
+ )}
+
{t({
- en: 'Filter users by group.',
- es: 'Filtrar los usuarios por grupo.',
- fr: 'Filtrer les utilisateurs par groupe.'
+ en: 'Select the username to associate with these records for audit and traceability.',
+ es: 'Seleccione el nombre de usuario que se asociará a estos registros para fines de auditoría y trazabilidad.',
+ fr: "Sélectionnez le nom d'utilisateur à associer à ces entrées pour l'audit et la traçabilité."
})}
-
{
- setSelectedGroupId(id);
- setSelectedUsername(undefined);
- }}
- >
+
-
+
- {currentUser?.groups.map((group) => (
-
- {group.name}
+ {groupUsers.data.map((user) => (
+
+ {user.username}
))}
+ {t({ en: 'N/A', es: 'N/D', fr: 'S.O.' })}
- )}
-
-
- {t({
- en: 'Select the username to associate with these records for audit and traceability.',
- es: 'Seleccione el nombre de usuario que se asociará a estos registros para fines de auditoría y trazabilidad.',
- fr: "Sélectionnez le nom d'utilisateur à associer à ces entrées pour l'audit et la traçabilité."
- })}
-
-
-
-
-
-
-
- {groupUsers.data.map((user) => (
-
- {user.username}
-
- ))}
- {t({ en: 'N/A', es: 'N/D', fr: 'S.O.' })}
-
-
-
-
-
-
-
void handleInstrumentCSV()}>
- {t('core.submit')}
-
-
-
-
- {t({ en: 'Template', es: 'Plantilla', fr: 'Modèle' })}
-
-
window.open('https://opendatacapture.org/en/docs/guides/how-to-upload-data/')}
- >
-
- {t({ en: 'Help', es: 'Ayuda', fr: 'Aide' })}
+
+
void handleInstrumentCSV()}>
+ {t('core.submit')}
+
+
+
+
+ {t({ en: 'Template', es: 'Plantilla', fr: 'Modèle' })}
+
+ window.open('https://opendatacapture.org/en/docs/guides/how-to-upload-data/')}
+ >
+
+ {t({ en: 'Help', es: 'Ayuda', fr: 'Aide' })}
+
+
-
- ) : (
-
-
-
- {t({
- en: 'Data currently uploading...',
- es: 'Cargando datos...',
- fr: 'Données en cours de téléversement...'
- })}
-
-
- )}
+ ) : (
+
+
+
+ {t({
+ en: 'Data currently uploading...',
+ es: 'Cargando datos...',
+ fr: 'Données en cours de téléversement...'
+ })}
+
+
+ )}
+
);
};
diff --git a/apps/web/src/routes/_app/upload/index.tsx b/apps/web/src/routes/_app/upload/index.tsx
index d82b3e939..d6f6e59e6 100644
--- a/apps/web/src/routes/_app/upload/index.tsx
+++ b/apps/web/src/routes/_app/upload/index.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import type { UnilingualInstrumentInfo } from '@opendatacapture/schemas/instrument';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
+import { InstanceCard } from '@/components/InstanceCard';
import { PageHeader } from '@/components/PageHeader';
import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery';
import { useSearch } from '@/hooks/useSearch';
@@ -65,22 +66,24 @@ const RouteComponent = () => {
})}
-
-
{
- void navigate({ params: { instrumentId: instrument.id }, to: '/upload/$instrumentId' });
- }}
- />
+
+
+ {
+ void navigate({ params: { instrumentId: instrument.id }, to: '/upload/$instrumentId' });
+ }}
+ />
+
);
};
diff --git a/apps/web/src/routes/_app/user.tsx b/apps/web/src/routes/_app/user.tsx
index 06ecd340c..53e9c4eff 100644
--- a/apps/web/src/routes/_app/user.tsx
+++ b/apps/web/src/routes/_app/user.tsx
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import { estimatePasswordStrength } from '@douglasneuroinformatics/libpasswd';
-import { Button, Card, Dialog, Form, Heading } from '@douglasneuroinformatics/libui/components';
+import { Button, Dialog, Form, Heading } from '@douglasneuroinformatics/libui/components';
import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { $Sex } from '@opendatacapture/schemas/subject';
import type { BasePermissionLevel } from '@opendatacapture/schemas/user';
@@ -9,6 +9,7 @@ import { createFileRoute } from '@tanstack/react-router';
import { KeyRoundIcon } from 'lucide-react';
import { z } from 'zod/v4';
+import { InstanceCard } from '@/components/InstanceCard';
import { PageHeader } from '@/components/PageHeader';
import { UserIcon } from '@/components/UserIcon';
import { useFindUserQuery } from '@/hooks/useFindUserQuery';
@@ -16,6 +17,7 @@ import { usePasswordErrorMessage } from '@/hooks/usePasswordErrorMessage';
import { useResetPasswordMutation } from '@/hooks/useResetPasswordMutation';
import { useSelfUpdateUserMutation } from '@/hooks/useSelfUpdateUserMutation';
import { useAppStore } from '@/store';
+import { PAGE_SUBMIT_BUTTON } from '@/utils/card-color';
import { $Email, $PhoneNumber, clearedIfBlank, omittedIfUnchanged } from '@/utils/validation';
type ProfileFormData = {
@@ -109,154 +111,157 @@ const RouteComponent = () => {
{t('user.account')}
-
-
-
-
-
-
- {currentUser?.username}
-
- {permissionLevel && (
-
- {t({ en: 'Role', es: 'Rol', fr: 'Rôle' })}: {permissionLevel}
-
- )}
+
+
+
+
+
+
+
+ {currentUser?.username}
+
+ {permissionLevel && (
+
+ {t({ en: 'Role', es: 'Rol', fr: 'Rôle' })}: {permissionLevel}
+
+ )}
+
-
-
setIsPasswordDialogOpen(true)}>
-
- {t('user.changePassword')}
-
-
-
-
setIsPasswordDialogOpen(true)}>
+
+ {t('user.changePassword')}
+
+
+
+ {
- updateSelfUserMutation.mutate({
- data: {
- ...rest,
- email: clearedIfBlank(email),
- phoneNumber: omittedIfUnchanged(phoneNumber, userInfo.data.phoneNumber)
- },
- id: currentUser!.id
- });
- }}
- />
-
-
-
- {t('user.changePassword')}
-
- {t({
- en: 'Enter a new password for your account, then confirm it to save the change.',
- es: 'Introduzca una nueva contraseña para su cuenta y confírmela para guardar el cambio.',
- fr: 'Saisissez un nouveau mot de passe pour votre compte, puis confirmez-le pour enregistrer la modification.'
- })}
-
-
-
- estimatePasswordStrength(password).score,
+ lastName: {
+ kind: 'string',
+ label: t('core.identificationData.lastName.label'),
+ variant: 'input'
+ },
+ dateOfBirth: {
+ kind: 'date',
+ label: t('core.identificationData.dateOfBirth.label')
+ },
+ sex: {
+ kind: 'string',
+ label: t('core.identificationData.sex.label'),
+ options: {
+ FEMALE: t('core.identificationData.sex.female'),
+ MALE: t('core.identificationData.sex.male')
+ },
+ variant: 'select'
+ },
+ email: {
kind: 'string',
- label: t('common.password'),
- variant: 'password'
+ label: t('common.email'),
+ variant: 'input'
},
- // eslint-disable-next-line perfectionist/sort-objects -- fields render in key order, so confirmPassword must follow password
- confirmPassword: {
+ phoneNumber: {
kind: 'string',
- label: t('common.confirmPassword'),
- variant: 'password'
+ label: t('common.phoneNumber'),
+ variant: 'input'
}
- }}
- data-form-type="other"
- data-lpignore="true"
- submitBtnLabel={t('core.save')}
- validationSchema={$PasswordFormData}
- onSubmit={(data) => {
- resetPasswordMutation.mutate(
- { id: currentUser!.id, password: data.password },
- {
- onError: (err) => {
- addNotification({
- message: passwordErrorMessage(
- err,
- t({
- en: 'Failed to change password',
- es: 'No se pudo cambiar la contraseña',
- fr: 'Échec du changement de mot de passe'
- })
- ),
- type: 'error'
- });
- },
- onSuccess: () => {
- addNotification({ type: 'success' });
- setIsPasswordDialogOpen(false);
- }
+ },
+ /* eslint-enable perfectionist/sort-objects */
+ title: t({
+ en: 'Personal Information',
+ es: 'Información personal',
+ fr: 'Informations personnelles'
+ })
+ }
+ ]}
+ customStyles={{ submitBtn: PAGE_SUBMIT_BUTTON }}
+ data-form-type="other"
+ data-lpignore="true"
+ data-testid="profile-form"
+ initialValues={profileInitialValues}
+ key={JSON.stringify(profileInitialValues)}
+ submitBtnLabel={t('core.save')}
+ validationSchema={$ProfileFormData}
+ onSubmit={({ email, phoneNumber, ...rest }) => {
+ updateSelfUserMutation.mutate({
+ data: {
+ ...rest,
+ email: clearedIfBlank(email),
+ phoneNumber: omittedIfUnchanged(phoneNumber, userInfo.data.phoneNumber)
+ },
+ id: currentUser!.id
+ });
+ }}
+ />
+
+
+
+ {t('user.changePassword')}
+
+ {t({
+ en: 'Enter a new password for your account, then confirm it to save the change.',
+ es: 'Introduzca una nueva contraseña para su cuenta y confírmela para guardar el cambio.',
+ fr: 'Saisissez un nouveau mot de passe pour votre compte, puis confirmez-le pour enregistrer la modification.'
+ })}
+
+
+
+ estimatePasswordStrength(password).score,
+ kind: 'string',
+ label: t('common.password'),
+ variant: 'password'
+ },
+ // eslint-disable-next-line perfectionist/sort-objects -- fields render in key order, so confirmPassword must follow password
+ confirmPassword: {
+ kind: 'string',
+ label: t('common.confirmPassword'),
+ variant: 'password'
}
- );
- }}
- />
-
-
-
+ }}
+ data-form-type="other"
+ data-lpignore="true"
+ submitBtnLabel={t('core.save')}
+ validationSchema={$PasswordFormData}
+ onSubmit={(data) => {
+ resetPasswordMutation.mutate(
+ { id: currentUser!.id, password: data.password },
+ {
+ onError: (err) => {
+ addNotification({
+ message: passwordErrorMessage(
+ err,
+ t({
+ en: 'Failed to change password',
+ es: 'No se pudo cambiar la contraseña',
+ fr: 'Échec du changement de mot de passe'
+ })
+ ),
+ type: 'error'
+ });
+ },
+ onSuccess: () => {
+ addNotification({ type: 'success' });
+ setIsPasswordDialogOpen(false);
+ }
+ }
+ );
+ }}
+ />
+
+
+
+
);
};
diff --git a/apps/web/src/utils/__tests__/card-color.test.ts b/apps/web/src/utils/__tests__/card-color.test.ts
new file mode 100644
index 000000000..3b28aed05
--- /dev/null
+++ b/apps/web/src/utils/__tests__/card-color.test.ts
@@ -0,0 +1,72 @@
+import { cn } from '@douglasneuroinformatics/libui/utils';
+import { DARK_CARD_THEMES, LIGHT_CARD_THEMES } from '@opendatacapture/schemas/setup';
+import { describe, expect, it } from 'vitest';
+
+import {
+ CARD_THEME_LABELS,
+ DEFAULT_CUSTOM_CARD_COLOR,
+ getDarkCardShades,
+ getInstanceCardStyle,
+ getLightCardShades,
+ PAGE_SUBMIT_BUTTON
+} from '../card-color';
+
+describe('getLightCardShades', () => {
+ it('should draw nothing for an invisible card', () => {
+ expect(getLightCardShades().invisible).toEqual({ border: 'transparent', fill: 'transparent' });
+ });
+
+ it('should fill a custom theme with the stored color and darken its border', () => {
+ expect(getLightCardShades('#123456').custom).toEqual({
+ border: 'color-mix(in oklab, #123456 88%, black)',
+ fill: '#123456'
+ });
+ });
+
+ it('should fall back to a default before a custom color is set', () => {
+ expect(getLightCardShades(null).custom.fill).toBe(DEFAULT_CUSTOM_CARD_COLOR.light);
+ });
+
+ it('should offer six themes beside no color and custom', () => {
+ expect(LIGHT_CARD_THEMES.filter((theme) => theme !== 'invisible' && theme !== 'custom')).toHaveLength(6);
+ });
+});
+
+describe('getDarkCardShades', () => {
+ it('should draw nothing for an invisible card', () => {
+ expect(getDarkCardShades().invisible).toEqual({ border: 'transparent', fill: 'transparent' });
+ });
+
+ it('should lighten the border of a custom theme, so it shows against a dark page', () => {
+ expect(getDarkCardShades('#123456').custom.border).toContain('white');
+ });
+
+ it('should fall back to a default before a custom color is set', () => {
+ expect(getDarkCardShades(null).custom.fill).toBe(DEFAULT_CUSTOM_CARD_COLOR.dark);
+ });
+
+ it('should offer six themes beside no color and custom', () => {
+ expect(DARK_CARD_THEMES.filter((theme) => theme !== 'invisible' && theme !== 'custom')).toHaveLength(6);
+ });
+});
+
+describe('getInstanceCardStyle', () => {
+ it('should set each mode from its own theme, so one can have no color', () => {
+ const style = getInstanceCardStyle({ darkCardTheme: 'invisible', lightCardTheme: 'silver' });
+ expect(style['--instance-card-fill-light']).toBe(getLightCardShades().silver.fill);
+ expect(style['--instance-card-fill-dark']).toBe('transparent');
+ expect(style['--instance-card-border-dark']).toBe('transparent');
+ });
+});
+
+describe('CARD_THEME_LABELS', () => {
+ it.each([...LIGHT_CARD_THEMES, ...DARK_CARD_THEMES])('should name %s in every language', (theme) => {
+ expect(Object.keys(CARD_THEME_LABELS[theme]).sort()).toEqual(['en', 'es', 'fr']);
+ });
+});
+
+describe('PAGE_SUBMIT_BUTTON', () => {
+ it("should win over the full width of libui's submit button, so it does not read as a bar", () => {
+ expect(cn('flex w-full', PAGE_SUBMIT_BUTTON).split(' ')).not.toContain('w-full');
+ });
+});
diff --git a/apps/web/src/utils/card-color.ts b/apps/web/src/utils/card-color.ts
new file mode 100644
index 000000000..3f9b8aa5f
--- /dev/null
+++ b/apps/web/src/utils/card-color.ts
@@ -0,0 +1,113 @@
+import type React from 'react';
+
+import type { Language } from '@opendatacapture/schemas/core';
+import type { DarkCardTheme, LightCardTheme, SetupState } from '@opendatacapture/schemas/setup';
+
+type CardMode = 'dark' | 'light';
+
+type CardShade = { border: string; fill: string };
+
+type CardShades = { [T in TTheme]: CardShade };
+
+type InstanceCardStyle = React.CSSProperties & {
+ [TVariable in `--instance-card-${keyof CardShade}-${CardMode}`]: string;
+};
+
+/**
+ * Neutrals plus two cool tints, chosen to sit with the app's slate palette. Each mode has its own list,
+ * and all keep body and muted text above WCAG AAA contrast in their mode.
+ */
+const LIGHT_CARD_SHADES: CardShades> = {
+ fog: { border: '#CDD5DF', fill: '#E6EBF1' },
+ heather: { border: '#C9BFDD', fill: '#E4DEF0' },
+ invisible: { border: 'transparent', fill: 'transparent' },
+ ivory: { border: '#E4DCC8', fill: '#F8F3E6' },
+ mist: { border: '#B5CCE2', fill: '#D7E5F2' },
+ paper: { border: '#E2E8F0', fill: '#FFFFFF' },
+ silver: { border: '#B8C2CE', fill: '#D5DCE5' }
+};
+
+const DARK_CARD_SHADES: CardShades> = {
+ charcoal: { border: '#3F4247', fill: '#27292D' },
+ graphite: { border: '#2E3035', fill: '#17181B' },
+ heather: { border: '#463463', fill: '#261B3C' },
+ invisible: { border: 'transparent', fill: 'transparent' },
+ midnight: { border: '#1E2430', fill: '#030509' },
+ mist: { border: '#23486E', fill: '#10253D' },
+ steel: { border: '#364459', fill: '#1E293B' }
+};
+
+const DEFAULT_CUSTOM_CARD_COLOR: { [TMode in CardMode]: string } = {
+ dark: DARK_CARD_SHADES.mist.fill,
+ light: LIGHT_CARD_SHADES.mist.fill
+};
+
+const getCustomCardShade = (mode: CardMode, customColor?: null | string): CardShade => {
+ const fill = customColor ?? DEFAULT_CUSTOM_CARD_COLOR[mode];
+ return { border: `color-mix(in oklab, ${fill} 88%, ${mode === 'dark' ? 'white' : 'black'})`, fill };
+};
+
+const getLightCardShades = (customColor?: null | string): CardShades => ({
+ ...LIGHT_CARD_SHADES,
+ custom: getCustomCardShade('light', customColor)
+});
+
+const getDarkCardShades = (customColor?: null | string): CardShades => ({
+ ...DARK_CARD_SHADES,
+ custom: getCustomCardShade('dark', customColor)
+});
+
+/** Pair with `INSTANCE_CARD_VARIABLES`, which picks between the two modes this sets. */
+const getInstanceCardStyle = (
+ state: Pick
+): InstanceCardStyle => {
+ const dark = getDarkCardShades(state.customCardColorDark)[state.darkCardTheme];
+ const light = getLightCardShades(state.customCardColorLight)[state.lightCardTheme];
+ return {
+ '--instance-card-border-dark': dark.border,
+ '--instance-card-border-light': light.border,
+ '--instance-card-fill-dark': dark.fill,
+ '--instance-card-fill-light': light.fill
+ };
+};
+
+/** Written as one literal because Tailwind finds classes by scanning this file's text. */
+const INSTANCE_CARD_VARIABLES =
+ '[--instance-card-border:var(--instance-card-border-light)] [--instance-card-fill:var(--instance-card-fill-light)] dark:[--instance-card-border:var(--instance-card-border-dark)] dark:[--instance-card-fill:var(--instance-card-fill-dark)]';
+
+const CARD_THEME_LABELS: { [TTheme in DarkCardTheme | LightCardTheme]: { [L in Language]: string } } = {
+ charcoal: { en: 'Charcoal', es: 'Carbón', fr: 'Charbon' },
+ custom: { en: 'Custom', es: 'Personalizado', fr: 'Personnalisé' },
+ fog: { en: 'Fog', es: 'Niebla', fr: 'Brouillard' },
+ graphite: { en: 'Graphite', es: 'Grafito', fr: 'Graphite' },
+ heather: { en: 'Heather', es: 'Brezo', fr: 'Bruyère' },
+ invisible: { en: 'No Color', es: 'Sin color', fr: 'Sans couleur' },
+ ivory: { en: 'Ivory', es: 'Marfil', fr: 'Ivoire' },
+ midnight: { en: 'Midnight', es: 'Medianoche', fr: 'Minuit' },
+ mist: { en: 'Mist', es: 'Neblina', fr: 'Brume' },
+ paper: { en: 'Paper', es: 'Papel', fr: 'Papier' },
+ silver: { en: 'Silver', es: 'Plata', fr: 'Argent' },
+ steel: { en: 'Steel', es: 'Acero', fr: 'Acier' }
+};
+
+/**
+ * The surface of a card painted in the instance's color. Falls back to transparent so a card outside
+ * the layout that sets the variables, such as in a dialog's portal, draws nothing.
+ */
+const INSTANCE_CARD_SURFACE =
+ 'border border-[color:var(--instance-card-border,transparent)] bg-[color:var(--instance-card-fill,transparent)] shadow-none';
+
+/** A page's submit button, which at libui's full width reads as a bar once the card around it is invisible. */
+const PAGE_SUBMIT_BUTTON = 'ml-auto w-auto min-w-32';
+
+export {
+ CARD_THEME_LABELS,
+ DEFAULT_CUSTOM_CARD_COLOR,
+ getDarkCardShades,
+ getInstanceCardStyle,
+ getLightCardShades,
+ INSTANCE_CARD_SURFACE,
+ INSTANCE_CARD_VARIABLES,
+ PAGE_SUBMIT_BUTTON
+};
+export type { CardMode, CardShades };
diff --git a/packages/schemas/src/setup/__tests__/setup.test.ts b/packages/schemas/src/setup/__tests__/setup.test.ts
index 180d76249..6ad13a9fb 100644
--- a/packages/schemas/src/setup/__tests__/setup.test.ts
+++ b/packages/schemas/src/setup/__tests__/setup.test.ts
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';
-import { $BrandingConfig, $InitAppOptions, $UpdateSetupStateData } from '../setup.js';
+import {
+ $BrandingConfig,
+ $InitAppOptions,
+ $UpdateSetupStateData,
+ DARK_CARD_THEMES,
+ LIGHT_CARD_THEMES
+} from '../setup.js';
describe('$BrandingConfig', () => {
describe('customLogoSrc', () => {
@@ -69,6 +75,31 @@ describe('$UpdateSetupStateData', () => {
});
});
+ describe('cardColor', () => {
+ it.each(LIGHT_CARD_THEMES)('should accept the light theme %s', (lightCardTheme) => {
+ expect($UpdateSetupStateData.safeParse({ lightCardTheme }).success).toBe(true);
+ });
+
+ it.each(DARK_CARD_THEMES)('should accept the dark theme %s', (darkCardTheme) => {
+ expect($UpdateSetupStateData.safeParse({ darkCardTheme }).success).toBe(true);
+ });
+
+ it('should reject a theme from the other mode, so a mode only holds what it can paint', () => {
+ expect($UpdateSetupStateData.safeParse({ lightCardTheme: 'midnight' }).success).toBe(false);
+ expect($UpdateSetupStateData.safeParse({ darkCardTheme: 'paper' }).success).toBe(false);
+ });
+
+ it('should reject a custom color that is not a hex, so a card style can only hold a color', () => {
+ expect($UpdateSetupStateData.safeParse({ customCardColorLight: 'red;' }).success).toBe(false);
+ expect($UpdateSetupStateData.safeParse({ customCardColorDark: '#123456' }).success).toBe(true);
+ });
+
+ it('should reject a theme outside the palette, so a card can never be painted arbitrarily', () => {
+ expect($UpdateSetupStateData.safeParse({ darkCardTheme: '#ff0000' }).success).toBe(false);
+ expect($UpdateSetupStateData.safeParse({ lightCardTheme: '#ff0000' }).success).toBe(false);
+ });
+ });
+
describe('activeLanguages', () => {
it('should accept a subset of the interface languages', () => {
expect($UpdateSetupStateData.safeParse({ activeLanguages: ['en', 'es'] }).success).toBe(true);
diff --git a/packages/schemas/src/setup/setup.ts b/packages/schemas/src/setup/setup.ts
index 89968d155..6cef8e90f 100644
--- a/packages/schemas/src/setup/setup.ts
+++ b/packages/schemas/src/setup/setup.ts
@@ -40,6 +40,27 @@ const LOGO_SOURCES = ['upload', 'url'] as const;
/** Independently orderable content sections in the login branding panel. */
const PANEL_SECTIONS = ['logo', 'name', 'tagline', 'details', 'resources'] as const;
+/**
+ * The themes an instance's cards can be painted in each mode. `invisible` draws no card at all and is
+ * what an instance that never chose has; `custom` paints the mode's stored hex color.
+ */
+const LIGHT_CARD_THEMES = ['invisible', 'paper', 'ivory', 'fog', 'silver', 'mist', 'heather', 'custom'] as const;
+
+const DARK_CARD_THEMES = [
+ 'invisible',
+ 'midnight',
+ 'graphite',
+ 'charcoal',
+ 'steel',
+ 'mist',
+ 'heather',
+ 'custom'
+] as const;
+
+const $LightCardTheme = z.enum(LIGHT_CARD_THEMES);
+
+const $DarkCardTheme = z.enum(DARK_CARD_THEMES);
+
const $HexColor = z.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/, 'Must be a valid hex color (e.g. #0ea5e9)');
/**
@@ -198,6 +219,9 @@ const DEFAULT_TABLE_COLOR_MODE = 'tags' satisfies z.infer;
export type ReleaseInfo = z.infer;
export type ResourceLink = z.infer;
+export type DarkCardTheme = z.infer;
+export type LightCardTheme = z.infer;
export type SetupState = z.infer;
export type TableColorMode = z.infer;
export type $UpdateSetupStateData = z.infer;
export {
$BrandingConfig,
+ $DarkCardTheme,
$DevelopmentReleaseInfo,
$InitAppOptions,
+ $LightCardTheme,
$ProductionReleaseInfo,
$ReleaseInfo,
$SetupState,
$TableColorMode,
$UpdateSetupStateData,
+ DARK_CARD_THEMES,
DEFAULT_TABLE_COLOR_MODE,
FONT_SIZES,
+ LIGHT_CARD_THEMES,
LOGIN_THEMES,
LOGO_ALIGNMENTS,
LOGO_SIZES,
diff --git a/testing/src/pages/_app/admin/settings.page.ts b/testing/src/pages/_app/admin/settings.page.ts
index 384e077eb..6463bb3e0 100644
--- a/testing/src/pages/_app/admin/settings.page.ts
+++ b/testing/src/pages/_app/admin/settings.page.ts
@@ -29,6 +29,10 @@ export class AdminSettingsPage extends AppPage {
return this.$ref.getByTestId(`active-language-${language}`);
}
+ cardThemeSwatch(mode: 'dark' | 'light', name: string) {
+ return this.$ref.getByTestId(`card-theme-${mode}`).getByRole('button', { name });
+ }
+
/** `name` is the language's autonym, which is how the toggle labels its options. */
async selectLanguage(name: string) {
await this.sidebar.getByTestId('language-toggle').getByRole('button').click();
diff --git a/testing/src/specs/admin-instrument-repos.spec.ts b/testing/src/specs/admin-instrument-repos.spec.ts
index 610aefe41..7ebf41866 100644
--- a/testing/src/specs/admin-instrument-repos.spec.ts
+++ b/testing/src/specs/admin-instrument-repos.spec.ts
@@ -176,7 +176,18 @@ test.describe('admin instrument repos activity log', () => {
await expect(reposPage.$ref.getByTestId('instrument-repo-url')).toHaveCount(0);
});
- test('should draw no card behind either tab', async ({ getPageModel }) => {
+ test('should draw no card behind either tab', async ({ getPageModel, page }) => {
+ // The card color is one instance-wide value another spec may be changing, so it is pinned here.
+ await page.route('**/v1/setup', async (route) => {
+ if (route.request().method() !== 'GET') {
+ return route.fallback();
+ }
+ const response = await route.fetch();
+ await route.fulfill({
+ json: { ...(await response.json()), darkCardTheme: 'invisible', lightCardTheme: 'invisible' },
+ response
+ });
+ });
const reposPage = await getPageModel('/admin/instrument-repos');
const expectInvisible = async () => {
await expect(reposPage.card).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
diff --git a/testing/src/specs/instance-card-color.spec.ts b/testing/src/specs/instance-card-color.spec.ts
new file mode 100644
index 000000000..7794f7ff3
--- /dev/null
+++ b/testing/src/specs/instance-card-color.spec.ts
@@ -0,0 +1,183 @@
+import type { DarkCardTheme, LightCardTheme } from '@opendatacapture/schemas/setup';
+import type { Page } from '@playwright/test';
+
+import { expect, test } from '../support/fixtures';
+
+const TRANSPARENT = 'rgba(0, 0, 0, 0)';
+
+const PAGES = [
+ '/about',
+ '/admin/audit/logs',
+ '/admin/branding',
+ '/admin/branding/login-page',
+ '/admin/groups',
+ '/admin/groups/create',
+ '/admin/instrument-repos',
+ '/admin/instruments',
+ '/admin/mail',
+ '/admin/permission-sets',
+ '/admin/permission-sets/create',
+ '/admin/settings',
+ '/admin/users',
+ '/admin/users/create',
+ '/contact',
+ '/datahub/instruments',
+ '/datahub/subjects',
+ '/instruments/accessible-instruments',
+ '/user'
+];
+
+/** The color is one instance-wide document, so it is answered per page rather than written and raced. */
+async function answerCardTheme(page: Page, lightCardTheme: LightCardTheme, darkCardTheme: DarkCardTheme) {
+ await page.route('**/v1/setup', async (route) => {
+ if (route.request().method() !== 'GET') {
+ return route.fallback();
+ }
+ const response = await route.fetch();
+ await route.fulfill({ json: { ...(await response.json()), darkCardTheme, lightCardTheme }, response });
+ });
+}
+
+const instanceCard = (page: Page) => page.locator('[data-instance-card]').first();
+
+test.describe('instance card color', () => {
+ test.use({ actingRole: 'ADMIN' });
+
+ for (const pathname of PAGES) {
+ test(`should give ${pathname} a card that draws nothing by default`, async ({ getPageModel, page }) => {
+ await answerCardTheme(page, 'invisible', 'invisible');
+ await getPageModel('/admin/settings');
+ await page.goto(pathname);
+ await expect(instanceCard(page)).toHaveCSS('background-color', TRANSPARENT);
+ await expect(instanceCard(page)).toHaveCSS('border-top-color', TRANSPARENT);
+ });
+
+ test(`should paint the card on ${pathname} in the instance color`, async ({ getPageModel, page }) => {
+ await answerCardTheme(page, 'mist', 'mist');
+ await getPageModel('/admin/settings');
+ await page.goto(pathname);
+ await expect(instanceCard(page)).not.toHaveCSS('background-color', TRANSPARENT);
+ });
+ }
+
+ test('should leave the dashboard out of the instance color', async ({ getPageModel, page }) => {
+ const tile = page.getByTestId('dashboard-chart-records-sessions');
+ const tileFill = () => tile.evaluate((element) => getComputedStyle(element).backgroundColor);
+ await answerCardTheme(page, 'invisible', 'invisible');
+ await getPageModel('/dashboard');
+ const uncolored = await tileFill();
+ await page.unroute('**/v1/setup');
+ await answerCardTheme(page, 'heather', 'heather');
+ await page.reload();
+ await expect(tile).toHaveCSS('background-color', uncolored);
+ await expect(page.locator('[data-instance-card]')).toHaveCount(0);
+ });
+
+ test('should keep the page color inside a thin card on the start session page', async ({ getPageModel, page }) => {
+ await answerCardTheme(page, 'mist', 'mist');
+ await getPageModel('/session/start-session');
+ const card = page.getByTestId('start-session-card');
+ await expect(card).toHaveCSS('background-color', TRANSPARENT);
+ await expect(card).toHaveCSS('border-top-width', '1px');
+ await expect(card).not.toHaveCSS('border-top-color', TRANSPARENT);
+ });
+
+ test('should give the settings card a width of its own rather than that of its content', async ({
+ getPageModel,
+ page
+ }) => {
+ await page.setViewportSize({ height: 900, width: 1440 });
+ await getPageModel('/admin/settings');
+ await expect(instanceCard(page)).toHaveCSS('width', '684px');
+ });
+
+ for (const pathname of [
+ '/admin/groups/create',
+ '/admin/users/create',
+ '/contact',
+ '/session/start-session',
+ '/user'
+ ]) {
+ test(`should not stretch the submit button on ${pathname} across the page`, async ({ getPageModel, page }) => {
+ await getPageModel('/admin/settings');
+ await page.goto(pathname);
+ const box = await page.getByRole('button', { exact: true, name: 'Submit' }).boundingBox();
+ expect(box?.width).toBeLessThan(200);
+ });
+ }
+
+ test('should not stretch the save button on the user page across the page', async ({ api, getPageModel, page }) => {
+ const { user } = await api.createUser();
+ await getPageModel('/admin/users/$userId', { userId: user.id });
+ const box = await page.getByTestId('save-user-changes').boundingBox();
+ expect(box?.width).toBeLessThan(200);
+ });
+
+ test('should leave dark mode without a color while light mode has one', async ({ getPageModel, page }) => {
+ await answerCardTheme(page, 'mist', 'invisible');
+ await getPageModel('/admin/settings');
+ await expect(instanceCard(page)).not.toHaveCSS('background-color', TRANSPARENT);
+ await page.evaluate(() => document.documentElement.setAttribute('data-mode', 'dark'));
+ await expect(instanceCard(page)).toHaveCSS('background-color', TRANSPARENT);
+ await expect(instanceCard(page)).toHaveCSS('border-top-color', TRANSPARENT);
+ });
+
+ test('should leave light mode without a color while dark mode has one', async ({ getPageModel, page }) => {
+ await answerCardTheme(page, 'invisible', 'mist');
+ await getPageModel('/admin/settings');
+ await expect(instanceCard(page)).toHaveCSS('background-color', TRANSPARENT);
+ await page.evaluate(() => document.documentElement.setAttribute('data-mode', 'dark'));
+ await expect(instanceCard(page)).not.toHaveCSS('background-color', TRANSPARENT);
+ });
+
+ test('should paint the card a custom color', async ({ getPageModel, page }) => {
+ await page.route('**/v1/setup', async (route) => {
+ if (route.request().method() !== 'GET') {
+ return route.fallback();
+ }
+ const response = await route.fetch();
+ const json = { ...(await response.json()), customCardColorLight: '#123456', lightCardTheme: 'custom' };
+ await route.fulfill({ json, response });
+ });
+ await getPageModel('/admin/settings');
+ await expect(page.locator('[data-instance-card]').first()).toHaveCSS('background-color', 'rgb(18, 52, 86)');
+ await expect(page.getByLabel('Custom Color')).toHaveValue('#123456');
+ });
+
+ test('should give each palette color its own fill', async ({ getPageModel, page }) => {
+ await answerCardTheme(page, 'mist', 'mist');
+ const settingsPage = await getPageModel('/admin/settings');
+ const sky = await instanceCard(page).evaluate((card) => getComputedStyle(card).backgroundColor);
+ await page.unroute('**/v1/setup');
+ await answerCardTheme(page, 'heather', 'heather');
+ await settingsPage.$ref.reload();
+ await expect(instanceCard(page)).not.toHaveCSS('background-color', sky);
+ await expect(instanceCard(page)).not.toHaveCSS('background-color', TRANSPARENT);
+ });
+
+ // Asserted on the PATCH's own answer: the value is instance-wide and a reload would race the same
+ // test running in another browser.
+ test('should save the chosen color for the instance', async ({ getPageModel, page }) => {
+ const settingsPage = await getPageModel('/admin/settings');
+ const waitForPatch = () => {
+ return page.waitForResponse((res) => res.url().endsWith('/v1/setup') && res.request().method() === 'PATCH');
+ };
+
+ const saved = waitForPatch();
+ await settingsPage.cardThemeSwatch('light', 'Silver').click();
+ expect(await (await saved).json()).toMatchObject({ darkCardTheme: 'invisible', lightCardTheme: 'silver' });
+ await expect(settingsPage.cardThemeSwatch('light', 'Silver')).toHaveAttribute('aria-pressed', 'true');
+
+ const restored = waitForPatch();
+ await settingsPage.cardThemeSwatch('light', 'No Color').click();
+ expect(await (await restored).json()).toMatchObject({ lightCardTheme: 'invisible' });
+ });
+});
+
+test.describe('instance card color for a group manager', () => {
+ test('should not stretch the submit button on /group/manage across the page', async ({ getPageModel, page }) => {
+ await getPageModel('/group/manage');
+ const box = await page.getByRole('button', { exact: true, name: 'Submit' }).boundingBox();
+ expect(box?.width).toBeLessThan(200);
+ });
+});