Skip to main content

Editor block reference

The Visual editor (/admin/ui-custom/<id> → Visual mode) composes a UI out of block trees. A block is a typed node with props, optional children, and optional named slots. BlockTreeRenderer walks the tree, looks each node up in a registry, and renders it through a small React component that applies the props.

This document is the reference for the block library and the editor UX. For the underlying event measurement that fires regardless of how the tree was authored, see event-schema.

Overview​

feed_tree / watch_tree (JSONB columns on ui_templates)
│
▼
┌─── BlockTreeRenderer ───┐
│ page = 'feed'/'watch' │ mounts FeedSurface or WatchSurface
│ │ pre-fetches feed / related / video
│ walk(tree): │ threads RenderEnv (page, pageVideo,
│ spec = lookup(type) │ iter, navigateToVideo, ...)
│ <spec.Component │ slots = named child arrays (e.g.
│ node env │ SplitColumn { main, sidebar };
│ renderChildren │ VideoList { item }; Tabs { <tabId> })
│ renderSlot/> │
└──────────────────────────┘

Atoms (Title / Channel / Views / …) read the active video out of RenderEnv: inside a VideoList iteration the iterator pushes env.iter.video, otherwise env.pageVideo (watch page) takes over. No prop wiring is required — drop a VideoTitle anywhere inside a VideoList slot and it reads the iteration's video; drop the same VideoTitle outside any iteration on a watch page and it reads the URL-pinned video.

The library at a glance​

BlockDisplayCategoryPurpose
PagePageLayoutTree root. Applies background, padding, optional max-width.
StackGroupLayoutVertical flex stack. Use Grid for horizontal arrangements.
GridGridLayoutCSS grid container. Equal columns or arbitrary columnsTemplate.
SplitColumnTwo ColumnsLayoutMain + sidebar slot pair (watch-page main / sidebar).
SpacerSpacerLayoutFixed gap when Stack.gap isn't fine-grained enough.
TabsTabsLayoutTab bar with one slot per tab id.
VideoListVideo ListMediaIterates feed or related videos. Grid or list layout.
VideoPlayerPlayerMediaInline <video> element (watch page only).
ThumbnailThumbnailMediaBound video thumbnail with optional duration badge.
ChannelAvatarChannel AvatarMediaLetter-circle avatar (color hashed from channel name).
VideoActionsLike/Dislike ButtonsMediaInteractive Like / Dislike (emits LIKE / DISLIKE).
CommentListCommentsMediaComment thread (uses useComments/useReplies).
VideoTitleTitleTextBound title with optional line clamp.
VideoChannelChannel NameTextBound channel name with optional @-prefix handle.
VideoViewsView CountTextBound view count (1.2K, 3.4M, …).
VideoLikesLike CountTextRead-only like count with optional heart icon.
VideoDurationDurationTextBound duration as mm:ss / hh:mm:ss.
VideoDescriptionDescriptionTextBound description with optional line clamp + expand toggle.
VideoTagsTagsTextBound tags rendered as #hashtag spans.

Categories drive the palette grouping. The internal type value in the JSON tree never changes; the Display name is the label rendered in the editor UI.

Layout blocks​

Page​

Tree root. Required as the outermost node — every other block lives inside its children array (or inside slots of descendants).

PropTypeNotes
backgroundcolorApplied to the full-height page body.
paddingspacingShorthand: '24px' or '8px 16px'.
maxWidthsizeOptional. When set, the inner content is centered with margin: 0 auto.

Stack (Group)​

Vertical flex stack of children. The block has no direction prop — horizontal arrangements use Grid with a column template (e.g. 'auto 1fr'). One block, one job: stack things vertically OR pick Grid for columns.

PropTypeNotes
gapsizeGap between children.
alignalignmentstart / center / end / stretch (cross-axis).
justifyselectstart / center / end / space-between / space-around.
backgroundcolorContainer background. Empty = transparent.
paddingspacing'16px' or '8px 16px'. Empty = no padding.
borderRadiussizeEmpty = sharp corners.

Grid​

CSS grid container. Children flow left-to-right, then wrap. Two prop shapes:

  • columns (number 1–12) → equal-width columns via repeat(N, 1fr).
  • columnsTemplate (string) → arbitrary template, e.g. 'auto 1fr', '168px 1fr'. When set, overrides columns.
PropTypeNotes
columnsnumberEqual columns count. Ignored when columnsTemplate is non-empty.
columnsTemplatetextRaw grid-template-columns value.
gapsize
backgroundcolorContainer background. Empty = transparent.
paddingspacing'16px' or '8px 16px'. Empty = no padding.
borderRadiussizeEmpty = sharp corners.

SplitColumn (Two Columns)​

Two named slots (main + sidebar) — the standard watch-page split.

PropTypeNotes
sidebarPositionselectleft / right / hidden. hidden collapses to a single full-width main.
sidebarWidthsizeFixed pixel/em width of the sidebar slot.
gapsize

