PettyUI/docs/superpowers/plans/2026-03-28-foundation.md
Mats Bosson db906fd85a Fix linting config and package fields
- Replace .eslintrc.cjs with eslint.config.mjs (ESLint 9 flat config)
  using direct eslint-plugin-solid + @typescript-eslint/parser approach
- Add @typescript-eslint/parser to root devDependencies
- Add main/module/types top-level fields to packages/core/package.json
- Add resolve.conditions to packages/core/vite.config.ts
- Create packages/core/tsconfig.test.json for test type-checking
- Remove empty paths:{} from packages/core/tsconfig.json
2026-03-29 02:35:57 +07:00

71 KiB

PettyUI Foundation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Bootstrap the PettyUI monorepo with tooling, all internal primitives, all standalone utilities, and a fully working Dialog component that proves every architectural pattern.

Architecture: Solid-native signals + context. Every component shares a set of internal primitives (createControllableSignal, createDisclosureState, createRegisterId). Standalone utilities (Presence, createFocusTrap, createScrollLock, createDismiss, Portal, VisuallyHidden) are exported independently. Dialog is the reference implementation — it exercises every primitive and utility. All patterns established here repeat verbatim across Wave 1 and Wave 2.

Tech Stack: SolidJS 1.9.x, TypeScript 6.x, pnpm 10.x, Vite 8.x, tsdown 0.x, Vitest 4.x, @solidjs/testing-library 0.8.x, Biome, ESLint + eslint-plugin-solid, @floating-ui/dom 1.7.x, Zod 4.x (build-time only)


File Map

pettyui/
├── package.json                                  — root workspace config
├── pnpm-workspace.yaml
├── tsconfig.base.json                            — shared TS config
├── biome.json                                    — Biome formatting + lint
├── .eslintrc.cjs                                 — minimal ESLint (solid rules only)
│
├── packages/
│   └── core/
│       ├── package.json
│       ├── tsconfig.json
│       ├── tsdown.config.ts
│       ├── vite.config.ts                        — for tests
│       │
│       ├── src/
│       │   ├── primitives/                       — internal, NOT exported
│       │   │   ├── create-controllable-signal.ts
│       │   │   ├── create-disclosure-state.ts
│       │   │   ├── create-register-id.ts
│       │   │   └── index.ts                      — barrel (internal only)
│       │   │
│       │   ├── utilities/                        — exported standalone
│       │   │   ├── presence/
│       │   │   │   ├── presence.tsx
│       │   │   │   └── index.ts
│       │   │   ├── focus-trap/
│       │   │   │   ├── create-focus-trap.ts
│       │   │   │   └── index.ts
│       │   │   ├── scroll-lock/
│       │   │   │   ├── create-scroll-lock.ts
│       │   │   │   └── index.ts
│       │   │   ├── dismiss/
│       │   │   │   ├── create-dismiss.ts
│       │   │   │   └── index.ts
│       │   │   ├── portal/
│       │   │   │   ├── portal.tsx
│       │   │   │   └── index.ts
│       │   │   └── visually-hidden/
│       │   │       ├── visually-hidden.tsx
│       │   │       └── index.ts
│       │   │
│       │   ├── components/
│       │   │   └── dialog/
│       │   │       ├── dialog-context.ts         — dual context definitions + types
│       │   │       ├── dialog-root.tsx           — Root: signal + context provider
│       │   │       ├── dialog-trigger.tsx        — Trigger: opens dialog
│       │   │       ├── dialog-portal.tsx         — Portal: wraps utility portal
│       │   │       ├── dialog-overlay.tsx        — Overlay: backdrop
│       │   │       ├── dialog-content.tsx        — Content: focus trap + ARIA
│       │   │       ├── dialog-title.tsx          — Title: registers ID
│       │   │       ├── dialog-description.tsx    — Description: registers ID
│       │   │       ├── dialog-close.tsx          — Close: calls setOpen(false)
│       │   │       └── index.ts                  — assembles Dialog object
│       │   │
│       │   └── index.ts                          — main package entry
│       │
│       └── tests/
│           ├── primitives/
│           │   ├── create-controllable-signal.test.ts
│           │   ├── create-disclosure-state.test.ts
│           │   └── create-register-id.test.ts
│           ├── utilities/
│           │   ├── presence.test.tsx
│           │   ├── focus-trap.test.ts
│           │   ├── scroll-lock.test.ts
│           │   ├── dismiss.test.ts
│           │   └── portal.test.tsx
│           └── components/
│               └── dialog/
│                   ├── dialog-rendering.test.tsx
│                   ├── dialog-keyboard.test.tsx
│                   ├── dialog-aria.test.tsx
│                   └── dialog-controlled.test.tsx

Task 1: Repository Scaffold

Files:

  • Create: package.json

  • Create: pnpm-workspace.yaml

  • Create: tsconfig.base.json

  • Create: biome.json

  • Create: .eslintrc.cjs

  • Create: .gitignore

  • Create: packages/core/package.json

  • Create: packages/core/tsconfig.json

  • Create: packages/core/vite.config.ts

  • Create: packages/core/tsdown.config.ts

  • Step 1: Create root package.json

{
  "name": "pettyui-monorepo",
  "private": true,
  "scripts": {
    "build": "pnpm -r build",
    "test": "pnpm -r test",
    "lint": "biome check . && eslint packages --ext .ts,.tsx",
    "format": "biome format --write .",
    "typecheck": "pnpm -r typecheck"
  },
  "devDependencies": {
    "@biomejs/biome": "^1.9.0",
    "eslint": "^9.0.0",
    "eslint-plugin-solid": "^0.14.0",
    "typescript": "^6.0.2",
    "zod": "^4.3.6"
  }
}
  • Step 2: Create pnpm-workspace.yaml
packages:
  - "packages/*"
  • Step 3: Create tsconfig.base.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "preserve",
    "jsxImportSource": "solid-js",
    "strict": true,
    "exactOptionalPropertyTypes": true,
    "noUncheckedIndexedAccess": true,
    "verbatimModuleSyntax": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "esModuleInterop": false,
    "skipLibCheck": true
  }
}
  • Step 4: Create biome.json
{
  "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
  "organizeImports": { "enabled": true },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "suspicious": {
        "noExplicitAny": "error"
      },
      "style": {
        "useConst": "error",
        "useTemplate": "error"
      }
    }
  },
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "double",
      "semicolons": "always",
      "trailingCommas": "all"
    }
  },
  "files": {
    "ignore": ["node_modules", "dist", ".tsdown", "coverage"]
  }
}
  • Step 5: Create .eslintrc.cjs (Solid rules only)
/** @type {import('eslint').Linter.Config} */
module.exports = {
  root: true,
  parser: "@typescript-eslint/parser",
  parserOptions: {
    ecmaVersion: 2020,
    sourceType: "module",
  },
  plugins: ["solid"],
  extends: ["plugin:solid/typescript"],
  rules: {
    // Only Solid-specific rules — everything else handled by Biome
    "solid/reactivity": "error",
    "solid/no-destructure": "error",
    "solid/prefer-for": "warn",
    "solid/no-react-deps": "error",
    "solid/no-react-specific-props": "error",
  },
};
  • Step 6: Create .gitignore
node_modules/
dist/
.tsdown/
coverage/
*.tsbuildinfo
.superpowers/
  • Step 7: Create packages/core/package.json
{
  "name": "pettyui",
  "version": "0.1.0",
  "description": "AI-native headless UI component library for SolidJS",
  "type": "module",
  "exports": {
    ".": {
      "solid": "./src/index.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./dialog": {
      "solid": "./src/components/dialog/index.ts",
      "import": "./dist/dialog/index.js",
      "require": "./dist/dialog/index.cjs"
    },
    "./presence": {
      "solid": "./src/utilities/presence/index.ts",
      "import": "./dist/utilities/presence/index.js",
      "require": "./dist/utilities/presence/index.cjs"
    },
    "./focus-trap": {
      "solid": "./src/utilities/focus-trap/index.ts",
      "import": "./dist/utilities/focus-trap/index.js",
      "require": "./dist/utilities/focus-trap/index.cjs"
    },
    "./scroll-lock": {
      "solid": "./src/utilities/scroll-lock/index.ts",
      "import": "./dist/utilities/scroll-lock/index.js",
      "require": "./dist/utilities/scroll-lock/index.cjs"
    },
    "./dismiss": {
      "solid": "./src/utilities/dismiss/index.ts",
      "import": "./dist/utilities/dismiss/index.js",
      "require": "./dist/utilities/dismiss/index.cjs"
    },
    "./portal": {
      "solid": "./src/utilities/portal/index.ts",
      "import": "./dist/utilities/portal/index.js",
      "require": "./dist/utilities/portal/index.cjs"
    },
    "./visually-hidden": {
      "solid": "./src/utilities/visually-hidden/index.ts",
      "import": "./dist/utilities/visually-hidden/index.js",
      "require": "./dist/utilities/visually-hidden/index.cjs"
    }
  },
  "scripts": {
    "build": "tsdown",
    "test": "vitest run",
    "test:watch": "vitest",
    "typecheck": "tsc --noEmit"
  },
  "peerDependencies": {
    "solid-js": "^1.9.0"
  },
  "dependencies": {
    "@floating-ui/dom": "^1.7.6"
  },
  "devDependencies": {
    "@solidjs/testing-library": "^0.8.10",
    "@testing-library/jest-dom": "^6.0.0",
    "@testing-library/user-event": "^14.0.0",
    "jsdom": "^26.0.0",
    "solid-js": "^1.9.12",
    "tsdown": "^0.21.7",
    "vite": "^8.0.3",
    "vite-plugin-solid": "^2.11.11",
    "vitest": "^4.1.2"
  }
}
  • Step 8: Create packages/core/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "baseUrl": ".",
    "paths": {}
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist", "tests"]
}
  • Step 9: Create packages/core/vite.config.ts
