diff --git a/projects/frontend/apps/experiment-portal/src/App.scss b/projects/frontend/apps/experiment-portal/src/App.scss
index 73272bed..38755ed3 100644
--- a/projects/frontend/apps/experiment-portal/src/App.scss
+++ b/projects/frontend/apps/experiment-portal/src/App.scss
@@ -46,7 +46,7 @@
min-height: 40px;
padding: 0 18px;
border: 1px solid var(--border-soft);
- border-radius: var(--radius-sm);
+ border-radius: var(--radius-pill);
background: #fff;
color: var(--text);
font-size: 0.875rem;
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..5c66036d
--- /dev/null
+++ b/projects/frontend/apps/experiment-portal/src/components/common/LiveSwitch.tsx
@@ -0,0 +1,35 @@
+import './LiveSwitch.scss'
+
+interface LiveSwitchProps {
+ live: boolean
+ onChange: (live: boolean) => void
+ labelOn?: string
+ labelOff?: string
+}
+
+function LiveSwitch({ live, onChange, labelOn = 'Live', labelOff = 'History' }: LiveSwitchProps) {
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === ' ' || e.key === 'Enter') {
+ e.preventDefault()
+ onChange(!live)
+ }
+ }
+ return (
+
onChange(!live)}
+ onKeyDown={handleKeyDown}
+ >
+
+
+
{live ? labelOn : labelOff}
+
+ )
+}
+
+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..d607ccfe 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}
+ {isPill ? (selectedValue ? selectedLabel : '') : (selectedLabel || placeholder)}
+ {isPill && label && (
+
+ {label}
+
+ )}