Slots: main, sidebar. The editor labels them "Main column" and "Sidebar".

Spacer​

Fixed-size gap. Useful when a Stack's uniform gap is too coarse.

PropTypeNotes
sizesizeBoth height and width are set; only one matters in a vertical stack.

Tabs​

Tab bar with one slot per tab id. The selected tab's slot is rendered; the rest stay mounted-but-hidden. Local state owns the active tab.

PropTypeNotes
tabs{ id, label }[]Tab bar definition. The slot name on the node must match each tab id.
initialTabtextTab id to start on. Falls back to the first tab.

The tabs prop has no UI widget yet — it's authored as raw JSON in the Code panel until a structured editor lands. The block is fully runtime-functional.

Media blocks​

VideoList​

Iterates the page's videos. The data source is automatic:

  • Feed page → uses env.feedVideos (the platform's recommendation).
  • Watch page → uses env.relatedVideos (related to the URL-pinned video).
PropTypeNotes
layout'grid' | 'list'Switch widget. grid = N-column CSS grid; list = single-column horizontal cards. Switching swaps the slot template (see "Layout switch" below).
columnsnumberOnly visible when layout = grid.
gapsize
maxItemsnumber0 = render all videos; otherwise slice.

Slot: item — the per-card template. Each iteration renders a <VideoSurface> (per-card events) wrapping the slot's children with env.iter set to the current video.

Layout switch. When the admin toggles layout, the editor replaces slot.item with a sensible default for that layout — a vertical Stack (gridCardTemplate) or a horizontal Grid (listCardTemplate). Any prior edits to the slot are discarded; the switch is a "starter template chooser", not a one-way lock. Edit the new template afterwards.

VideoPlayer (Player)​

Inline <video> player. Reads the bound video from env.iter.video or env.pageVideo. Only valid on a watch page. Wraps the player with a watch-context VideoSurface, so the full playback event family (VIDEO_PLAY / PAUSE / SEEK / ENDED / PROGRESS / WATCHED_1S / WATCHED_5S / BUFFERING, plus PLAYBACK_RATE_CHANGE, VOLUME_CHANGE, FULLSCREEN_CHANGE, KEYBOARD_SHORTCUT) is emitted without per-author wiring.

PropTypeNotes
aspectaspect ratio'16/9', '9/16', custom.

Thumbnail​

Bound thumbnail with extension fallback (jpg → png → webp for extensionless paths) and a duration badge overlay.

PropTypeNotes
aspectaspect
borderRadiussize
showDurationtoggleRender a duration badge over the thumbnail.
durationPositionselectbottom-right / bottom-left / top-right / top-left.

ChannelAvatar​

Letter-circle avatar (color hashed from channel name). Compose alongside VideoChannel (in a Grid with columnsTemplate: 'auto 1fr') for an avatar+name handle row.

PropTypeNotes
sizenumberPixel diameter (16–96).
shapeselectcircle / square.
fontSizesizeLetter font size. Defaults to 40% of size.

VideoActions​

Interactive Like / Dislike buttons. Uses useLikes(videoId) so toggles emit LIKE / DISLIKE automatically.

PropTypeNotes
showLiketoggle
showDisliketoggle
backgroundcolorPill container background. Empty = Tailwind bg-gray-100 default.
colorcolorText + heart-icon color in inactive state. Empty = Tailwind text-gray-700.
activeColorcolorColor when liked / disliked. Empty = Tailwind text-blue-600.

CommentList​

Comment thread for the bound video. Reads via useComments / useReplies (top-level + replies) internally.

PropTypeNotes
defaultExpandedtoggleRender the thread expanded on mount.

Text blocks (atoms)​

All atoms read the active video out of RenderEnv (env.iter.video if inside an iteration, otherwise env.pageVideo). They return null if no video is bound — this is the correct behaviour outside the iteration on a feed page.

VideoTitle​

PropTypeNotes
fontSizesize
fontWeightselect400 / 500 / 600 / 700.
colorcolor
linesnumberLine clamp (1–5).

VideoChannel​

PropTypeNotes
fontSizesize
colorcolor
prefixtextPrepended to the channel name. '@' lowercases + replaces spaces with _ (TikTok-style handle).

VideoViews​

PropTypeNotes
fontSizesize
colorcolor

Renders formatted view count (e.g. 1.2K views, 3.4M views).

VideoLikes​

Read-only like count. For interactive Like buttons see VideoActions.

PropTypeNotes
fontSizesize
colorcolor
showHearttoggleRender a heart icon prefix.

VideoDuration​

Renders mm:ss (or hh:mm:ss for >1h). Returns null if the bound video has no duration.

PropTypeNotes
fontSizesize
colorcolor

VideoDescription​

PropTypeNotes
lineClampnumberLines before truncation (1–20).
expandabletoggleWhen true, renders an expand toggle (...Show more / Show less).
fontSizesize
colorcolorText color.
backgroundcolorWrapper background — only applies when expandable=true. Empty = Tailwind bg-gray-100.

VideoTags​

PropTypeNotes
fontSizesize
colorcolor

Editor UX​

Tree editor (left panel)​

A flat accordion. Every block in the tree renders as one row; clicking the row selects it and expands a property editor inline. Re-clicking the selected row deselects (closes the inline panel).

Hierarchy is conveyed by:

  • Top-level rows (Page, top-level VideoList) sit flush at the highest level.
  • Slot headers (gray, ↳-prefixed) appear above each named slot (Main column / Sidebar / per-tab panels). Click + on a slot to insert a child.
  • Card group rows are synthetic rows for VideoList.item — clicking one expands the card composition below it (with a "↓ inside the card" divider) and zooms the preview into a single mock card. Click again to collapse.

Drag-drop:

  • Drag a block row onto another block to nest it as a child.
  • Drag onto a slot header to drop into that slot.
  • The root (Page) cannot be dragged or deleted.
  • Inside a card group, the slot's first child (the card root) cannot be deleted (× disabled) — emptying the slot would break iteration.

Preview panel (center)​

Two modes, switched implicitly by selection:

  1. Full preview (default) — renders the tree exactly as production via <BlockTreeRenderer mock>, using the bundled mock dataset.
  2. Card focus — when the selected node lives inside a VideoList.item slot, the panel zooms in on a single mock card. The admin can edit individual atoms close-up; the full grid reappears when a node outside the card is selected (e.g. the Page row).

Selecting a row outlines the corresponding rendered region with an inset blue outline. Clicking any rendered block in the preview walks the DOM up to find the nearest data-block-id and selects that row in the tree — selection works in both directions.

Generated TSX (right panel)​

The right panel shows the live-generated TSX for the current trees (both feed and watch, with a pathname.startsWith('/watch') branch). Buttons:

  • Copy — clipboard.
  • Export — downloads as <template-name>.tsx. Drop into frontend/src/ui-presets/<key>/feed.tsx or paste into the admin Code editor.
  • Eject → — confirms, then copies the generated TSX into the editor's Code mode and switches modes. The block trees stay in the database (not destroyed) but the editor's source-of-truth is now Code mode. Reversible only by manually flipping template_type back to 'tree' via SQL.

The Custom CSS textarea below is a raw escape hatch saved to feed_css / watch_css, scoped to .ui-custom-template in the production dispatcher.

Composition recipes​

YouTube-style feed card (vertical, atom composition)​

VideoList(layout=grid, columns=4, gap=16px)
slot.item:
Stack(gap=8px)
Thumbnail(aspect=16/9, showDuration=true, borderRadius=12px)
Grid(columnsTemplate='auto 1fr', gap=8px)
ChannelAvatar(size=36, shape=circle)
Stack(gap=2px)
VideoTitle(fontSize=14px, lines=2)
VideoChannel(fontSize=12px, color=#606060)
VideoViews(fontSize=12px, color=#606060)

YouTube-style sidebar card (horizontal)​

VideoList(layout=list, gap=12px)
slot.item:
Grid(columnsTemplate='168px 1fr', gap=8px)
Thumbnail(aspect=16/9, borderRadius=8px)
Stack(gap=2px)
VideoTitle(...)
VideoChannel(...)
VideoViews(...)

YouTube-style watch page​

Page(background=#fff, padding=24px, maxWidth=1800px)
SplitColumn(sidebarPosition=right, sidebarWidth=400px, gap=24px)
slot.main:
Stack(gap=12px)
VideoPlayer(aspect=16/9)
VideoTitle(fontSize=20px, fontWeight=600)
Grid(columnsTemplate='1fr auto', gap=16px)
VideoViews(fontSize=14px)
VideoActions(showLike, showDislike)
Grid(columnsTemplate='auto 1fr', gap=8px)
ChannelAvatar(size=32)
VideoChannel(fontSize=14px)
VideoDescription(lineClamp=3, expandable=true)
CommentList(defaultExpanded=true)
slot.sidebar:
VideoList(layout=list)
slot.item: (sidebar card; same shape as the recipe above)

The bundled BlockTreeDemo template (/admin/ui-custom/11111111-2222-3333-4444-555555555555) is built from these recipes — load it as a starting reference.

Known gaps​

  • Tabs.tabs widget: structured array prop, edited as raw JSON in the Code panel until a per-tab editor ships.
  • Layer / Overlay block: there's no block for absolute-positioned overlay (e.g. caption-on-thumbnail). Layouts that require it (the TikTok feed's caption-over-thumbnail) currently degrade to "caption-below-thumbnail" via the regular Stack.
  • Page color prop: missing. Text on dark backgrounds requires per-atom color overrides.
  • Shorts watch: kept as the legacy <ShortsWatch> component (not yet re-encoded as a tree). Use the Code track preset for now.

These are tracked in the project plan as Phase 4 polish; the current library covers the YouTube-classic feed, YouTube-classic watch, the TikTok-style 9:16 feed, and the TikTok-style watch with the meta-below fallback.