--- name: corgi description: Use this skill when working with any UI code --- Corgi is a custom React-like UI framework with JSX support, server-side rendering, hydration, and a controller-based architecture for managing state and handling events. ## Core Concepts ### Virtual DOM and JSX Corgi uses JSX with a custom factory function: ```tsx import * as corgi from 'external/dev_april_corgi+/js/corgi'; // JSX compiles to corgi.createVirtualElement calls function MyComponent() { return
Hello World
; } ``` Configure your tsconfig.json: ```json { "compilerOptions": { "jsx": "react", "jsxFactory": "corgi.createVirtualElement", "jsxFragmentFactory": "corgi.Fragment" } } ``` ### Component Functions Components are functions that receive props, state, and an updateState callback: ```tsx interface State { count: number; } function Counter( props: { initialValue?: number }, state: State | undefined, updateState: (newState: State) => void ) { // Initialize state on first render if (!state) { state = { count: props.initialValue ?? 0 }; } return (
Count: {state.count}
); } ``` ### Fragments Use `<>` or `corgi.Fragment` to group elements without a wrapper: ```tsx function List() { return <>
  • Item 1
  • Item 2
  • ; } ``` ## Controllers Controllers are classes that manage component behavior, handle events, and maintain state. They extend `Controller` and are attached to elements via the `js` prop. ### Basic Controller ```tsx import { Controller, Response } from 'external/dev_april_corgi+/js/corgi/controller'; import { EmptyDeps } from 'external/dev_april_corgi+/js/corgi/deps'; interface Args { multiplier: number; } interface State { count: number; } class CounterController extends Controller { constructor(response: Response) { super(response); // Access args via response.args // Access initial state via this.state // Access root element via this.root } increment(): void { this.updateState({ count: this.state.count + 1, }); } } ``` ### Controller Type Parameters Controllers have four type parameters: 1. `A` - Args type: Props passed from the component 2. `D` - Deps method type: Dependencies (services and child controllers) 3. `E` - Element type: The DOM element type (HTMLDivElement, HTMLInputElement, etc.) 4. `S` - State type: Component state ### Binding Controllers with `js=` Prop Use `corgi.bind()` to attach a controller to an element: ```tsx function Counter( props: {}, state: State | undefined, updateState: (newState: State) => void ) { if (!state) { state = { count: 0 }; } return (
    Count: {state.count}
    ); } ``` ### Bind Options - `controller`: The controller class constructor - `args`: Props to pass to the controller (accessible via `response.args`) - `state`: Tuple of [currentState, updateStateFn] - `events`: Map of DOM events to controller method names - `ref`: String identifier for dependency injection (see `ref=` section below) - `key`: Unique key for controller instance identity during patching. When a component re-renders and patches an element with a controller, Corgi decides whether to reuse the existing controller or create a new one. If the controller type and key both match, the existing controller instance is reused and only `updateArgs()` is called. If the key differs (or one binding has a key and the other doesn't), the old controller is disposed and a new one is created. This is useful when rendering lists where each item has the same controller type - without keys, reordering items would cause controllers to receive args for different items rather than being recreated. With unique keys (e.g., item IDs), each controller stays paired with its logical item. ## Event Handling ### Standard DOM Events via `events=` Map DOM events to controller methods: ```tsx js={corgi.bind({ controller: MyController, events: { click: 'handleClick', keydown: 'handleKeyDown', focus: 'handleFocus', blur: 'handleBlur', input: 'handleInput', change: 'handleChange', mousedown: 'handleMouseDown', mouseover: 'handleMouseOver', pointerdown: 'handlePointerDown', dragstart: 'handleDragStart', drop: 'handleDrop', render: 'wakeup', // Special: called when controller is instantiated }, })} ``` Available events: - Mouse: `click`, `mousedown`, `mouseup`, `mouseover`, `mouseout` - Pointer: `pointerdown`, `pointerup`, `pointermove`, `pointerenter`, `pointerleave`, `pointerover`, `pointerout` - Keyboard: `keydown`, `keypress`, `keyup` - Focus: `focus`, `blur`, `focusin`, `focusout` - Form: `input`, `change` - Drag: `drag`, `dragstart`, `dragend`, `dragenter`, `dragleave`, `dragover`, `drop` - Context: `contextmenu` - Special: `render` (called on controller instantiation) ### Custom Corgi Events Declare and handle custom events that bubble up through the component tree: ```tsx // Declare events in a shared file import { declareEvent } from 'external/dev_april_corgi+/js/corgi/events'; export const ACTION = declareEvent<{}>('myapp.action'); export const VALUE_CHANGED = declareEvent<{ value: string }>('myapp.valueChanged'); export const ITEM_SELECTED = declareEvent<{ id: number; label: string }>('myapp.itemSelected'); ``` Listen for custom events via the `corgi` key in events: ```tsx js={corgi.bind({ controller: ParentController, events: { corgi: [ [ACTION, 'handleAction'], [VALUE_CHANGED, 'handleValueChanged'], ], }, })} ``` Trigger custom events from a controller: ```tsx class ChildController extends Controller<...> { buttonClicked(): void { // Trigger a custom event that bubbles up to parent controllers this.trigger(ACTION, {}); this.trigger(VALUE_CHANGED, { value: this.root.value }); } } ``` ### CorgiEvent Type Event handlers receive a CorgiEvent with actionElement, targetElement, and detail: ```tsx import { CorgiEvent, DOM_KEYBOARD, DOM_MOUSE } from 'external/dev_april_corgi+/js/corgi/events'; class MyController extends Controller<...> { // For custom events handleValueChanged(e: CorgiEvent): void { console.log(e.detail.value); console.log(e.actionElement); // QueryOne for element that bound the event console.log(e.targetElement); // QueryOne for element that triggered it } // For DOM events, detail contains the native event handleKeyUp(e: CorgiEvent): void { if (e.detail.key === 'Enter') { this.trigger(ACTION, {}); } } handleClick(e: CorgiEvent): void { if (e.detail.ctrlKey) { // Ctrl+click handling } } } ``` ## Unbound Events with `unboundEvents=` For elements that don't have their own controller but need to trigger events on a parent controller, use `unboundEvents`. The handler names are strings that reference methods on the nearest ancestor controller. ```tsx function MyComponent(props: {}, state: State | undefined, updateState: (s: State) => void) { return (
    {/* These buttons don't have their own controller */} {/* Unbound events can also listen for custom corgi events */}
    ); } class ParentController extends Controller<...> { handleSave(): void { // Called when the Save button is clicked } handleCancel(): void { // Called when the Cancel button is clicked } handleInputChanged(e: CorgiEvent): void { console.log('Input changed:', e.detail.value); } } ``` Key differences from `events`: - `unboundEvents` is a prop on any element, not just controller-bound elements - Handler names are strings (the method name on the parent controller) - Events bubble up to find the nearest ancestor with a controller - Custom events use the same `corgi: [[EVENT, 'handler']]` syntax ## The `ref=` Prop and Dependency Injection The `ref` prop enables parent controllers to access child controllers via dependency injection. ### Setting a Ref ```tsx
    ``` This also adds `data-js-ref="childWidget"` attribute to the element. ### Declaring Dependencies Controllers declare dependencies via a static `deps()` method: ```tsx class ParentController extends Controller { static deps() { return { controllers: { // Single controller dependency (must find exactly one matching ref) childWidget: ChildController, }, controllerss: { // Multiple controllers with the same ref (finds all matching refs) listItems: ListItemController, }, services: { dialog: DialogService, history: HistoryService, }, }; } private readonly child: ChildController; private readonly items: ListItemController[]; private readonly dialog: DialogService; constructor(response: Response) { super(response); this.child = response.deps.controllers.childWidget; this.items = response.deps.controllerss.listItems; this.dialog = response.deps.services.dialog; } } ``` ### Dependency Resolution - `controllers`: Maps ref names to single controller instances - `controllerss`: Maps ref names to arrays of controller instances (note the double 's') - `services`: Maps names to singleton service instances The dependency system searches within the element's subtree for elements with matching `data-js-ref` attributes, stopping at elements that have their own `data-js` (controller boundary). ## Services Services are singletons that provide shared functionality across the application. ### Creating a Service ```tsx import { Service, ServiceResponse } from 'external/dev_april_corgi+/js/corgi/service'; import { EmptyDeps } from 'external/dev_april_corgi+/js/corgi/deps'; export class NotificationService extends Service { constructor(response: ServiceResponse) { super(response); } show(message: string): void { // Implementation } } ``` ### Service with Dependencies Services can depend on other services: ```tsx type Deps = typeof ApiService.deps; export class ApiService extends Service { static deps() { return { services: { auth: AuthService, }, }; } private readonly auth: AuthService; constructor(response: ServiceResponse) { super(response); this.auth = response.deps.services.auth; } } ``` ### Built-in Services **HistoryService**: Browser history management ```tsx import { HistoryService } from 'external/dev_april_corgi+/js/corgi/history/history_service'; class MyController extends Controller<...> { static deps() { return { services: { history: HistoryService } }; } navigateHome(): void { this.history.goTo('/'); // Push new URL this.history.replaceTo('/new'); // Replace current URL this.history.back(); // Go back this.history.reload(); // Notify listeners of current URL } } ``` **ViewsService**: Route matching and navigation ```tsx import { ViewsService, DiscriminatedRoute, matchPath } from 'external/dev_april_corgi+/js/corgi/history/views_service'; interface Routes { home: {}; user: { id: string }; } const routes: { [k in keyof Routes]: RegExp } = { home: /^\/$/, detail: /^\/items\/(?[^/]+)$/, }; class RouteController extends Controller<{}, Deps, HTMLDivElement, State> { static getInitialState(): State { const url = currentUrl(); const match = matchPath(url.pathname, routes); if (!match) throw new NotFoundError(); return { active: match }; } static deps() { return { services: { views: ViewsService }, }; } constructor(response: Response) { super(response); const views = response.deps.services.views; views.addListener(this); views.addRoutes(routes); this.registerDisposer(() => views.removeListener(this)); } routeChanged(active: DiscriminatedRoute, parameters: Record): Promise { return this.updateState({ active, parameters }); } } ``` **DialogService**: Modal dialog management ```tsx import { DialogService } from 'external/dev_april_corgi+/js/emu/dialog'; class MyController extends Controller<...> { static deps() { return { services: { dialog: DialogService } }; } async showConfirmation(): Promise { try { await this.dialog.display(); // User confirmed } catch { // User cancelled (clicked outside) } } } ``` ``` // In dialog content, trigger close this.trigger(CLOSE, { kind: 'resolve' }); // or 'reject' ``` ## Controller Lifecycle ### Instantiation Controllers are lazily instantiated when an event is first triggered on their element. Use the special `render` event to force immediate instantiation: ```tsx events: { render: 'wakeup', } ``` ### Disposal Controllers extend `Disposable` and are automatically disposed when their element is removed from the DOM. Use lifecycle hooks: ```tsx class MyController extends Controller<...> { constructor(response: Response) { super(response); // Register cleanup functions this.registerDisposer(() => { console.log('Controller being disposed'); }); // Register event listeners that auto-cleanup this.registerListener(window, 'resize', this.handleResize); // Register child disposables this.registerDisposable(someOtherDisposable); } } ``` ### State Updates Call `updateState()` to update state and trigger a re-render: ```tsx class CounterController extends Controller<...> { async increment(): Promise { await this.updateState({ ...this.state, count: this.state.count + 1, }); // State is now updated and component has re-rendered } } ``` State updates are debounced to batch rapid changes. ### Updating Args Override `updateArgs` to respond to prop changes: ```tsx class MyController extends Controller<...> { updateArgs(newArgs: Args): void { // Called when parent re-renders with new args if (newArgs.value !== this.currentValue) { this.handleValueChange(newArgs.value); } } } ``` ## DOM Queries Controllers have access to DOM query utilities: ```tsx class MyController extends Controller<...> { findChild(): void { // Query from root element const query = this.query(); // Find descendants const buttons = query.descendants('button'); // Returns Query const firstButton = buttons.one(); // Returns QueryOne (throws if not exactly 1) const allButtons = buttons.all(); // Returns QueryOne[] } } ``` There are many helper functions on queries: ```typescript // In controller const element = this.query() .descendants('.my-class') // Find all descendants matching selector .one() // Expect exactly one match .element(); // Get the DOM element // Query methods query.children(selector?) // Direct children query.descendants(selector) // All descendants matching selector query.parent(selector?) // Find parent(s) query.refs(refName) // Find elements with data-ref or data-js-ref query.filter(fn) // Filter elements query.map(fn) // Map over elements query.one() // Get single QueryOne (throws if not exactly one) query.all() // Get array of QueryOne // QueryOne methods queryOne.attr(key) // Get attribute as DataValue queryOne.data(key) // Get data-* attribute as DataValue queryOne.element() // Get DOM element // DataValue methods dataValue.string() // Get as string dataValue.number() // Get as number (throws if NaN) ``` ## Rendering and Hydration ### Client-Side Rendering ```tsx import * as corgi from 'external/dev_april_corgi+/js/corgi'; // Append element to DOM corgi.appendElement(document.body, ); ``` ### Server-Side Rendering with Hydration ```tsx // On the server: render to HTML string const html = renderToString(); // On the client: hydrate existing HTML if (process.env.CORGI_FOR_BROWSER) { corgi.hydrateElement(checkExists(document.getElementById('root')), ); } ``` ## Emu Component Library Corgi includes the Emu component library with pre-built components: ### Button ```tsx import { Button } from 'external/dev_april_corgi+/js/emu/button'; import { ACTION } from 'external/dev_april_corgi+/js/emu/events'; ``` ### Input ```tsx import { Input } from 'external/dev_april_corgi+/js/emu/input'; import { CHANGED, ACTION } from 'external/dev_april_corgi+/js/emu/events'; ``` ### Checkbox ```tsx import { Checkbox } from 'external/dev_april_corgi+/js/emu/checkbox'; import { ACTION } from 'external/dev_april_corgi+/js/emu/events'; I agree to the terms ``` ### Select ```tsx import { Select } from 'external/dev_april_corgi+/js/emu/select'; import { CHANGED } from 'external/dev_april_corgi+/js/emu/events';
      {state.todos.map(todo =>
    • {todo}
    • )}
    ); } // Bootstrap if (process.env.CORGI_FOR_BROWSER) { corgi.hydrateElement(checkExists(document.getElementById('root')), ); } ``` ## Best Practices 1. **Initialize state in components**: Always check `if (!state)` and initialize 2. **Use `checkExists`**: Instead of `!` assertions for null checks 3. **Use refs for controller dependencies**: Name child controllers with `ref` for injection 4. **Prefer unboundEvents for simple handlers**: When a child doesn't need its own controller 5. **Trigger custom events for component communication**: Use `declareEvent` and `this.trigger()` 6. **Use custom events for child-to-parent communication**: Don't pass callbacks as props 7. **Register cleanup with registerDisposer**: Prevent memory leaks 8. **Use registerListener for window/document events**: Auto-cleanup on disposal 9. **Debounce state updates**: `updateState` is already debounced, but batch related changes 10. **Type your state and args**: Use TypeScript interfaces for type safety 11. **Type your controllers**: Use all four generic parameters `Controller` 12. **Use `render: 'wakeup'` event**: To run initialization code after DOM is ready