Skip to main content
Audience: AI coding agents (and teams running them). This page is part of the deterministic agent workflow for building and verifying Velt UI customizations. Customizing by hand? Start with the Approaches and the feature overviews. This is the workflow the UI Customization Plugin runs for you.
These are common failure modes when building Velt UI customizations from a design. Check them before assuming the SDK is broken.

Inspecting

  • Inspect the LIVE rendered node, not the registry template. A wireframe is cloned: the velt-*-wireframe custom-element tags are the hidden registry copy (0-size, empty). document.querySelector('.vc-x') may hit that copy. Always pick the element with getBoundingClientRect().width > 0 (the visible clone) and read its classes/computed styles. Measuring the wrong node is how you “verify” something that’s actually broken.

Wireframe clone behavior

  • Some slots OVERWRITE their inner markup with their own label. ToggleReply replaced custom <svg/> + <span>Reply</span> with a plain “Reply” text node; CopyLink did the same. Two verified fixes: (a) inject the icon via CSS ::before (a data-URI SVG on your .vc-* class), which survives the clone; or (b) put the custom icon in a plain sibling wrapper next to the slot’s .Text subslot, never inside the slot itself. If you use a different reply slot with nested children, live-verify the children survive adoption before certifying, overwrite behavior is per-slot.
  • Wireframe MARKUP changes need a FULL page reload to take effect, not just new wireframes. CSS edits hot-reload fine, but the <VeltWireframe> registry is built at mount: changing an existing template’s markup (e.g. switching the composer’s send to a self-closing ActionButton, or swapping Cancel to a VeltButtonWireframe) re-renders under Fast Refresh but does NOT re-register the template: the browser keeps rendering the OLD wireframe, so your fix “doesn’t work” until a hard reload. After ANY *Wf.tsx change: hard-reload (Cmd-Shift-R), re-auth, reopen, and verify in a freshly-loaded tab, never a hot-reloaded one. (This masked a correct composer-submit fix as “still broken”.)
  • Container slots drop undeclared children: declare the full child tree you intend to use.
  • velt-if/velt-class attributes on plain HTML elements NEVER fire (R28). A velt-if="…" on a <div>/<span> (or a helper spread like {...veltIf("…")}) survives the clone as inert markup, the element renders unconditionally, the class never toggles, nothing errors. Directives resolve only on Velt elements. Gate custom HTML by wrapping it in <VeltIf condition="{…}">; toggle classes via velt-class on a Velt wireframe element or key CSS off Velt’s own state classes (e.g. .velt-composer-open, the submit button’s :disabled).

Styling / scoping

  • Class CSS needs shadow off + !important (R6/R9b). With shadowDom={false} the live classes are reachable; Velt’s runtime CSS is high-specificity, so overrides need !important.
  • The page-mode composer renders the WHOLE dialog wireframe (your .vc-card and all). It inherits the card chrome (border/shadow/resolve-icon) and crushes the input. Fix: scope the card chrome off in that context: .velt-comment-dialog--page-mode-composer .vc-card { border:none; box-shadow:none; padding:0; background:transparent }, leaving just the composer pill. (Alternatively give the page-mode composer its own variant via pageModeComposerVariant.)
  • Composer “active” state = the .velt-composer-open ancestor class (focus/compose), not a “has-text” class (there is none). Style the send button: grey/disabled by default, dark/enabled under .velt-composer-open.
  • Avatar fill color is user-data-driven, not CSS: “User 1” renders peach. To match a design that shows a flat dark avatar, override .…s-user-avatar-initial-container { background } + the initial color (this overrides per-user colors: a deliberate choice to flag).
  • The send-arrow indigo lives on an inner element (.velt-composer--input-button), not the outer .velt-composer--submit-button: override the inner one.

