drawnui/drawnui.net/drawnui-blazor

Claude Code and agent skill by drawnui/drawnui.net

Trust tier 3
  • community
  • ext:[{"type":"hook","description":"Pre/post lifecycle hooks"}]
Source check pending

Checking that the SKILL.md file is still in the repository.

DrawnUI in Blazor WebAssembly apps — startup (UseDrawnUiAsync), Canvas gestures and rendering modes, BROWSER preprocessor + shared-project pattern, single-threaded WASM divergences, fullscreen/backdrop CSS, canvas blink root cause, font payload slimming (subsetting), and publishing to GitHub Pages under a repo subpath. Trigger on "drawnui blazor", "blazor canvas", "BROWSER preprocessor", "blazor wasm drawnui", "github pages drawnui", "font subsetting wasm".

Install

npx vskill@latest install drawnui/drawnui.net/drawnui-blazor

Semi-extensibleMentions customization, but needs skill-specific knowledge. Read the extensible skills standard

SKILL.md

DrawnUI Blazor

DrawnUI's Blazor head (NuGet DrawnUi.Blazor.Wasm / DrawnUi.Blazor.Server): a Canvas Razor component drawing on WebGL or raster. For pure-WASM without Blazor see the drawnui-web-app skill; framework rules in drawnui, C# composition in drawnui-fluent.

Startup

csharp
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
await Super.UseDrawnUi(builder)
.WithBaseUrl(builder.HostEnvironment.BaseAddress)
.WithOptions(o => o.UseDesktopKeyboard = true)
.ConfigureFonts(fonts => { fonts.AddEmojis(); fonts.AddSymbols(); fonts.AddFont("OpenSans-Regular.ttf", "FontText"); })
.BuildAndRunAsync(); // = builder.UseDrawnUiAsync(settings) + host.RunAsync()

(tpls/Blazor/EmptyCode/Program.cs.) UseDrawnUiAsync(settings) returns the built host without running it: builds host → Super.Services → inits SkiaFontManager + SkiaImageManager + SkiaSvg (async) → Super.Init() → app lifecycle hooks → attaches KeyboardManager when UseDesktopKeyboard. Host the tree in Razor: <Canvas Content="@RootControl" RenderingMode="@RenderingModeType.Accelerated" Gestures="@GesturesMode.Enabled" />.

