From 358c66983966b55d6e0cb4985b9e544b8b90b93f Mon Sep 17 00:00:00 2001 From: Ivan Khokhlov Date: Tue, 21 Apr 2026 20:08:52 +0000 Subject: [PATCH 1/8] =?UTF-8?q?feat(frontend):=20Signal=20Route=20B1=20pil?= =?UTF-8?q?l=20toolbar=20=E2=80=94=20compact=20filter=20capsule=20across?= =?UTF-8?q?=20portal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the bulky Signal Route filter panel with a compact always-visible pill capsule (B1 design): LiveSwitch toggle + three pill selects with icons (project/experiment/run). Applies the same visual language to ExperimentsList and SensorsList filters and Login inputs. - Add LiveSwitch component (animated toggle, live pulse animation) - Add variant="pill" + icon prop to MaterialSelect (floating label, rounded border) - Add .filter-capsule, .filter-capsule__search, .login-input-field to components.scss - TelemetryViewer: capsule replaces radio + 3 selects, history controls stay collapsible - ExperimentsList: capsule with search + project + status + New button + export row - SensorsList: capsule with search + project + status, client-side name filter - Login: lg inputs with user/lock icons All tests pass (23/23, 2 skipped). Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/common/LiveSwitch.scss | 76 +++++++++++ .../src/components/common/LiveSwitch.tsx | 33 +++++ .../src/components/common/MaterialSelect.scss | 107 +++++++++++++++ .../src/components/common/MaterialSelect.tsx | 35 ++++- .../src/components/common/index.ts | 1 + .../src/pages/ExperimentsList.scss | 24 ++++ .../src/pages/ExperimentsList.tsx | 80 ++++-------- .../experiment-portal/src/pages/Login.scss | 4 + .../experiment-portal/src/pages/Login.tsx | 46 ++++--- .../src/pages/SensorsList.scss | 11 ++ .../src/pages/SensorsList.tsx | 92 +++++++------ .../src/pages/TelemetryViewer.scss | 10 +- .../src/pages/TelemetryViewer.tsx | 65 +++------- .../src/styles/components.scss | 122 ++++++++++++++++++ 14 files changed, 534 insertions(+), 172 deletions(-) create mode 100644 projects/frontend/apps/experiment-portal/src/components/common/LiveSwitch.scss create mode 100644 projects/frontend/apps/experiment-portal/src/components/common/LiveSwitch.tsx diff --git a/projects/frontend/apps/experiment-portal/src/components/common/LiveSwitch.scss b/projects/frontend/apps/experiment-portal/src/components/common/LiveSwitch.scss new file mode 100644 index 00000000..8f1f877e --- /dev/null +++ b/projects/frontend/apps/experiment-portal/src/components/common/LiveSwitch.scss @@ -0,0 +1,76 @@ +@import '../../styles/colors'; + +.liveswitch { + display: inline-flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 12px; + border-radius: var(--radius-pill); + border: 1px solid $input-border; + background: #fff; + user-select: none; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; + flex: 0 0 auto; + white-space: nowrap; + + &:hover { border-color: $outline-muted; } + + &:focus-visible { + outline: none; + box-shadow: var(--focus-ring); + border-color: $primary; + } +} + +.liveswitch__track { + position: relative; + width: 32px; + height: 18px; + border-radius: 999px; + background: $input-border; + transition: background 0.2s ease; + flex: none; +} + +.liveswitch__thumb { + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + background: #fff; + border-radius: 50%; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.2); + transition: transform 0.2s ease; +} + +.liveswitch--on .liveswitch__track { background: $primary; } +.liveswitch--on .liveswitch__thumb { transform: translateX(14px); } + +.liveswitch__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: $input-border; + flex: none; + transition: background 0.2s ease, box-shadow 0.2s ease; +} + +.liveswitch--on .liveswitch__dot { + background: #22c55e; + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); + animation: live-pulse 1.6s ease-in-out infinite; +} + +@keyframes live-pulse { + 0%, 100% { box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); } + 50% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.06); } +} + +.liveswitch__label { + font-size: 0.82rem; + font-weight: 600; + color: $text; +} diff --git a/projects/frontend/apps/experiment-portal/src/components/common/LiveSwitch.tsx b/projects/frontend/apps/experiment-portal/src/components/common/LiveSwitch.tsx new file mode 100644 index 00000000..0839156c --- /dev/null +++ b/projects/frontend/apps/experiment-portal/src/components/common/LiveSwitch.tsx @@ -0,0 +1,33 @@ +import './LiveSwitch.scss' + +interface LiveSwitchProps { + live: boolean + onChange: (live: boolean) => void +} + +function LiveSwitch({ live, onChange }: LiveSwitchProps) { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault() + onChange(!live) + } + } + return ( +
onChange(!live)} + onKeyDown={handleKeyDown} + > +
+
+
+ + {live ? 'Live' : 'History'} +
+ ) +} + +export default LiveSwitch diff --git a/projects/frontend/apps/experiment-portal/src/components/common/MaterialSelect.scss b/projects/frontend/apps/experiment-portal/src/components/common/MaterialSelect.scss index 0429382c..0fbf27f4 100644 --- a/projects/frontend/apps/experiment-portal/src/components/common/MaterialSelect.scss +++ b/projects/frontend/apps/experiment-portal/src/components/common/MaterialSelect.scss @@ -165,3 +165,110 @@ padding-bottom: 9px; font-size: 0.88rem; } + +// ── Pill variant (B1 capsule style) ── +.md-select--pill .md-select__label { + display: none; +} + +.md-select--pill .md-select__control { + min-height: 40px; + border-radius: var(--radius-pill); + border-color: $input-border; + background: #fff; +} + +.md-select--pill .md-select__control:hover { + border-color: $outline-muted; + background: #f8fafc; +} + +.md-select--pill .md-select__control--open { + border-color: $primary; + box-shadow: var(--focus-ring); + background: #fff; +} + +.md-select--pill .md-select__control--disabled { + opacity: 0.62; + background: $surface-variant; +} + +.md-select--pill .md-select__trigger { + position: relative; + min-height: 40px; + padding: 0 28px 0 12px; + font-size: 0.85rem; + border-radius: var(--radius-pill); + gap: 6px; +} + +.md-select--pill.md-select--has-icon .md-select__trigger { + padding-left: 10px; +} + +.md-select__pill-icon { + color: $text-secondary; + flex: none; + display: flex; + align-items: center; + transition: color 0.15s ease; +} + +.md-select--pill .md-select__control--open .md-select__pill-icon { + color: $primary; +} + +.md-select--pill .md-select__icon { + right: 10px; + width: 14px; + height: 14px; + color: $text-secondary; + transition: transform 0.2s ease, color 0.15s ease; +} + +.md-select--pill .md-select__control--open .md-select__icon { + color: $primary; +} + +.md-select__float-label { + position: absolute; + left: 36px; + top: 50%; + transform: translateY(-50%); + font-size: 0.85rem; + color: $text-secondary; + pointer-events: none; + background: #fff; + padding: 0 3px; + transition: top 0.15s ease, font-size 0.15s ease, color 0.15s ease, left 0.15s ease, transform 0.15s ease; + white-space: nowrap; +} + +.md-select--pill:not(.md-select--has-icon) .md-select__float-label { + left: 12px; +} + +.md-select__float-label.is-floating { + top: 0; + transform: translateY(-50%); + font-size: 0.63rem; + font-weight: 600; + letter-spacing: 0.04em; + color: $primary; + left: 10px; +} + +.md-select--pill .md-select__control--open .md-select__float-label { + top: 0; + transform: translateY(-50%); + font-size: 0.63rem; + font-weight: 600; + letter-spacing: 0.04em; + color: $primary; + left: 10px; +} + +.md-select--pill .md-select__value { + font-size: 0.85rem; +} diff --git a/projects/frontend/apps/experiment-portal/src/components/common/MaterialSelect.tsx b/projects/frontend/apps/experiment-portal/src/components/common/MaterialSelect.tsx index 1de64d98..d8a51742 100644 --- a/projects/frontend/apps/experiment-portal/src/components/common/MaterialSelect.tsx +++ b/projects/frontend/apps/experiment-portal/src/components/common/MaterialSelect.tsx @@ -27,6 +27,8 @@ type MaterialSelectProps = { multiple?: boolean size?: number placeholder?: string + icon?: ReactNode + variant?: 'default' | 'pill' } function MaterialSelect({ @@ -43,6 +45,8 @@ function MaterialSelect({ multiple = false, size, placeholder = '', + icon, + variant = 'default', }: MaterialSelectProps) { const [isOpen, setIsOpen] = useState(false) const menuRef = useRef(null) @@ -202,9 +206,19 @@ function MaterialSelect({ setIsOpen(false) } } + const isPill = variant === 'pill' + const rootClass = [ + 'md-select', + isPill ? 'md-select--pill' : '', + isPill && icon ? 'md-select--has-icon' : '', + className, + ] + .filter(Boolean) + .join(' ') + return ( -
- {label && ( +
+ {label && !isPill && ( @@ -233,17 +247,30 @@ function MaterialSelect({ id={id} ref={triggerRef} type="button" - className="md-select__trigger" + className={`md-select__trigger${isPill ? ' md-select__trigger--pill' : ''}`} onClick={toggleMenu} onKeyDown={handleTriggerKeyDown} disabled={isDisabled} aria-haspopup="listbox" aria-expanded={isOpen} aria-controls={listboxId} + aria-label={isPill && label ? label : undefined} > + {isPill && icon && ( + {icon} + )} - {selectedLabel || placeholder} + {selectedLabel || (variant !== 'pill' ? placeholder : '')} + {isPill && label && ( + + {label} + + )} * { + flex: 1 1 45%; + } } diff --git a/projects/frontend/apps/experiment-portal/src/pages/ExperimentsList.tsx b/projects/frontend/apps/experiment-portal/src/pages/ExperimentsList.tsx index f6c314fc..9c2c4e14 100644 --- a/projects/frontend/apps/experiment-portal/src/pages/ExperimentsList.tsx +++ b/projects/frontend/apps/experiment-portal/src/pages/ExperimentsList.tsx @@ -114,80 +114,39 @@ function ExperimentsList() { {!isBusy && !error && ( <> -
-
-
-
Command Filters
-

- Отберите нужный проект, статус или найдите эксперимент по названию и описанию. -

-
-
- - -
-
- -
-
- - +
+
+
+ { - setSearchQuery(e.target.value) - setPage(1) - }} + onChange={(e) => { setSearchQuery(e.target.value); setPage(1) }} disabled={isBusy} />
- { - setProjectId(id) - setActiveProjectId(id) - setPage(1) - }} + onChange={(id) => { setProjectId(id); setActiveProjectId(id); setPage(1) }} disabled={isBusy} + variant="pill" + icon={} > {projectsData?.projects?.map((project) => ( - + ))} - { - setStatus(value) - setPage(1) - }} + onChange={(value) => { setStatus(value); setPage(1) }} disabled={isBusy} + variant="pill" + icon={} > @@ -196,6 +155,21 @@ function ExperimentsList() { + +
+
+ +
diff --git a/projects/frontend/apps/experiment-portal/src/pages/Login.scss b/projects/frontend/apps/experiment-portal/src/pages/Login.scss index 5c365eff..57948e33 100644 --- a/projects/frontend/apps/experiment-portal/src/pages/Login.scss +++ b/projects/frontend/apps/experiment-portal/src/pages/Login.scss @@ -16,3 +16,7 @@ color: var(--text-secondary); font-size: 0.9rem; } + +.login-input-field input { + min-height: unset; +} diff --git a/projects/frontend/apps/experiment-portal/src/pages/Login.tsx b/projects/frontend/apps/experiment-portal/src/pages/Login.tsx index fdaf2631..5e39e548 100644 --- a/projects/frontend/apps/experiment-portal/src/pages/Login.tsx +++ b/projects/frontend/apps/experiment-portal/src/pages/Login.tsx @@ -102,30 +102,36 @@ function Login() {
- setFormData({ ...formData, username: e.target.value })} - required - autoComplete="username" - placeholder="Введите имя пользователя" - disabled={loginMutation.isPending} - /> +
+ + setFormData({ ...formData, username: e.target.value })} + required + autoComplete="username" + placeholder="Введите имя пользователя" + disabled={loginMutation.isPending} + /> +
- setFormData({ ...formData, password: e.target.value })} - required - autoComplete="current-password" - placeholder="Введите пароль" - disabled={loginMutation.isPending} - /> +
+ + setFormData({ ...formData, password: e.target.value })} + required + autoComplete="current-password" + placeholder="Введите пароль" + disabled={loginMutation.isPending} + /> +
-
-
-
- -
- - -
-
- +
+ setViewMode(live ? 'live' : 'history')} + /> } > {projectsData?.projects.map((project) => ( ))} - } > {experiments.map((experiment) => ( ))} - } > {runs.map((run) => ( ))} +
+
+
{canManageCaptureSession && (