Imported from GuiGPaP/TD_starter_pack (
.claude/skills/td-sketch-ui/SKILL.md). Install upstream withnpx skills add GuiGPaP/TD_starter_pack --skill td-sketch-ui. Copyright stays with the author.
TouchDesigner Sketch-to-UI
Cache rule: If you already loaded this skill or read a reference file in the current conversation, do NOT re-read it. Use your memory of the content.
Execution mode rule: Default to
safe-writeforexecute_python_scriptwhen configuring widgets. Only usefull-execwhen destroying operators. Useread-onlyfor introspection.
Post-build rule: After building ANY UI, run
scan_network_errorson the root container. Must be 0 errors before reporting success. If errors are cached,cook(force=True)on affected ops and rescan.
Prerequisites
- TouchDesigner running and connected via MCP
- Palette indexed (
index_palette— cached after first run)
Skill Routing
For specialized work within the UI, use the appropriate skill:
| Task | Skill |
|---|---|
| GLSL shaders for widget backgrounds or visual effects | td-glsl |
| Python extensions on widgets (callbacks, custom logic) | td-python |
| General TD network creation, operator wiring | td-guide |
Reference Files
Load these as needed — do NOT re-read if already loaded in this conversation:
| Reference | Content | Load |
|---|---|---|
| Widget catalog | All 55+ Palette widgets with names, params, sizes | @references/widget-catalog.md |
| Layout patterns | 6 reusable layout patterns with code | @references/layout-patterns.md |
UI Design Principles
Resolution and root container
- Always create the root container at the target output resolution (e.g., 1920x1080, 1280x720)
- If the sketch doesn't specify, ask the user for the target resolution before building
- Use
par.w/par.hon the root to match the target
Separation: UI / Logic / Render
Keep these in separate containers — never mix UI widgets with scene rendering:
/project1/
├── ui_root/ ← Panel widgets (this skill builds this)
├── logic/ ← CHOP/DAT control, routing, mapping
└── scene/ ← TOPs/COMPs for rendering
The UI exports clean Panel CHOP values. The scene reads those values. Neither depends on the other's internal structure.
Naming conventions
ui_prefix for UI containers:ui_mixer,ui_padsbtn_for buttons,slider_for sliders,knob_for knobs- Descriptive names:
slider_speed,btn_reset— neverslider1,button2 - Tab pages:
page_mixer,page_pads
Visual consistency
- Define a color palette up front (background, accent, text, active/inactive/error states)
- Use 1-2 fonts max with consistent sizes (titles: 12-14px, labels: 10px, values: 10px)
- Button states must be visually distinct: normal / pressed / active / disabled
- Ensure sufficient contrast for live performance use (dark venues, projectors)
Interaction architecture
- Panel Execute DAT: centralize callbacks per container (not scattered scripts)
- Panel CHOP: convert panel values to CHOP channels for downstream use
- Extensions (advanced): for complex UI logic, route to td-python skill
- Define a clear "contract": UI outputs normalized 0-1 values or well-named parameters
Critical Guardrails
1. Two-level widget architecture — ALWAYS configure both levels
Every Palette widget loads as: containerCOMP (outer) → widgetCOMP (inner).
- Outer controls layout:
hmode,vmode,w,h,alignorder - Inner controls behavior:
Value0,Widgetlabel, colors, etc.
After loading ANY widget, ALWAYS run:
for child in op('{container_path}').children:
# Outer: fill parent
child.par.hmode = 'fill'
child.par.vmode = 'fill' # or 'fixed' with explicit h
child.par.alignorder = {i}
# Inner: fill outer — MANDATORY or widgets will be cropped/misaligned
if child.children:
inner = child.children[0]
inner.par.hmode = 'fill'
inner.par.vmode = 'fill'
2. Inner widget sub-components must ALSO be set to fill
Widgets like knobFixed, slider2D, buttonMomentary have internal sub-components (Knob, 2DSlider, Button) with their own sizing. These default to fixed and must be set to fill:
| Widget | Internal sizing pars to set to 'fill' |
|---|---|
knobFixed / knobEndless |
Knobhorizontalmode, Knobverticalmode |
slider2D |
Slider2dhorizontalmode, Slider2dverticalmode |
buttonMomentary / buttonToggle / buttonCheckbox |
Buttonhorizontalmode, Buttonverticalmode |
sliderHorz / sliderVert |
(inner fill is usually enough) |
3. Don't add labels unless the user asked for them
- Set
Labeldisplay = Falseon all widgets by default - Only show labels when the sketch explicitly has text annotations next to controls
- For knobs:
Knoblabelis the text INSIDE the dial (default "A"),Widgetlabelis the external label
4. Button text lives in Button page, not Label page
Buttonofflabel/Buttononlabel— text displayed ON the button faceButtonfont— defaults to "Material Design Icons" (shows icons). Set to'Arial'for textButtonofffacecolorr/g/b/Buttononfacecolorr/g/b— button colors (off/on states)Buttonofffontcolorr/g/b/Buttononfontcolorr/g/b— text colors
5. Disable rollover on ALL widgets
Each Palette widget has internal rollover/overlay containers that cook on every mouse movement. With many widgets and deep nesting (8+ levels), a mouse sweep triggers thousands of cooks propagating up the parent chain — measured: 4000+ unnecessary cooks causing visible FPS drops. Always disable at creation:
inner.par.Enablerollover = False
For existing UIs where rollover is already enabled, disable retroactively at startup:
# In an Execute DAT onStart
def disable_rollovers(comp, depth=0):
if depth > 10:
return
for c in comp.children:
if c.isCOMP and c.name in ('rollover', 'overlay'):
c.allowCooking = False
if c.isCOMP:
disable_rollovers(c, depth + 1)
Click/drag interaction is unaffected — only the hover highlight disappears.
6. Container height — calculate, don't guess
total = sum(child.par.h.val for child in parent.children)
total += parent.par.spacing.val * (len(parent.children) - 1)
parent.par.h = int(total) + 4 # small padding
7. Grid layout: use sub-containers, not gridrows/gridcols
gridrows/gridcols with alignmax is unreliable. For a 2-column grid:
container (horizlr)
├── col_left (verttb, fill)
│ ├── widget1
│ ├── widget2
│ └── widget3
└── col_right (verttb, fill)
├── widget4
├── widget5
└── widget6
8. folderTabs configuration
Tab names are set via string pars on the inner widgetCOMP (NOT via menuNames on Value0):
inner = op('{tabs_path}/folderTabs')
inner.par.Menunames = 'tab1 tab2 tab3' # space-separated names (no spaces in names!)
inner.par.Menulabels = 'Label1 Label2 Label3' # space-separated display labels (no spaces in labels!)
inner.par.Value0 = 'tab1' # default selected tab
For labels with spaces, use a Menuoptiontable DAT instead.
9. Page switching with folderTabs
Use me.parent().op() in display expressions — relative paths resolve from the operator's context:
page.par.display.expr = "me.parent().op('tabs/folderTabs').par.Value0 == 'tabname'"
page.par.display.mode = page.par.display.mode.EXPRESSION
Critical performance addition: display = False only hides visually — children still cook. ALWAYS also create a parameterexecuteDAT that toggles allowCooking on inactive pages:
# parameterexecuteDAT watching folderTabs.par.Value0, onValueChange:
def onValueChange(par, prev):
active = str(par)
ui = me.parent()
for page_name, tab_id in [('page_A', 'tabA'), ('page_B', 'tabB')]:
page = ui.op(page_name)
if page:
page.allowCooking = (active == tab_id)
allowCooking is a Python property (not a parameter) — requires a callback. Apply initial state in an Execute DAT onStart too.
10. Error detection and cleanup
After building any UI, ALWAYS run scan_network_errors to check for problems.
If errors are cached/stale, force-cook the affected operators:
op('{path}').cook(force=True)
Then rescan to confirm 0 errors.
11. No containerCOMP in exec namespace
containerCOMP, tableDAT, etc. are NOT available in execute_python_script. Use create_td_node MCP tool to create operators, then execute_python_script only for configuring parameters.
12. par.align uses string names, not integers
| par.align value | Mode |
|---|---|
'horizlr' |
Left to Right |
'horizrl' |
Right to Left |
'verttb' |
Top to Bottom |
'vertbt' |
Bottom to Top |
'gridrows' |
Grid by Rows |
'gridcols' |
Grid by Columns |
13. Panel CHOP for value export
After building the UI, offer to add a Panel CHOP per widget to export values:
# Inside a widget container, create a panelCHOP to export the value
# Pattern: widget → Panel CHOP → export to target parameter
panelchop = op('{widget_path}/panelCHOP')
panelchop.par.comp = '../{inner_widget_name}'
panelchop.par.select = 'state' # or 'u', 'v', 'text'
14. Performance — disable Viewer Active on hidden pages
For multi-page UIs, hidden pages still cook if Viewer Active is on:
# Use display expression (already handles this) OR explicitly:
page.par.viewer = False # when page is hidden
Also: avoid high-resolution TOPs in UI containers. For large UIs (50+ widgets), use pagination via tabs or collapsible sections.
15. Perform Mode reminder
After building any UI meant for live use, remind the user to:
- Test in Perform Mode via Window COMP at the target projection resolution
- Verify touch/mouse interaction works at the real output size
- Check that
get_performanceshows stable FPS under interaction
Workflow
Step 1: Analyze the Sketch
Look at the image and identify:
- Elements: What UI controls are visible (buttons, sliders, knobs, text fields, labels, menus, etc.)
- Hierarchy: How elements are grouped (panels, sections, tabs)
- Layout: Spatial arrangement — vertical stacks, horizontal rows, grids, nested groups
- Labels: Any text annotations — only add labels if the user explicitly wrote them
- Proportions: Relative sizes of elements to each other
Output a structured description before proceeding.
Step 2: Map to Palette Widgets
| Sketch Element | Palette Widget | Notes |
|---|---|---|
| Rectangle with text (action) | buttonMomentary |
Fires once on click |
| Rectangle with ON/OFF states | buttonToggle |
Stays on/off |
| Checkbox / checkmark box | buttonCheckbox |
Toggle with checkbox visual |
| Radio button / dot selector | buttonRadio |
Exclusive selection in group |
| Rocker / switch | buttonRocker |
Two-state rocker visual |
| Horizontal bar / slider | sliderHorz |
0-1 normalized value |
| Vertical bar / slider | sliderVert |
0-1 normalized value |
| 2D pad / XY control | slider2D |
Two axes |
| Circle with indicator / knob | knobFixed |
Rotary control, fixed range |
| Endless knob / rotary | knobEndless |
No min/max, infinite rotation |
| Text input / edit field | fieldString |
Editable string |
| Text area / multiline | fieldTextArea |
Multiline text input |
| Number field / numeric input | float1 |
Single float with label |
| 2-4 number fields in row | float2/float3/float4 |
Multi-value (XY, RGB, RGBA) |
| Integer field | int1 |
Single integer |
| Range slider (min/max) | range |
Dual-handle range |
| Color picker (RGB) | slider3Rgb |
3 sliders R/G/B |
| Color picker (HSV) | slider3Hsv |
3 sliders H/S/V |
| Color picker (RGBA) | slider4Rgba |
4 sliders with alpha |
| Dropdown / select menu | dropDownMenu |
Click to reveal options |
| Text label / annotation | label |
Static text display |
| Section title / heading | header |
Bold title bar |
| Window title bar | windowHeader |
Draggable window header |
| Collapsible group | section |
Expandable/collapsible section |
| Tab bar / page selector | folderTabs |
Switchable tab navigation |
| File path input | fieldFileBrowser |
String + browse button |
| Crossfader | sliderHorzXFade |
Horizontal crossfade slider |
Step 3: Build the UI
3a. Create containers via create_td_node (NOT execute_python_script)
create_td_node:
parentPath: /project1
nodeType: containerCOMP
nodeName: ui_panel
Then configure via execute_python_script:
n = op('{root_path}')
n.par.w = {total_width}
n.par.h = {total_height}
n.par.align = 'verttb'
n.par.spacing = 2
3b. Load widgets from Palette
load_palette_component:
name: {widget_palette_name}
parentPath: {parent_container_path}
componentName: {descriptive_name}
3c. Configure ALL sizing levels (critical checklist)
For every loaded widget, in a single execute_python_script:
outer = op('{widget_path}')
outer.par.hmode = 'fill'
outer.par.vmode = 'fill' # or 'fixed' + explicit h
outer.par.alignorder = {order}
inner = outer.children[0]
inner.par.hmode = 'fill'
inner.par.vmode = 'fill'
inner.par.Labeldisplay = False
inner.par.Enablerollover = False
# Widget-specific internal fill (check reference for each type):
# inner.par.Knobhorizontalmode = 'fill' # knobs
# inner.par.Slider2dhorizontalmode = 'fill' # slider2D
# inner.par.Buttonhorizontalmode = 'fill' # buttons
3d. Set button text with Arial font
inner.par.Buttonfont = 'Arial'
inner.par.Buttonofflabel = 'My Label'
inner.par.Buttononlabel = 'My Label'
Step 4: Verify (mandatory)
- Run
scan_network_errorson the root container — must be 0 errors - If errors found:
op('{path}').cook(force=True)on affected ops, rescan - Ask user for a screenshot to compare with sketch
- Check
get_performance— FPS should stay at 60
Widget-Specific Internal Sizing Reference
| Widget Type | Internal H-mode par | Internal V-mode par | Default | Must set to |
|---|---|---|---|---|
knobFixed |
Knobhorizontalmode |
Knobverticalmode |
fixed |
'fill' |
knobEndless |
Knobhorizontalmode |
Knobverticalmode |
fixed |
'fill' |
slider2D |
Slider2dhorizontalmode |
Slider2dverticalmode |
fixed |
'fill' |
buttonMomentary |
Buttonhorizontalmode |
Buttonverticalmode |
fixed/fill |
'fill' |
buttonToggle |
Buttonhorizontalmode |
Buttonverticalmode |
fixed/fill |
'fill' |
buttonCheckbox |
Buttonhorizontalmode |
Buttonverticalmode |
fixed/fill |
'fill' |
sliderHorz |
— | — | — | inner fill is enough |
sliderVert |
— | — | — | inner fill is enough |
folderTabs |
— | — | — | inner fill is enough |
Knob-Specific Parameters
| Parameter | Purpose | Default |
|---|---|---|
Knoblabel |
Text displayed INSIDE the knob dial | "A" |
Knoblabeldisplay |
Show/hide knob dial text | True |
Knobfontsize |
Font size of dial text | 20 |
Knobsensitivity |
Mouse drag sensitivity | 1.0 |
Knobdialscale |
Visual size of the dial | 0.686 |
folderTabs Reference
Configuration
inner = op('{path}/folderTabs')
inner.par.Menunames = 'tab1 tab2' # space-separated, NO spaces in names
inner.par.Menulabels = 'Tab1 Tab2' # display labels, NO spaces
inner.par.Value0 = 'tab1' # default tab
For labels with spaces: use Menuoptiontable pointing to a table DAT with name/label columns.
Page switching
page.par.display.expr = "me.parent().op('tabs/folderTabs').par.Value0 == 'tab1'"
page.par.display.mode = page.par.display.mode.EXPRESSION
Outputs
out_menu0Index(outCHOP) — numeric index of selected tabout_menu1Selected(outDAT) — selected rowout_menu2Options(outDAT) — all tab options
Limitations
- Palette widgets must be loaded one at a time (no batch loading)
get_node_parameter_schemadoes not work on widgetCOMPs — useexecute_python_scriptto read/write custom parsscreenshot_operatoronly works on TOPs, not COMPs — ask user for visual verification- TD type constants (
containerCOMP,tableDAT) not available in exec namespace — usecreate_td_nodeMCP tool - Grid align modes (
gridrows/gridcols+alignmax) are unreliable — use nested sub-containers instead - Expression errors can persist in cache — force-cook affected operators to clear