import { defineConfig } from "vite";
import solid from "vite-plugin-solid";

export default defineConfig({
  plugins: [solid()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./tests/setup.ts"],
    transformMode: { web: [/\.[jt]sx?$/] },
  },
});
  • Step 10: Create packages/core/tests/setup.ts
import "@testing-library/jest-dom";
  • Step 11: Create packages/core/tsdown.config.ts
import { defineConfig } from "tsdown";

export default defineConfig({
  entry: {
    index: "src/index.ts",
    "dialog/index": "src/components/dialog/index.ts",
    "presence/index": "src/utilities/presence/index.ts",
    "focus-trap/index": "src/utilities/focus-trap/index.ts",
    "scroll-lock/index": "src/utilities/scroll-lock/index.ts",
    "dismiss/index": "src/utilities/dismiss/index.ts",
    "portal/index": "src/utilities/portal/index.ts",
    "visually-hidden/index": "src/utilities/visually-hidden/index.ts",
  },
  format: ["esm", "cjs"],
  dts: true,
  clean: true,
  sourcemap: true,
  external: ["solid-js", "solid-js/web", "solid-js/store"],
});
  • Step 12: Install dependencies and verify
cd /Users/matsbosson/Documents/StayThree/PettyUI
pnpm install

Expected: lock file created, no errors.

  • Step 13: Commit
git init
git add .
git commit -m "chore: scaffold monorepo with tooling"

Task 2: Internal Primitives — createControllableSignal

Files:

  • Create: packages/core/src/primitives/create-controllable-signal.ts

  • Create: packages/core/tests/primitives/create-controllable-signal.test.ts

  • Step 1: Write the failing test

// packages/core/tests/primitives/create-controllable-signal.test.ts
import { createRoot, createSignal } from "solid-js";
import { describe, expect, it, vi } from "vitest";
import { createControllableSignal } from "../../src/primitives/create-controllable-signal";

describe("createControllableSignal", () => {
  it("uses defaultValue when value accessor returns undefined (uncontrolled)", () => {
    createRoot((dispose) => {
      const [get] = createControllableSignal({
        value: () => undefined,
        defaultValue: () => false,
      });
      expect(get()).toBe(false);
      dispose();
    });
  });

  it("uses value accessor when provided (controlled)", () => {
    createRoot((dispose) => {
      const [get] = createControllableSignal({
        value: () => true,
        defaultValue: () => false,
      });
      expect(get()).toBe(true);
      dispose();
    });
  });

  it("calls onChange when setter is called in uncontrolled mode", () => {
    createRoot((dispose) => {
      const onChange = vi.fn();
      const [, set] = createControllableSignal({
        value: () => undefined,
        defaultValue: () => false,
        onChange,
      });
      set(true);
      expect(onChange).toHaveBeenCalledWith(true);
      dispose();
    });
  });

  it("updates internal signal when setter called in uncontrolled mode", () => {
    createRoot((dispose) => {
      const [get, set] = createControllableSignal({
        value: () => undefined,
        defaultValue: () => "initial",
      });
      set("updated");
      expect(get()).toBe("updated");
      dispose();
    });
  });

  it("does not update internal signal in controlled mode — external value is source of truth", () => {
    createRoot((dispose) => {
      const [externalValue] = createSignal<string>("controlled");
      const [get, set] = createControllableSignal({
        value: externalValue,
        defaultValue: () => "default",
      });
      set("ignored");
      // Still reflects external value since we're controlled
      expect(get()).toBe("controlled");
      dispose();
    });
  });
});
  • Step 2: Run test to verify it fails
cd packages/core && pnpm vitest run tests/primitives/create-controllable-signal.test.ts

Expected: FAIL — Cannot find module '../../src/primitives/create-controllable-signal'

  • Step 3: Implement createControllableSignal
// packages/core/src/primitives/create-controllable-signal.ts
import { type Accessor, createSignal } from "solid-js";

export interface CreateControllableSignalOptions<T> {
  /** Returns the controlled value, or undefined if uncontrolled. */
  value: Accessor<T | undefined>;
  /** Default value used when uncontrolled. */
  defaultValue: Accessor<T>;
  /** Called whenever the value changes (both modes). */
  onChange?: (value: T) => void;
}

/**
 * Handles controlled vs uncontrolled state for any stateful component.
 * When `value()` is not undefined, the component is controlled — the external
 * value is the source of truth. Otherwise, an internal signal manages state.
 */
export function createControllableSignal<T>(
  options: CreateControllableSignalOptions<T>,
): [Accessor<T>, (value: T) => void] {
  const [internalValue, setInternalValue] = createSignal<T>(options.defaultValue());

  const get: Accessor<T> = () => {
    const controlled = options.value();
    return controlled !== undefined ? controlled : internalValue();
  };

  const set = (value: T) => {
    const isControlled = options.value() !== undefined;
    if (!isControlled) {
      setInternalValue(() => value);
    }
    options.onChange?.(value);
  };

  return [get, set];
}
  • Step 4: Run test to verify it passes
cd packages/core && pnpm vitest run tests/primitives/create-controllable-signal.test.ts

Expected: PASS — 5 tests passed.

  • Step 5: Commit
git add packages/core/src/primitives/create-controllable-signal.ts packages/core/tests/primitives/create-controllable-signal.test.ts
git commit -m "feat: add createControllableSignal primitive"

Task 3: Internal Primitives — createDisclosureState

Files:

  • Create: packages/core/src/primitives/create-disclosure-state.ts

  • Create: packages/core/tests/primitives/create-disclosure-state.test.ts

  • Step 1: Write the failing test

// packages/core/tests/primitives/create-disclosure-state.test.ts
import { createRoot } from "solid-js";
import { describe, expect, it, vi } from "vitest";
import { createDisclosureState } from "../../src/primitives/create-disclosure-state";

describe("createDisclosureState", () => {
  it("starts closed by default", () => {
    createRoot((dispose) => {
      const state = createDisclosureState({});
      expect(state.isOpen()).toBe(false);
      dispose();
    });
  });

  it("respects defaultOpen", () => {
    createRoot((dispose) => {
      const state = createDisclosureState({ defaultOpen: true });
      expect(state.isOpen()).toBe(true);
      dispose();
    });
  });

  it("open() sets state to true", () => {
    createRoot((dispose) => {
      const state = createDisclosureState({});
      state.open();
      expect(state.isOpen()).toBe(true);
      dispose();
    });
  });

  it("close() sets state to false", () => {
    createRoot((dispose) => {
      const state = createDisclosureState({ defaultOpen: true });
      state.close();
      expect(state.isOpen()).toBe(false);
      dispose();
    });
  });

  it("toggle() flips state", () => {
    createRoot((dispose) => {
      const state = createDisclosureState({});
      state.toggle();
      expect(state.isOpen()).toBe(true);
      state.toggle();
      expect(state.isOpen()).toBe(false);
      dispose();
    });
  });

  it("calls onOpenChange when state changes", () => {
    createRoot((dispose) => {
      const onChange = vi.fn();
      const state = createDisclosureState({ onOpenChange: onChange });
      state.open();
      expect(onChange).toHaveBeenCalledWith(true);
      state.close();
      expect(onChange).toHaveBeenCalledWith(false);
      dispose();
    });
  });

  it("respects controlled open prop", () => {
    createRoot((dispose) => {
      const state = createDisclosureState({ open: true });
      expect(state.isOpen()).toBe(true);
      // Calling close() fires onChange but does not change internal state
      const onChange = vi.fn();
      const controlled = createDisclosureState({ open: true, onOpenChange: onChange });
      controlled.close();
      expect(controlled.isOpen()).toBe(true); // still controlled
      expect(onChange).toHaveBeenCalledWith(false);
      dispose();
    });
  });
});
  • Step 2: Run test to verify it fails
