diff --git a/.changeset/notification-initial.md b/.changeset/notification-initial.md
new file mode 100644
index 000000000..d3d6c1eda
--- /dev/null
+++ b/.changeset/notification-initial.md
@@ -0,0 +1,14 @@
+---
+"@solid-primitives/notification": minor
+---
+
+Add `@solid-primitives/notification` package (Stage 0)
+
+New primitives for the browser [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API).
+
+- **`isNotificationSupported()`** — SSR-safe runtime check for Notifications API availability.
+- **`makeNotification(title, options?)`** — Non-reactive helper returning `[show, close]`. `show()` creates and returns a `Notification` instance (or `null` when permission is not `"granted"`); calling it again replaces the previous notification. No Solid lifecycle dependency.
+- **`createNotification(title, options?)`** — Reactive primitive returning `{ show, close, notification, supported }`. Accepts reactive accessors for `title` and `options` — their current values are read at `show()` time. The `notification` accessor tracks the live instance, updating to `null` when it is dismissed by the OS or closed programmatically. Cleans up automatically on owner disposal.
+- **`createNotificationPermission()`** — Reactive permission manager returning `{ permission, requestPermission }`. The `permission` accessor reflects `Notification.permission` and updates after each `requestPermission()` call. Degrades gracefully to `"denied"` on the server.
+
+Peer dependencies: `solid-js@^2.0.0-beta.10` and `@solidjs/web@^2.0.0-beta.10`.
diff --git a/.changeset/sweet-olives-talk.md b/.changeset/sweet-olives-talk.md
new file mode 100644
index 000000000..a2c30d8f5
--- /dev/null
+++ b/.changeset/sweet-olives-talk.md
@@ -0,0 +1,5 @@
+---
+"@solid-primitives/permission": major
+---
+
+updated to Solid-2.0
diff --git a/packages/notification/LICENSE b/packages/notification/LICENSE
new file mode 100644
index 000000000..38b41d975
--- /dev/null
+++ b/packages/notification/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2021 Solid Primitives Working Group
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/packages/notification/README.md b/packages/notification/README.md
new file mode 100644
index 000000000..d8ef38d4d
--- /dev/null
+++ b/packages/notification/README.md
@@ -0,0 +1,186 @@
+
+
+
+
+# @solid-primitives/notification
+
+[](https://bundlephobia.com/package/@solid-primitives/notification)
+[](https://www.npmjs.com/package/@solid-primitives/notification)
+[](https://github.com/solidjs-community/solid-primitives#contribution-process)
+
+Primitives for the browser [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API) with reactive permission management.
+
+- **`isNotificationSupported`** — SSR-safe check for Notifications API availability.
+- **`makeNotification`** — Non-reactive helper returning `[show, close]`. No Solid lifecycle dependency.
+- **`createNotification`** — Reactive primitive that tracks the live `Notification` instance and cleans up on owner disposal.
+- **`createNotificationPermission`** — Reactive permission manager that exposes a live permission signal and a `requestPermission` function.
+
+## Installation
+
+```bash
+npm install @solid-primitives/notification
+# or
+yarn add @solid-primitives/notification
+# or
+pnpm add @solid-primitives/notification
+```
+
+## How to use it
+
+### `isNotificationSupported`
+
+Returns `true` when the Notifications API is available. Always `false` on the server.
+
+```ts
+import { isNotificationSupported } from "@solid-primitives/notification";
+
+if (isNotificationSupported()) {
+ console.log("notifications available");
+}
+```
+
+---
+
+### `makeNotification`
+
+Non-reactive helper with no Solid lifecycle dependency. Both returned functions are no-ops when the API is unavailable.
+
+`show()` returns `null` when `Notification.permission` is not `"granted"` — use `createNotificationPermission` to request permission first.
+
+Because `makeNotification` has no reactive owner, **cleanup is the caller's responsibility**. Inside a reactive scope, register `close` with `onCleanup`:
+
+```ts
+import { onCleanup } from "solid-js";
+import { makeNotification } from "@solid-primitives/notification";
+
+const [show, close] = makeNotification("New message", { body: "Hello!" });
+
+// Register cleanup with the current reactive owner
+onCleanup(close);
+
+button.addEventListener("click", () => show());
+
+// Or close programmatically at any time
+close();
+```
+
+Outside a reactive scope (e.g. in plain event handlers), call `close()` directly when done.
+
+---
+
+### `createNotification`
+
+Reactive primitive tied to the current reactive owner.
+
+- `title` and `options` can be plain values **or** reactive accessors — their current values are read each time `show()` is called.
+- `notification` is a reactive `Accessor` that reflects the live instance, updating to `null` when the notification is dismissed (either programmatically or by the OS).
+- The notification is automatically closed when the reactive owner is disposed.
+- Pass an optional `handlers` object to respond to notification events.
+
+```ts
+import { createNotification } from "@solid-primitives/notification";
+
+const { show, close, notification, supported } = createNotification(
+ () => `You have ${unread()} messages`,
+ { icon: "/icon.png" },
+ {
+ onClick: n => { window.focus(); },
+ onClose: n => { console.log("dismissed"); },
+ onError: n => { console.error("notification failed"); },
+ },
+);
+
+// Show a notification (reads reactive title at call time)
+show();
+
+// React to visibility changes
+createEffect(() => {
+ if (notification()) console.log("notification visible");
+ else console.log("notification gone");
+});
+
+// Close programmatically
+close();
+```
+
+---
+
+### `createNotificationPermission`
+
+Reactive permission manager built on the browser [Permissions API](https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API).
+
+The `permission` accessor reflects the **live** permission state and updates automatically whenever it changes — including after `requestPermission()` resolves or the user edits their browser settings directly.
+
+Permission values follow Permissions API vocabulary: `"granted"`, `"denied"`, `"prompt"` (not yet asked), or `"unknown"` while the initial async query is still resolving. Note that the Notifications API uses `"default"` for the same concept that the Permissions API calls `"prompt"`.
+
+On the server or when the API is unavailable, `permission` always returns `"unknown"` and `requestPermission` resolves immediately to `"denied"`.
+
+```ts
+import { createNotificationPermission } from "@solid-primitives/notification";
+
+const { permission, requestPermission } = createNotificationPermission();
+
+// Gate UI on permission state
+
+
+
+
+// Await the result (returns the raw NotificationPermission value)
+const result = await requestPermission();
+// result: "granted" | "denied" | "default"
+```
+
+---
+
+### Full example
+
+```tsx
+import {
+ createNotification,
+ createNotificationPermission,
+ isNotificationSupported,
+} from "@solid-primitives/notification";
+
+const NotificationDemo: Component = () => {
+ const { permission, requestPermission } = createNotificationPermission();
+ const { show, close, notification } = createNotification(
+ "Solid Primitives",
+ { body: "Hello from SolidJS!" },
+ { onClick: () => window.focus() },
+ );
+
+ return (
+ Not supported}>
+
Permission: {permission()}
+
Active: {notification() ? "yes" : "no"}
+
+
+
+
+
+
+ );
+};
+```
+
+## Types
+
+```ts
+/** Event handler callbacks for `createNotification`. */
+type NotificationEventHandlers = {
+ /** Called when the user clicks the notification. */
+ onClick?: (notification: Notification) => void;
+ /** Called when the notification is dismissed, whether by the user, the OS, or `close()`. */
+ onClose?: (notification: Notification) => void;
+ /** Called when the notification fails to display. */
+ onError?: (notification: Notification) => void;
+};
+```
+
+## Browser Support
+
+The [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API#browser_compatibility) is supported in all modern browsers. It is not available in iOS Safari (as of 2025) or on the server. All primitives degrade gracefully — `show()` returns `null`, `close()` is a no-op, and `permission()` returns `"unknown"`.
+
+## Changelog
+
+See [CHANGELOG.md](./CHANGELOG.md)
diff --git a/packages/notification/dev/index.tsx b/packages/notification/dev/index.tsx
new file mode 100644
index 000000000..1aeecbefa
--- /dev/null
+++ b/packages/notification/dev/index.tsx
@@ -0,0 +1,62 @@
+import { type Component, createSignal, Show } from "solid-js";
+import {
+ isNotificationSupported,
+ createNotification,
+ createNotificationPermission,
+} from "../src/index.js";
+
+const App: Component = () => {
+ const supported = isNotificationSupported();
+ const [body, setBody] = createSignal("Hello from Solid Primitives!");
+ const { permission, requestPermission } = createNotificationPermission();
+ const { show, close, notification } = createNotification(
+ () => "Solid Primitives Notification",
+ () => ({ body: body() }),
+ );
+
+ return (
+
+
+
Notification Primitive
+
+ Notifications API is not supported in this browser.}
+ >
+
+ Permission: {permission()}
+
+
+ Active notification: {notification() ? "visible" : "none"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default App;
diff --git a/packages/notification/package.json b/packages/notification/package.json
new file mode 100644
index 000000000..a8f18e92e
--- /dev/null
+++ b/packages/notification/package.json
@@ -0,0 +1,70 @@
+{
+ "name": "@solid-primitives/notification",
+ "version": "0.0.100",
+ "description": "Primitives for the browser Notifications API with reactive permission management",
+ "author": "David Di Biase ",
+ "contributors": [],
+ "license": "MIT",
+ "homepage": "https://primitives.solidjs.community/package/notification",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/solidjs-community/solid-primitives.git"
+ },
+ "bugs": {
+ "url": "https://github.com/solidjs-community/solid-primitives/issues"
+ },
+ "primitive": {
+ "name": "notification",
+ "stage": 0,
+ "list": [
+ "isNotificationSupported",
+ "makeNotification",
+ "createNotification",
+ "createNotificationPermission"
+ ],
+ "category": "Browser APIs"
+ },
+ "keywords": [
+ "solid",
+ "notification",
+ "browser",
+ "permission",
+ "primitives"
+ ],
+ "private": false,
+ "sideEffects": false,
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "browser": {},
+ "exports": {
+ "import": {
+ "@solid-primitives/source": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "typesVersions": {},
+ "scripts": {
+ "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts",
+ "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts",
+ "vitest": "vitest -c ../../configs/vitest.config.ts",
+ "test": "pnpm run vitest",
+ "test:ssr": "pnpm run vitest --mode ssr"
+ },
+ "peerDependencies": {
+ "@solidjs/web": "^2.0.0-beta.10",
+ "solid-js": "^2.0.0-beta.10"
+ },
+ "dependencies": {
+ "@solid-primitives/permission": "workspace:^",
+ "@solid-primitives/utils": "workspace:^"
+ },
+ "devDependencies": {
+ "@solidjs/web": "2.0.0-beta.10",
+ "solid-js": "2.0.0-beta.10"
+ }
+}
diff --git a/packages/notification/src/index.ts b/packages/notification/src/index.ts
new file mode 100644
index 000000000..d6fa2df68
--- /dev/null
+++ b/packages/notification/src/index.ts
@@ -0,0 +1,226 @@
+import { createSignal, onCleanup, type Accessor } from "solid-js";
+import { isServer } from "@solidjs/web";
+import { INTERNAL_OPTIONS, isDev, noop, access, type MaybeAccessor } from "@solid-primitives/utils";
+import { createPermission } from "@solid-primitives/permission";
+
+/**
+ * Returns `true` when the [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API)
+ * is available in the current environment.
+ */
+export const isNotificationSupported = (): boolean => !isServer && "Notification" in window;
+
+/**
+ * Non-reactive notification helper. No Solid lifecycle dependency.
+ * Both returned functions are no-ops / return `null` when the API is unavailable.
+ *
+ * Permission must be `"granted"` before calling `show()` — use
+ * `createNotificationPermission` to request it reactively.
+ *
+ * @param title Notification title.
+ * @param options Standard `NotificationOptions` (body, icon, tag, etc.).
+ * @returns `[show, close]` — `show()` creates and returns the `Notification`
+ * (or `null` when permission is not `"granted"`); `close()` dismisses it.
+ *
+ * @example
+ * ```ts
+ * const [show, close] = makeNotification("New message", { body: "Hello!" });
+ * button.addEventListener("click", () => show());
+ * ```
+ */
+export function makeNotification(
+ title: string,
+ options?: NotificationOptions,
+): [show: () => Notification | null, close: VoidFunction] {
+ if (!isNotificationSupported()) return [() => null, noop];
+
+ let current: Notification | undefined;
+ let closeHandler: VoidFunction | undefined;
+
+ const close: VoidFunction = () => {
+ if (current && closeHandler) {
+ current.removeEventListener("close", closeHandler);
+ closeHandler = undefined;
+ }
+ current?.close();
+ current = undefined;
+ };
+
+ const show = (): Notification | null => {
+ if (Notification.permission !== "granted") {
+ // eslint-disable-next-line no-console
+ if (isDev) console.warn(
+ `[@solid-primitives/notification] show() called with Notification.permission "${Notification.permission}" — must be "granted".`,
+ );
+ return null;
+ }
+ close();
+ const n = new Notification(title, options);
+ current = n;
+ closeHandler = () => {
+ if (current === n) {
+ current = undefined;
+ closeHandler = undefined;
+ }
+ };
+ n.addEventListener("close", closeHandler);
+ return n;
+ };
+
+ return [show, close];
+}
+
+/** Event handler callbacks for `createNotification`. */
+export type NotificationEventHandlers = {
+ /** Called when the user clicks the notification. */
+ onClick?: (notification: Notification) => void;
+ /** Called when the notification is dismissed, whether by the user, the OS, or `close()`. */
+ onClose?: (notification: Notification) => void;
+ /** Called when the notification fails to display. */
+ onError?: (notification: Notification) => void;
+};
+
+/**
+ * Reactive notification primitive tied to the current reactive owner.
+ *
+ * Accepts reactive `title` and `options` — their current values are read each
+ * time `show()` is called. The `notification` accessor tracks the live
+ * `Notification` instance, updating to `null` when it is dismissed or closed.
+ * The notification is closed automatically on owner disposal.
+ *
+ * Permission must be `"granted"` before calling `show()` — use
+ * `createNotificationPermission` to request it reactively.
+ *
+ * @param title Notification title, or a reactive accessor returning one.
+ * @param options Standard `NotificationOptions`, or a reactive accessor.
+ * @param handlers Optional event callbacks (`onClick`, `onClose`, `onError`).
+ * @returns `{ show, close, notification, supported }`
+ *
+ * @example
+ * ```ts
+ * const { show, close, notification } = createNotification(
+ * () => `You have ${unread()} messages`,
+ * { icon: "/icon.png" },
+ * { onClick: () => window.focus() },
+ * );
+ * ```
+ */
+export function createNotification(
+ title: MaybeAccessor,
+ options?: MaybeAccessor,
+ handlers?: NotificationEventHandlers,
+): {
+ show: () => Notification | null;
+ close: VoidFunction;
+ notification: Accessor;
+ supported: boolean;
+} {
+ const supported = isNotificationSupported();
+
+ if (!supported) {
+ return { show: () => null, close: noop, notification: () => null, supported };
+ }
+
+ const [notification, setNotification] = createSignal(null, INTERNAL_OPTIONS);
+ let current: Notification | null = null;
+ let currentCleanup: VoidFunction | undefined;
+
+ const close: VoidFunction = () => {
+ const n = current;
+ currentCleanup?.();
+ currentCleanup = undefined;
+ n?.close();
+ current = null;
+ setNotification(null);
+ if (n) handlers?.onClose?.(n);
+ };
+
+ const show = (): Notification | null => {
+ if (Notification.permission !== "granted") {
+ // eslint-disable-next-line no-console
+ if (isDev) console.warn(
+ `[@solid-primitives/notification] show() called with Notification.permission "${Notification.permission}" — must be "granted".`,
+ );
+ return null;
+ }
+ close();
+ const n = new Notification(access(title), access(options));
+ current = n;
+
+ const onCloseEvent = () => {
+ if (current === n) {
+ currentCleanup?.();
+ currentCleanup = undefined;
+ current = null;
+ setNotification(null);
+ handlers?.onClose?.(n);
+ }
+ };
+
+ n.addEventListener("close", onCloseEvent);
+ const cleanups: VoidFunction[] = [() => n.removeEventListener("close", onCloseEvent)];
+
+ if (handlers?.onClick) {
+ const h = () => handlers.onClick!(n);
+ n.addEventListener("click", h);
+ cleanups.push(() => n.removeEventListener("click", h));
+ }
+
+ if (handlers?.onError) {
+ const h = () => handlers.onError!(n);
+ n.addEventListener("error", h);
+ cleanups.push(() => n.removeEventListener("error", h));
+ }
+
+ currentCleanup = () => cleanups.forEach(fn => fn());
+ setNotification(n);
+ return n;
+ };
+
+ onCleanup(close);
+
+ return { show, close, notification, supported };
+}
+
+/**
+ * Reactive notification permission manager built on `createPermission`.
+ *
+ * The `permission` accessor reflects the live state from the browser
+ * [Permissions API](https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API)
+ * and updates automatically whenever permission changes — including after
+ * `requestPermission()` resolves or the user edits browser settings.
+ *
+ * Permission values follow the Permissions API vocabulary: `"granted"`,
+ * `"denied"`, `"prompt"` (not yet asked), or `"unknown"` while the query
+ * is still resolving. Note that the Notifications API uses `"default"` for
+ * the same concept that the Permissions API calls `"prompt"`.
+ *
+ * @returns `{ permission, requestPermission }`
+ *
+ * @example
+ * ```ts
+ * const { permission, requestPermission } = createNotificationPermission();
+ *
+ *
+ *
+ *
+ * ```
+ */
+export function createNotificationPermission(): {
+ permission: Accessor;
+ requestPermission: () => Promise;
+} {
+ if (!isNotificationSupported()) {
+ return {
+ permission: () => "unknown" as const,
+ requestPermission: () => Promise.resolve("denied" as NotificationPermission),
+ };
+ }
+
+ const permission = createPermission("notifications");
+
+ // createPermission tracks state via the change event — no manual update needed
+ const requestPermission = async (): Promise =>
+ Notification.requestPermission();
+
+ return { permission, requestPermission };
+}
diff --git a/packages/notification/test/index.test.ts b/packages/notification/test/index.test.ts
new file mode 100644
index 000000000..e53a88d9b
--- /dev/null
+++ b/packages/notification/test/index.test.ts
@@ -0,0 +1,502 @@
+import { describe, test, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
+import { createRoot, createSignal, flush, onCleanup } from "solid-js";
+import {
+ isNotificationSupported,
+ makeNotification,
+ createNotification,
+ createNotificationPermission,
+} from "../src/index.js";
+
+// ── Mock Notification API ─────────────────────────────────────────────────────
+
+class MockNotification {
+ static permission: NotificationPermission = "granted";
+ static requestPermission = vi.fn().mockResolvedValue("granted" as NotificationPermission);
+ static instances: MockNotification[] = [];
+
+ title: string;
+ private listeners: Map void)[]> = new Map();
+
+ constructor(title: string, _options?: NotificationOptions) {
+ this.title = title;
+ MockNotification.instances.push(this);
+ }
+
+ close = vi.fn().mockImplementation(() => {
+ this.listeners.get("close")?.forEach(fn => fn());
+ });
+
+ addEventListener = vi.fn().mockImplementation((event: string, fn: () => void) => {
+ const list = this.listeners.get(event) ?? [];
+ list.push(fn);
+ this.listeners.set(event, list);
+ });
+
+ removeEventListener = vi.fn().mockImplementation((event: string, fn: () => void) => {
+ const list = this.listeners.get(event) ?? [];
+ this.listeners.set(
+ event,
+ list.filter(f => f !== fn),
+ );
+ });
+
+ simulateClose() {
+ this.listeners.get("close")?.forEach(fn => fn());
+ }
+
+ simulateClick() {
+ this.listeners.get("click")?.forEach(fn => fn());
+ }
+
+ simulateError() {
+ this.listeners.get("error")?.forEach(fn => fn());
+ }
+}
+
+// ── Mock Permissions API ──────────────────────────────────────────────────────
+
+const mockPermStatus = {
+ state: "granted" as PermissionState,
+ _listeners: [] as (() => void)[],
+ addEventListener(_: string, fn: () => void) {
+ this._listeners.push(fn);
+ },
+ removeEventListener(_: string, fn: () => void) {
+ const i = this._listeners.indexOf(fn);
+ if (i >= 0) this._listeners.splice(i, 1);
+ },
+ dispatchChange(state: PermissionState) {
+ this.state = state;
+ this._listeners.forEach(fn => fn());
+ },
+};
+
+// ── Global setup ──────────────────────────────────────────────────────────────
+
+beforeAll(() => {
+ Object.defineProperty(window, "Notification", {
+ value: MockNotification,
+ configurable: true,
+ writable: true,
+ });
+
+ (navigator as any).permissions ??= {} as any;
+ navigator.permissions.query = vi.fn().mockImplementation(({ name }: PermissionDescriptor) => {
+ if (name === "notifications") return Promise.resolve(mockPermStatus);
+ return Promise.reject(new Error(`Unhandled permission: ${name}`));
+ });
+});
+
+afterAll(() => {
+ Object.defineProperty(window, "Notification", { value: undefined, configurable: true });
+});
+
+beforeEach(() => {
+ MockNotification.instances = [];
+ MockNotification.permission = "granted";
+ MockNotification.requestPermission.mockClear().mockResolvedValue("granted");
+ mockPermStatus.state = "granted";
+ mockPermStatus._listeners = [];
+});
+
+// ── isNotificationSupported ───────────────────────────────────────────────────
+
+describe("isNotificationSupported", () => {
+ test("returns true when Notification is available", () => {
+ expect(isNotificationSupported()).toBe(true);
+ });
+});
+
+// ── makeNotification ──────────────────────────────────────────────────────────
+
+describe("makeNotification", () => {
+ test("show creates a Notification with the given title", () => {
+ const [show] = makeNotification("Hello");
+ show();
+ expect(MockNotification.instances).toHaveLength(1);
+ expect(MockNotification.instances[0]!.title).toBe("Hello");
+ });
+
+ test("show returns the Notification instance", () => {
+ const [show] = makeNotification("Hello");
+ expect(show()).toBeInstanceOf(MockNotification);
+ });
+
+ test("show returns null when permission is not granted", () => {
+ MockNotification.permission = "denied";
+ const [show] = makeNotification("Hello");
+ expect(show()).toBeNull();
+ expect(MockNotification.instances).toHaveLength(0);
+ });
+
+ test("close dismisses the current notification", () => {
+ const [show, close] = makeNotification("Hello");
+ show();
+ const instance = MockNotification.instances[0]!;
+ close();
+ expect(instance.close).toHaveBeenCalled();
+ });
+
+ test("close removes the event listener before closing", () => {
+ const [show, close] = makeNotification("Hello");
+ show();
+ const instance = MockNotification.instances[0]!;
+ close();
+ expect(instance.removeEventListener).toHaveBeenCalledWith("close", expect.any(Function));
+ });
+
+ test("show replaces an existing notification", () => {
+ const [show] = makeNotification("Hello");
+ show();
+ const first = MockNotification.instances[0]!;
+ show();
+ expect(first.close).toHaveBeenCalled();
+ expect(MockNotification.instances).toHaveLength(2);
+ });
+
+ test("external close clears internal reference so close() becomes a no-op", () => {
+ const [show, close] = makeNotification("Hello");
+ show();
+ const instance = MockNotification.instances[0]!;
+ instance.simulateClose();
+ instance.close.mockClear();
+ close();
+ expect(instance.close).not.toHaveBeenCalled();
+ });
+
+ test("close can be registered with onCleanup by the caller for reactive cleanup", () => {
+ const { dispose } = createRoot(dispose => {
+ const [show, close] = makeNotification("Hello");
+ onCleanup(close);
+ show();
+ return { dispose };
+ });
+
+ const instance = MockNotification.instances[0]!;
+ instance.close.mockClear();
+
+ dispose();
+ expect(instance.close).toHaveBeenCalled();
+ });
+});
+
+// ── createNotification ────────────────────────────────────────────────────────
+
+describe("createNotification", () => {
+ test("initial state: notification is null, supported is true", () => {
+ createRoot(dispose => {
+ const { notification, supported } = createNotification("Hello");
+ expect(notification()).toBeNull();
+ expect(supported).toBe(true);
+ dispose();
+ });
+ });
+
+ test("show creates a Notification and updates the signal", () => {
+ const { show, notification, dispose } = createRoot(dispose => {
+ const { show, notification } = createNotification("Hello");
+ return { show, notification, dispose };
+ });
+
+ show();
+ flush();
+ expect(notification()).toBeInstanceOf(MockNotification);
+ expect((notification() as MockNotification).title).toBe("Hello");
+
+ dispose();
+ });
+
+ test("show returns null and warns when permission is not granted", () => {
+ MockNotification.permission = "denied";
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ const { show, notification, dispose } = createRoot(dispose => {
+ const { show, notification } = createNotification("Hello");
+ return { show, notification, dispose };
+ });
+
+ expect(show()).toBeNull();
+ flush();
+ expect(notification()).toBeNull();
+ expect(warnSpy).toHaveBeenCalled();
+
+ warnSpy.mockRestore();
+ dispose();
+ });
+
+ test("close dismisses the notification and sets signal to null", () => {
+ const { show, close, notification, dispose } = createRoot(dispose => {
+ const { show, close, notification } = createNotification("Hello");
+ return { show, close, notification, dispose };
+ });
+
+ show();
+ flush();
+
+ close();
+ flush();
+ expect(notification()).toBeNull();
+ expect(MockNotification.instances[0]!.close).toHaveBeenCalled();
+
+ dispose();
+ });
+
+ test("close removes the event listener before closing", () => {
+ const { show, close, dispose } = createRoot(dispose => {
+ const { show, close } = createNotification("Hello");
+ return { show, close, dispose };
+ });
+
+ show();
+ flush();
+ close();
+ expect(MockNotification.instances[0]!.removeEventListener).toHaveBeenCalledWith(
+ "close",
+ expect.any(Function),
+ );
+
+ dispose();
+ });
+
+ test("external close (OS dismiss) sets signal to null", () => {
+ const { show, notification, dispose } = createRoot(dispose => {
+ const { show, notification } = createNotification("Hello");
+ return { show, notification, dispose };
+ });
+
+ show();
+ flush();
+
+ MockNotification.instances[0]!.simulateClose();
+ flush();
+ expect(notification()).toBeNull();
+
+ dispose();
+ });
+
+ test("show replaces an existing notification", () => {
+ const { show, notification, dispose } = createRoot(dispose => {
+ const { show, notification } = createNotification("Hello");
+ return { show, notification, dispose };
+ });
+
+ show();
+ flush();
+ const first = notification();
+
+ show();
+ flush();
+ expect(notification()).not.toBe(first);
+ expect((first as MockNotification).close).toHaveBeenCalled();
+
+ dispose();
+ });
+
+ test("dispose closes the notification", () => {
+ const { show, dispose } = createRoot(dispose => {
+ const { show } = createNotification("Hello");
+ return { show, dispose };
+ });
+
+ show();
+ flush();
+ const instance = MockNotification.instances[0]!;
+ instance.close.mockClear();
+
+ dispose();
+ expect(instance.close).toHaveBeenCalled();
+ });
+
+ test("reactive title: reads current accessor value at show() time", () => {
+ const [title, setTitle] = createSignal("First");
+
+ const { show, notification, dispose } = createRoot(dispose => {
+ const { show, notification } = createNotification(title);
+ return { show, notification, dispose };
+ });
+
+ show();
+ flush();
+ expect((notification() as MockNotification).title).toBe("First");
+
+ setTitle("Second");
+ flush();
+ // not re-shown automatically — title only read on next show() call
+ expect((notification() as MockNotification).title).toBe("First");
+
+ show();
+ flush();
+ expect((notification() as MockNotification).title).toBe("Second");
+
+ dispose();
+ });
+
+ // ── Event callbacks ─────────────────────────────────────────────────────────
+
+ test("onClick fires when click event is dispatched", () => {
+ const onClick = vi.fn();
+
+ const { show, dispose } = createRoot(dispose => {
+ const { show } = createNotification("Hello", undefined, { onClick });
+ return { show, dispose };
+ });
+
+ show();
+ flush();
+ MockNotification.instances[0]!.simulateClick();
+
+ expect(onClick).toHaveBeenCalledOnce();
+ expect(onClick).toHaveBeenCalledWith(MockNotification.instances[0]);
+
+ dispose();
+ });
+
+ test("onClose fires when OS dismisses the notification", () => {
+ const onClose = vi.fn();
+
+ const { show, dispose } = createRoot(dispose => {
+ const { show } = createNotification("Hello", undefined, { onClose });
+ return { show, dispose };
+ });
+
+ show();
+ flush();
+ MockNotification.instances[0]!.simulateClose();
+
+ expect(onClose).toHaveBeenCalledOnce();
+ expect(onClose).toHaveBeenCalledWith(MockNotification.instances[0]);
+
+ dispose();
+ });
+
+ test("onClose fires when close() is called programmatically", () => {
+ const onClose = vi.fn();
+
+ const { show, close, dispose } = createRoot(dispose => {
+ const { show, close } = createNotification("Hello", undefined, { onClose });
+ return { show, close, dispose };
+ });
+
+ show();
+ flush();
+ close();
+
+ expect(onClose).toHaveBeenCalledOnce();
+
+ dispose();
+ });
+
+ test("onError fires when error event is dispatched", () => {
+ const onError = vi.fn();
+
+ const { show, dispose } = createRoot(dispose => {
+ const { show } = createNotification("Hello", undefined, { onError });
+ return { show, dispose };
+ });
+
+ show();
+ flush();
+ MockNotification.instances[0]!.simulateError();
+
+ expect(onError).toHaveBeenCalledOnce();
+ expect(onError).toHaveBeenCalledWith(MockNotification.instances[0]);
+
+ dispose();
+ });
+
+ test("event listeners are removed when close() is called", () => {
+ const onClick = vi.fn();
+ const onClose = vi.fn();
+
+ const { show, close, dispose } = createRoot(dispose => {
+ const { show, close } = createNotification("Hello", undefined, { onClick, onClose });
+ return { show, close, dispose };
+ });
+
+ show();
+ flush();
+ close();
+ onClose.mockClear();
+
+ // After close(), simulating OS events should not trigger callbacks
+ MockNotification.instances[0]!.simulateClick();
+ MockNotification.instances[0]!.simulateClose();
+
+ expect(onClick).not.toHaveBeenCalled();
+ expect(onClose).not.toHaveBeenCalled();
+
+ dispose();
+ });
+});
+
+// ── createNotificationPermission ──────────────────────────────────────────────
+
+describe("createNotificationPermission", () => {
+ test("permission starts as unknown before query resolves", () => {
+ createRoot(dispose => {
+ const { permission } = createNotificationPermission();
+ expect(permission()).toBe("unknown");
+ dispose();
+ });
+ });
+
+ test("permission resolves to current state after query", async () => {
+ mockPermStatus.state = "granted";
+
+ const { permission, dispose } = createRoot(dispose => {
+ const { permission } = createNotificationPermission();
+ return { permission, dispose };
+ });
+
+ expect(permission()).toBe("unknown");
+ await Promise.resolve();
+ flush();
+ expect(permission()).toBe("granted");
+
+ dispose();
+ });
+
+ test("permission updates reactively when state changes externally", async () => {
+ mockPermStatus.state = "granted";
+
+ const { permission, dispose } = createRoot(dispose => {
+ const { permission } = createNotificationPermission();
+ return { permission, dispose };
+ });
+
+ await Promise.resolve();
+ flush();
+ expect(permission()).toBe("granted");
+
+ mockPermStatus.dispatchChange("denied");
+ flush();
+ expect(permission()).toBe("denied");
+
+ dispose();
+ });
+
+ test("requestPermission calls Notification.requestPermission", async () => {
+ const { requestPermission, dispose } = createRoot(dispose => {
+ const { requestPermission } = createNotificationPermission();
+ return { requestPermission, dispose };
+ });
+
+ await requestPermission();
+ expect(MockNotification.requestPermission).toHaveBeenCalledOnce();
+
+ dispose();
+ });
+
+ test("requestPermission returns the resolved permission value", async () => {
+ MockNotification.requestPermission.mockResolvedValue("granted");
+
+ const { requestPermission, dispose } = createRoot(dispose => {
+ const { requestPermission } = createNotificationPermission();
+ return { requestPermission, dispose };
+ });
+
+ expect(await requestPermission()).toBe("granted");
+
+ dispose();
+ });
+});
diff --git a/packages/notification/test/server.test.ts b/packages/notification/test/server.test.ts
new file mode 100644
index 000000000..e19f755c0
--- /dev/null
+++ b/packages/notification/test/server.test.ts
@@ -0,0 +1,42 @@
+import { describe, test, expect } from "vitest";
+import {
+ isNotificationSupported,
+ makeNotification,
+ createNotification,
+ createNotificationPermission,
+} from "../src/index.js";
+
+describe("isNotificationSupported (SSR)", () => {
+ test("returns false on the server", () => {
+ expect(isNotificationSupported()).toBe(false);
+ });
+});
+
+describe("makeNotification (SSR)", () => {
+ test("returns no-op functions without throwing", () => {
+ const [show, close] = makeNotification("Hello", { body: "World" });
+ expect(typeof show).toBe("function");
+ expect(typeof close).toBe("function");
+ expect(show()).toBeNull();
+ expect(() => close()).not.toThrow();
+ });
+});
+
+describe("createNotification (SSR)", () => {
+ test("returns static defaults without throwing", () => {
+ const { show, close, notification, supported } = createNotification("Hello");
+ expect(supported).toBe(false);
+ expect(notification()).toBeNull();
+ expect(show()).toBeNull();
+ expect(() => close()).not.toThrow();
+ });
+});
+
+describe("createNotificationPermission (SSR)", () => {
+ test("returns unknown permission and resolves denied without throwing", async () => {
+ const { permission, requestPermission } = createNotificationPermission();
+ expect(permission()).toBe("unknown");
+ const result = await requestPermission();
+ expect(result).toBe("denied");
+ });
+});
diff --git a/packages/notification/tsconfig.json b/packages/notification/tsconfig.json
new file mode 100644
index 000000000..cc6a7fe80
--- /dev/null
+++ b/packages/notification/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "composite": true,
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "references": [
+ {
+ "path": "../permission"
+ },
+ {
+ "path": "../utils"
+ }
+ ],
+ "include": [
+ "src"
+ ]
+}
\ No newline at end of file
diff --git a/packages/permission/README.md b/packages/permission/README.md
index fdc864749..2931b08c7 100644
--- a/packages/permission/README.md
+++ b/packages/permission/README.md
@@ -8,25 +8,89 @@
[](https://www.npmjs.com/package/@solid-primitives/permission)
[](https://github.com/solidjs-community/solid-primitives#contribution-process)
-Creates a primitive to query user permissions.
+Reactive wrapper around the browser [Permissions API](https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API). Queries a named permission and returns a live signal that updates automatically whenever the permission state changes.
## Installation
-```
+```bash
npm install @solid-primitives/permission
# or
yarn add @solid-primitives/permission
+# or
+pnpm add @solid-primitives/permission
```
## How to use it
+### `createPermission`
+
+Queries a browser permission by name (or descriptor object) and returns a reactive accessor reflecting its current state.
+
+```ts
+import { createPermission } from "@solid-primitives/permission";
+
+const permission = createPermission("microphone");
+// permission(): "unknown" | "granted" | "denied" | "prompt"
+```
+
+The signal starts as `"unknown"` — the Permissions API query is async and the initial value is not available synchronously. After the first microtask, the signal resolves to the current state and begins tracking changes.
+
+The signal updates automatically when the permission changes — for example when the user grants or revokes access in browser settings, or after an API call prompts the user.
+
+**Accepted values** follow the [PermissionName](https://developer.mozilla.org/en-US/docs/Web/API/Permissions/query#name) vocabulary. Pass either a plain string or a full `PermissionDescriptor` object:
+
```ts
-const state: "unknown" | PermissionState = createPermission(descriptor: PermissionDescription | PermissionName);
+// Plain name
+const mic = createPermission("microphone");
+
+// Descriptor object (required for some permissions)
+const cam = createPermission({ name: "camera" });
+
+// Used by @solid-primitives/notification
+const notifs = createPermission("notifications");
```
-## Demo
+**Return values** map to [PermissionState](https://developer.mozilla.org/en-US/docs/Web/API/PermissionStatus/state):
+
+| Value | Meaning |
+|-------|---------|
+| `"unknown"` | Initial state — query has not resolved yet |
+| `"granted"` | Permission has been granted |
+| `"denied"` | Permission has been denied |
+| `"prompt"` | Not yet asked; prompting the user is possible |
+
+### SSR
-TODO
+On the server, `createPermission` returns a static `() => "unknown"` accessor. No query is made and no listeners are registered.
+
+### Reactive usage example
+
+```tsx
+import { createPermission } from "@solid-primitives/permission";
+
+const CameraGate: Component = () => {
+ const permission = createPermission("camera");
+
+ return (
+
+
+
Checking camera permission…
+
+
+
+
+
+
Camera access denied. Enable it in browser settings.