Accessibility overlay and keyboard

  • Each node of the accessibility snapshot is an invisible element over the canvas; interactive ones are focusable. Inside an arrow-key group (a container with Aria.RoleList, RoleToolbar...) only the group's current item has tabindex=0, the rest -1.
  • Enter / Space activate, the arrow keys go to the node and then to its group (the engine moves DOM focus to the next item's element); the element's CSS outline is the focus ring, the canvas draws none.
  • An element that has a role takes the pointer from the canvas: that control gets no hover.
  • What an element carries: role, aria-label, the hint as title, aria-live; a pressed state on the attribute its role is read from (aria-checked for switch / checkbox / radio, aria-selected for option / tab, aria-pressed for a toggle button: with aria-pressed Chrome reads a switch as unchecked); aria-valuenow / min / max / valuetext / orientation for sliders and progress bars; aria-disabled for a control role that takes no input. Non-interactive nodes have tabindex=-1 so a screen reader can be moved to them (refocus after a page closes).
  • A focused element is the screen reader's node and scrolls its node into view. Verify in Chrome with CDP Accessibility.getFullAXTree (role, value, min, max, checked); its valuetext comes back empty for every element, a CDP gap, not the overlay.

Shared-project pattern + BROWSER symbol

  • Shared code compiles into each head from a shared-source project (.shproj/.projitems) — never a class library (base types differ per head package).
  • File convention: Foo.Shared.cs (cross-platform), Foo.Blazor.cs (Blazor partial), Foo.Maui.cs. New platform behavior goes in a platform partial, never #if inside shared files when a partial works.
  • Symbols: BROWSER (Blazor/WASM only — JS interop, WASM APIs), !BROWSER (exclude from web), ONPLATFORM (any real platform), plus MAUI's ANDROID/IOS/WINDOWS/MACCATALYST.

Gestures

Pipeline: JS gesture package → Canvas.OnTouchAction(TouchActionEventArgs) → ProcessGestures(SkiaGesturesParameters) → control tree.

  • GesturesMode (Canvas Gestures parameter): Disabled (default, no capture), Enabled (standard), Lock/SoftLock (panning controls). Lock applies touch-action:none; user-select:none on the canvas. Enabled lets AppoMobi.Blazor.Gestures (3.11.2+) set touch-action to the axes the page can scroll (vertical always inside an iframe, 3.11.3+), so a finger pan along a page axis scrolls the page like MAUI Enabled in a ScrollView, and the wheel scrolls the page unless a control used it.
  • Touch mapping: Pressed→Down, Moved/Pan*→Panning, Released/Cancelled/Exited→Up, Wheel→Wheel. Multi-touch tracked per pointer id.
  • Gesture callbacks arrive far slower than frames (measured 2026-08-30, prod Blazor WASM): pointer moves reach WithGestures/Panning at roughly 16/s while the canvas renders a steady 60 fps — browser pointer coalescing plus the JS→.NET hop. Anything that follows a finger (paddle, drag handle, slider thumb) must NOT be assigned the raw event value, or it visibly steps ~16 times a second. Store the pointer value as a target and chase it in the per-frame animator: x += (target - x) * Math.Min(1, dt * 22); — reaches the target in ~3 frames, so it reads as instant and glides between events.
  • For gesture bugs on scaled/transformed controls prefer built-in transform-aware helpers (HitIsInside(), HitBoxAuto, IsGestureForChild(...), rescaled args.Event.Location) over hand-rolled transform math.

Rendering modes

ModeViewUse for
DefaultSkiaView (raster)simple/static UI
AcceleratedSkiaViewAccelerated (WebGL)heavy animation, games, shaders

HasDrawn detection: check paintArgs.BackendRenderTarget dimensions, NOT CanvasSize (fires late).

Single-threaded WASM divergences

AreaMAUIBlazor
FrameTimeInterpolatorused for physics stabilityskip — raw delta
CanUseCacheDoubleBufferingenabledforced false (`#if WEB \\BROWSER) — no background thread exists, so ImageDoubleBuffered resolves to Image`
SkiaCachedStack.AsyncPlaneAllowedasync plane bakeforced false — blocking-wait on the only thread deadlocks the bake it waits for; sync SKPicture record instead. Rule: never blocking-wait on WASM main thread for work only that thread can run
CanvasSizeafter layoutunreliable until ResizeObserver fires; use paintArgs dimensions
Keyboardnative eventsJS global listeners
PDF/file systemsupportedexcluded / bundle-only
GetDisplayRefreshRate()device rateconstant 60
Offscreen cache bakesdedicated worker threadsdrained INLINE (DrainOffscreenQueueInline) — queued items would otherwise starve indefinitely under continuous frame load (observed: two lotties, first-queued control never pumped = permanent empty box). Same total main-thread cost, guaranteed execution

Super.OnFrame ticks from a Task.Delay loop off Super.MaxFps (60 when unset), not rAF; the canvas paint itself goes through SkiaSharp's SKHtmlCanvas.requestAnimationFrame.

SkiaImageManager cache keys (Blazor) — slash-agnostic (2026-08-20)

Symptom: a control's SkiaImageManager.Instance.GetFromCache("/media/x.png") silently returns null after PreloadImages(["media/x.png"]) → sprite/overlay just missing, zero console errors. Cause: lib commit 96caf1e8 changed NormalizeFilePath from always-forcing a leading / on cache keys to storing relative keys — old-contract callers prepending / miss forever. Fixed in lib: GetCacheKey normalizes symmetrically in Add/Get/Update/RemoveFromCache, so /media/x.png == media/x.png. WASM trap inside that fix: on the browser runtime (unix path rules) Uri.TryCreate("/media/x.png", Absolute) SUCCEEDS as file:///media/x.png (fails on Windows — desktop shell tests mislead), so the root slash must be trimmed BEFORE NormalizeFilePath, not after. Debugging rule: an image cache miss draws nothing silently — check key both with and without leading slash before theorizing about render code.

Fullscreen canvas backgrounds

Real browser fullscreen targets the canvas host element (.xaml-canvas), not the page root — and browsers apply :not(:root):fullscreen::backdrop { background: black; }. If a scene expects an image/gradient behind a centered surface in fullscreen, style the actual fullscreen host AND its backdrop in GLOBAL CSS (scoped .razor.css can miss child pseudo-elements):

css
.game-page .xaml-canvas:fullscreen,
.game-page .xaml-canvas:fullscreen::backdrop { background-image: url('../Images/back.jpg'); }

CSS under wwwroot/css/ resolves urls relative to that folder (../Images/...).

WebGL context loss (GPU reset, driver update, too many canvases)

Handled since 1.10.6.22 on Blazor and pure Wasm: the canvas host prevents the default on webglcontextlost (else the browser never gives the context back) and draws no frames while lost; on webglcontextrestored the same WebGL object gets a new Emscripten GL handle, the lost Skia context is abandoned without GL calls and the next frame draws everything on a new one (GPU caches fail the context check and are made again). The console shows DrawnUI: WebGL context lost / restored (pure Wasm: DrawnUI.Web: WebGL context lost / restored). Test: canvas.getContext('webgl2').getExtension('WEBGL_lose_context'), loseContext(), then restoreContext(). Older builds stayed blank until a reload.

Canvas blink on first tap (auto-height Canvas) — FIXED 2026-09-07

Symptom: <Canvas> with no HeightRequest, blank flash on the FIRST tap only, never after; other pages (fixed HeightRequest) fine. NOT context loss, NOT element recreation — the same <canvas> is RESIZED once (host 790.4 -> 755.2px, buffer 987 -> 943), and any width/height write clears the drawing buffer.

Root cause: Canvas.razor GetIntrinsicAutoCanvasSize() passed UNIT constraints to Content.Measure(), which takes PIXELS. Probe: constraints=400x∞ scale=1,25 -> 320x790,4 — content measured at 400px = 320 units, 20% too narrow, so a label wrapped an extra line and the host was reported too tall; the first text change that dirtied the parent re-measured and the canvas shrank. Fix: UnitsToPixels(constraint, scale) before Measure. Rule: DrawnUi Measure(w,h,scale) constraints are PIXELS; MeasuredSize.Units are units — never mix.

Two related defects fixed alongside (neither was the trigger):

  • SkiaView/SkiaViewAccelerated did not declare Width/Height, so SKGLView/SKCanvasView CaptureUnmatchedValues splatted the floats onto the html canvas as culture-formatted attributes (height="755,2" under a ru browser locale) — invalid, browser resets the buffer to 300x150. Declare params so they are consumed.
  • After a canvas element size change nothing requested a repaint; under UpdateModeType.Dynamic the cleared buffer could stay blank until the next input. Canvas.OnAfterRenderAsync now calls Update() on size change.

Diagnostic recipe: MutationObserver on .xaml-canvas-element (style) + its canvas (width/height/style) + webglcontextlost — if the size changes, it is auto-size, not GPU.

Canvas blink (random one-frame blank while idle)

Root cause: heavy CSS filter: blur() / backdrop-filter: blur() elements elsewhere on the page. Each blur is its own GPU layer; large animated ones compete with the WebGL canvas layer and the browser randomly drops a canvas frame. Page-level — the same canvas doesn't blink in a plainer app.

  • NOT: canvas removal/hide (MutationObserver catches nothing), resize, Blazor re-render, preserveDrawingBuffer (constant repaint doesn't fix it), contain (makes it worse).
  • Diagnose by page-level elimination: display:none the blurred elements / backdrop-filter:none the header — blink stops → narrow to the biggest-radius animated blur.
  • Fix (also a perf win): replace blur-glow decoration with radial-gradient(circle, color 0%, transparent ~68%) backgrounds + plain transform: translate() drift. Reserve real blur for small static elements.
  • Often invisible to headless automation at DPR 1 — A/B on a real scaled display.
  • Diagnostics warning: never pipe Console into an on-screen panel while debugging canvas rendering — the render feedback loop fakes a per-frame-render symptom.

Full-height layout CSS (required)

Without this the canvas won't fill the viewport:

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 90 of 100Pattern checks on the skill's files.
  • Model reviewPassed

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

Repository stars
409
Repository forks
39
7-day trend score
0

Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.

Extension points

  • hookPre/post lifecycle hooks

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/drawnui/drawnui.net/drawnui-blazor/badge)](https://verified-skill.com/skills/drawnui/drawnui.net/drawnui-blazor)

Something wrong with this skill or its page? Tell us.