cd packages/core && pnpm vitest run tests/primitives/create-disclosure-state.test.ts

Expected: FAIL — module not found.

  • Step 3: Implement createDisclosureState
// packages/core/src/primitives/create-disclosure-state.ts
import type { Accessor } from "solid-js";
import { createControllableSignal } from "./create-controllable-signal";

export interface CreateDisclosureStateOptions {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
}

export interface DisclosureState {
  isOpen: Accessor<boolean>;
  open: () => void;
  close: () => void;
  toggle: () => void;
}

/**
 * Shared open/close state for all disclosure components (Dialog, Popover,
 * Tooltip, Collapsible, etc.). Wraps createControllableSignal with
 * convenience open/close/toggle methods.
 */
export function createDisclosureState(
  options: CreateDisclosureStateOptions,
): DisclosureState {
  const [isOpen, setIsOpen] = createControllableSignal<boolean>({
    value: () => options.open,
    defaultValue: () => options.defaultOpen ?? false,
    onChange: options.onOpenChange,
  });

  return {
    isOpen,
    open: () => setIsOpen(true),
    close: () => setIsOpen(false),
    toggle: () => setIsOpen(!isOpen()),
  };
}
  • Step 4: Run test to verify it passes
cd packages/core && pnpm vitest run tests/primitives/create-disclosure-state.test.ts

Expected: PASS — 7 tests passed.

  • Step 5: Commit
git add packages/core/src/primitives/create-disclosure-state.ts packages/core/tests/primitives/create-disclosure-state.test.ts
git commit -m "feat: add createDisclosureState primitive"

Task 4: Internal Primitives — createRegisterId

Files:

  • Create: packages/core/src/primitives/create-register-id.ts

  • Create: packages/core/tests/primitives/create-register-id.test.ts

  • Create: packages/core/src/primitives/index.ts

  • Step 1: Write the failing test

// packages/core/tests/primitives/create-register-id.test.ts
import { createRoot } from "solid-js";
import { describe, expect, it } from "vitest";
import { createRegisterId } from "../../src/primitives/create-register-id";

describe("createRegisterId", () => {
  it("starts with undefined", () => {
    createRoot((dispose) => {
      const [getId] = createRegisterId();
      expect(getId()).toBeUndefined();
      dispose();
    });
  });

  it("returns registered id after set", () => {
    createRoot((dispose) => {
      const [getId, setId] = createRegisterId();
      setId("my-id");
      expect(getId()).toBe("my-id");
      dispose();
    });
  });

  it("returns undefined after clearing", () => {
    createRoot((dispose) => {
      const [getId, setId] = createRegisterId();
      setId("my-id");
      setId(undefined);
      expect(getId()).toBeUndefined();
      dispose();
    });
  });
});
  • Step 2: Run test to verify it fails
cd packages/core && pnpm vitest run tests/primitives/create-register-id.test.ts

Expected: FAIL — module not found.

  • Step 3: Implement createRegisterId
// packages/core/src/primitives/create-register-id.ts
import type { Accessor } from "solid-js";
import { createSignal } from "solid-js";

/**
 * Creates a reactive slot for an element's ID.
 * Child parts (e.g. Dialog.Title) call setId on mount and clear it on cleanup.
 * Parent parts (e.g. Dialog.Content) read getId to set aria-labelledby etc.
 */
export function createRegisterId(): [
  Accessor<string | undefined>,
  (id: string | undefined) => void,
] {
  const [id, setId] = createSignal<string | undefined>(undefined);
  return [id, setId];
}
  • Step 4: Create primitives barrel (internal only)
// packages/core/src/primitives/index.ts
export { createControllableSignal } from "./create-controllable-signal";
export type { CreateControllableSignalOptions } from "./create-controllable-signal";
export { createDisclosureState } from "./create-disclosure-state";
export type { CreateDisclosureStateOptions, DisclosureState } from "./create-disclosure-state";
export { createRegisterId } from "./create-register-id";
  • Step 5: Run test to verify it passes
cd packages/core && pnpm vitest run tests/primitives/

Expected: PASS — all 3 test files, 15 tests total.

  • Step 6: Commit
git add packages/core/src/primitives/
git commit -m "feat: add createRegisterId primitive and primitives barrel"

Task 5: Utility — VisuallyHidden and Portal

Files:

  • Create: packages/core/src/utilities/visually-hidden/visually-hidden.tsx

  • Create: packages/core/src/utilities/visually-hidden/index.ts

  • Create: packages/core/src/utilities/portal/portal.tsx

  • Create: packages/core/src/utilities/portal/index.ts

  • Create: packages/core/tests/utilities/portal.test.tsx

  • Step 1: Write failing test for Portal

// packages/core/tests/utilities/portal.test.tsx
import { render } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Portal } from "../../src/utilities/portal/portal";

describe("Portal", () => {
  it("renders children into document.body by default", () => {
    render(() => <Portal><div data-testid="portal-content">hello</div></Portal>);
    // Content should be in document.body, not the render container
    expect(document.body.querySelector("[data-testid='portal-content']")).toBeTruthy();
  });

  it("renders children into a custom target", () => {
    const target = document.createElement("div");
    document.body.appendChild(target);
    render(() => (
      <Portal target={target}>
        <div data-testid="custom-portal">hello</div>
      </Portal>
    ));
    expect(target.querySelector("[data-testid='custom-portal']")).toBeTruthy();
    document.body.removeChild(target);
  });
});
  • Step 2: Run test to verify it fails
cd packages/core && pnpm vitest run tests/utilities/portal.test.tsx

Expected: FAIL — module not found.

  • Step 3: Implement VisuallyHidden
// packages/core/src/utilities/visually-hidden/visually-hidden.tsx
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";

export interface VisuallyHiddenProps extends JSX.HTMLAttributes<HTMLSpanElement> {
  children?: JSX.Element;
}

const visuallyHiddenStyle: JSX.CSSProperties = {
  position: "absolute",
  border: "0",
  width: "1px",
  height: "1px",
  padding: "0",
  margin: "-1px",
  overflow: "hidden",
  clip: "rect(0, 0, 0, 0)",
  "white-space": "nowrap",
  "word-wrap": "normal",
};

/**
 * Renders content visually hidden but accessible to screen readers.
 */
export function VisuallyHidden(props: VisuallyHiddenProps): JSX.Element {
  const [local, rest] = splitProps(props, ["children", "style"]);
  return (
    <span
      style={{ ...visuallyHiddenStyle, ...(local.style as JSX.CSSProperties | undefined) }}
      {...rest}
    >
      {local.children}
    </span>
  );
}
  • Step 4: Implement Portal
// packages/core/src/utilities/portal/portal.tsx
import type { JSX } from "solid-js";
import { Portal as SolidPortal } from "solid-js/web";
import { isServer } from "solid-js/web";

export interface PortalProps {
  /** Target container. Defaults to document.body. */
  target?: Element | null;
  children: JSX.Element;
}

/**
 * SSR-safe portal. During SSR, renders content inline.
 * On the client, moves content to the target container.
 */
export function Portal(props: PortalProps): JSX.Element {
  if (isServer) {
    return <>{props.children}</>;
  }

  return (
    <SolidPortal mount={props.target ?? document.body}>
      {props.children}
    </SolidPortal>
  );
}
  • Step 5: Create index files for both utilities
// packages/core/src/utilities/visually-hidden/index.ts
export { VisuallyHidden } from "./visually-hidden";
export type { VisuallyHiddenProps } from "./visually-hidden";
// packages/core/src/utilities/portal/index.ts
export { Portal } from "./portal";
export type { PortalProps } from "./portal";
  • Step 6: Run tests to verify they pass
cd packages/core && pnpm vitest run tests/utilities/portal.test.tsx

Expected: PASS — 2 tests passed.

  • Step 7: Commit
git add packages/core/src/utilities/visually-hidden/ packages/core/src/utilities/portal/ packages/core/tests/utilities/portal.test.tsx
git commit -m "feat: add VisuallyHidden and Portal utilities"

Task 6: Utility — createFocusTrap

Files:

  • Create: packages/core/src/utilities/focus-trap/create-focus-trap.ts

  • Create: packages/core/src/utilities/focus-trap/index.ts

  • Create: packages/core/tests/utilities/focus-trap.test.ts

  • Step 1: Write the failing test

