From qol-langs
Use when writing Preact + htm code in this workspace. Workspace-specific patterns and gotchas (no JSX, no build step, htm tagged templates from `lib/html.js`, keyboard-first hard rule, icon component extraction, toast/dissolve helpers, focus trapping, custom inputs, router guards, provider unmount/remount) — NOT a generic Preact reference. For canonical Preact docs, use context7. qol-tray-specific component hierarchy (Surface trait, plugin-config fields) lives in `qol-tray-ui-systems`.
How this skill is triggered — by the user, by Claude, or both
Slash command
/qol-langs:preact-conventionsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Preact + htm (tagged template literals), no JSX, no build step.
Preact + htm (tagged template literals), no JSX, no build step.
Every interactive component MUST be fully operable via keyboard BEFORE adding mouse/click handlers. This is non-negotiable.
role="tablist", role="tab", role="list", role="listitem")tabIndex="0" on containers that handle keyboard eventsdata-selected attribute on selected items for scroll-into-viewIf a PR adds a new view or component without keyboard navigation, it is incomplete.
import { html } from '../lib/html.js';
import { useState, useEffect, useCallback, useRef, useMemo } from 'preact/hooks';
htm uses html tagged templates, not JSX:
return html`<div class="foo" onClick=${handler}>${children}</div>`;
tabIndex="0" not tabindex="0". Preact uses camelCase DOM properties.onClick, onKeyDown, onBlur, onFocus, onWheel, onMouseEnter — camelCase.ref: works on elements and components, set during commit phase (available in useEffect/useLayoutEffect, NOT during render).key: works on any element, not just mapped lists. Changing key forces unmount+remount.dangerouslySetInnerHTML: dangerouslySetInnerHTML=${{ __html: str }} — avoid when possible.NEVER inline SVG markup in component files. SVG paths are opaque noise that drowns out the declarative intent of the component. Always extract SVGs into dedicated icon component files.
Store icons as individual Preact component files in ui/assets/:
ui/assets/
icon-copy.js
icon-close.js
icon-settings.js
icon-cog.js
Each file exports one component:
import { html } from '../lib/html.js';
export function IconCopy({ size = 13 }) {
return html`
<svg viewBox="0 0 16 16" width=${size} height=${size} fill="currentColor">
<path d="..."/>
</svg>
`;
}
Why not .svg files? <img src="icon.svg"> does not inherit currentColor from CSS. Inline SVG via Preact components does.
Why not a single icons.js? Individual files are discoverable, tree-shakeable, and avoid a growing monolith.
Why not inline in the component? SVG markup is long, cryptic, and not declarative. <${IconCog} size=${14} /> communicates intent; a raw <svg viewBox="0 0 16 16"><path d="M7 1h2l.3..."/> does not. Components read better when icons are named imports.
Usage:
import { IconCopy } from '../assets/icon-copy.js';
// ...
html`<button><${IconCopy} size=${16} /></button>`
Single unified mechanism for all user-facing messages (errors, success, info).
Dispatch from anywhere:
import { toast } from '../lib/toast.js';
toast('error', 'Failed to save config');
toast('success', 'Plugin updated');
toast('info', 'No configuration available');
How it works:
toast() dispatches a CustomEvent('app-toast') on windowGlobalToast component (mounted once in App.js) listens and renders toastsHTTP errors are automatic — the global fetch interceptor in api/client.js calls toast('error', ...) for any non-OK response.
Do NOT use per-view feedback state (useFeedback, setFeedback prop drilling). Use toast() directly.
dissolveIn(element, opts) from lib/dissolve.js creates a canvas overlay that dissolves away, revealing content underneath.
Trigger from callbacks (before state change):
const onSelect = useCallback((option) => {
dissolveIn(contentRef.current, DISSOLVE_OPTS);
updateState(option);
}, []);
Trigger across unmount/remount (module-level state):
let prevMode = null;
export function MyComponent({ mode }) {
const ref = useRef(null);
useEffect(() => {
if (prevMode !== null && prevMode !== mode && ref.current) {
dissolveIn(ref.current, DISSOLVE_OPTS);
}
prevMode = mode;
});
}
Requirements:
position: relative for the canvas to anchor correctlyoverflow: auto/hidden clips the canvas — use bleed: 0 for constrained containersdissolveIn appends a child canvas — if a MutationObserver is on the same element, filter out .dissolve-canvas nodes to avoid infinite loopsPattern for trapping Tab within a panel (only Escape exits):
const FOCUSABLE = 'input, select, button, [tabindex="0"]';
function handleKey(e) {
if (e.key === 'Tab') {
e.preventDefault();
if (detailRef.current?.contains(document.activeElement)) {
cycleFieldFocus(detailRef, e.shiftKey);
return;
}
navigateSidebar(e.shiftKey ? -1 : 1);
}
}
function cycleFieldFocus(detailRef, reverse) {
const focusables = Array.from(detailRef.current?.querySelectorAll(FOCUSABLE) || []);
if (focusables.length === 0) return;
const idx = focusables.indexOf(document.activeElement);
if (idx < 0) {
focusables[reverse ? focusables.length - 1 : 0].focus();
return;
}
const next = reverse
? (idx - 1 + focusables.length) % focusables.length
: (idx + 1) % focusables.length;
focusables[next].focus();
}
Key rules:
detailRef.contains(activeElement) for trap detection, not isFocusable() — catches everything including dropdown lists with tabIndex="-1"cycleFieldFocus can't find activeElement in the list (idx < 0), jump to first/last instead of wrapping from -1return without stopPropagation) so the parent trap handles itstopPropagation() to prevent the parent from also handling ittabIndex="0" div, arrow keys step value, wheel adjustseditInitRef)useEffect focuses the input and applies initial character or selects allactiveElement is body)onTriggerKeyDown — let the button's native click handle them (avoids Space keyup race condition)tabIndex="-1")stopPropagation()onBlur on list closes if relatedTarget is outside containertabIndex, role="switch", aria-checked, and onKeyDown go on the ROW, not the trackValidate before setting state, not after:
const openPluginConfig = useCallback(async (pluginId) => {
if (!await validatePluginConfig(pluginId)) return false;
setActivePluginId(pluginId);
return true;
}, []);
Apply the guard at EVERY entry point:
Do NOT use auto-close (navigate back after mount) as a guard — it causes visual glitches and violates the principle that invalid state should never be entered.
Changing the component type inside a provider causes the entire subtree to unmount:
// BAD — switches between ActiveProvider and bare Provider
function ConfigProvider({ pluginId, children }) {
if (!pluginId) return html`<${Context.Provider} value=${null}>${children}<//>`;
return html`<${ActiveProvider} pluginId=${pluginId}>${children}<//>`;
}
This unmounts and remounts ALL children (including sidebar, config view, etc.) when pluginId changes. Module-level state or refs survive, component state does not.
When a component unmounts/remounts (e.g., due to provider switching), use module-level variables to preserve state:
let prevMode = null; // survives unmount
export function MyComponent({ mode }) {
useEffect(() => {
if (prevMode !== null && prevMode !== mode) doTransition();
prevMode = mode;
});
}
For per-key persistence (e.g., selected section per plugin), use usePersistedIndex(storageKey, default) which reads/writes localStorage.
Preact does not forward ref through functional components by default. <MyButton ref={r} /> where MyButton is a function component leaves r.current null (or set to internal Preact state), NOT the DOM node MyButton rendered. Code that depends on r.current.focus() will silently no-op.
// BAD: r.current is not the rendered button
function Button({ children, ...rest }) {
return html`<button class="btn" ...${rest}>${children}<//>`;
}
function Caller() {
const r = useRef(null);
return html`<${Button} ref=${r}>Save<//>`;
}
// GOOD (option A): wrapper span with display:contents
function Caller() {
const r = useRef(null);
const focusBtn = () => r.current?.querySelector('button')?.focus();
return html`<span ref=${r} style="display: contents">
<${Button}>Save<//>
</span>`;
}
// GOOD (option B): forwardRef from preact/compat
import { forwardRef } from 'preact/compat';
const Button = forwardRef(({ children, ...rest }, ref) =>
html`<button ref=${ref} class="btn" ...${rest}>${children}<//>`);
Common symptom: focus restoration after a modal/input unmounts looks correct in code but does nothing at runtime. display: contents keeps flex and grid layouts intact while giving you a stable DOM handle.
npx claudepluginhub qol-tools/qol-skills --plugin qol-langsCreates structured, bite-sized implementation plans from specs or requirements before writing code. Useful for breaking down multi-step tasks into testable steps with file structure and task boundaries.