Composer actions, collapsed replies, resolved state

  • Cancel button: use VeltButtonWireframe, never a raw <button>. The dialog/reply composer has no native Cancel slot.
    • Render Cancel as a VeltButtonWireframe (a Velt-owned button) and wire it in the host via useVeltEventCallback("veltButtonClick") to clear and collapse the composer.
    • A raw <button onClick> in wireframe markup does not run because it is cloned to plain DOM (R4). It breaks specifically in the in-thread reply composer: page mode can look fine while the reply composer is dead.
    • The host handler must scope to both composers, but commentAnnotation does not distinguish them because both carry one. The real distinguisher is the annotation’s comment count: the page-mode composer fires with a fresh draft (commentAnnotation.comments.length === 0); a reply composer fires with the existing thread (comments.length > 0). Route the clear accordingly.
    • Key the Send button’s enabled/dark state off the submit button’s :disabled attribute rather than only .velt-composer-open; it tracks empty-vs-filled exactly.
  • Send button → leave Composer.ActionButton SELF-CLOSING; paint the arrow with CSS. Injecting a child (<svg>/<span>) into ActionButton is dropped by the clone and can kill the native submit in the reply composer. Leave it empty so Velt renders the functional submit, then mask the up-arrow via CSS (::after/mask on the live button class: grey idle → dark under .velt-composer-open).
  • “Show N replies…” (MoreReply): the clone DROPS trailing text, so add the ellipsis in CSS. Velt renders the text in .velt-hidden-count; the cloner drops every node after the last velt element, so the trailing is gone.
    • Re-add it via ::after { content:'…' } on velt-comment-dialog-more-reply-text-internal.
    • Gate the row on :has(.velt-hidden-count) (NOT :not(:empty)) so a fully-expanded thread (empty MoreReply) doesn’t show a lone chevron.
    • Indent to the message column and draw the rail-line continuation (a 1px line in the avatar gutter joining the chevron to the next avatar); suppress Velt’s default full-width divider.
    • Collapse the EMPTY slots: a single comment / fully-expanded thread renders an empty velt-comment-dialog-more-reply-internal AND empty velt-comment-dialog-toggle-reply-internal that still take vertical space inside the card border, leaving dead space below “Reply”. Add velt-comment-dialog-more-reply-internal:not(:has(.velt-hidden-count)), velt-comment-dialog-toggle-reply-internal:not(:has(…count…:not(:empty))) { display:none !important } so only a populated “Show N replies” row occupies space (on open AND resolved cards).
  • Resolved card = muted AND no reply. Per the design a resolved comment hides its reply affordance. Velt adds no --resolved class: detect resolved via the rendered unresolve button (.velt-comment-dialog:has(velt-comment-dialog-unresolve-button-internal .vc-icon-btn)), then mute colours/avatar AND display:none the reply + velt-comment-dialog-toggle-reply-internal + any reply composer. Leaving them shows empty bottom space.

Interaction-transition traps (the surface moves DURING a click) (R27)