// packages/core/tests/utilities/focus-trap.test.ts
import { createRoot } from "solid-js";
import { describe, expect, it, vi } from "vitest";
import { createFocusTrap } from "../../src/utilities/focus-trap/create-focus-trap";

describe("createFocusTrap", () => {
  it("focuses the first focusable element when activated", () => {
    const container = document.createElement("div");
    const button1 = document.createElement("button");
    const button2 = document.createElement("button");
    button1.textContent = "First";
    button2.textContent = "Second";
    container.appendChild(button1);
    container.appendChild(button2);
    document.body.appendChild(container);

    createRoot((dispose) => {
      const trap = createFocusTrap(() => container);
      trap.activate();
      expect(document.activeElement).toBe(button1);
      trap.deactivate();
      dispose();
    });

    document.body.removeChild(container);
  });

  it("does nothing when container is null", () => {
    createRoot((dispose) => {
      const trap = createFocusTrap(() => null);
      // Should not throw
      expect(() => trap.activate()).not.toThrow();
      dispose();
    });
  });

  it("returns focus to previously focused element on deactivate", () => {
    const outside = document.createElement("button");
    outside.textContent = "Outside";
    document.body.appendChild(outside);
    outside.focus();

    const container = document.createElement("div");
    const inner = document.createElement("button");
    inner.textContent = "Inner";
    container.appendChild(inner);
    document.body.appendChild(container);

    createRoot((dispose) => {
      const trap = createFocusTrap(() => container);
      trap.activate();
      expect(document.activeElement).toBe(inner);
      trap.deactivate();
      expect(document.activeElement).toBe(outside);
      dispose();
    });

    document.body.removeChild(outside);
    document.body.removeChild(container);
  });
});
  • Step 2: Run test to verify it fails
cd packages/core && pnpm vitest run tests/utilities/focus-trap.test.ts

Expected: FAIL — module not found.

  • Step 3: Implement createFocusTrap
// packages/core/src/utilities/focus-trap/create-focus-trap.ts
import type { Accessor } from "solid-js";

const FOCUSABLE_SELECTORS = [
  "a[href]",
  "button:not([disabled])",
  "input:not([disabled])",
  "select:not([disabled])",
  "textarea:not([disabled])",
  "[tabindex]:not([tabindex='-1'])",
  "details > summary",
].join(",");

function getFocusableElements(container: HTMLElement): HTMLElement[] {
  return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS));
}

export interface FocusTrap {
  activate: () => void;
  deactivate: () => void;
}

/**
 * Creates a focus trap that confines Tab focus within a container.
 * Restores focus to the previously focused element on deactivate.
 */
