diff --git a/packages/ui/src/components/PianoRoll/SkiaPianoRollGrid.tsx b/packages/ui/src/components/PianoRoll/SkiaPianoRollGrid.tsx index 90eb74a..c6a6da6 100644 --- a/packages/ui/src/components/PianoRoll/SkiaPianoRollGrid.tsx +++ b/packages/ui/src/components/PianoRoll/SkiaPianoRollGrid.tsx @@ -39,7 +39,7 @@ import { Gesture, GestureDetector, } from 'react-native-gesture-handler'; -import { runOnJS, useSharedValue } from 'react-native-reanimated'; +import { runOnJS, useSharedValue, type SharedValue } from 'react-native-reanimated'; import { Text } from '../Text'; import { Icon, Icons } from '../SFSymbol'; import { useTheme, hexToRgba } from '../../theme'; @@ -91,6 +91,14 @@ export interface SkiaPianoRollGridProps { onZoomOut?: () => void; onZoomChange?: (zoom: number) => void; showNoteLabels?: boolean; + /** Active loop region (1-based, inclusive bars). Drawn with the track color + * over the looped bars inside the grid. null/undefined = no region. */ + loopRange?: { start: number; end: number } | null; + /** Playhead X in grid coordinates (px from grid origin). Rendered inside the + * canvas so it scrolls with the content and loops within the region. */ + playheadX?: SharedValue; + /** Playhead visibility (0 hidden, 1 shown) — driven on the UI thread. */ + playheadOpacity?: SharedValue; } export const SkiaPianoRollGrid = memo(function SkiaPianoRollGrid({ @@ -114,6 +122,9 @@ export const SkiaPianoRollGrid = memo(function SkiaPianoRollGrid({ onZoomOut, onZoomChange, showNoteLabels = false, + loopRange, + playheadX, + playheadOpacity, }: SkiaPianoRollGridProps) { const { colors } = useTheme(); const { width: screenWidth } = useWindowDimensions(); @@ -594,6 +605,39 @@ export const SkiaPianoRollGrid = memo(function SkiaPianoRollGrid({ ) )} + {/* Loop region — track-color highlight over the looped bars + * (grid coords, so it scrolls with the content and aligns with + * bar lines). */} + {loopRange && (() => { + const loopX = (loopRange.start - 1) * 4 * beatWidth; + const loopW = + (loopRange.end - loopRange.start + 1) * 4 * beatWidth; + return ( + <> + + + + + ); + })()} + {/* Notes — styled to match AudioKit PianoRoll */} {notes.map((note, idx) => { let pitchIdx: number; @@ -676,6 +720,19 @@ export const SkiaPianoRollGrid = memo(function SkiaPianoRollGrid({ ); })} + + {/* Playhead — grid coordinates, so it scrolls with the content + * and loops within the selected region. Driven on the UI thread. */} + {playheadX && ( + + )} {/* Touch overlay — gesture handler for tap/drag/resize */} diff --git a/packages/ui/src/features/playground/components/ClipEditor/ClipEditorView.tsx b/packages/ui/src/features/playground/components/ClipEditor/ClipEditorView.tsx index 0e83f4b..96fb60f 100644 --- a/packages/ui/src/features/playground/components/ClipEditor/ClipEditorView.tsx +++ b/packages/ui/src/features/playground/components/ClipEditor/ClipEditorView.tsx @@ -217,49 +217,13 @@ const getNoteName = (pitch: number): string => { }; // ─── Playhead ─────────────────────────────────────────────────────────────── - -/** - * Playhead driven by the sequencer clock. One source of truth. - * - * Playhead driven by the transport clock (DAW-style frame-pull model). - * - * The parent runs a requestAnimationFrame loop that reads the transport - * clock at display frame rate (~60fps) and writes the pixel position - * directly to a SharedValue. No React props updated at 60fps, no - * withTiming interpolation, no events from the audio engine. - * - * The playhead position IS the sequencer position — direct and accurate. - */ -const PlayheadLine = memo(function PlayheadLine({ - posX, - color, -}: { - /** Pixel X position driven by rAF loop reading the transport clock. */ - posX: Animated.SharedValue; - color: string; -}) { - const animStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: posX.value }], - })); - - return ( - - ); -}); +// +// The playhead is rendered inside the Skia piano-roll canvas (see +// SkiaPianoRollGrid `playheadX`/`playheadOpacity`) so it lives in grid +// coordinates — scrolling with the content and looping within the selected +// region. The parent runs a requestAnimationFrame loop that reads the transport +// clock at display frame rate (~60fps) and writes the grid-space pixel position +// directly to a SharedValue. No React props updated at 60fps. // ─── PianoRoll Grid ───────────────────────────────────────────────────────── @@ -531,20 +495,29 @@ const PianoRollGrid = memo(function PianoRollGrid({ // Orange bar: "— | 1 2 3 4 | +" /** - * ClipLengthBar — Matches iOS ClipLengthBar exactly. + * ClipLengthBar — bar strip with resize (±) and loop-region selection. * - * iOS layout: HStack(spacing:0) { minus | ForEach bars { barButton } | plus } - * - Minus/Plus: mcOrange icon on mcBlack bg, 34x34 - * - Active bars: mcOrange bg, mcBlack text (mcExtraSmallSemiBold) - * - Inactive bars: mcBlack3 bg, mcWhite3 text - * - Current playing bar: mcOrange2 bg with bottom accent line + * Layout: HStack(spacing:0) { minus | ForEach bars { barButton } | plus } + * - Minus/Plus: mcOrange icon on mcBlack bg, 34x34 — add/remove bars (resize). + * - Tap a bar → loop only that bar (isolate); tap the sole looped bar to clear. + * - Drag across bars → loop that contiguous region. + * - Selected loop region: mcOrange bg, mcBlack text. + * - Unselected bars: disabled mcBlack3 bg, mcWhite3 text. + * - No explicit loop region means the whole active clip is selected. + * - Current playing bar: bottom accent line. + * - Leading/trailing triangles mark the draggable loop handles. * - Height: 34 */ +type LoopRange = { start: number; end: number }; + interface ClipLengthBarProps { lengthInBars: number; activeLengthInBars: number; currentBarIndex?: number; - onSetActiveLength?: (barIndex: number) => void; + /** Active loop region (1-based, inclusive), or null when looping the whole clip. */ + loopRange?: LoopRange | null; + /** Set/clear the loop region. A single-bar range isolates that bar. */ + onSetLoopRange?: (range: LoopRange | null) => void; onDecrease?: () => void; onIncrease?: () => void; } @@ -556,7 +529,8 @@ const ClipLengthBar = memo(function ClipLengthBar({ lengthInBars, activeLengthInBars, currentBarIndex, - onSetActiveLength, + loopRange, + onSetLoopRange, onDecrease, onIncrease, }: ClipLengthBarProps) { @@ -566,9 +540,62 @@ const ClipLengthBar = memo(function ClipLengthBar({ const canAdd = barCount < 16; const canRemove = barCount > 1; + // Drag-to-select the loop region: capture the bar-row width on layout and + // map the gesture's x position to a 1-based bar index on the UI thread. + // Keep the initial touch x separately from Pan.onStart: onStart fires only + // after activeOffsetX, so left drags would otherwise anchor one bar too far + // left after the finger has already crossed the activation threshold. + const rowWidth = useSharedValue(0); + const dragTouchStartX = useSharedValue(0); + const dragStartBar = useSharedValue(0); + + const emitRange = useCallback( + (a: number, b: number) => { + onSetLoopRange?.({ start: Math.min(a, b), end: Math.max(a, b) }); + }, + [onSetLoopRange] + ); + + const handleBarTap = useCallback( + (barIndex: number) => { + const isSoleLoop = + loopRange != null && + loopRange.start === barIndex && + loopRange.end === barIndex; + onSetLoopRange?.(isSoleLoop ? null : { start: barIndex, end: barIndex }); + }, + [loopRange, onSetLoopRange] + ); + + // activeOffsetX makes the pan wait for horizontal movement, so simple taps + // still reach the per-bar Pressables (tap = isolate, drag = region). + const dragGesture = Gesture.Pan() + .activeOffsetX([-12, 12]) + .onBegin((e) => { + 'worklet'; + dragTouchStartX.value = e.x; + }) + .onStart(() => { + 'worklet'; + const w = rowWidth.value; + const idx = + w > 0 + ? Math.min(Math.max(1, Math.floor((dragTouchStartX.value / w) * barCount) + 1), barCount) + : 1; + dragStartBar.value = idx; + runOnJS(emitRange)(idx, idx); + }) + .onUpdate((e) => { + 'worklet'; + const w = rowWidth.value; + const idx = + w > 0 ? Math.min(Math.max(1, Math.floor((e.x / w) * barCount) + 1), barCount) : 1; + runOnJS(emitRange)(dragStartBar.value, idx); + }); + return ( - {/* Minus button */} + {/* Minus button — resize (remove bar) */} - {/* Bar buttons */} - - {Array.from({ length: barCount }, (_, i) => { - const barIndex = i + 1; - const isActive = barIndex <= activeCount; - const isCurrent = currentBarIndex === barIndex; - return ( - onSetActiveLength?.(barIndex)} - style={[ - styles.clipLengthNum, - { - backgroundColor: isCurrent - ? colors.mcOrange2 - : isActive - ? colors.mcOrange - : colors.mcBlack3, - }, - ]} - > - + { + rowWidth.value = e.nativeEvent.layout.width; + }} + > + {Array.from({ length: barCount }, (_, i) => { + const barIndex = i + 1; + const isCurrent = currentBarIndex === barIndex; + const selectedStart = loopRange?.start ?? 1; + const selectedEnd = loopRange?.end ?? activeCount; + const isLooped = + barIndex >= selectedStart && + barIndex <= selectedEnd && + barIndex <= activeCount; + const isExplicitLoop = loopRange != null; + const isLoopStart = isExplicitLoop && barIndex === selectedStart; + const isLoopEnd = isExplicitLoop && barIndex === selectedEnd; + const backgroundColor = isLooped ? colors.mcOrange : colors.mcBlack3; + return ( + handleBarTap(barIndex)} + style={[styles.clipLengthNum, { backgroundColor }]} + accessibilityLabel={ + isExplicitLoop && isLooped + ? `Bar ${barIndex}, looping` + : `Loop bar ${barIndex}` + } > - {barIndex} - - {isCurrent && ( - - )} - - ); - })} - + {isLoopStart && ( + + )} + {isLoopEnd && ( + + )} + + {barIndex} + + {isCurrent && ( + + )} + + ); + })} + + - {/* Plus button */} + {/* Plus button — resize (add bar) */} void; onClipLengthDecrease?: () => void; onClipLengthSet?: (bars: number) => void; + /** Active loop region (1-based, inclusive bars), or null to loop the whole clip. */ + loopRange?: { start: number; end: number } | null; + /** Set/clear the loop region. A single-bar range isolates that bar. */ + onSetLoopRange?: (range: { start: number; end: number } | null) => void; onShowSettings?: () => void; /** Recording count-in remaining (3, 2, 1, null) */ recordingCountIn?: number | null; @@ -906,7 +967,8 @@ export const ClipEditorView = memo(function ClipEditorView({ onToggleMetronome, onClipLengthIncrease, onClipLengthDecrease, - onClipLengthSet, + loopRange, + onSetLoopRange, recordingCountIn, tempo = 120, showPianoNoteNames = false, @@ -929,27 +991,37 @@ export const ClipEditorView = memo(function ClipEditorView({ // The playhead reads the transport clock at display frame rate via rAF, // writing the pixel position directly to a SharedValue. No React props // at 60fps, no withTiming interpolation, no audio engine events. - const clipBeats = clip.activeLengthInBars * 4; + // When a loop region is active the engine loops only that span, so the + // transport beat runs [0, loopBeats). Offset the playhead by the region's + // start bar so it sweeps the looped bars on the full piano roll (rather than + // restarting at bar 1). With no region it sweeps the whole clip. + const loopStartBeat = loopRange ? (loopRange.start - 1) * 4 : 0; + const loopBeats = loopRange + ? (loopRange.end - loopRange.start + 1) * 4 + : clip.activeLengthInBars * 4; const playheadPosX = useSharedValue(0); + const playheadOpacity = useSharedValue(0); useEffect(() => { if (!isPlaying || !getBeatPosition) { - playheadPosX.value = 0; + playheadOpacity.value = 0; + playheadPosX.value = loopStartBeat * beatWidth; return; } + playheadOpacity.value = 1; let rafId: number; const tick = () => { const beat = getBeatPosition(); - const wrapped = clipBeats > 0 ? beat % clipBeats : 0; - playheadPosX.value = wrapped * beatWidth; + const wrapped = loopBeats > 0 ? beat % loopBeats : 0; + playheadPosX.value = (loopStartBeat + wrapped) * beatWidth; rafId = requestAnimationFrame(tick); }; rafId = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafId); - // eslint-disable-next-line react-hooks/exhaustive-deps -- playheadPosX is a stable SharedValue ref - }, [isPlaying, getBeatPosition, clipBeats, beatWidth]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- playhead SharedValues are stable refs + }, [isPlaying, getBeatPosition, loopStartBeat, loopBeats, beatWidth]); // iOS: PerformanceControlsView visible when config.isPerformanceControlsVisible && !isExpanded const shouldShowPerformanceControls = @@ -994,6 +1066,9 @@ export const ClipEditorView = memo(function ClipEditorView({ isExpanded={isExpanded} selectedPitchIndex={selectedPitchIndex} melodicMinPitch={melodicMinPitch} + loopRange={loopRange} + playheadX={playheadPosX} + playheadOpacity={playheadOpacity} onNotePress={(idx) => callbacks?.onNoteDelete?.(idx)} onNoteResize={(idx, newDuration) => callbacks?.onNoteResize?.(idx, newDuration) @@ -1020,7 +1095,6 @@ export const ClipEditorView = memo(function ClipEditorView({ onZoomChange={(z) => setZoom(Math.max(1, Math.min(3, z)))} showNoteLabels={showPianoNoteNames} /> - @@ -1029,9 +1103,10 @@ export const ClipEditorView = memo(function ClipEditorView({ )} @@ -1250,6 +1325,30 @@ const styles = StyleSheet.create({ right: 0, height: 2, }, + loopStartHandle: { + position: 'absolute', + left: 3, + top: 11, + width: 0, + height: 0, + borderTopWidth: 6, + borderBottomWidth: 6, + borderLeftWidth: 8, + borderTopColor: 'transparent', + borderBottomColor: 'transparent', + }, + loopEndHandle: { + position: 'absolute', + right: 3, + top: 11, + width: 0, + height: 0, + borderTopWidth: 6, + borderBottomWidth: 6, + borderRightWidth: 8, + borderTopColor: 'transparent', + borderBottomColor: 'transparent', + }, // Velocity lane velocityLane: { flex: 1, borderTopWidth: 1 }, diff --git a/packages/ui/src/features/playground/components/ClipEditor/__tests__/ClipEditorView.test.tsx b/packages/ui/src/features/playground/components/ClipEditor/__tests__/ClipEditorView.test.tsx index a4dead0..453c057 100644 --- a/packages/ui/src/features/playground/components/ClipEditor/__tests__/ClipEditorView.test.tsx +++ b/packages/ui/src/features/playground/components/ClipEditor/__tests__/ClipEditorView.test.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; -import { ThemeProvider } from '../../../../../theme'; -import { ClipEditorView } from '../ClipEditorView'; +import { StyleSheet } from 'react-native'; +import { ThemeProvider, palette } from '../../../../../theme'; +import { ClipEditorView, ClipLengthBar } from '../ClipEditorView'; import { createMockDrumClip, createMockMelodyClip, @@ -58,6 +59,41 @@ describe('ClipEditorView snapshots', () => { }); }); +describe('ClipLengthBar', () => { + it('renders the selected loop region orange and unselected bars disabled', () => { + const { getByLabelText } = renderWithTheme( + + ); + + const bar1Style = StyleSheet.flatten(getByLabelText('Loop bar 1').props.style); + const bar2Style = StyleSheet.flatten(getByLabelText('Bar 2, looping').props.style); + const bar3Style = StyleSheet.flatten(getByLabelText('Bar 3, looping').props.style); + const bar4Style = StyleSheet.flatten(getByLabelText('Loop bar 4').props.style); + + expect(bar1Style.backgroundColor).toBe(palette.mcBlack3); + expect(bar2Style.backgroundColor).toBe(palette.mcOrange); + expect(bar3Style.backgroundColor).toBe(palette.mcOrange); + expect(bar4Style.backgroundColor).toBe(palette.mcBlack3); + }); + + it('shows leading and trailing loop handle affordances', () => { + const { getByLabelText } = renderWithTheme( + + ); + + expect(getByLabelText('Loop region start handle')).toBeTruthy(); + expect(getByLabelText('Loop region end handle')).toBeTruthy(); + }); +}); + describe('ClipEditorView interactions', () => { it('calls onClose when back is pressed', () => { const onClose = jest.fn(); diff --git a/packages/ui/src/features/playground/components/ClipEditor/__tests__/__snapshots__/ClipEditorView.test.tsx.snap b/packages/ui/src/features/playground/components/ClipEditor/__tests__/__snapshots__/ClipEditorView.test.tsx.snap index b8bbb69..8ce71e9 100644 --- a/packages/ui/src/features/playground/components/ClipEditor/__tests__/__snapshots__/ClipEditorView.test.tsx.snap +++ b/packages/ui/src/features/playground/components/ClipEditor/__tests__/__snapshots__/ClipEditorView.test.tsx.snap @@ -351,14 +351,18 @@ exports[`ClipEditorView snapshots matches snapshot when playing 1`] = ` - - - + } + > + - - Note 11 - - - + Note 11 + + + - - Note 10 - - - + Note 10 + + + - - Note 9 - - - + Note 9 + + + - - Note 8 - - - + Note 8 + + + - - Note 7 - - - + Note 7 + + + - - Note 6 - - - + Note 6 + + + - - Note 5 - - - + Note 5 + + + - - Note 4 - - - + Note 4 + + + - - Note 3 - - - + Note 3 + + + - - Note 2 - - - + Note 2 + + + - - Note 1 - - - + Note 1 + + + - - Note 0 - - - - - - + Note 0 + + + + + } + > + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - + - - - - - - - + > + + + - - - - 100 - % - - - + + + + + + 100 + % + + + - - + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + { + "alignItems": "center", + "backgroundColor": "rgba(0,0,0,0.6)", + "borderRadius": 8, + "height": 36, + "justifyContent": "center", + "width": 36, + } + } + > + + + - - - - + } + > + - - FX - - - + FX + + + - - Perc 2 - - - + Perc 2 + + + - - Perc 1 - - - + Perc 1 + + + - - Shaker - - - + Shaker + + + - - Cowbell - - - + Cowbell + + + - - Rim - - - + Rim + + + - - Ride - - - + Ride + + + - - Cymbal - - - + Cymbal + + + - - Tom High - - - + Tom High + + + - - Tom Mid - - - + Tom Mid + + + - - Tom Low - - - + Tom Low + + + - - Clap - - - + Clap + + + - - Open HH - - - + Open HH + + + - - Closed HH - - - + Closed HH + + + - - Snare - - - + Snare + + + - - Kick - - - - - - + Kick + + + + + } + > + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - + - - - - - - - + > + + + - - - - 100 - % - - - + + + + + + 100 + % + + + - - + accessibilityValue={ + { + "max": undefined, + "min": undefined, + "now": undefined, + "text": undefined, + } + } + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + { + "alignItems": "center", + "backgroundColor": "rgba(0,0,0,0.6)", + "borderRadius": 8, + "height": 36, + "justifyContent": "center", + "width": 36, + } + } + > + + + - - - - + } + > + - - B6 - - - + B6 + + + - - A#6 - - - + A#6 + + + - - A6 - - - + A6 + + + - - G#6 - - - + G#6 + + + - - G6 - - - + G6 + + + - - F#6 - - - + F#6 + + + - - F6 - - - + F6 + + + - - E6 - - - + E6 + + + - - D#6 - - - + D#6 + + + - - D6 - - - + D6 + + + - - C#6 - - - + C#6 + + + - - C6 - - - + C6 + + + - - B5 - - - + B5 + + + - - A#5 - - - + A#5 + + + - - A5 - - - + A5 + + + - - G#5 - - - + G#5 + + + - - G5 - - - + G5 + + + - - F#5 - - - + F#5 + + + - - F5 - - - + F5 + + + - - E5 - - - + E5 + + + - - D#5 - - - + D#5 + + + - - D5 - - - + D5 + + + - - C#5 - - - + C#5 + + + - - C5 - - - - - - + C5 + + + + + } + > + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - + + - - - + - - - - - - - + > + + + - - - - 100 - % - - - + + + + + + 100 + % + + + - - + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + { + "alignItems": "center", + "backgroundColor": "rgba(0,0,0,0.6)", + "borderRadius": 8, + "height": 36, + "justifyContent": "center", + "width": 36, + } + } + > + + + - - - - - - - - - - - Note 11 - - - - - Note 10 - - - - - Note 9 - - - - - Note 8 - - - - - Note 7 - - - - - Note 6 - - - - - Note 5 - - - - - Note 4 - - - - - Note 3 - - - - - Note 2 - - - + + + + + + - - Note 1 - - + } + > - - Note 0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + Note 11 + + + + - - + Note 10 + + + + - + Note 9 + + + + - - - - + Note 8 + + + + - - + Note 7 + + + + - - - - + Note 6 + + + + - - + Note 5 + + + + - - - - + Note 4 + + + + - - + Note 3 + + + + - - - - + Note 2 + + + + - - + Note 1 + + + + - + "fontFamily": "System", + "fontSize": 8, + "fontWeight": "400", + "lineHeight": 12, + }, + { + "color": "#000000", + }, + { + "fontSize": 10, + "textAlign": "center", + }, + ] + } + > + Note 0 + + + + + + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - - - + - - - - - - - + > + + + - - - - 100 - % - - - + + + + + + 100 + % + + + - - + accessible={true} + collapsable={false} + focusable={true} + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + { + "alignItems": "center", + "backgroundColor": "rgba(0,0,0,0.6)", + "borderRadius": 8, + "height": 36, + "justifyContent": "center", + "width": 36, + } + } + > + + + -