From 5c7515292c8bacf92049024e76b8002f56c6dd52 Mon Sep 17 00:00:00 2001 From: Mike Cousins Date: Tue, 17 Sep 2019 11:04:46 -0500 Subject: [PATCH] fix(app): Disable run start button if missing modules (#3994) Closes #2676 --- .../CalibratePanel/LabwareListItem.js | 1 - app/src/components/ConnectModules/index.js | 40 +++------ app/src/components/DeckMap/index.js | 28 ++---- .../FileInfo/ProtocolModulesCard.js | 48 ++++------ .../InstrumentSettings/AttachedModulesCard.js | 71 ++++++--------- .../components/InstrumentSettings/index.js | 4 +- .../components/ModuleLiveStatusCards/index.js | 16 +--- app/src/components/PrepareModules/index.js | 23 +---- app/src/components/RunPanel/RunControls.js | 40 ++++++--- app/src/components/RunPanel/index.js | 17 +++- .../components/calibrate-pipettes/styles.css | 1 + app/src/robot-api/resources/modules.js | 89 ++++++++++++------- components/src/hooks/useInterval.js | 14 ++- components/src/nav/SidePanel.css | 2 +- 14 files changed, 180 insertions(+), 214 deletions(-) diff --git a/app/src/components/CalibratePanel/LabwareListItem.js b/app/src/components/CalibratePanel/LabwareListItem.js index 9a8858faf99..c51f782b178 100644 --- a/app/src/components/CalibratePanel/LabwareListItem.js +++ b/app/src/components/CalibratePanel/LabwareListItem.js @@ -51,7 +51,6 @@ export default function LabwareListItem(props: LabwareListItemProps) { activeClassName={styles.active} > mixed, fetchModules: () => mixed |} -type Props = { ...OP, ...SP, ...DP } +type Props = {| ...OP, ...SP, ...DP |} export default connect( mapStateToProps, @@ -38,24 +35,19 @@ function ConnectModules(props: Props) { const onPromptClick = modulesMissing ? fetchModules : setReviewed return ( - -
- -
- -
+
+ +
+
- +
) } function mapStateToProps(state: State, ownProps: OP): SP { - const sessionModules = robotSelectors.getModules(state) - const actualModules = getModulesState(state, ownProps.robot.name) - return { - modulesRequired: sessionModules.length !== 0, - modulesMissing: checkModulesMissing(sessionModules, actualModules), + modulesRequired: robotSelectors.getModules(state).length > 0, + modulesMissing: getMissingModules(state).length > 0, } } @@ -65,15 +57,3 @@ function mapDispatchToProps(dispatch: Dispatch, ownProps: OP): DP { fetchModules: () => dispatch(fetchModules(ownProps.robot)), } } - -function checkModulesMissing( - required: Array, - actual: ?Array -): boolean { - const requiredNames = countBy(required, 'name') - const actualNames = countBy(actual, 'name') - - return Object.keys(requiredNames).some( - n => requiredNames[n] !== actualNames[n] - ) -} diff --git a/app/src/components/DeckMap/index.js b/app/src/components/DeckMap/index.js index 7e8e563aa1b..167df44610e 100644 --- a/app/src/components/DeckMap/index.js +++ b/app/src/components/DeckMap/index.js @@ -5,7 +5,6 @@ import { withRouter } from 'react-router' import some from 'lodash/some' import map from 'lodash/map' import mapValues from 'lodash/mapValues' -import countBy from 'lodash/countBy' import { type DeckSlotId } from '@opentrons/shared-data' import type { ContextRouter } from 'react-router' @@ -19,8 +18,7 @@ import { type SessionModule, } from '../../robot' -import { getModulesState } from '../../robot-api' -import { getConnectedRobot } from '../../discovery' +import { getMissingModules } from '../../robot-api' import LabwareItem from './LabwareItem' @@ -128,30 +126,18 @@ function DeckMap(props: Props) { function mapStateToProps(state: State, ownProps: OP): SP { let modulesBySlot = mapValues( robotSelectors.getModulesBySlot(state), - module => ({ - ...module, - mode: 'default', - }) + module => ({ ...module, mode: 'default' }) ) // only show necessary modules if still need to connect some - if (ownProps.modulesRequired) { - const robot = getConnectedRobot(state) - const sessionModules = robotSelectors.getModules(state) - const actualModules = robot ? getModulesState(state, robot.name) : [] - - const requiredNames = countBy(sessionModules, 'name') - const actualNames = countBy(actualModules, 'name') + if (ownProps.modulesRequired === true) { + const missingModules = getMissingModules(state) modulesBySlot = mapValues( robotSelectors.getModulesBySlot(state), module => { - const present = - !module || requiredNames[module.name] === actualNames[module.name] - return { - ...module, - mode: present ? 'present' : 'missing', - } + const present = !missingModules.some(mm => mm.name === module.name) + return { ...module, mode: present ? 'present' : 'missing' } } ) return { @@ -166,7 +152,7 @@ function mapStateToProps(state: State, ownProps: OP): SP { }), {} ) - if (!ownProps.enableLabwareSelection) { + if (ownProps.enableLabwareSelection !== true) { return { labwareBySlot, modulesBySlot, diff --git a/app/src/components/FileInfo/ProtocolModulesCard.js b/app/src/components/FileInfo/ProtocolModulesCard.js index a1b2bf7c567..fb4ca5d759f 100644 --- a/app/src/components/FileInfo/ProtocolModulesCard.js +++ b/app/src/components/FileInfo/ProtocolModulesCard.js @@ -5,9 +5,8 @@ import { connect } from 'react-redux' import { getModuleDisplayName } from '@opentrons/shared-data' import { selectors as robotSelectors } from '../../robot' -import { getModulesState, fetchModules } from '../../robot-api' +import { getModulesState } from '../../robot-api' -import { RefreshWrapper } from '../Page' import InfoSection from './InfoSection' import { SectionContentHalf } from '../layout' import InstrumentItem from './InstrumentItem' @@ -26,19 +25,18 @@ type SP = {| attachModulesUrl: string, |} -type DP = {| fetchModules: () => mixed |} +type DP = {| dispatch: Dispatch |} -type Props = { ...OP, ...SP, ...DP } +type Props = {| ...OP, ...SP, ...DP |} const TITLE = 'Required Modules' -export default connect( - mapStateToProps, - mapDispatchToProps -)(ProtocolModulesCard) +export default connect(mapStateToProps)( + ProtocolModulesCard +) function ProtocolModulesCard(props: Props) { - const { modules, actualModules, fetchModules, attachModulesUrl } = props + const { modules, actualModules, attachModulesUrl } = props if (modules.length < 1) return null @@ -52,20 +50,18 @@ function ProtocolModulesCard(props: Props) { const modulesMatch = moduleInfo.every(m => m.modulesMatch) return ( - - - - {moduleInfo.map(m => ( - - {m.displayName}{' '} - - ))} - - {!modulesMatch && ( - - )} - - + + + {moduleInfo.map(m => ( + + {m.displayName}{' '} + + ))} + + {!modulesMatch && ( + + )} + ) } @@ -80,9 +76,3 @@ function mapStateToProps(state: State, ownProps: OP): SP { attachModulesUrl: `/robots/${robot.name}/instruments`, } } - -function mapDispatchToProps(dispatch: Dispatch, ownProps: OP): DP { - return { - fetchModules: () => dispatch(fetchModules(ownProps.robot)), - } -} diff --git a/app/src/components/InstrumentSettings/AttachedModulesCard.js b/app/src/components/InstrumentSettings/AttachedModulesCard.js index 2ca59c57954..8fb7c54b3c7 100644 --- a/app/src/components/InstrumentSettings/AttachedModulesCard.js +++ b/app/src/components/InstrumentSettings/AttachedModulesCard.js @@ -1,64 +1,47 @@ // @flow // attached modules container card import * as React from 'react' -import { connect } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' -import { Card, IntervalWrapper } from '@opentrons/components' +import { Card, useInterval } from '@opentrons/components' import { fetchModules, getModulesState } from '../../robot-api' import ModulesCardContents from './ModulesCardContents' import { getConfig } from '../../config' import type { State, Dispatch } from '../../types' -import type { Module } from '../../robot-api' import type { Robot } from '../../discovery' -type OP = {| robot: Robot |} - -type SP = {| - modules: Array, - __tempControlsEnabled: boolean, -|} - -type DP = {| fetchModules: () => mixed |} - -type Props = { ...OP, ...SP, ...DP } +type Props = {| robot: Robot |} const TITLE = 'Modules' const POLL_MODULE_INTERVAL_MS = 5000 -export default connect( - mapStateToProps, - mapDispatchToProps -)(AttachedModulesCard) +export default function AttachedModulesCard(props: Props) { + const { robot } = props + const dispatch = useDispatch() -function AttachedModulesCard(props: Props) { - return ( - - - - - + const modules = useSelector((state: State) => + getModulesState(state, robot.name) + ) + const __tempControlsEnabled = Boolean( + useSelector(getConfig).devInternal?.tempdeckControls ) -} -function mapStateToProps(state: State, ownProps: OP): SP { - return { - modules: getModulesState(state, ownProps.robot.name), - __tempControlsEnabled: Boolean( - getConfig(state).devInternal?.tempdeckControls - ), - } -} + // this component may be mounted if the robot is not currently connected, so + // GET /modules ourselves instead of relying on the poll while connected epic + useInterval( + () => dispatch(fetchModules(robot)), + POLL_MODULE_INTERVAL_MS, + true + ) -function mapDispatchToProps(dispatch: Dispatch, ownProps: OP): DP { - return { - fetchModules: () => dispatch(fetchModules(ownProps.robot)), - } + return ( + + + + ) } diff --git a/app/src/components/InstrumentSettings/index.js b/app/src/components/InstrumentSettings/index.js index e4c457e2646..d724370cf4b 100644 --- a/app/src/components/InstrumentSettings/index.js +++ b/app/src/components/InstrumentSettings/index.js @@ -8,9 +8,7 @@ import { CardContainer, CardRow } from '../layout' import type { Robot } from '../../discovery' -type Props = { - robot: Robot, -} +type Props = {| robot: Robot |} export default function InstrumentSettings(props: Props) { return ( diff --git a/app/src/components/ModuleLiveStatusCards/index.js b/app/src/components/ModuleLiveStatusCards/index.js index 8c2812fbfd8..158005ff61b 100644 --- a/app/src/components/ModuleLiveStatusCards/index.js +++ b/app/src/components/ModuleLiveStatusCards/index.js @@ -4,7 +4,6 @@ import { connect } from 'react-redux' import { getConnectedRobot } from '../../discovery' import { - fetchModules, getModulesState, sendModuleCommand, type ModuleCommandRequest, @@ -13,8 +12,6 @@ import { import { selectors as robotSelectors } from '../../robot' import { getConfig } from '../../config' -import { IntervalWrapper } from '@opentrons/components' - import type { State, Dispatch } from '../../types' import type { Robot } from '../../discovery' @@ -22,7 +19,6 @@ import TempDeckCard from './TempDeckCard' import MagDeckCard from './MagDeckCard' import ThermocyclerCard from './ThermocyclerCard' -const POLL_MODULES_INTERVAL_MS = 1000 const LIVE_STATUS_MODULES = ['magdeck', 'tempdeck', 'thermocycler'] type SP = {| @@ -33,7 +29,6 @@ type SP = {| |} type DP = {| - _fetchModules: (_robot: Robot) => mixed, _sendModuleCommand: ( _robot: Robot, serial: string, @@ -44,7 +39,6 @@ type DP = {| type Props = {| liveStatusModules: Array, isProtocolActive: boolean, - fetchModules: () => mixed, sendModuleCommand: (serial: string, request: ModuleCommandRequest) => mixed, __tempdeckControlsEnabled: boolean, |} @@ -53,14 +47,14 @@ const ModuleLiveStatusCards = (props: Props) => { const { liveStatusModules, isProtocolActive, - fetchModules, sendModuleCommand, __tempdeckControlsEnabled, } = props + if (liveStatusModules.length === 0) return null return ( - + <> {liveStatusModules.map(module => { switch (module.name) { case 'tempdeck': @@ -88,7 +82,7 @@ const ModuleLiveStatusCards = (props: Props) => { return null } })} - + ) } @@ -112,14 +106,13 @@ function mapStateToProps(state: State): SP { function mapDispatchToProps(dispatch: Dispatch): DP { return { - _fetchModules: _robot => dispatch(fetchModules(_robot)), _sendModuleCommand: (_robot, serial, request) => dispatch(sendModuleCommand(_robot, serial, request)), } } function mergeProps(stateProps: SP, dispatchProps: DP): Props { - const { _fetchModules, _sendModuleCommand } = dispatchProps + const { _sendModuleCommand } = dispatchProps const { _robot, liveStatusModules, @@ -130,7 +123,6 @@ function mergeProps(stateProps: SP, dispatchProps: DP): Props { return { liveStatusModules, isProtocolActive, - fetchModules: () => _robot && _fetchModules(_robot), sendModuleCommand: (serial, request) => _robot && _sendModuleCommand(_robot, serial, request), __tempdeckControlsEnabled, diff --git a/app/src/components/PrepareModules/index.js b/app/src/components/PrepareModules/index.js index f1ab7e01def..91ed2b3e55b 100644 --- a/app/src/components/PrepareModules/index.js +++ b/app/src/components/PrepareModules/index.js @@ -2,39 +2,20 @@ import * as React from 'react' import { useDispatch } from 'react-redux' import some from 'lodash/some' -import { - useInterval, - PrimaryButton, - AlertModal, - Icon, -} from '@opentrons/components' +import { PrimaryButton, AlertModal, Icon } from '@opentrons/components' -import { - fetchModules, - sendModuleCommand, - type Module, - type RobotHost, -} from '../../robot-api' +import { sendModuleCommand, type Module, type RobotHost } from '../../robot-api' import type { Dispatch } from '../../types' import DeckMap from '../DeckMap' import styles from './styles.css' import { Portal } from '../portal' -const FETCH_MODULES_POLL_INTERVAL_MS = 1000 - type Props = {| robot: RobotHost, modules: Array |} function PrepareModules(props: Props) { const { modules, robot } = props - const dispatch = useDispatch() - // update on interval to respond to prepared modules - useInterval( - () => robot && dispatch(fetchModules(robot)), - FETCH_MODULES_POLL_INTERVAL_MS - ) - const handleOpenLidClick = () => { modules .filter(mod => mod.name === 'thermocycler') diff --git a/app/src/components/RunPanel/RunControls.js b/app/src/components/RunPanel/RunControls.js index 7e8c87ea2b9..5334e920497 100644 --- a/app/src/components/RunPanel/RunControls.js +++ b/app/src/components/RunPanel/RunControls.js @@ -2,12 +2,16 @@ // play pause run buttons for sidepanel import * as React from 'react' import { Link } from 'react-router-dom' -import { OutlineButton } from '@opentrons/components' +import { OutlineButton, HoverTooltip } from '@opentrons/components' import styles from './styles.css' -type RunProps = { +const MISSING_MODULES = + 'Please attach all required modules before running this protocol' + +type RunProps = {| disabled: boolean, + modulesReady: boolean, isReadyToRun: boolean, isPaused: boolean, isRunning: boolean, @@ -15,10 +19,12 @@ type RunProps = { onPauseClick: () => mixed, onResumeClick: () => mixed, onResetClick: () => mixed, -} +|} + export default function RunControls(props: RunProps) { const { disabled, + modulesReady, isReadyToRun, isPaused, isRunning, @@ -38,14 +44,24 @@ export default function RunControls(props: RunProps) { let resetButton if (isReadyToRun && !isRunning) { + // TODO(mc, 2019-09-03): add same check for pipettes + const runDisabled = disabled || !modulesReady + let tooltip = modulesReady ? null : MISSING_MODULES + runButton = ( - - Start Run - + + {hoverTooltipHandlers => ( +
+ + Start Run + +
+ )} +
) } else if (isRunning) { pauseResumeButton = ( @@ -81,11 +97,11 @@ export default function RunControls(props: RunProps) { } return ( -
+ <> {runButton} {pauseResumeButton} {cancelButton} {resetButton} -
+ ) } diff --git a/app/src/components/RunPanel/index.js b/app/src/components/RunPanel/index.js index ce2ce7e9c42..f1301f2ce58 100644 --- a/app/src/components/RunPanel/index.js +++ b/app/src/components/RunPanel/index.js @@ -6,6 +6,7 @@ import { actions as robotActions, selectors as robotSelectors, } from '../../robot' +import { getMissingModules } from '../../robot-api' import { SidePanel, SidePanelGroup } from '@opentrons/components' import RunTimer from './RunTimer' @@ -19,6 +20,7 @@ type SP = {| isPaused: boolean, startTime: ?number, isReadyToRun: boolean, + modulesReady: boolean, runTime: string, disabled: boolean, |} @@ -30,13 +32,14 @@ type DP = {| onResetClick: () => mixed, |} -type Props = { ...SP, ...DP } +type Props = {| ...SP, ...DP |} const mapStateToProps = (state: State): SP => ({ isRunning: robotSelectors.getIsRunning(state), isPaused: robotSelectors.getIsPaused(state), startTime: robotSelectors.getStartTime(state), isReadyToRun: robotSelectors.getIsReadyToRun(state), + modulesReady: getMissingModules(state).length === 0, runTime: robotSelectors.getRunTime(state), disabled: !robotSelectors.getSessionIsLoaded(state) || @@ -59,7 +62,17 @@ function RunPanel(props: Props) { - + diff --git a/app/src/components/calibrate-pipettes/styles.css b/app/src/components/calibrate-pipettes/styles.css index 5b12347c557..d75a6d4de64 100644 --- a/app/src/components/calibrate-pipettes/styles.css +++ b/app/src/components/calibrate-pipettes/styles.css @@ -17,5 +17,6 @@ .alert { @apply --absolute-fill; + z-index: 1; bottom: auto; } diff --git a/app/src/robot-api/resources/modules.js b/app/src/robot-api/resources/modules.js index ba741c19317..97d3b7b4442 100644 --- a/app/src/robot-api/resources/modules.js +++ b/app/src/robot-api/resources/modules.js @@ -2,9 +2,17 @@ // modules endpoints import { combineEpics, ofType } from 'redux-observable' import pathToRegexp from 'path-to-regexp' -import { of } from 'rxjs' +import { of, interval } from 'rxjs' +import countBy from 'lodash/countBy' -import { switchMap, withLatestFrom, filter } from 'rxjs/operators' +import { + switchMap, + mergeMap, + withLatestFrom, + filter, + map, + takeWhile, +} from 'rxjs/operators' import { getRobotApiState, @@ -17,6 +25,7 @@ import { import { getConnectedRobot } from '../../discovery' import { selectors as robotSelectors } from '../../robot' import type { ConnectResponseAction } from '../../robot/actions' +import type { SessionModule } from '../../robot/types' import type { State as AppState, ActionLike, Epic } from '../../types' import type { RobotHost, RobotApiAction } from '../types' @@ -41,6 +50,8 @@ export const MODULES_PATH = '/modules' export const MODULE_DATA_PATH = '/modules/:serial/data' export const MODULE_BY_SERIAL_PATH = '/modules/:serial' +const POLL_MODULE_INTERVAL_MS = 2000 + const RE_MODULE_DATA_PATH = pathToRegexp(MODULE_DATA_PATH) export const fetchModules = (host: RobotHost): RobotApiAction => ({ @@ -48,6 +59,7 @@ export const fetchModules = (host: RobotHost): RobotApiAction => ({ payload: { host, method: GET, path: MODULES_PATH }, }) +// TODO(mc, 2019-09-03): this endpoint is not used anywhere; is it needed? export const fetchModuleData = ( host: RobotHost, id: string @@ -71,25 +83,28 @@ const fetchModulesEpic = createBaseRobotApiEpic(FETCH_MODULES) const fetchModuleDataEpic = createBaseRobotApiEpic(FETCH_MODULE_DATA) const sendModuleCommandEpic = createBaseRobotApiEpic(SEND_MODULE_COMMAND) -const eagerlyLoadModulesEpic: Epic = (action$, state$) => +// TODO(mc, 2019-09-03): replace polling with real-time WS notifications +const pollModulesWhileConnectedEpic: Epic = (action$, state$) => action$.pipe( ofType('robot:CONNECT_RESPONSE'), - filter(action => !action.payload?.error), - withLatestFrom(state$), - switchMap<[ConnectResponseAction, AppState], _, mixed>( - ([action, state]) => { - const robotHost = getConnectedRobot(state) - return robotHost ? of(fetchModules(robotHost)) : of(null) - } - ), - filter(Boolean) + filter(action => !action.payload?.error), + mergeMap<_, _, mixed>(() => + interval(POLL_MODULE_INTERVAL_MS).pipe( + withLatestFrom(state$), + map<[number, AppState], ?RobotHost>(([_, state]) => + getConnectedRobot(state) + ), + takeWhile(robot => Boolean(robot)), + switchMap(robot => of(fetchModules(robot))) + ) + ) ) export const modulesEpic = combineEpics( fetchModulesEpic, fetchModuleDataEpic, sendModuleCommandEpic, - eagerlyLoadModulesEpic + pollModulesWhileConnectedEpic ) export function modulesReducer( @@ -121,6 +136,8 @@ export function modulesReducer( return state } +const PREPARABLE_MODULES = ['thermocycler'] + export function getModulesState( state: AppState, robotName: string @@ -130,31 +147,35 @@ export function getModulesState( return robotState?.resources.modules || [] } -const PREPARABLE_MODULES = ['thermocycler'] +const isModulePrepared = (module: Module): boolean => { + if (module.name === 'thermocycler') return module.data.lid === 'open' + return false +} -export const getUnpreparedModules = (state: AppState): Array => { +export function getUnpreparedModules(state: AppState): Array { const robot = getConnectedRobot(state) - if (!robot) return [] + const sessionModules = robotSelectors.getModules(state) + const actualModules = robot ? getModulesState(state, robot.name) : [] + const preparableSessionModules = sessionModules + .map(m => m.name) + .filter(name => PREPARABLE_MODULES.includes(name)) + + // return actual modules that are both + // a) required to be prepared by the session + // b) not prepared according to isModulePrepared + return actualModules.filter( + m => preparableSessionModules.includes(m.name) && isModulePrepared(m) + ) +} +export function getMissingModules(state: AppState): Array { + const robot = getConnectedRobot(state) const sessionModules = robotSelectors.getModules(state) - const actualModules = getModulesState(state, robot.name) || [] + const actualModules = robot ? getModulesState(state, robot.name) : [] + const requiredCountMap: { [string]: number } = countBy(sessionModules, 'name') + const actualCountMap: { [string]: number } = countBy(actualModules, 'name') - const preparableModules = sessionModules.reduce( - (acc, mod) => - PREPARABLE_MODULES.includes(mod.name) ? [...acc, mod.name] : acc, - [] + return sessionModules.filter( + m => requiredCountMap[m.name] > (actualCountMap[m.name] || 0) ) - if (preparableModules.length > 0) { - const actualPreparableModules = actualModules.filter(mod => - preparableModules.includes(mod.name) - ) - return actualPreparableModules.reduce((acc, mod) => { - if (mod.name === 'thermocycler' && mod.data.lid !== 'open') { - return [...acc, mod] - } - return acc - }, []) - } else { - return [] - } } diff --git a/components/src/hooks/useInterval.js b/components/src/hooks/useInterval.js index 314da7f4ce1..eef93ba3ca8 100644 --- a/components/src/hooks/useInterval.js +++ b/components/src/hooks/useInterval.js @@ -7,10 +7,15 @@ import { useEffect, useRef } from 'react' * * @template T (type of the input value) * @param {() => mixed} callback (function to call on an interval) - * @param {number | null} callback (interval delay, or null to stop interval) + * @param {number | null} delay (interval delay, or null to stop interval) + * @param {boolean} [immediate=false] (trigger the callback immediately before starting the interval) * @returns {void} */ -export function useInterval(callback: () => mixed, delay: number | null): void { +export function useInterval( + callback: () => mixed, + delay: number | null, + immediate: boolean = false +): void { const savedCallback = useRef() // remember the latest callback @@ -21,9 +26,10 @@ export function useInterval(callback: () => mixed, delay: number | null): void { // set up the interval useEffect(() => { const tick = () => savedCallback.current && savedCallback.current() - if (delay !== null) { + if (delay !== null && delay > 0) { + if (immediate) tick() const id = setInterval(tick, delay) return () => clearInterval(id) } - }, [delay]) + }, [delay, immediate]) } diff --git a/components/src/nav/SidePanel.css b/components/src/nav/SidePanel.css index 6f608fbf234..e8f5c980511 100644 --- a/components/src/nav/SidePanel.css +++ b/components/src/nav/SidePanel.css @@ -6,7 +6,7 @@ .panel { flex-shrink: 0; - overflow: hidden; + overflow: visible; border-right: var(--bd-light); background-color: var(--c-bg-light); position: relative;