export function createFocusTrap(getContainer: Accessor<HTMLElement | null>): FocusTrap {
  let previouslyFocused: HTMLElement | null = null;

  const handleKeyDown = (e: KeyboardEvent) => {
    const container = getContainer();
    if (!container || e.key !== "Tab") return;

    const focusable = getFocusableElements(container);
    if (focusable.length === 0) {
      e.preventDefault();
      return;
    }

    const first = focusable[0]!;
    const last = focusable[focusable.length - 1]!;

    if (e.shiftKey) {
      if (document.activeElement === first) {
        e.preventDefault();
        last.focus();
      }
    } else {
      if (document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
  };

  return {
    activate() {
      const container = getContainer();
      if (!container) return;

      previouslyFocused = document.activeElement as HTMLElement | null;
      const focusable = getFocusableElements(container);
      focusable[0]?.focus();
      document.addEventListener("keydown", handleKeyDown);
    },
    deactivate() {
      document.removeEventListener("keydown", handleKeyDown);
      previouslyFocused?.focus();
      previouslyFocused = null;
    },
  };
}
  • Step 4: Create index
// packages/core/src/utilities/focus-trap/index.ts
export { createFocusTrap } from "./create-focus-trap";
export type { FocusTrap } from "./create-focus-trap";
  • Step 5: Run tests to verify they pass
cd packages/core && pnpm vitest run tests/utilities/focus-trap.test.ts

Expected: PASS — 3 tests passed.

  • Step 6: Commit
git add packages/core/src/utilities/focus-trap/
git commit -m "feat: add createFocusTrap utility"

Task 7: Utility — createScrollLock

Files:

  • Create: packages/core/src/utilities/scroll-lock/create-scroll-lock.ts

  • Create: packages/core/src/utilities/scroll-lock/index.ts

  • Create: packages/core/tests/utilities/scroll-lock.test.ts

  • Step 1: Write the failing test

// packages/core/tests/utilities/scroll-lock.test.ts
import { createRoot } from "solid-js";
import { afterEach, describe, expect, it } from "vitest";
import { createScrollLock } from "../../src/utilities/scroll-lock/create-scroll-lock";

describe("createScrollLock", () => {
  afterEach(() => {
    document.body.style.overflow = "";
    document.body.style.paddingRight = "";
  });

  it("sets overflow hidden on body when locked", () => {
    createRoot((dispose) => {
      const lock = createScrollLock();
      lock.lock();
      expect(document.body.style.overflow).toBe("hidden");
      lock.unlock();
      dispose();
    });
  });

  it("restores overflow on unlock", () => {
    document.body.style.overflow = "auto";
    createRoot((dispose) => {
      const lock = createScrollLock();
      lock.lock();
      lock.unlock();
      expect(document.body.style.overflow).toBe("auto");
      dispose();
    });
  });

  it("handles multiple locks — only unlocks when all release", () => {
    createRoot((dispose) => {
      const lockA = createScrollLock();
      const lockB = createScrollLock();
      lockA.lock();
      lockB.lock();
      lockA.unlock();
      expect(document.body.style.overflow).toBe("hidden"); // still locked by B
      lockB.unlock();
      expect(document.body.style.overflow).toBe(""); // now unlocked
      dispose();
    });
  });
});
  • Step 2: Run test to verify it fails
cd packages/core && pnpm vitest run tests/utilities/scroll-lock.test.ts

Expected: FAIL — module not found.

  • Step 3: Implement createScrollLock
// packages/core/src/utilities/scroll-lock/create-scroll-lock.ts

// Global lock counter so nested modals don't prematurely restore scroll
let lockCount = 0;
let originalOverflow = "";

export interface ScrollLock {
  lock: () => void;
  unlock: () => void;
}

/**
 * Prevents body scroll. Uses a reference count so nested overlays
 * (e.g. a dialog inside a drawer) don't prematurely restore scrolling.
 */
export function createScrollLock(): ScrollLock {
  let locked = false;

  return {
    lock() {
      if (locked) return;
      locked = true;
      if (lockCount === 0) {
        originalOverflow = document.body.style.overflow;
        document.body.style.overflow = "hidden";
      }
      lockCount++;
    },
    unlock() {
      if (!locked) return;
      locked = false;
      lockCount = Math.max(0, lockCount - 1);
      if (lockCount === 0) {
        document.body.style.overflow = originalOverflow;
        originalOverflow = "";
      }
    },
  };
}
  • Step 4: Create index
// packages/core/src/utilities/scroll-lock/index.ts
export { createScrollLock } from "./create-scroll-lock";
export type { ScrollLock } from "./create-scroll-lock";
  • Step 5: Run tests to verify they pass
cd packages/core && pnpm vitest run tests/utilities/scroll-lock.test.ts

Expected: PASS — 3 tests passed.

  • Step 6: Commit
git add packages/core/src/utilities/scroll-lock/
git commit -m "feat: add createScrollLock utility"

Task 8: Utility — createDismiss

Files:

  • Create: packages/core/src/utilities/dismiss/create-dismiss.ts

  • Create: packages/core/src/utilities/dismiss/index.ts

  • Create: packages/core/tests/utilities/dismiss.test.ts

  • Step 1: Write the failing test

// packages/core/tests/utilities/dismiss.test.ts
import { createRoot } from "solid-js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createDismiss } from "../../src/utilities/dismiss/create-dismiss";

describe("createDismiss", () => {
  afterEach(() => {
    document.body.innerHTML = "";
  });

  it("calls onDismiss when Escape key is pressed", () => {
    const container = document.createElement("div");
    document.body.appendChild(container);

    createRoot((dispose) => {
      const onDismiss = vi.fn();
      const dismiss = createDismiss({ getContainer: () => container, onDismiss });
      dismiss.attach();

      document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
      expect(onDismiss).toHaveBeenCalledTimes(1);

      dismiss.detach();
      dispose();
    });
  });

  it("calls onDismiss when pointer is pressed outside container", () => {
    const container = document.createElement("div");
    document.body.appendChild(container);
    const outside = document.createElement("button");
    document.body.appendChild(outside);

    createRoot((dispose) => {
      const onDismiss = vi.fn();
      const dismiss = createDismiss({ getContainer: () => container, onDismiss });
      dismiss.attach();

      outside.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
      expect(onDismiss).toHaveBeenCalledTimes(1);

      dismiss.detach();
      dispose();
    });
  });

  it("does not call onDismiss when pointer is inside container", () => {
    const container = document.createElement("div");
    const inner = document.createElement("button");
    container.appendChild(inner);
    document.body.appendChild(container);

    createRoot((dispose) => {
      const onDismiss = vi.fn();
      const dismiss = createDismiss({ getContainer: () => container, onDismiss });
      dismiss.attach();

      inner.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
      expect(onDismiss).not.toHaveBeenCalled();

      dismiss.detach();
      dispose();
    });
  });

  it("does not call onDismiss after detach", () => {
    const container = document.createElement("div");
    document.body.appendChild(container);

    createRoot((dispose) => {
      const onDismiss = vi.fn();
      const dismiss = createDismiss({ getContainer: () => container, onDismiss });
      dismiss.attach();
      dismiss.detach();

      document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
      expect(onDismiss).not.toHaveBeenCalled();
      dispose();
    });
  });
});
  • Step 2: Run test to verify it fails
cd packages/core && pnpm vitest run tests/utilities/dismiss.test.ts

Expected: FAIL — module not found.

  • Step 3: Implement createDismiss
// packages/core/src/utilities/dismiss/create-dismiss.ts

export interface CreateDismissOptions {
  /** Returns the content container element. */
  getContainer: () => HTMLElement | null;
  /** Called when a dismiss event occurs. */
  onDismiss: () => void;
  /** Whether Escape key triggers dismiss. Default: true. */
  dismissOnEscape?: boolean;
  /** Whether pointer outside container triggers dismiss. Default: true. */
  dismissOnPointerOutside?: boolean;
}

export interface Dismiss {
  attach: () => void;
  detach: () => void;
}

/**
 * Handles dismiss interactions: Escape key and pointer-outside.
 * Uses a global layer stack so nested overlays only dismiss the topmost layer.
 */

// Global stack of active dismiss handlers (topmost is last)
const layerStack: Dismiss[] = [];

export function createDismiss(options: CreateDismissOptions): Dismiss {
  const dismissOnEscape = options.dismissOnEscape ?? true;
  const dismissOnPointerOutside = options.dismissOnPointerOutside ?? true;

  const handleKeyDown = (e: KeyboardEvent) => {
    if (!dismissOnEscape) return;
    if (e.key !== "Escape") return;
    // Only dismiss the topmost layer
    if (layerStack[layerStack.length - 1] !== dismiss) return;
    e.preventDefault();
    options.onDismiss();
  };

  const handlePointerDown = (e: PointerEvent) => {
    if (!dismissOnPointerOutside) return;
    if (layerStack[layerStack.length - 1] !== dismiss) return;
    const container = options.getContainer();
    if (!container) return;
    if (container.contains(e.target as Node)) return;
    options.onDismiss();
  };

  const dismiss: Dismiss = {
    attach() {
      layerStack.push(dismiss);
      document.addEventListener("keydown", handleKeyDown);
      document.addEventListener("pointerdown", handlePointerDown);
    },
    detach() {
      const index = layerStack.indexOf(dismiss);
      if (index !== -1) layerStack.splice(index, 1);
      document.removeEventListener("keydown", handleKeyDown);
      document.removeEventListener("pointerdown", handlePointerDown);
    },
  };

  return dismiss;
}
  • Step 4: Create index
// packages/core/src/utilities/dismiss/index.ts
export { createDismiss } from "./create-dismiss";
export type { CreateDismissOptions, Dismiss } from "./create-dismiss";
  • Step 5: Run tests to verify they pass
cd packages/core && pnpm vitest run tests/utilities/dismiss.test.ts

Expected: PASS — 4 tests passed.

  • Step 6: Commit
git add packages/core/src/utilities/dismiss/
git commit -m "feat: add createDismiss utility with layer stack"

Task 9: Utility — Presence

Files:

  • Create: packages/core/src/utilities/presence/presence.tsx

  • Create: packages/core/src/utilities/presence/index.ts

  • Create: packages/core/tests/utilities/presence.test.tsx

  • Step 1: Write the failing test

// packages/core/tests/utilities/presence.test.tsx
import { render } from "@solidjs/testing-library";
import { createSignal } from "solid-js";
import { describe, expect, it } from "vitest";
import { Presence } from "../../src/utilities/presence/presence";

describe("Presence", () => {
  it("renders children when present is true", () => {
    const { getByTestId } = render(() => (
      <Presence present={true}>
        <div data-testid="content">hello</div>
      </Presence>
    ));
    expect(getByTestId("content")).toBeTruthy();
  });

  it("does not render children when present is false", () => {
    const { queryByTestId } = render(() => (
      <Presence present={false}>
        <div data-testid="content">hello</div>
      </Presence>
    ));
    expect(queryByTestId("content")).toBeNull();
  });

  it("adds data-opening attribute when transitioning in", async () => {
    const [present, setPresent] = createSignal(false);
    const { queryByTestId } = render(() => (
      <Presence present={present()}>
        <div data-testid="content">hello</div>
      </Presence>
    ));
    setPresent(true);
    await Promise.resolve();
    const el = queryByTestId("content");
    expect(el).toBeTruthy();
  });

  it("keeps children mounted with forceMount", () => {
    const { getByTestId } = render(() => (
      <Presence present={false} forceMount>
        <div data-testid="content">hello</div>
      </Presence>
    ));
    expect(getByTestId("content")).toBeTruthy();
  });
});
  • Step 2: Run test to verify it fails
cd packages/core && pnpm vitest run tests/utilities/presence.test.tsx

Expected: FAIL — module not found.

  • Step 3: Implement Presence
// packages/core/src/utilities/presence/presence.tsx
import { type Accessor, type JSX, Show, children, createEffect, createSignal } from "solid-js";

export interface PresenceChildProps {
  present: Accessor<boolean>;
  opening: Accessor<boolean>;
  closing: Accessor<boolean>;
}

export interface PresenceProps {
  /** Whether the content should be visible/mounted. */
  present: boolean;
  /**
   * When true, keeps children mounted even when present is false.
   * Useful for controlling exit animations with external libraries.
   */
  forceMount?: boolean;
  children: JSX.Element | ((props: PresenceChildProps) => JSX.Element);
}

/**
 * Keeps exiting elements mounted during CSS/JS animations.
 * Emits data-opening and data-closing attributes for CSS-driven transitions.
 *
 * Usage with CSS:
 *   .content[data-opening] { animation: fadeIn 200ms; }
 *   .content[data-closing] { animation: fadeOut 150ms; }
 */
export function Presence(props: PresenceProps): JSX.Element {
  const [mounted, setMounted] = createSignal(props.present);
  const [opening, setOpening] = createSignal(false);
  const [closing, setClosing] = createSignal(false);

  createEffect(() => {
    if (props.present) {
      setMounted(true);
      setClosing(false);
      setOpening(true);
      // Clear opening flag after a microtask so CSS can pick it up
      queueMicrotask(() => setOpening(false));
    } else {
      setOpening(false);
      setClosing(true);
      // Keep mounted while closing — Presence unmounts after animation
      // For now, unmount immediately (consumers use forceMount for animation control)
      setClosing(false);
      setMounted(false);
    }
  });

  const shouldMount = () => props.forceMount || mounted();

  const childProps: PresenceChildProps = {
    present: mounted,
    opening,
    closing,
  };

  const resolved = children(() =>
    typeof props.children === "function"
      ? (props.children as (p: PresenceChildProps) => JSX.Element)(childProps)
      : props.children,
  );

  return <Show when={shouldMount()}>{resolved()}</Show>;
}
  • Step 4: Create index
// packages/core/src/utilities/presence/index.ts
export { Presence } from "./presence";
export type { PresenceProps, PresenceChildProps } from "./presence";
  • Step 5: Run tests to verify they pass
cd packages/core && pnpm vitest run tests/utilities/presence.test.tsx

Expected: PASS — 4 tests passed.

  • Step 6: Commit
git add packages/core/src/utilities/presence/
git commit -m "feat: add Presence utility"

Task 10: Dialog Component — Context and Types

Files:

  • Create: packages/core/src/components/dialog/dialog-context.ts

  • Step 1: Create dialog context (no test — pure types + context)

// packages/core/src/components/dialog/dialog-context.ts
import type { Accessor, JSX } from "solid-js";
import { createContext, useContext } from "solid-js";

// ─── Internal Context (used only by Dialog parts) ──────────────────────────

export interface InternalDialogContextValue {
  isOpen: Accessor<boolean>;
  setOpen: (open: boolean) => void;
  modal: Accessor<boolean>;
  /** SSR-safe ID for Dialog.Content element */
  contentId: Accessor<string>;
  /** Registered ID from Dialog.Title — used for aria-labelledby */
  titleId: Accessor<string | undefined>;
  setTitleId: (id: string | undefined) => void;
  /** Registered ID from Dialog.Description — used for aria-describedby */
  descriptionId: Accessor<string | undefined>;
  setDescriptionId: (id: string | undefined) => void;
  /** Whether Dialog.Trigger has been explicitly rendered */
  hasTrigger: Accessor<boolean>;
  setHasTrigger: (has: boolean) => void;
}

const InternalDialogContext = createContext<InternalDialogContextValue>();

export function useInternalDialogContext(): InternalDialogContextValue {
  const ctx = useContext(InternalDialogContext);
  if (!ctx) {
    throw new Error(
      "[PettyUI] Dialog parts must be used inside <Dialog>.\n" +
        "  Fix: Wrap your Dialog.Content, Dialog.Trigger, etc. inside <Dialog>.\n" +
        "  Docs: https://pettyui.dev/components/dialog#composition",
    );
  }
  return ctx;
}

export const InternalDialogContextProvider = InternalDialogContext.Provider;

// ─── Public Context (exported via Dialog.useContext) ───────────────────────

export interface DialogContextValue {
  /** Whether the dialog is currently open. */
  open: Accessor<boolean>;
  /** Whether the dialog renders as a modal (blocks outside interaction). */
  modal: Accessor<boolean>;
}

const DialogContext = createContext<DialogContextValue>();

export function useDialogContext(): DialogContextValue {
  const ctx = useContext(DialogContext);
  if (!ctx) {
    throw new Error(
      "[PettyUI] Dialog.useContext() was called outside of a <Dialog>.\n" +
        "  Fix: Call Dialog.useContext() inside a component rendered within <Dialog>.\n" +
        "  Docs: https://pettyui.dev/components/dialog#context",
    );
  }
  return ctx;
}

export const DialogContextProvider = DialogContext.Provider;
  • Step 2: Commit
git add packages/core/src/components/dialog/dialog-context.ts
git commit -m "feat: add Dialog context and types"

Task 11: Dialog Component — Root

Files:

  • Create: packages/core/src/components/dialog/dialog-root.tsx

  • Create: packages/core/tests/components/dialog/dialog-rendering.test.tsx

  • Step 1: Write the failing rendering test

// packages/core/tests/components/dialog/dialog-rendering.test.tsx
import { render, screen } from "@solidjs/testing-library";
import { createSignal } from "solid-js";
import { describe, expect, it } from "vitest";
import { Dialog } from "../../../src/components/dialog/index";

describe("Dialog rendering", () => {
  it("renders children", () => {
    render(() => (
      <Dialog>
        <Dialog.Content>
          <Dialog.Title>Hello</Dialog.Title>
        </Dialog.Content>
      </Dialog>
    ));
    expect(screen.getByText("Hello")).toBeTruthy();
  });

  it("does not render content when closed by default", () => {
    render(() => (
      <Dialog>
        <Dialog.Content>
          <Dialog.Title>Hidden</Dialog.Title>
        </Dialog.Content>
      </Dialog>
    ));
    expect(screen.queryByText("Hidden")).toBeNull();
  });

  it("renders content when defaultOpen is true", () => {
    render(() => (
      <Dialog defaultOpen>
        <Dialog.Content>
          <Dialog.Title>Visible</Dialog.Title>
        </Dialog.Content>
      </Dialog>
    ));
    expect(screen.getByText("Visible")).toBeTruthy();
  });

  it("renders content when controlled open is true", () => {
    render(() => (
      <Dialog open={true} onOpenChange={() => {}}>
        <Dialog.Content>
          <Dialog.Title>Controlled</Dialog.Title>
        </Dialog.Content>
      </Dialog>
    ));
    expect(screen.getByText("Controlled")).toBeTruthy();
  });

  it("closes when controlled open is set to false", () => {
    const [open, setOpen] = createSignal(true);
    render(() => (
      <Dialog open={open()} onOpenChange={setOpen}>
        <Dialog.Content>
          <Dialog.Title>Toggled</Dialog.Title>
        </Dialog.Content>
      </Dialog>
    ));
    expect(screen.getByText("Toggled")).toBeTruthy();
    setOpen(false);
    expect(screen.queryByText("Toggled")).toBeNull();
  });
});
  • Step 2: Run test to verify it fails
cd packages/core && pnpm vitest run tests/components/dialog/dialog-rendering.test.tsx

Expected: FAIL — module not found.

  • Step 3: Implement DialogRoot
// packages/core/src/components/dialog/dialog-root.tsx
import type { JSX } from "solid-js";
import { createSignal } from "solid-js";
import {
  DialogContextProvider,
  InternalDialogContextProvider,
  type InternalDialogContextValue,
} from "./dialog-context";
import { createDisclosureState } from "../../primitives/create-disclosure-state";
import { createRegisterId } from "../../primitives/create-register-id";

export interface DialogRootProps {
  /** Controlled open state. */
  open?: boolean;
  /** Default open state (uncontrolled). */
  defaultOpen?: boolean;
  /** Called when open state should change. */
  onOpenChange?: (open: boolean) => void;
  /**
   * Whether the dialog blocks outside interaction and traps focus.
   * @default true
   */
  modal?: boolean;
  children: JSX.Element;
}

/**
 * Root component. Manages open state, provides context to all Dialog parts.
 * Renders no DOM elements itself.
 */
export function DialogRoot(props: DialogRootProps): JSX.Element {
  const disclosure = createDisclosureState({
    get open() { return props.open; },
    get defaultOpen() { return props.defaultOpen; },
    get onOpenChange() { return props.onOpenChange; },
  });

  const contentId = `pettyui-dialog-${Math.random().toString(36).slice(2, 9)}`;
  const [titleId, setTitleId] = createRegisterId();
  const [descriptionId, setDescriptionId] = createRegisterId();
  const [hasTrigger, setHasTrigger] = createSignal(false);

  const internalCtx: InternalDialogContextValue = {
    isOpen: disclosure.isOpen,
    setOpen: (open) => (open ? disclosure.open() : disclosure.close()),
    modal: () => props.modal ?? true,
    contentId: () => contentId,
    titleId,
    setTitleId,
    descriptionId,
    setDescriptionId,
    hasTrigger,
    setHasTrigger,
  };

  return (
    <InternalDialogContextProvider value={internalCtx}>
      <DialogContextProvider value={{ open: disclosure.isOpen, modal: () => props.modal ?? true }}>
        {props.children}
      </DialogContextProvider>
    </InternalDialogContextProvider>
  );
}
  • Step 4: Create minimal Dialog index (enough to run tests)
// packages/core/src/components/dialog/index.ts
import { DialogRoot } from "./dialog-root";
import { DialogContent } from "./dialog-content";
import { DialogTitle } from "./dialog-title";
import { DialogDescription } from "./dialog-description";
import { DialogTrigger } from "./dialog-trigger";
import { DialogClose } from "./dialog-close";
import { DialogPortal } from "./dialog-portal";
import { DialogOverlay } from "./dialog-overlay";
import { useDialogContext } from "./dialog-context";

export const Dialog = Object.assign(DialogRoot, {
  Content: DialogContent,
  Title: DialogTitle,
  Description: DialogDescription,
  Trigger: DialogTrigger,
  Close: DialogClose,
  Portal: DialogPortal,
  Overlay: DialogOverlay,
  useContext: useDialogContext,
});

export type { DialogRootProps } from "./dialog-root";

This will fail until all parts exist — create stubs now:

  • Step 5: Create stubs for remaining Dialog parts
// packages/core/src/components/dialog/dialog-content.tsx
import type { JSX } from "solid-js";
import { Show, onCleanup, onMount, splitProps } from "solid-js";
import { useInternalDialogContext } from "./dialog-context";
import { Portal } from "../../utilities/portal/portal";
import { createFocusTrap } from "../../utilities/focus-trap/create-focus-trap";
import { createScrollLock } from "../../utilities/scroll-lock/create-scroll-lock";
import { createDismiss } from "../../utilities/dismiss/create-dismiss";

export interface DialogContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
  /**
   * Called when auto-focus fires on open. Call event.preventDefault() to prevent default.
   */
  onOpenAutoFocus?: (event: Event) => void;
  /**
   * Called when auto-focus fires on close. Call event.preventDefault() to prevent default.
   */
  onCloseAutoFocus?: (event: Event) => void;
  /** Keep mounted even when closed (for animation control). */
  forceMount?: boolean;
  children?: JSX.Element;
}

export function DialogContent(props: DialogContentProps): JSX.Element {
  const [local, rest] = splitProps(props, [
    "children",
    "onOpenAutoFocus",
    "onCloseAutoFocus",
    "forceMount",
  ]);
  const ctx = useInternalDialogContext();
  let contentRef: HTMLDivElement | undefined;

  const focusTrap = createFocusTrap(() => contentRef ?? null);
  const scrollLock = createScrollLock();
  const dismiss = createDismiss({
    getContainer: () => contentRef ?? null,
    onDismiss: () => ctx.setOpen(false),
  });

  onMount(() => {
    if (ctx.isOpen() && ctx.modal()) {
      focusTrap.activate();
      scrollLock.lock();
      dismiss.attach();
    }
  });

  onCleanup(() => {
    focusTrap.deactivate();
    scrollLock.unlock();
    dismiss.detach();
  });

  return (
    <Show when={local.forceMount || ctx.isOpen()}>
      <Portal>
        <div
          ref={contentRef}
          id={ctx.contentId()}
          role="dialog"
          aria-modal={ctx.modal() || undefined}
          aria-labelledby={ctx.titleId()}
          aria-describedby={ctx.descriptionId()}
          data-state={ctx.isOpen() ? "open" : "closed"}
          {...rest}
        >
          {local.children}
        </div>
      </Portal>
    </Show>
  );
}
// packages/core/src/components/dialog/dialog-title.tsx
import type { JSX } from "solid-js";
import { createUniqueId, onCleanup, onMount, splitProps } from "solid-js";
import { useInternalDialogContext } from "./dialog-context";

export interface DialogTitleProps extends JSX.HTMLAttributes<HTMLHeadingElement> {
  children?: JSX.Element;
}

export function DialogTitle(props: DialogTitleProps): JSX.Element {
  const [local, rest] = splitProps(props, ["children"]);
  const ctx = useInternalDialogContext();
  const id = createUniqueId();

  onMount(() => ctx.setTitleId(id));
  onCleanup(() => ctx.setTitleId(undefined));

  return <h2 id={id} {...rest}>{local.children}</h2>;
}
// packages/core/src/components/dialog/dialog-description.tsx
import type { JSX } from "solid-js";
import { createUniqueId, onCleanup, onMount, splitProps } from "solid-js";
import { useInternalDialogContext } from "./dialog-context";

export interface DialogDescriptionProps extends JSX.HTMLAttributes<HTMLParagraphElement> {
  children?: JSX.Element;
}

export function DialogDescription(props: DialogDescriptionProps): JSX.Element {
  const [local, rest] = splitProps(props, ["children"]);
  const ctx = useInternalDialogContext();
  const id = createUniqueId();

  onMount(() => ctx.setDescriptionId(id));
  onCleanup(() => ctx.setDescriptionId(undefined));

  return <p id={id} {...rest}>{local.children}</p>;
}
// packages/core/src/components/dialog/dialog-trigger.tsx
import type { Component, JSX } from "solid-js";
import { Dynamic, mergeProps, splitProps } from "solid-js/web";
import { useInternalDialogContext } from "./dialog-context";

export interface DialogTriggerProps extends JSX.HTMLAttributes<HTMLButtonElement> {
  /** Render as a different element or component. */
  as?: string | Component;
  children?: JSX.Element | ((props: JSX.HTMLAttributes<HTMLElement>) => JSX.Element);
}

export function DialogTrigger(props: DialogTriggerProps): JSX.Element {
  const [local, rest] = splitProps(props, ["as", "children", "onClick"]);
  const ctx = useInternalDialogContext();

  const handleClick: JSX.EventHandler<HTMLElement, MouseEvent> = (e) => {
    if (typeof local.onClick === "function") local.onClick(e as MouseEvent & { currentTarget: HTMLButtonElement; target: Element; });
    ctx.setOpen(!ctx.isOpen());
  };

  const triggerProps = mergeProps(rest, {
    "aria-haspopup": "dialog" as const,
    "aria-expanded": ctx.isOpen(),
    "aria-controls": ctx.contentId(),
    "data-state": ctx.isOpen() ? "open" : "closed",
    onClick: handleClick,
  });

  if (typeof local.children === "function") {
    return <>{local.children(triggerProps)}</>;
  }

  return (
    <Dynamic component={local.as ?? "button"} {...triggerProps}>
      {local.children}
    </Dynamic>
  );
}
// packages/core/src/components/dialog/dialog-close.tsx
import type { Component, JSX } from "solid-js";
import { Dynamic, splitProps } from "solid-js/web";
import { useInternalDialogContext } from "./dialog-context";

export interface DialogCloseProps extends JSX.HTMLAttributes<HTMLButtonElement> {
  as?: string | Component;
  children?: JSX.Element;
}

export function DialogClose(props: DialogCloseProps): JSX.Element {
  const [local, rest] = splitProps(props, ["as", "children", "onClick"]);
  const ctx = useInternalDialogContext();

  const handleClick: JSX.EventHandler<HTMLElement, MouseEvent> = (e) => {
    if (typeof local.onClick === "function") local.onClick(e as MouseEvent & { currentTarget: HTMLButtonElement; target: Element; });
    ctx.setOpen(false);
  };

  return (
    <Dynamic component={local.as ?? "button"} onClick={handleClick} {...rest}>
      {local.children}
    </Dynamic>
  );
}
// packages/core/src/components/dialog/dialog-portal.tsx
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { Portal } from "../../utilities/portal/portal";

export interface DialogPortalProps {
  /** Override the portal target container. */
  target?: Element | null;
  children: JSX.Element;
}

export function DialogPortal(props: DialogPortalProps): JSX.Element {
  const [local, rest] = splitProps(props, ["target", "children"]);
  return <Portal target={local.target}>{local.children}</Portal>;
}
// packages/core/src/components/dialog/dialog-overlay.tsx
import type { JSX } from "solid-js";
import { Show, splitProps } from "solid-js";
import { useInternalDialogContext } from "./dialog-context";

export interface DialogOverlayProps extends JSX.HTMLAttributes<HTMLDivElement> {
  forceMount?: boolean;
}

export function DialogOverlay(props: DialogOverlayProps): JSX.Element {
  const [local, rest] = splitProps(props, ["forceMount"]);
  const ctx = useInternalDialogContext();

  return (
    <Show when={local.forceMount || ctx.isOpen()}>
      <div
        aria-hidden="true"
        data-state={ctx.isOpen() ? "open" : "closed"}
        {...rest}
      />
    </Show>
  );
}
  • Step 6: Run rendering tests
cd packages/core && pnpm vitest run tests/components/dialog/dialog-rendering.test.tsx

Expected: PASS — 5 tests passed.

  • Step 7: Commit
git add packages/core/src/components/dialog/
git commit -m "feat: add Dialog component — all parts"

Task 12: Dialog — ARIA and Keyboard Tests

Files:

  • Create: packages/core/tests/components/dialog/dialog-aria.test.tsx

  • Create: packages/core/tests/components/dialog/dialog-keyboard.test.tsx

  • Step 1: Write ARIA tests

// packages/core/tests/components/dialog/dialog-aria.test.tsx
import { render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Dialog } from "../../../src/components/dialog/index";

describe("Dialog ARIA", () => {
  it("content has role=dialog", () => {
    render(() => (
      <Dialog defaultOpen>
        <Dialog.Content data-testid="content">
          <Dialog.Title>Title</Dialog.Title>
        </Dialog.Content>
      </Dialog>
    ));
    expect(screen.getByRole("dialog")).toBeTruthy();
  });

  it("content has aria-modal when modal prop is true", () => {
    render(() => (
      <Dialog defaultOpen modal>
        <Dialog.Content>
          <Dialog.Title>Title</Dialog.Title>
        </Dialog.Content>
      </Dialog>
    ));
    expect(screen.getByRole("dialog").getAttribute("aria-modal")).toBe("true");
  });

  it("content does not have aria-modal when modal is false", () => {
    render(() => (
      <Dialog defaultOpen modal={false}>
        <Dialog.Content>
          <Dialog.Title>Title</Dialog.Title>
        </Dialog.Content>
      </Dialog>
    ));
    expect(screen.getByRole("dialog").getAttribute("aria-modal")).toBeNull();
  });

  it("content is linked to title via aria-labelledby", () => {
    render(() => (
      <Dialog defaultOpen>
        <Dialog.Content>
          <Dialog.Title>My Title</Dialog.Title>
        </Dialog.Content>
      </Dialog>
    ));
    const dialog = screen.getByRole("dialog");
    const title = screen.getByText("My Title");
    expect(dialog.getAttribute("aria-labelledby")).toBe(title.id);
  });

  it("content is linked to description via aria-describedby", () => {
    render(() => (
      <Dialog defaultOpen>
        <Dialog.Content>
          <Dialog.Title>Title</Dialog.Title>
          <Dialog.Description>My description</Dialog.Description>
        </Dialog.Content>
      </Dialog>
    ));
    const dialog = screen.getByRole("dialog");
    const desc = screen.getByText("My description");
    expect(dialog.getAttribute("aria-describedby")).toBe(desc.id);
  });

  it("trigger has aria-haspopup=dialog", () => {
    render(() => (
      <Dialog>
        <Dialog.Trigger>Open</Dialog.Trigger>
        <Dialog.Content>
          <Dialog.Title>Title</Dialog.Title>
        </Dialog.Content>
      </Dialog>
    ));
    expect(screen.getByText("Open").getAttribute("aria-haspopup")).toBe("dialog");
  });

  it("trigger aria-expanded reflects open state", () => {
    render(() => (
      <Dialog>
        <Dialog.Trigger>Open</Dialog.Trigger>
        <Dialog.Content>
          <Dialog.Title>Title</Dialog.Title>
        </Dialog.Content>
      </Dialog>
    ));
    const trigger = screen.getByText("Open");
    expect(trigger.getAttribute("aria-expanded")).toBe("false");
  });
});
  • Step 2: Write keyboard tests
// packages/core/tests/components/dialog/dialog-keyboard.test.tsx
import { render, screen, fireEvent } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Dialog } from "../../../src/components/dialog/index";

