Building React Quill Canvases
PostHogOfficialPublished by PostHog
- Skill
- Guidelines
- Data and AI
- MIT
- Low risk
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides the technical requirements and design patterns for building native-looking PostHog canvases. It covers the single-component React architecture, the mandatory use of the Quill component library, theme-aware styling, and data-handling practices. It emphasizes using platform-provided modules for data fetching and state management while ensuring consistent loading, error, and empty states across the interface.
What it does
- Defines the single-component contract for React-based PostHog canvases.
- Mandates the use of Quill components for all UI elements to ensure native look and feel.
- Provides patterns for theme-aware styling using design tokens instead of hardcoded values.
- Outlines requirements for independent loading, error, and empty states for each data point.
- Specifies the integration of the PostHog date picker and platform-provided data modules.
- Establishes rules for safe external library usage and network capability declarations.
When to use it
- When building a custom canvas that requires a native PostHog appearance.
- When implementing dashboards, data boards, or interactive tools within the PostHog environment.
- When creating checklists or runbooks that need to persist state across reloads.
- When transitioning a canvas request from a conceptual build to a functional React implementation.
Advantages
- Ensures visual and functional consistency with the PostHog platform.
- Provides a structured approach to handling complex data-driven UI states.
- Reduces development friction by offering a validated starter scaffold.
- Promotes secure and performant canvas behavior through strict import and styling rules.
Usage examples
- “How do I implement a table in my PostHog canvas?”
- Use the Quill Table component with Card, TableHeader, TableRow, and TableBody, ensuring the Table is wrapped in a Card with the flush prop for proper alignment.
- “How should I handle loading states for multiple charts on one page?”
- Render a skeleton in each individual Card while its specific query is pending, ensuring that one slow query does not block the rendering of other components.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/posthog/building-react-quill-canvases/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip building-react-quill-canvases.zip -d .agents/skills
Documentation by PostHog
Building React + Quill canvases
The whole application is one React/TSX file (src/canvas.tsx in the source project). It must
export default a single React component that takes no props — the host mounts it. Do not import
react-dom or call createRoot.
Start from the working scaffold in references/starter-scaffold.md
on a first build: it already wires the date picker, theme tokens, per-query loading state (every
card fills in independently as its own data lands), and correct typed-node result reading. Keep
that wiring; replace the sample metrics and layout.
Keep its pattern of one component per card with editable() params, and follow the "Params" section of the building-canvases skill for each card you add.
Imports
Use React, Quill, Recharts, Lucide, and Day.js for the standard application shell. The platform also admits ten optional libraries for specialized work. Read references/platform-libraries.md before choosing one.
PostHog data comes through import { ph } from "@posthog/canvas-sdk" — a platform-provided
module, so it needs no dependencies entry. The same object exists as the window.ph global
(how existing canvases reach it); prefer the import in new code.
Other bare imports, dynamic import(), require(), <script> tags, and remote code fail
validation. Direct network requests and external images, fonts, media, or frames require an exact
HTTPS origin in capabilities.network.origins. They work only in the published canvas — the
edit-mode preview blocks direct network access regardless of declaration. Stylesheets from declared
origins are allowed; remote scripts remain blocked, so bundle code with the canvas.
Quill component rules
A PostHog data board must be built entirely from @posthog/quill components — never a native
control or a styled <div> standing in for one:
Dropdown/picker →
Select(never a native<select>); button →Button(never<button>); text field →Input/Textarea; checkbox →Checkbox; label →Label.Table →
Table(TableHeader>TableRow>TableHead, thenTableBody>TableRow>TableCell); panel →Card(CardHeader+CardTitle+CardContent); pill →Badge; titles →Heading; body →Text.A
Tableinside aCardneeds<Card size="sm" flush>around<Table size="sm" fullWidth>. Withoutflushthe card's padding insets the table, so its header and rows stop meeting the card's edges.flushdrops the card's section gap, bottom padding, and theCardContentinline padding, while the header keeps its own padding and divider. Matchingsize="sm"on both lines the table's edge columns up with the card's title.The only non-Quill tags allowed are plain layout
<div>s andrechartselements.Quill is built on Base UI: compose compound parts (
Select+SelectTrigger/SelectContent/SelectItem), use controlledvalue+onValueChange, and swap a part's element with therenderprop (e.g.<PopoverTrigger render={<Button …/>} />) instead of wrapping it.Quill components are already themed — never restyle one with Tailwind classes or inline
style; use theirvariant/sizeprops. Put layout utilities (flex,grid,gap-4,p-4) on your own wrapper<div>s.Buttons: default to
variant="outline";variant="primary"for the one main action only.
Styling and theme
Give the canvas's outermost element
min-h-screen(min-height: 100vh) so it fills the iframe viewport and grows past it as content demands. Do not useh-screenthere: a fixed viewport height caps a flex column, so tall children shrink and clip instead of scrolling. Do not useh-fullthere either: a published canvas's artifact shell gives itshtml,body, and#rootelements no explicit height, so a percentage root height collapses to content height.The root needs no
overflow-y-auto: once it grows past the viewport the iframe's own document scrolls.A
min-heightroot is not a definite height, soh-fullon a direct child still collapses to content height. Give an intermediate wrapper an explicit height (h-[280px]) when a child must fill a box.Style with Tailwind utilities and Quill components; reserve inline
stylefor genuinely dynamic runtime values (fixed sizes use arbitrary-value utilities likeh-[280px]).Write specific interface copy. Never use lorem ipsum or placeholder labels in a finished canvas.
The canvas follows the user's PostHog theme; a
.darkclass on the document root flips at runtime. Color only from the design-token utilities — surfacesbg-background bg-card bg-muted bg-primary bg-success bg-warning bg-info bg-destructive; texttext-foreground text-muted-foreground text-card-foreground; bordersborder-border. Never a hardcoded hex or light-only color.Status tokens invert the usual convention: the bare token (
bg-success) is a pale background fill and-foreground(text-success-foreground) is the strong readable color. Colored text or icons always use the-foregroundutility; a filled pill pairsbg-success text-success-foreground. Prefer the QuillBadge(variant="success"/"destructive") for deltas so you don't hand-pick.bg-secondary,text-secondary,bg-accent, andbg-popoverare not defined in the canvas — avoid them.Never declare a CSS variable with a platform token name (
--background,--border,--card,--chrome,--input,--muted,--primary,--fill-*), in a stylesheet or a<style>block. Quill sets those on every element, so your value never applies and text can turn unreadable. Prefix your own variables (--doc-muted); validation rejects the collision withplatform_token_redeclared.recharts strokes/fills use token CSS variables (
stroke="var(--primary)", grid/axes invar(--border)/var(--muted-foreground)).Some recharts defaults are hardcoded for a light theme and go wrong on a dark canvas. The platform stylesheet corrects them, so do not work around one in canvas code. Pie slice outlines are one: recharts strokes every sector white, and the stylesheet removes the outline. The antialiasing seam between stacked bar segments is another, and so is the default tooltip, which the stylesheet inverts against the page so its text always meets contrast. A bare
<Tooltip />is the right thing to write.Write Unicode glyphs (curly quotes, ellipsis, arrows, emoji) as literal characters in JSX —
\uXXXXescapes render verbatim in JSX text.
Loading, error, and empty states
Every data point renders a skeleton in its own Card while loading or refreshing: SkeletonText
(matching lines and text-size className) for text/number values, Skeleton for blocks/charts.
Render progressively — each query owns its loading state. The chrome (heading, date picker,
card frames with skeletons inside) renders immediately, every independent query fires
concurrently on mount, and each card swaps its skeleton for data the moment its own query
resolves, so a slow query only holds back its own card. Never drive the whole canvas off one
shared loading flag or a Promise.all across independent queries — that makes the fastest
metric wait for the slowest. Set each section's loading state true again on refresh; never show
a blank or a jumping layout. Content the first paint doesn't show (an inactive tab, a collapsed
section, a drill-down) defers its query until the user reveals it.
A failed query and an empty result are different states — never let one render as the other.
.catch on every ph.query/ph.loadInsight must set an error state that renders visibly (the
message plus a Retry button wired to the refresh nonce, as in the starter scaffold), not fall
through to zeros, an empty chart, or a "no data yet" message. A query that silently swallows its
error makes real breakage (a missing table, an auth failure, a bad query) look like missing data.
Reserve the empty state for a query that succeeded with no rows.
Data-product patterns
Treat these as starting shapes and adapt them to the request and available data.
Product dashboard
Build a live board from verified PostHog data, never a static mockup.
Start with a
Heading, then a responsive grid of compactCardKPIs, trend charts, and useful breakdown tables.Show a
Badgedelta for KPIs when a meaningful comparison period exists.Use a
LineChartfor time series and aBarChartfor discrete categories. Do not turn every result into a table.Give every KPI, chart, and table its own loading, empty, and error state.
Make every figure verifiable: an insight-backed card gets a "View in PostHog" affordance opening its saved insight (
ph.openExternalwith a URL minted bygenerate-app-url); an ad-hocph.querycard gets a "View query"DialogorCollapsibleshowing the exact query that ran — see "Verifiability" inquerying-canvas-data.
Web analytics board
Use this shape when the request or legacy requested pattern says web-analytics:
Show Visitors, Page views, Sessions, Session duration, and Bounce rate as KPI cards, with deltas against the previous equal-length period.
Plot unique visitors over time, with a second line for the previous period when comparison data is available.
Follow with compact tables for top paths, traffic sources or channels, devices, and geography.
Prefix countries with flag emoji. Add retention or active-hours views only when the available data makes them useful.
Use the web analytics query kinds described in querying-canvas-data; do not recreate bounce rate,
sessionization, attribution, or unique visitors in HogQL. Format large values for display, such as
236K, while preserving the raw value for calculations and accessible labels.
Interactive explorer
Use controlled Quill inputs for each dimension, event, or date choice. Keep result sets small and refresh every dependent query when a control changes.
Checklist or runbook
For a checklist, QA runbook, launch plan, onboarding sequence, or any list of steps people work
through and tick off, start from the complete, validated project in
references/checklist-example.md. Its load-bearing parts — the
typed content module separate from the component, one shared ph.state key per step, the
debounced ref-alongside-state update path, an expected outcome on every step, and visible
load/save failure states — are what break when improvised. Keep them; replace the content.
Date window
A data board owns its own date control — render Quill's DateTimePicker (never a custom Select or
native date input) inside a Popover whose trigger is a Quill Button. PopoverContent gets
exactly className="w-auto p-0" and nothing is added to DateTimePicker beyond
value/onApply/onCancel (it self-sizes; don't pass compact or widths). Re-run every query
when the window changes — see the querying-canvas-data skill for feeding it into dateRange.
State and actions
Persisting values across reloads (ph.state, with user/shared scopes) and writing into PostHog
from a button (ph.actions.invoke, e.g. filing a task or an annotation, or ph.tools.call, e.g.
changing a feature flag rollout) have their own API contracts, capability declarations, and
gesture rules — read the "Runtime memory", "PostHog writes" and "PostHog MCP tools" sections of
the querying-canvas-data skill before using them. Wire actions to a
Button onClick that disables itself while the call is in flight, and render the returned
error (they are real PostHog writes, throttled server-side).
Files
- ATTRIBUTION.mdAdded by AutoGineer877 B
- LICENSELicence file added by AutoGineer1.0 KiB
- SKILL.md12.0 KiB
- references/checklist-example.md18.0 KiB
- references/platform-libraries.md3.3 KiB
- references/starter-scaffold.md10.7 KiB
references/checklist-example.md
A known-good checklist canvas
A complete, buildable checklist/runbook canvas, verified against the real canvas builder. Start from it whenever the request is a checklist, QA runbook, launch plan, onboarding sequence, or any list of steps people work through and tick off — keep its structure and replace the content.
The parts that break when improvised, in the order they matter:
Content lives in its own data module (
src/plan.ts), typed, separate from the component. Editing the steps later is a data edit, not a component rewrite — and a follow-up request like "add a step" touches one file.One shared
ph.statekey per step (step:<id>), holding{ done, note }. Progress is team-visible and survives reloads; per-key writes mean two people ticking different steps don't clobber each other the way one big list value would. An entry that returns to blank is deleted with a null write, so state never accumulates empty rows. Declarestate: ["shared"]incapabilities.posthog.A ref alongside React state for updates. Note saves are debounced per step; checkbox saves are immediate. Both go through one
update()that reads the latest entry from a ref, so a keystroke and a checkbox click in the same debounce window can't overwrite each other with stale values.Loading and failure are visible states. Skeletons while
ph.state.listresolves; a load failure says progress won't be remembered; a save failure says a change didn't save. Save errors are keyed per step, so one step's later success can't hide another step's failed write. Never fall through to an empty checklist that looks freshly reset.Every step states its expected outcome. A checkbox alone tells the runner what to do, not how to know it worked — the
expectline is what makes the list a runbook instead of a todo list. A notes field per step captures deviations where they happened.Destructive reset asks twice, then cancels pending saves. The reset button swaps to a confirm button instead of clearing shared progress on one click, and the reset clears every pending debounced timer first so an in-flight note save can't write itself back after the keys are deleted.
Capabilities for this project: the full capabilities.posthog shape with state: ["shared"] and everything else empty (insights: [], inlineQueries: false, captureEvents: [], actions: [], network.origins: []). Keep index.html and dependencies exactly as canvas-source-retrieve returned them.
The content module (src/plan.ts)
Replace the sample sections with the user's actual steps. Keep the shape: stable ids (they key the persisted state — renaming an id orphans its saved progress), an expect on every step, optional detail, cmd, and tag.
export type Step = {
id: string
title: string
detail?: string
cmd?: string
expect: string
tag?: string
}
export type Section = {
id: string
title: string
blurb: string
steps: Step[]
}
export const SECTIONS: Section[] = [
{
id: 'setup',
title: 'Environment',
blurb: 'Get a clean build running before touching the flows below.',
steps: [
{
id: 'setup-build',
title: 'Build and boot the app',
cmd: 'pnpm install && pnpm dev',
expect: 'The app opens with no errors in the console.',
},
{
id: 'setup-health',
title: 'Check the API is up',
cmd: 'curl -s https://api.example.com/healthz',
expect: 'A 200 response with {"status":"ok"}.',
tag: 'blocking',
},
],
},
{
id: 'flows',
title: 'Core flows',
blurb: 'The paths most users hit. Each step assumes the environment section passed.',
steps: [
{
id: 'flows-signup',
title: 'Sign up a fresh account',
detail: 'Use a throwaway address so the welcome email path runs end to end.',
expect: 'The account lands on the onboarding screen and the welcome email arrives.',
},
{
id: 'flows-invite',
title: 'Invite a teammate',
expect: 'The invite email arrives and its accept link joins the right workspace.',
},
{
id: 'flows-export',
title: 'Export a report',
detail: 'Any dashboard will do; the point is that the download completes.',
expect: 'A CSV downloads and opens with the expected columns.',
tag: 'flaky area',
},
],
},
]
export const TOTAL_STEPS = SECTIONS.reduce((n, s) => n + s.steps.length, 0)The component (src/canvas.tsx)
import React, { useEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Checkbox,
Heading,
Label,
Skeleton,
Textarea,
} from '@posthog/quill'
import { Check, ChevronDown, ChevronRight, Copy, RotateCcw } from 'lucide-react'
import { SECTIONS, TOTAL_STEPS } from './plan'
type Entry = { done: boolean; note: string }
type Entries = Record<string, Entry>
const PREFIX = 'step:'
const BLANK: Entry = { done: false, note: '' }
const RESET_KEY = '*reset'
const errorMessage = (err: unknown): string => String((err as { message?: string })?.message ?? err)
function CommandBlock({
id,
cmd,
copied,
onCopy,
}: {
id: string
cmd: string
copied: string | null
onCopy: (id: string, text: string) => Promise<void>
}) {
const ok = copied === id
const failed = copied === 'fail:' + id
return (
<div className="flex flex-col gap-1">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => void onCopy(id, cmd)}>
{ok ? <Check size={13} /> : <Copy size={13} />}
{ok ? 'Copied' : failed ? 'Select it manually' : 'Copy'}
</Button>
</div>
<pre className="overflow-x-auto rounded-md border border-border bg-muted p-3 text-xs leading-relaxed text-foreground">
<code>{cmd}</code>
</pre>
</div>
)
}
export default function Canvas() {
const [entries, setEntries] = useState<Entries>({})
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [saveErrors, setSaveErrors] = useState<Record<string, string>>({})
const [expanded, setExpanded] = useState<Record<string, boolean>>({ setup: true })
const [hideDone, setHideDone] = useState(false)
const [copied, setCopied] = useState<string | null>(null)
const [confirmReset, setConfirmReset] = useState(false)
const entriesRef = useRef<Entries>({})
const timers = useRef<Record<string, number>>({})
useEffect(() => {
let cancelled = false
const load = async (): Promise<void> => {
try {
const rows = await ph.state.list({ scope: 'shared' })
if (cancelled) {
return
}
const next: Entries = {}
for (const row of rows ?? []) {
const key = row?.key
const value = row?.value
if (typeof key === 'string' && key.startsWith(PREFIX) && value) {
next[key.slice(PREFIX.length)] = {
done: Boolean(value.done),
note: typeof value.note === 'string' ? value.note : '',
}
}
}
entriesRef.current = next
setEntries(next)
setLoading(false)
} catch (err) {
if (cancelled) {
return
}
setLoading(false)
setLoadError(errorMessage(err))
}
}
void load()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const pending = timers.current
return () => {
for (const id of Object.keys(pending)) {
window.clearTimeout(pending[id])
}
}
}, [])
// Save failures are tracked per step: a later success on one step must not
// hide another step's failed write.
const persist = async (stepId: string, entry: Entry): Promise<void> => {
const empty = !entry.done && entry.note.trim() === ''
try {
await ph.state.set(PREFIX + stepId, empty ? null : entry, { scope: 'shared' })
setSaveErrors(({ [stepId]: _cleared, ...rest }) => rest)
} catch (err) {
setSaveErrors((prev) => ({ ...prev, [stepId]: errorMessage(err) }))
}
}
// Keep a ref alongside state so a note keystroke and a checkbox click in the
// same debounce window can't read each other's stale value.
const update = (stepId: string, patch: Partial<Entry>, delay: number) => {
const next = { ...(entriesRef.current[stepId] ?? BLANK), ...patch }
entriesRef.current = { ...entriesRef.current, [stepId]: next }
setEntries(entriesRef.current)
window.clearTimeout(timers.current[stepId])
if (delay === 0) {
void persist(stepId, next)
} else {
timers.current[stepId] = window.setTimeout(() => void persist(stepId, next), delay)
}
}
const onCopy = async (id: string, text: string): Promise<void> => {
const flash = (value: string, ms: number) => {
setCopied(value)
window.setTimeout(() => setCopied((c) => (c === value ? null : c)), ms)
}
try {
await navigator.clipboard.writeText(text)
flash(id, 1500)
} catch {
flash('fail:' + id, 2500)
}
}
const resetAll = async (): Promise<void> => {
// Cancel pending debounced saves first, or a note typed just before the
// reset writes itself back after the keys are cleared.
for (const id of Object.keys(timers.current)) {
window.clearTimeout(timers.current[id])
}
timers.current = {}
try {
const keys = Object.keys(entriesRef.current)
await Promise.all(keys.map((k) => ph.state.set(PREFIX + k, null, { scope: 'shared' })))
entriesRef.current = {}
setEntries({})
setConfirmReset(false)
setSaveErrors({})
} catch (err) {
setSaveErrors((prev) => ({ ...prev, [RESET_KEY]: errorMessage(err) }))
}
}
const doneCount = useMemo(() => Object.values(entries).filter((e) => e.done).length, [entries])
const pct = TOTAL_STEPS ? Math.round((doneCount / TOTAL_STEPS) * 100) : 0
const allOpen = SECTIONS.every((s) => expanded[s.id])
const toggleAll = () => {
if (allOpen) {
setExpanded({})
return
}
const next: Record<string, boolean> = {}
for (const s of SECTIONS) {
next[s.id] = true
}
setExpanded(next)
}
return (
<div className="min-h-screen bg-background">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4 p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<Heading size="xl">Release QA</Heading>
<p className="mt-1 text-sm text-muted-foreground">
Work down the page: each section assumes the one above it passed. Checkmarks and notes are shared with
everyone who opens this canvas, and save as you type.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" onClick={toggleAll}>
{allOpen ? 'Collapse all' : 'Expand all'}
</Button>
{confirmReset ? (
<Button variant="primary" size="sm" onClick={() => void resetAll()}>
Confirm reset
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => setConfirmReset(true)}>
<RotateCcw size={14} />
Reset
</Button>
)}
</div>
</div>
<div className="flex items-center gap-3">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary" style={{ width: pct + '%' }} />
</div>
<span className="shrink-0 text-sm tabular-nums text-muted-foreground">
{doneCount} of {TOTAL_STEPS} done
</span>
</div>
<div className="flex items-center gap-2">
<Checkbox id="hide-done" checked={hideDone} onCheckedChange={(v) => setHideDone(Boolean(v))} />
<Label htmlFor="hide-done">Hide finished steps</Label>
</div>
{loadError && (
<Card size="sm">
<CardContent>
<p className="text-sm text-destructive-foreground">
Could not load saved progress: {loadError}. Your checkmarks and notes will not be remembered until this
clears.
</p>
</CardContent>
</Card>
)}
{Object.keys(saveErrors).length > 0 && (
<Card size="sm">
<CardContent>
<p className="text-sm text-destructive-foreground">
A change did not save: {Object.values(saveErrors)[0]}
</p>
</CardContent>
</Card>
)}
{loading ? (
<div className="flex flex-col gap-3">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : (
SECTIONS.map((section, sectionIndex) => {
const sectionDone = section.steps.filter((s) => entries[s.id]?.done).length
const complete = sectionDone === section.steps.length
const open = Boolean(expanded[section.id])
const visible = hideDone ? section.steps.filter((s) => !entries[s.id]?.done) : section.steps
return (
<Card key={section.id} size="sm">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
setExpanded((prev) => ({
...prev,
[section.id]: !prev[section.id],
}))
}
>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</Button>
<div className="min-w-0">
<CardTitle>
{sectionIndex + 1}. {section.title}
</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">{section.blurb}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variant={complete ? 'success' : undefined}>
{sectionDone}/{section.steps.length}
</Badge>
</div>
</div>
</CardHeader>
{open && (
<CardContent>
{visible.length === 0 ? (
<p className="text-sm text-muted-foreground">
Every step here is done. Untick Hide finished steps to see them.
</p>
) : (
<div className="flex flex-col gap-4">
{visible.map((step) => {
const entry = entries[step.id] ?? BLANK
const number = section.steps.findIndex((s) => s.id === step.id) + 1
return (
<div
key={step.id}
className="flex gap-3 border-t border-border pt-4 first:border-t-0 first:pt-0"
>
<div className="pt-0.5">
<Checkbox
checked={entry.done}
onCheckedChange={(v) => update(step.id, { done: Boolean(v) }, 0)}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<span
className={
entry.done
? 'text-sm font-medium text-muted-foreground line-through'
: 'text-sm font-medium text-foreground'
}
>
{sectionIndex + 1}.{number} {step.title}
</span>
{step.tag && <Badge>{step.tag}</Badge>}
</div>
{step.detail && <p className="text-sm text-muted-foreground">{step.detail}</p>}
{step.cmd && (
<CommandBlock id={step.id} cmd={step.cmd} copied={copied} onCopy={onCopy} />
)}
<div className="flex gap-2 rounded-md bg-muted p-2">
<span className="shrink-0 text-xs font-medium text-muted-foreground">Expect</span>
<span className="text-xs text-foreground">{step.expect}</span>
</div>
<Textarea
value={entry.note}
placeholder="Notes, deviations, anything you found"
onChange={(e) => update(step.id, { note: e.target.value }, 700)}
/>
</div>
</div>
)
})}
</div>
)}
</CardContent>
)}
</Card>
)
})
)}
</div>
</div>
)
}Adapt freely above the persistence layer: a "Before you start" card of prerequisites under the header, a per-section owner badge, or ordering hints all fit the same shape. What should survive every adaptation: the per-step shared-state keys, the ref-alongside-state update path, the visible load/save failure states, and the expect line on every step.
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs Node.js
Security
What our scanner noticed· Low risk
- Instructions that use the network
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Published by
- PostHog
- Publisher
- PostHog (Official)
- Source
- PostHog/skills · skills/omnibus/building-react-quill-canvases · commit a5d30e7
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by PostHog or PostHog.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download building-react-quill-canvases.zip