From be2226a38ffad7c202a23c97a3f40e518fe7e3b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 18:36:31 +0000 Subject: [PATCH] Polish navigation menu and view params editor Bring both drawers onto the list and field patterns already used by the settings panel, rather than each carrying its own treatment. Navigation menu - Rows adopt the PanelList styling: neutral idle colour with blue reserved for the current view, which previously competed with a dozen other blue rows - Group the list under named headings instead of bare separators - Display options become toggle rows with a switch matching the Switch component, replacing the inconsistent "Active" text notes - Leading icons on a fixed box so labels line up, and an icon for Rename Client - Swap :focus for :focus-visible so clicking no longer leaves an outline - Show the client name in the header, next to the action that renames it View params editor - Compact fields (booleans, colours) sit beside their label instead of below it, which removes a third of the scroll height and pairs control with label - Restore the type hierarchy: option titles were dimmer than their section header. Titles now read as primary with descriptions muted underneath - Sections become cards with hairline separated rows - Section headers are keyboard reachable and mark sections holding custom values - Sticky header and footer with borders, so content no longer scrolls visibly underneath the Apply button, dropping the 10vh padding workaround - Give the presets block a heading and a tinted applied state Collapsed and hidden fields stay mounted and hidden with CSS: the form reads its values from the DOM, so unmounting them would drop them on Apply. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014L7R2LgjMSH2WQbiSP4uRn --- .../NavigationMenu.module.scss | 58 +++++++-- .../navigation-menu/NavigationMenu.tsx | 120 ++++++++++-------- .../NavigationMenuItem.module.scss | 87 +++++++++++-- .../NavigationMenuToggle.tsx | 38 ++++++ .../OtherAddresses.module.scss | 9 +- .../ViewParamsEditor.module.scss | 48 ++++--- .../view-params-editor/ViewParamsEditor.tsx | 26 +++- .../ViewParamsPresets.module.scss | 35 ++++- .../view-params-editor/ViewParamsPresets.tsx | 35 ++--- .../ViewParamsSection.module.scss | 112 ++++++++++++---- .../view-params-editor/ViewParamsSection.tsx | 58 ++++++--- 11 files changed, 469 insertions(+), 157 deletions(-) create mode 100644 apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuToggle.tsx diff --git a/apps/client/src/common/components/navigation-menu/NavigationMenu.module.scss b/apps/client/src/common/components/navigation-menu/NavigationMenu.module.scss index 5b2b2fdcc..cf0fb9661 100644 --- a/apps/client/src/common/components/navigation-menu/NavigationMenu.module.scss +++ b/apps/client/src/common/components/navigation-menu/NavigationMenu.module.scss @@ -1,7 +1,3 @@ -.separator { - border-color: $border-color-ondark; -} - .backdrop { position: fixed; inset: 0; @@ -23,14 +19,15 @@ width: 22rem; height: 100vh; + height: 100dvh; display: flex; flex-direction: column; - padding-block: 1rem; background-color: $gray-1250; color: $ui-white; border-right: 1px solid $gray-1100; + box-shadow: $box-shadow-right; &[data-open] { transform: translateX(0%); @@ -50,22 +47,63 @@ } .header { + flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; - height: 3.5rem; - padding-inline: 1.5rem; + gap: 1rem; + min-height: 4rem; + padding: 0.75rem 1rem 0.75rem 1.25rem; + border-bottom: 1px solid $white-10; +} +.headerText { + display: flex; + flex-direction: column; + min-width: 0; +} + +.title { font-weight: 600; font-size: 1.25rem; + line-height: 1.2; +} + +.clientName { + font-size: $aux-text-size; + color: $gray-600; + @include ellipsis-text; } .body { flex: 1; overflow-y: auto; + padding-block: 0.5rem 1rem; + + scrollbar-width: thin; + scrollbar-color: $gray-1000 transparent; } -.note { - margin-left: auto; - color: $white-20; +.group { + display: flex; + flex-direction: column; + + &:not(:first-child) { + margin-top: 0.75rem; + } +} + +.groupLabel { + padding: 0.5rem 1.25rem 0.25rem; + font-size: $aux-text-size; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: $gray-700; +} + +.footer { + flex: 0 0 auto; + padding: 0.75rem 0; + border-top: 1px solid $white-10; } diff --git a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx index 7f78b6e5d..48ca0eca1 100644 --- a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx +++ b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx @@ -1,7 +1,7 @@ import { Dialog } from '@base-ui/react/dialog'; import { useDisclosure, useFullscreenDocument } from '@mantine/hooks'; -import { memo } from 'react'; -import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; +import { memo, PropsWithChildren } from 'react'; +import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5'; import { LuCoffee } from 'react-icons/lu'; import { useLocation } from 'react-router'; @@ -17,6 +17,7 @@ import { RenameClientModal } from '../client-modal/RenameClientModal'; import ClientLink from './client-link/ClientLink'; import EditorNavigation from './editor-navigation/EditorNavigation'; import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem'; +import NavigationMenuToggle from './navigation-menu-item/NavigationMenuToggle'; import OtherAddresses from './other-addresses/OtherAddresses'; import style from './NavigationMenu.module.scss'; @@ -52,66 +53,77 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
- Ontime - +
+ Ontime + {name && {name}} +
+
- {supportsFullscreen && ( - - Toggle Fullscreen - {fullscreen ? : } + + {supportsFullscreen && ( + } label='Fullscreen' onClick={toggle} /> + )} + } + label='Flip Screen' + onClick={() => toggleMirror()} + /> + {canUseWakeLock && ( + } + label='Keep Awake' + onClick={toggleKeepAwake} + /> + )} + + + Rename Client - )} - toggleMirror()}> - Flip Screen - - {mirror && Active} - - {canUseWakeLock && ( - - Keep Awake - - {keepAwake && Active} - - )} - Rename Client + -
- - - - - Cuesheet - - - - Operator - - -
- - {navigatorConstants.map((route) => ( + + - {route.label} + + Cuesheet - ))} + + + Operator + + + + + {navigatorConstants.map((route) => ( + + {route.label} + + ))} +
{isLocalhost && ( -
+
)} @@ -121,6 +133,15 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) { ); } +function MenuGroup({ label, children }: PropsWithChildren<{ label: string }>) { + return ( +
+
{label}
+ {children} +
+ ); +} + function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) { const location = useLocation(); const { data: urlPresets } = useUrlPresets(); @@ -129,8 +150,7 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; if (navPresets.length === 0) return null; return ( - <> -
+ {navPresets.map((preset) => ( ))} - + ); } diff --git a/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuItem.module.scss b/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuItem.module.scss index f7c0a34a0..981c090f8 100644 --- a/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuItem.module.scss +++ b/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuItem.module.scss @@ -2,35 +2,98 @@ display: flex; align-items: center; - padding: 0.75rem 1.5rem; - gap: 0.5rem; + padding: 0.625rem 1.25rem; + gap: 0.625rem; width: 100%; - border-left: 4px solid transparent; - color: $action-text-color; + border-left: 3px solid transparent; + color: $gray-200; + font-size: $text-body-size; white-space: nowrap; + text-align: left; cursor: pointer; - transition-property: color; + transition-property: color, background-color; transition-duration: $transition-time-action; + // keep icons on a fixed box so labels align regardless of the glyph + > svg { + flex-shrink: 0; + width: 1.125rem; + height: 1.125rem; + color: $gray-500; + transition: color $transition-time-action; + } + &:hover { - color: $ontime-color; - background-color: $gray-1350; + background-color: $white-3; + color: $ui-white; + + > svg { + color: $gray-300; + } } &:active { - background-color: $border-color-ondark; + background-color: $white-7; } - &:focus { + &:focus-visible { outline: 2px solid $blue-500; - background-color: $gray-1350; + outline-offset: -2px; + background-color: $white-3; } &.current { - background-color: $gray-1300; - border-left: 4px solid $action-text-color; + background-color: rgba($blue-500, 0.16); + border-left-color: $blue-400; + color: $ui-white; + font-weight: 600; + + > svg { + color: $blue-300; + } + } +} + +.label { + flex: 1; + min-width: 0; + @include ellipsis-text; +} + +/** + * Presentational switch for toggle rows. + * The row itself owns the interaction (role=switch), so this is aria-hidden: + * nesting a real control inside a clickable row would double up the hit target. + * Metrics and colours are kept in sync with the medium Switch component. + */ +.toggleTrack { + flex-shrink: 0; + box-sizing: content-box; + width: 2.5rem; + height: calc(1.5rem - 4px); + padding: 2px; + border-radius: $component-border-radius-full; + background-color: $gray-1100; + transition: background-color 125ms cubic-bezier(0.26, 0.75, 0.38, 0.45); + + &::after { + content: ''; + display: block; + width: calc(1.5rem - 4px); + height: 100%; + border-radius: $component-border-radius-full; + background-color: $ui-white; + transition: translate 150ms ease; + } +} + +[aria-checked='true'] > .toggleTrack { + background-color: $blue-700; + + &::after { + translate: calc(2.5rem - (1.5rem - 4px)) 0; } } diff --git a/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuToggle.tsx b/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuToggle.tsx new file mode 100644 index 000000000..4538de7d3 --- /dev/null +++ b/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuToggle.tsx @@ -0,0 +1,38 @@ +import { ReactNode } from 'react'; + +import { isKeyEnter } from '../../../utils/keyEvent'; + +import style from './NavigationMenuItem.module.scss'; + +interface NavigationMenuToggleProps { + checked: boolean; + icon: ReactNode; + label: string; + onClick: () => void; +} + +/** + * A menu row which reflects an on/off state. + * We keep the div + Enter handling of NavigationMenuItem instead of a button: + * Space is a global hotkey for toggling the menu, and a button would react to it as well. + */ +export default function NavigationMenuToggle({ checked, icon, label, onClick }: NavigationMenuToggleProps) { + return ( +
{ + if (isKeyEnter(event)) { + onClick(); + } + }} + > + {icon} + {label} + +
+ ); +} diff --git a/apps/client/src/common/components/navigation-menu/other-addresses/OtherAddresses.module.scss b/apps/client/src/common/components/navigation-menu/other-addresses/OtherAddresses.module.scss index 9045d2685..02fe094cc 100644 --- a/apps/client/src/common/components/navigation-menu/other-addresses/OtherAddresses.module.scss +++ b/apps/client/src/common/components/navigation-menu/other-addresses/OtherAddresses.module.scss @@ -1,11 +1,14 @@ .header { - font-size: calc(1rem - 2px); - margin-left: 1rem; + padding: 0 1.25rem 0.25rem; + font-size: $aux-text-size; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; color: $gray-700; } .interfaces { - padding: 0.5rem 0.5rem; + padding: 0.25rem 1.25rem 0; display: flex; flex-wrap: wrap; gap: 0.5rem; diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss index 4cb5a43e6..b6262805e 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss +++ b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss @@ -1,13 +1,3 @@ -.drawerFooter { - display: flex; - justify-content: end; - gap: $section-spacing; - - button { - padding: 0 2em; - } -} - .backdrop { position: fixed; inset: 0; @@ -34,11 +24,11 @@ display: flex; flex-direction: column; - padding-block: 1rem 1.5rem; background-color: $gray-1250; color: $ui-white; border-left: 1px solid $gray-1100; + box-shadow: $box-shadow-l1; &[data-open] { transform: translateX(0%); @@ -58,36 +48,58 @@ } .header { - padding-inline: 1rem; + flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; - height: 3.5rem; + gap: 1rem; + min-height: 4rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid $white-10; +} +.headerText { + display: flex; + flex-direction: column; + min-width: 0; +} + +.title { font-weight: 600; font-size: 1.25rem; + line-height: 1.2; +} + +.target { + font-size: $aux-text-size; + color: $gray-600; } .body { flex: 1; - padding-inline: 1rem; - padding-bottom: 10vh; + padding: 1rem; overflow-y: auto; + + scrollbar-width: thin; + scrollbar-color: $gray-1000 transparent; } .footer { + flex: 0 0 auto; display: flex; - padding-inline: 1rem; + padding: 0.75rem 1rem; gap: 1rem; align-items: center; justify-content: flex-end; + + background-color: $gray-1250; + border-top: 1px solid $white-10; } .sectionList { display: flex; flex-direction: column; - gap: 2rem; - padding-right: 0.5rem; + gap: 1rem; } .info { diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx index 6e376ccc3..95068206f 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx +++ b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx @@ -18,6 +18,19 @@ import ViewParamsSection from './ViewParamsSection'; import style from './ViewParamsEditor.module.scss'; +/** User facing names for the views we can customise */ +const viewLabels: Record = { + [OntimeView.Editor]: 'Editor', + [OntimeView.Cuesheet]: 'Cuesheet', + [OntimeView.Operator]: 'Operator', + [OntimeView.Timer]: 'Timer', + [OntimeView.Backstage]: 'Backstage', + [OntimeView.Timeline]: 'Timeline', + [OntimeView.StudioClock]: 'Studio Clock', + [OntimeView.Countdown]: 'Countdown', + [OntimeView.ProjectInfo]: 'Project Info', +}; + interface EditFormDrawerProps { target: OntimeView; viewOptions: ViewOption[]; @@ -71,8 +84,17 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
- Customise - +
+ Customise + {viewLabels[target]} +
+
diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsPresets.module.scss b/apps/client/src/common/components/view-params-editor/ViewParamsPresets.module.scss index f8877f094..4476c3538 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsPresets.module.scss +++ b/apps/client/src/common/components/view-params-editor/ViewParamsPresets.module.scss @@ -1,22 +1,45 @@ .presetSection { - background-color: $gray-1350; - display: flex; - flex-direction: column; - gap: 0.25rem; - padding: 1rem 0.5rem; + background-color: $white-2; + border: 1px solid $gray-1100; + border-radius: $panel-border-radius; margin-bottom: 1rem; +} + +.header { + padding: 0.5rem 1rem; + min-height: 2.75rem; + display: flex; + align-items: center; + + font-size: $aux-text-size; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: $gray-300; +} + +.presetList { + padding-inline: 0.5rem; + border-top: 1px solid $white-7; max-height: 10rem; overflow-y: auto; scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: $gray-1000 transparent; } .preset { display: flex; align-items: center; gap: 0.5rem; + padding: 0.375rem 0.5rem; + border-radius: $component-border-radius-md; + font-size: $inner-section-text-size; &.active { - color: $blue-500; + background-color: rgba($blue-500, 0.16); + color: $ui-white; + font-weight: 600; } } diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsPresets.tsx b/apps/client/src/common/components/view-params-editor/ViewParamsPresets.tsx index 80386b75c..a65c8caf9 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsPresets.tsx +++ b/apps/client/src/common/components/view-params-editor/ViewParamsPresets.tsx @@ -26,22 +26,25 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) { return (
- {viewPresets.map((preset) => { - const active = searchParams.get('alias') === preset.alias; - return ( -
-
{preset.alias}
- -
- ); - })} +
Presets
+
+ {viewPresets.map((preset) => { + const active = searchParams.get('alias') === preset.alias; + return ( +
+
{preset.alias}
+ +
+ ); + })} +
); } diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsSection.module.scss b/apps/client/src/common/components/view-params-editor/ViewParamsSection.module.scss index 9a3a010e2..97c63d814 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsSection.module.scss +++ b/apps/client/src/common/components/view-params-editor/ViewParamsSection.module.scss @@ -1,48 +1,116 @@ .section { color: $ui-white; font-size: 1rem; + background-color: $white-2; + border: 1px solid $gray-1100; + border-radius: $panel-border-radius; } .sectionHeader { display: flex; justify-content: space-between; align-items: center; - height: 2rem; + gap: 0.5rem; + min-height: 2.75rem; + padding: 0.5rem 1rem; + + font-size: $aux-text-size; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: $gray-300; &.collapsible { cursor: pointer; + border-radius: $panel-border-radius; + transition-property: color, background-color; + transition-duration: $transition-time-action; + + &:hover { + background-color: $white-3; + color: $ui-white; + } + + &:focus-visible { + outline: 2px solid $blue-500; + outline-offset: -2px; + } } } +.sectionTitle { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +/** marks a section which holds values coming from the URL */ +.customised { + width: 0.4rem; + height: 0.4rem; + border-radius: 50%; + background-color: $active-indicator; + flex-shrink: 0; +} + +.chevron { + flex-shrink: 0; + rotate: 180deg; + transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005); + + &.closed { + rotate: 0deg; + } +} + +.options { + padding-inline: 1rem; + border-top: 1px solid $white-7; +} + .label { - margin-top: $section-spacing; + display: grid; + grid-template-columns: minmax(0, 1fr); + row-gap: 0.25rem; + padding: 0.75rem 0; font-size: $inner-section-text-size; - color: $label-gray; - display: flex; - flex-direction: column; - gap: 0.25rem; + + &:not(:last-child) { + border-bottom: 1px solid $white-7; + } +} + +/** + * Compact controls sit beside their label instead of below it. + * Placement is done with grid areas so that the markup stays flat. + */ +.inline { + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + column-gap: 1rem; + + > .title { + grid-area: 1 / 1; + } + + > .description { + grid-area: 2 / 1; + } + + > *:last-child { + grid-area: 1 / 2 / span 2; + } } .title { - font-size: $inner-section-text-size; - display: block; - width: 100%; + color: $ui-white; } .description { - font-size: $inner-section-text-size; - display: block; - color: $modal-note-color; -} - -.closed { - transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005); - rotate: 0deg; -} - -.open { - transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005); - rotate: 180deg; + color: $gray-400; + font-size: $aux-text-size; + line-height: 1.4; + max-width: 60ch; } .hidden { diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsSection.tsx b/apps/client/src/common/components/view-params-editor/ViewParamsSection.tsx index 28e5ea134..16a51da7e 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsSection.tsx +++ b/apps/client/src/common/components/view-params-editor/ViewParamsSection.tsx @@ -1,6 +1,8 @@ import { useLocalStorage } from '@mantine/hooks'; import { IoChevronDown } from 'react-icons/io5'; +import { useSearchParams } from 'react-router'; +import { isKeyEnter } from '../../utils/keyEvent'; import { cx } from '../../utils/styleUtils'; import { OptionTitle } from './constants'; import ParamInput from './ParamInput'; @@ -14,8 +16,19 @@ interface ViewParamsSectionProps { options: ParamField[]; } +/** Fields which are compact enough to share a row with their label */ +function isInlineField(option: ParamField): boolean { + return option.type === 'boolean' || option.type === 'colour'; +} + export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) { const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false }); + const [searchParams] = useSearchParams(); + + // hidden options are inputs without a UI, there is nothing to frame them with + if (title === OptionTitle.Hidden) { + return ; + } const handleCollapse = () => { if (collapsible) { @@ -23,34 +36,43 @@ export default function ViewParamsSection({ title, collapsible, options }: ViewP } }; + const isCollapsed = Boolean(collapsible && collapsed); + const isCustomised = options.some((option) => searchParams.has(option.id)); + return (
- {title === OptionTitle.Hidden ? ( - - ) : ( - <> -
- {title} - {collapsible && } -
- - - )} +
{ + if (isKeyEnter(event)) { + handleCollapse(); + } + }} + role={collapsible ? 'button' : undefined} + tabIndex={collapsible ? 0 : undefined} + aria-expanded={collapsible ? !isCollapsed : undefined} + > + + {title} + {isCustomised && } + + {collapsible && } +
+ {/* collapsed options stay mounted: the form reads its values from the DOM */} +
+ +
); } -interface SectionContentsProps { - options: ParamField[]; - collapsed: boolean; -} - -function SectionContents({ options, collapsed }: SectionContentsProps) { +function SectionContents({ options }: { options: ParamField[] }) { return ( <> {options.map((option) => { return ( -