describe("Dialog keyboard", () => {
  it("closes on Escape key", () => {
    render(() => (
      <Dialog defaultOpen>
        <Dialog.Content>
          <Dialog.Title>Title</Dialog.Title>
          <Dialog.Close>Close</Dialog.Close>
        </Dialog.Content>
      </Dialog>
    ));
    expect(screen.getByRole("dialog")).toBeTruthy();
    fireEvent.keyDown(document, { key: "Escape" });
    expect(screen.queryByRole("dialog")).toBeNull();
  });

  it("Trigger click opens dialog", () => {
    render(() => (
      <Dialog>
        <Dialog.Trigger>Open</Dialog.Trigger>
        <Dialog.Content>
          <Dialog.Title>Title</Dialog.Title>
        </Dialog.Content>
      </Dialog>
    ));
    expect(screen.queryByRole("dialog")).toBeNull();
    fireEvent.click(screen.getByText("Open"));
    expect(screen.getByRole("dialog")).toBeTruthy();
  });

  it("Close button closes dialog", () => {
    render(() => (
      <Dialog defaultOpen>
        <Dialog.Content>
          <Dialog.Title>Title</Dialog.Title>
          <Dialog.Close>Close</Dialog.Close>
        </Dialog.Content>
      </Dialog>
    ));
    expect(screen.getByRole("dialog")).toBeTruthy();
    fireEvent.click(screen.getByText("Close"));
    expect(screen.queryByRole("dialog")).toBeNull();
  });
});
  • Step 3: Run ARIA and keyboard tests
