Appearance
Figma-like Inputs — Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add Figma-style drag-scrubbing on labels and keyboard modifiers to number inputs in the builder sidebar.
Architecture: A useScrubInput composable provides drag-tracking and keyboard handlers. OptionRow integrates the composable via a scrub prop — attaches drag behavior to the title element and emits value changes through existing update:value event. InputField adds keyboard modifier support (↑/↓ with Shift/Alt) to all number inputs. Section components opt in by adding scrub to their OptionRow.
Tech Stack: Vue 2 Options API, CSS custom properties, TypeScript
File Structure
| File | Action | Responsibility |
|---|---|---|
src/composables/useScrubInput.ts | Create | Core drag + keyboard logic, Options API compatible |
src/styles/theme.css | Modify | Add --accent-primary-rgb token per theme |
src/pages/Chatbots/components/OptionRow/OptionRow.vue | Modify | scrub prop, title ref, composable integration, CSS classes |
src/components/common/InputField.vue | Modify | Keyboard modifier support + Enter/Escape on number inputs |
~10 × *Section.vue files (Group A) | Modify | Add scrub + :value + @update:value to OptionRow |
Not in scope for initial rollout (Group B — multi-input sections): PaddingSection, MarginSection, PositionSection, TranslateSection, BoxShadowSection — these have multiple number inputs in a single OptionRow. Scrubbing requires one label per value, so these need restructuring before scrub can work. FilterSection has nested OptionRows — needs scrub on inner rows, not the parent.
Note: axis option from spec is intentionally omitted — only horizontal scrubbing is needed. requestAnimationFrame throttling is omitted — the math is trivial and direct mousemove updates are smooth enough.
Chunk 1: Foundation
Task 1: Add --accent-primary-rgb token to all themes
Files:
Modify:
src/styles/theme.css[ ] Step 1: Add RGB token to each theme block
Add --accent-primary-rgb after --accent-primary in each of the 5 theme blocks:
css
/* [data-theme="dark"] — after --accent-primary: #a5b4fc; */
--accent-primary-rgb: 165, 180, 252;
/* [data-theme="light"] — after --accent-primary */
--accent-primary-rgb: 11, 132, 194;
/* [data-theme="cavai"] — after --accent-primary: #e8606a; */
--accent-primary-rgb: 232, 96, 106;
/* [data-theme="nord"] — after --accent-primary: #88c0d0; */
--accent-primary-rgb: 136, 192, 208;
/* [data-theme="mocha"] — after --accent-primary: #cba6f7; */
--accent-primary-rgb: 203, 166, 247;- [ ] Step 2: Verify token resolves
Run: npm run dev, open browser console, run:
js
getComputedStyle(document.documentElement).getPropertyValue('--accent-primary-rgb')Expected: returns the RGB values for the current theme.
- [ ] Step 3: Commit
bash
git add src/styles/theme.css
git commit -m "feat(theme): add --accent-primary-rgb token for rgba() usage in scrub styles"Task 2: Create useScrubInput.ts composable
Files:
- Create:
src/composables/useScrubInput.ts
This composable follows the same module pattern as useTheme.ts but is per-instance (not singleton). It works with Options API — returns an object with methods the component calls from mounted() and beforeDestroy().
- [ ] Step 1: Create the composable file
typescript
import Vue from 'vue'
export interface ScrubInputOptions {
/** Reactive getter for current numeric value */
getValue: () => number
/** Callback when value changes */
onChange: (value: number) => void
/** Minimum allowed value */
min?: number
/** Maximum allowed value */
max?: number
/** Base step for ↑/↓ and drag (default: 1) */
step?: number
/** Step when Shift is held (default: 10) */
shiftStep?: number
/** Step when Alt/Option is held (default: 0.1) */
altStep?: number
/** Pixels of mouse movement per step (default: 1) */
sensitivity?: number
}
export function useScrubInput(options: ScrubInputOptions) {
const step = options.step ?? 1
const shiftStep = options.shiftStep ?? 10
const altStep = options.altStep ?? 0.1
const sensitivity = options.sensitivity ?? 1
const state = Vue.observable({
isScrubbing: false,
})
let labelEl: HTMLElement | null = null
let startX = 0
let startValue = 0
function clamp(value: number): number {
let v = value
if (options.min != null) v = Math.max(options.min, v)
if (options.max != null) v = Math.min(options.max, v)
return v
}
function getStep(e: MouseEvent | KeyboardEvent): number {
if (e.shiftKey) return shiftStep
if (e.altKey) return altStep
return step
}
function roundToStep(value: number, currentStep: number): number {
return Math.round(value / currentStep) * currentStep
}
// --- Drag handlers ---
function onMousedown(e: MouseEvent): void {
if (e.button !== 0) return
e.preventDefault()
startX = e.clientX
startValue = options.getValue()
state.isScrubbing = true
document.addEventListener('mousemove', onMousemove)
document.addEventListener('mouseup', onMouseup)
document.body.style.cursor = 'ew-resize'
document.body.style.userSelect = 'none'
}
function onMousemove(e: MouseEvent): void {
const dx = e.clientX - startX
const currentStep = getStep(e)
const steps = Math.round(dx / Math.max(sensitivity, 0.5))
const rawValue = startValue + steps * currentStep
const newValue = clamp(roundToStep(rawValue, currentStep))
if (newValue !== options.getValue()) {
options.onChange(newValue)
}
}
function onMouseup(): void {
state.isScrubbing = false
document.removeEventListener('mousemove', onMousemove)
document.removeEventListener('mouseup', onMouseup)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
// --- Keyboard handler ---
function handleKeydown(e: KeyboardEvent): void {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
e.preventDefault()
const direction = e.key === 'ArrowUp' ? 1 : -1
const currentStep = getStep(e)
const currentValue = options.getValue()
const newValue = clamp(
roundToStep(currentValue + direction * currentStep, currentStep)
)
options.onChange(newValue)
}
// --- Lifecycle ---
function attachLabel(el: HTMLElement): void {
labelEl = el
labelEl.addEventListener('mousedown', onMousedown)
}
function detachLabel(): void {
if (labelEl) {
labelEl.removeEventListener('mousedown', onMousedown)
labelEl = null
}
// Cleanup in case component is destroyed mid-drag
document.removeEventListener('mousemove', onMousemove)
document.removeEventListener('mouseup', onMouseup)
document.body.style.cursor = ''
document.body.style.userSelect = ''
state.isScrubbing = false
}
return {
state,
attachLabel,
detachLabel,
handleKeydown,
}
}- [ ] Step 2: Verify file compiles
Run: npx tsc --noEmit src/composables/useScrubInput.ts or just check that npm run dev starts without errors.
- [ ] Step 3: Commit
bash
git add src/composables/useScrubInput.ts
git commit -m "feat: add useScrubInput composable for drag-scrubbing and keyboard modifiers"Task 3: Add scrub CSS classes
Files:
- Modify:
src/styles/theme.css(already modified in Task 1) - The scrub-specific CSS will go in OptionRow's scoped
<style>in Task 4 — no separate file needed here.
This task adds the InputField focus-state polish that applies globally to number inputs (subtler inset ring instead of the current thick border).
- [ ] Step 1: Update InputField focus style for number inputs
In src/components/common/InputField.vue, find the existing focus style in the scoped <style> section. The current .selection-rect class applies a blue inset border on focus. Add a refined focus style for number inputs:
scss
// After the existing .selection-rect styles
&.number-input {
::v-deep .v-input--is-focused .v-input__slot {
box-shadow: 0 0 0 1.5px rgba(var(--accent-primary-rgb), 0.45) inset;
border-bottom-color: transparent;
}
}- [ ] Step 2: Verify number input focus styling
Run: npm run dev, open builder, click a number input (e.g. Size). Should show subtle inset glow instead of the previous border.
- [ ] Step 3: Commit
bash
git add src/components/common/InputField.vue
git commit -m "style: refine number input focus ring to subtle inset glow"Chunk 2: Component Integration
Task 4: Integrate scrub into OptionRow.vue
Files:
- Modify:
src/pages/Chatbots/components/OptionRow/OptionRow.vue
This is the main integration point. OptionRow gets a scrub prop, attaches drag behavior to its title element, and emits value changes.
- [ ] Step 1: Add scrub prop and import composable
Add to the <script> section:
javascript
import { useScrubInput } from '@/composables/useScrubInput'Add to props:
javascript
scrub: {
type: Boolean,
default: false,
},
scrubStep: {
type: Number,
default: 1,
},
scrubMin: {
type: Number,
default: undefined,
},
scrubMax: {
type: Number,
default: undefined,
},Add to data:
javascript
scrubHandler: null,- [ ] Step 2: Add ref on title element
In the template, find the <span> that renders inside .title-section (around line 41-54). Add a ref:
html
<span
ref="titleLabel"
:class="{
'overflow-protected': inputVisible && !noInput,
'has-info': hasInfoTooltip,
'option-row-title--scrub': scrub,
'is-scrubbing': scrubHandler && scrubHandler.state.isScrubbing,
}"
>- [ ] Step 3: Add mounted/beforeDestroy hooks
If mounted already exists, add to it. Otherwise create:
javascript
mounted() {
if (this.scrub && this.$refs.titleLabel) {
this.scrubHandler = useScrubInput({
getValue: () => Number(this.value) || 0,
onChange: (v) => {
this.$emit('update:value', v)
this.$emit('onChange')
},
min: this.scrubMin,
max: this.scrubMax,
step: this.scrubStep,
})
this.scrubHandler.attachLabel(this.$refs.titleLabel)
}
},
beforeDestroy() {
if (this.scrubHandler) {
this.scrubHandler.detachLabel()
}
},- [ ] Step 4: Add scrub CSS classes in scoped style
scss
.option-row-title--scrub {
cursor: ew-resize;
transition: color 120ms ease, border-bottom-color 120ms ease;
border-bottom: 1px dashed transparent;
padding-bottom: 1px;
&:hover {
color: var(--text-secondary);
border-bottom-color: rgba(var(--accent-primary-rgb), 0.35);
}
&.is-scrubbing {
color: var(--accent-primary);
border-bottom-color: rgba(var(--accent-primary-rgb), 0.50);
border-bottom-style: solid;
transition: none;
}
}
// Highlight the input value during label drag
.option-row.is-scrubbing-active {
::v-deep .input-field input {
color: var(--accent-primary);
}
::v-deep .input-field .v-input__slot {
outline: 1px solid rgba(var(--accent-primary-rgb), 0.3);
outline-offset: -1px;
}
}Also add is-scrubbing-active class to the OptionRow root element (the CardSection wrapper):
html
:class="{ 'is-scrubbing-active': scrubHandler && scrubHandler.state.isScrubbing }"- [ ] Step 5: Manual test
Run: npm run dev, open builder, find a Section that already passes :value to OptionRow. Temporarily add scrub to one OptionRow in a Section component (e.g. GapSection). Verify:
- Label shows ↔ cursor on hover
- Dashed underline appears on hover
- Dragging left/right changes the value
- Shift+drag = 10x step
- Alt+drag = 0.1x step
- Label turns accent color during drag
- [ ] Step 6: Commit
bash
git add src/pages/Chatbots/components/OptionRow/OptionRow.vue
git commit -m "feat: integrate useScrubInput into OptionRow with scrub prop"Task 5: Add keyboard modifiers to InputField.vue
Files:
- Modify:
src/components/common/InputField.vue
Keyboard modifiers (↑/↓ with Shift/Alt) activate on all number inputs automatically — no opt-in needed.
- [ ] Step 1: Add keydown handler
Add a method:
javascript
onKeydown(e) {
if (this.inputType !== 'number') return
// Enter → confirm and blur
if (e.key === 'Enter') {
e.target.blur()
return
}
// Escape → revert to value prop and blur
if (e.key === 'Escape') {
this.internalValue = this.value
this.$nextTick(() => e.target.blur())
return
}
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
e.preventDefault()
const direction = e.key === 'ArrowUp' ? 1 : -1
let step = 1
if (e.shiftKey) step = 10
if (e.altKey) step = 0.1
const current = Number(this.internalValue) || 0
let newValue = current + direction * step
// Round to avoid floating point artifacts
newValue = Math.round(newValue * 1000) / 1000
if (this.min !== '' && this.min != null) {
newValue = Math.max(Number(this.min), newValue)
}
if (this.max !== '' && this.max != null) {
newValue = Math.min(Number(this.max), newValue)
}
this.internalValue = newValue
this.emitChange('input', newValue)
},Known issue: emitChange uses value || fallbackValue which treats 0 as falsy. If scrubbing/stepping through zero causes the value to snap to fallback, fix emitChange to use value ?? fallbackValue or a strict null check. This is a pre-existing bug but scrub makes it more visible.
- [ ] Step 2: Bind keydown on v-text-field
Add @keydown="onKeydown" to the <v-text-field> element (around line 22-50), alongside the existing @input, @change, @focus, @blur handlers:
html
@keydown="onKeydown"- [ ] Step 3: Manual test
In builder, click a number input (e.g. Size = 18px):
- Press ↑ → value becomes 19
- Press ↓ → value becomes 18
- Press Shift+↑ → value becomes 28
- Press Alt+↑ → value becomes 28.1
- [ ] Step 4: Commit
bash
git add src/components/common/InputField.vue
git commit -m "feat: add keyboard modifiers (↑/↓ with Shift/Alt) to number inputs"Chunk 3: Section Rollout
Task 6: Enable scrub on Section components
Files to modify (all in src/pages/Chatbots/components/BuilderVisuals/Configuration/components/):
Group A — single-value sections (one OptionRow, one number input): Each needs scrub on its OptionRow, plus ensuring :value is passed and @update:value is handled.
- SizeSection.vue
- AdvancedSizeSection.vue
- MaxSizeSection.vue
- GapSection.vue
- RotationSection.vue
- DelaySection.vue
- DurationSection.vue
- SpeedSection.vue
- ScaleSection.vue
- IterationsSection.vue
- PlaybackSection.vue
Group B — deferred (multi-input or nested structure): These have multiple number inputs in a single OptionRow or complex nesting. Scrub requires one label per value, so these need restructuring before scrub can work. Skip for initial rollout.
PaddingSection.vue (4 inputs: top/right/bottom/left in one OptionRow)
MarginSection.vue (4 inputs in one OptionRow)
PositionSection.vue (4 inputs in one OptionRow)
TranslateSection.vue (multiple inputs in one OptionRow)
BoxShadowSection.vue (angle/distance/blur/spread, no wrapping OptionRows)
FilterSection.vue (nested OptionRows — would need scrub on each inner row)
[ ] Step 1: For each Group A section, add
scruband ensure value flow
For each file, check the OptionRow usage:
If section uses v-bind="overrideProps" + v-on="overrideHandlers": First check if overrideProps includes value (from sectionLogic mixin). If not, add :value explicitly:
html
<OptionRow v-bind="overrideProps" v-on="overrideHandlers" scrub :value="value">And add a handler for scrub-initiated value changes:
html
@update:value="$emit('update:value', $event)"If section manages value directly (e.g. GapSection):
html
<OptionRow :title="..." scrub :value="numericValue" @update:value="onScrubChange">
<MultiPostfixInput :value="numericValue" @input="onInput" />
</OptionRow>Add method:
javascript
onScrubChange(newValue) {
this.onInput({ value: newValue, postfix: this.postfix })
}If section uses InputField directly (e.g. DelaySection):
html
<OptionRow v-bind="overrideProps" v-on="overrideHandlers" scrub :value="delay">
<InputField :value="delay" number-input @input="..." />
</OptionRow>- [ ] Step 2: Read each file before modifying
For each Section component, read the template to understand which pattern it uses. Don't assume — verify the value prop and emit pattern before adding scrub.
- [ ] Step 3: Commit
bash
git add src/pages/Chatbots/components/BuilderVisuals/Configuration/components/
git commit -m "feat: enable scrub on Group A single-value Section components"Task 7: Visual QA and final polish
- [ ] Step 1: Test in all 5 themes
For each theme (dark, light, cavai, nord, mocha):
- Open builder with a block that shows many sections (e.g. Form block or Text block)
- Verify label hover: ↔ cursor + dashed underline in accent color
- Verify label drag: accent color text + solid underline + value changes
- Verify keyboard modifiers: ↑/↓, Shift+↑/↓, Alt+↑/↓
- Verify focus ring: subtle inset glow on number inputs
- Check that non-number inputs (text, textarea, dropdowns) are unaffected
- [ ] Step 2: Fix any visual issues found
Common issues to watch for:
Label underline color not visible in light themes (check contrast)
Cursor not resetting after drag ends
Value jumping on first drag pixel (startValue issue)
Floating point artifacts in displayed value
[ ] Step 3: Update theming-reference.md
Add a section about scrub input tokens and classes to docs/theming-reference.md.
- [ ] Step 4: Final commit
bash
git add -A
git commit -m "fix: visual QA fixes for scrub input across themes"