These don’t show up in a static per-state capture; they only bite mid-interaction. The whole class: a layout/visibility decision keyed on a state that changes at the worst moment.
  • Send/Cancel “shifts down and never fires”, visibility keyed on a TRANSIENT state. The reply composer’s “Reply” link was hidden via .velt-composer-input-focused (focus). Focus drops the instant the pointer leaves the input to press a button → the rule re-shows the “Reply” link → everything below shoves down by a line → the composer (Send/Cancel) jumps out from under the cursor, so the click lands on empty air. Symptom reads as “the button doesn’t work”; the real bug is it moved. Fix: anchor on a STABLE state that holds through the whole interaction, hide the link via .velt-comment-dialog--selected (the card is open the entire time you click) instead of …input-focused. Same visual result, zero flicker, the target never moves. Never key hide/show or re-layout on :focus/.velt-composer-input-focused/:hover/:active, they flip on pointer-down. (Send-button enabled/dark may key off the submit’s :disabled, that tracks empty-vs-filled, not pointer position, so it’s safe.) Prove it: measure the target’s box, blur the input (what the click does), re-measure → must be 0px, then do the real thing, type a reply, real-click Send, confirm it posts.
  • Flex gap reserves space for a 0-height/empty child → trailing whitespace below the card. A card laid out as display:flex; flex-direction:column; gap:16px adds the 16px gap even when the next child (the collapsed reply composer / empty more-reply host) is 0px tall but still present. A gap can’t tell “invisible” from “absent”. Fix: gap:0 on the column and move the spacing to margin-top on the child that should be spaced, a margin only takes effect when its element actually has height. Verify in the collapsed/empty-composer state by measuring body-bottom→card-bottom = 0 extra px, not just the populated state.
  • Gutter-pinned glyphs must be PINNED, not inline. The “Show N replies” chevron belongs in the avatar gutter under the avatar (design), but drawn inline it flows next to the text and pushes right. Pin it: position:absolute; left:4px against the card’s positioned ancestor, and draw it as a thin glyph via a CSS mask (a data-URI SVG), not a heavier default.
  • The minimal-filter SelectedIcon slot renders as ONE standalone, full-width, opacity:0 element: it does NOT auto-place a tick per row. Fix: hide that slot and put a ✓ on the actually-selected row via CSS: .…content-item--selected .vc-filter-item::after { content:"✓"; margin-left:auto }. (Gating with VeltIf {isSelected} does NOT work in this V1 sort/filter context: it resolves falsy.)
  • A right-edge-anchored dropdown can overflow the viewport (the trigger sits at the sidebar’s right edge). The content’s default left/right may push a wide menu off-screen, clipping the tick. Fix: shift the menu into view (e.g. transform: translateX(-Npx) on the menu, or right-anchor it) so it opens leftward.

Interaction driving (when verifying in the browser)

  • Velt triggers need a real pointer click; element.click() (JS) often won’t fire the Angular handler. Use a real click at the element’s coordinates.
  • Velt auth or documentsReady can stall after reloads (useCurrentUser doesn’t emit, so nothing mounts and the velt-* count is 0). This is an environment block, not a build failure. Wait longer for the mount, recover with a fresh tab, or re-authenticate in the app. Triage app vs. build before blaming the customization.

Brief/drive selectors must survive the BUILD (post-build stability)

Probe briefs and drive steps are authored at plan time, against the app’s pre-build DOM, but the wireframe registration replaces that DOM. A selector keyed to default Velt markup or host structure that the wireframe supersedes matches nothing the moment the first registration renders, and every downstream drive/measure dies on a selector that “worked when I verified it.” Key every brief/drive selector to something that survives the build:
  • a contract wireframe tag (velt-*-wireframe, the manifest contract.parts selectorHints exist precisely for this),
  • a stable velt-* runtime class (.velt-comment-dialog--sidebar-mode, .velt-composer--submit-button, …, verified in CSS classes),
  • or your own .vc-* classes from the first-shot stylesheet (you control that markup, so those classes are guaranteed post-build).
Never a pre-build class, a default-slot structure the design replaces, or a host wrapper the reconciliation may neutralize. Content-anchored selectors (:has-text("…")) are fine only on top of a stable base selector, fixture text survives; structure doesn’t.

Wireframe-host semantics FLIP at registration (selectors AND CSS)

The moment a family’s wireframe registers, the live DOM’s shape changes, not just its content:
  • The live card itself starts carrying --wireframe-host, and the live inner elements render as *-internal tags (e.g. velt-comment-dialog-header-internal), not the tag names you saw pre-registration.
  • The hidden 0-size registry twins disappear, the pre-registration DOM had every wireframe tag twice (a hidden registry copy under <velt-wireframe> plus the live clone); post-registration there is one live tree.
  • Consequently any :not(--wireframe-host) selector written pre-registration silently stops matching, it was excluding the live element’s own new class. The same applies to selectors assuming the twin structure (velt-wireframe > *), and to drive/probe waits keyed to a pre-registration tag.
Author selectors against the post-registration DOM: contract wireframe tags, *-internal live tags, stable velt-* classes, or your own .vc-* classes, and after the first registration of each family, re-verify any selector written before it.