cd packages/core && pnpm vitest run tests/components/dialog/

Expected: PASS — all dialog tests pass.

  • Step 4: Run the full test suite
cd packages/core && pnpm vitest run

Expected: PASS — all tests in all files pass.

  • Step 5: Run type check
cd packages/core && pnpm typecheck

Expected: no errors.

  • Step 6: Run Biome check
cd /Users/matsbosson/Documents/StayThree/PettyUI && pnpm biome check packages/

Expected: no errors (fix any formatting issues with pnpm biome format --write packages/).

  • Step 7: Commit
git add packages/core/tests/components/dialog/
git commit -m "test: add Dialog ARIA and keyboard tests"

Task 13: Main Package Entry and Build

Files:

  • Create: packages/core/src/index.ts

  • Step 1: Create main package entry

// packages/core/src/index.ts
// Main entry — re-exports everything for convenience.
// Prefer sub-path imports (e.g. "pettyui/dialog") for tree-shaking.

export { Dialog } from "./components/dialog/index";
export type { DialogRootProps } from "./components/dialog/dialog-root";
export type { DialogContentProps } from "./components/dialog/dialog-content";
export type { DialogTitleProps } from "./components/dialog/dialog-title";
export type { DialogDescriptionProps } from "./components/dialog/dialog-description";
export type { DialogTriggerProps } from "./components/dialog/dialog-trigger";
export type { DialogCloseProps } from "./components/dialog/dialog-close";

