Skip to content

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

FileActionResponsibility
src/composables/useScrubInput.tsCreateCore drag + keyboard logic, Options API compatible
src/styles/theme.cssModifyAdd --accent-primary-rgb token per theme
src/pages/Chatbots/components/OptionRow/OptionRow.vueModifyscrub prop, title ref, composable integration, CSS classes
src/components/common/InputField.vueModifyKeyboard modifier support + Enter/Escape on number inputs
~10 × *Section.vue files (Group A)ModifyAdd 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:

  1. Label shows ↔ cursor on hover
  2. Dashed underline appears on hover
  3. Dragging left/right changes the value
  4. Shift+drag = 10x step
  5. Alt+drag = 0.1x step
  6. 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):

  1. Press ↑ → value becomes 19
  2. Press ↓ → value becomes 18
  3. Press Shift+↑ → value becomes 28
  4. 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 scrub and 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):

  1. Open builder with a block that shows many sections (e.g. Form block or Text block)
  2. Verify label hover: ↔ cursor + dashed underline in accent color
  3. Verify label drag: accent color text + solid underline + value changes
  4. Verify keyboard modifiers: ↑/↓, Shift+↑/↓, Alt+↑/↓
  5. Verify focus ring: subtle inset glow on number inputs
  6. 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"

Internal documentation