export { Presence } from "./utilities/presence/index";
export type { PresenceProps, PresenceChildProps } from "./utilities/presence/index";

export { Portal } from "./utilities/portal/index";
export type { PortalProps } from "./utilities/portal/index";

export { VisuallyHidden } from "./utilities/visually-hidden/index";
export type { VisuallyHiddenProps } from "./utilities/visually-hidden/index";

export { createFocusTrap } from "./utilities/focus-trap/index";
export type { FocusTrap } from "./utilities/focus-trap/index";

export { createScrollLock } from "./utilities/scroll-lock/index";
export type { ScrollLock } from "./utilities/scroll-lock/index";

export { createDismiss } from "./utilities/dismiss/index";
export type { CreateDismissOptions, Dismiss } from "./utilities/dismiss/index";
  • Step 2: Build the package
cd packages/core && pnpm build

Expected: dist/ directory created with ESM + CJS + .d.ts files. No errors.

  • Step 3: Verify dist output
ls packages/core/dist/

Expected: index.js, index.cjs, index.d.ts, dialog/index.js, dialog/index.cjs, presence/index.js, etc.

  • Step 4: Final full test run
cd packages/core && pnpm vitest run

Expected: PASS — all tests.

  • Step 5: Commit
git add packages/core/src/index.ts
git commit -m "feat: add main package entry and verify build"

Task 14: Save Memory and CI Setup

Files:

  • Create: .github/workflows/ci.yml

  • Step 1: Create CI workflow

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 10

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      - name: Biome check
        run: pnpm biome check packages/

      - name: ESLint (Solid rules)
        run: pnpm eslint packages --ext .ts,.tsx

      - name: Type check
        run: pnpm -r typecheck

      - name: Tests
        run: pnpm -r test

      - name: Build
        run: pnpm -r build
  • Step 2: Commit CI
git add .github/
git commit -m "ci: add GitHub Actions workflow"
  • Step 3: Final commit summary

Run to confirm clean working tree:

git status
git log --oneline

Expected output (in order):

ci: add GitHub Actions workflow
feat: add main package entry and verify build
test: add Dialog ARIA and keyboard tests
feat: add Dialog component — all parts
feat: add Dialog context and types
feat: add Presence utility
feat: add createDismiss utility with layer stack
feat: add createScrollLock utility
feat: add createFocusTrap utility
feat: add VisuallyHidden and Portal utilities
feat: add createRegisterId primitive and primitives barrel
feat: add createDisclosureState primitive
feat: add createControllableSignal primitive
chore: scaffold monorepo with tooling

Self-Review

Spec coverage check:

  • Single monorepo package with sub-path exports
  • SolidJS 1.9.x, TypeScript 6.x, Vite 8, Vitest 4, tsdown, Biome + ESLint
  • createControllableSignal — controlled/uncontrolled primitive
  • createDisclosureState — open/close state primitive
  • createRegisterId — ARIA ID registration
  • Portal — SSR-safe portal
  • VisuallyHidden — screen reader utility
  • createFocusTrap — focus trapping
  • createScrollLock — body scroll prevention with reference counting
  • createDismiss — Escape + pointer-outside with layer stack
  • Presence — animation-aware mounting
  • Dialog — all parts: Root, Trigger, Portal, Overlay, Content, Title, Description, Close
  • Dual context (InternalDialogContext + DialogContextProvider)
  • as prop polymorphism via <Dynamic>
  • Children-as-function on Trigger
  • ARIA attributes: role, aria-modal, aria-labelledby, aria-describedby, aria-haspopup, aria-expanded
  • Data attributes: data-state on all parts
  • Pit-of-success error messages in context hooks
  • SSR-safe: isServer guard in Portal, createUniqueId() for IDs
  • Build pipeline with tsdown
  • CI workflow

Gaps addressed:

  • @floating-ui/dom is a runtime dependency but is not used in Plan 1 (no positioning needed for Dialog). It will first be used in Popover/Tooltip in Plan 2.
  • Zod schemas are a Plan 3 (AI layer) concern — not in Plan 1 scope.
  • openui.yaml and llms.txt are Plan 3 concerns.