diff --git a/.changeset/favicon-initial.md b/.changeset/favicon-initial.md new file mode 100644 index 000000000..3597465d0 --- /dev/null +++ b/.changeset/favicon-initial.md @@ -0,0 +1,35 @@ +--- +"@solid-primitives/favicon": major +--- + +New package: `@solid-primitives/favicon` + +Primitives for controlling the document favicon, built for Solid 2.0 (beta.24). + +### `makeFavicon` / `createFavicon` + +Non-reactive base and reactive primitive for swapping the favicon `` href. Reuses an existing `` if present and restores its previous href on `dispose()`/cleanup; creates and removes one otherwise. Nested/sequential calls compose correctly under normal mount/unmount ordering. + +### `makeFaviconAnimation` / `createFaviconAnimation` + +Cycles the favicon through a sequence of hrefs on an interval — for build-status spinners and similar loading indicators. The reactive version accepts a static or reactive frame list, exposes `frame`/`playing` signals and `play`/`pause`, and automatically pauses while the tab is hidden (`document.visibilitychange`), resuming if it was playing before. + +### `makeFaviconBadge` / `createFaviconBadge` + +Composites a notification count, short string, or plain dot onto a base icon via an offscreen canvas. `0`/`false`/`undefined`/`""` render no badge; `true` renders a dot; numbers clamp to `"{max}+"`. Falls back to the plain base href if the base image fails to load. + +### `makeFaviconScheme` / `createFaviconScheme` + +Swaps between a light/dark icon to match `prefers-color-scheme`, staying in sync via a `matchMedia` listener as the OS/browser preference changes. Treats SSR as `"light"` (no reliable server-side signal for OS theme). + +### `makeFaviconProgress` / `createFaviconProgress` + +Composites a progress ring (0–100, clamped) onto a base icon via an offscreen canvas — for upload/download-style indicators. `undefined` shows the base icon with no ring; `0` still draws the (empty) ring track. Falls back to the plain base href if the base image fails to load. + +### `FaviconLink` + +A component that renders the favicon `` directly, so its initial href is part of the server-rendered HTML instead of only applying once client JS hydrates. Place it once in your document's real `` region (e.g. your SSR framework's root document component); any `make*`/`create*` call elsewhere composes with it automatically, since `bindFaviconLink`'s existing "reuse an existing link" lookup finds and takes over the exact element it rendered. + +### Design notes + +Rather than one `createAdvancedFavicon` combining all of this behind an options object, each capability is its own small `make*`/`create*` pair sharing internal DOM (`link.ts`) and canvas (`canvas.ts`) helpers — consistent with this repo's existing layered-primitive packages (e.g. `video`). See [DESIGN.md](../packages/favicon/DESIGN.md) for the full reasoning. diff --git a/deno.lock b/deno.lock index b68a629f0..fda88cc32 100644 --- a/deno.lock +++ b/deno.lock @@ -3757,7 +3757,7 @@ "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", "dependencies": [ "browserslist", - "caniuse-lite@1.0.30001800", + "caniuse-lite", "fraction.js", "picocolors@1.1.1", "postcss@8.5.16", @@ -3769,7 +3769,7 @@ "integrity": "sha512-eM9d/swFopRt5gdJ7jrpCwgvEMIayITpojhkkSMRsFHYuH5bkSQ4p/9qTEHtmNudUZh22Tehu7I6CxAW0IXTKA==", "dependencies": [ "browserslist", - "caniuse-lite@1.0.30001800", + "caniuse-lite", "normalize-range", "num2fraction", "picocolors@0.2.1", @@ -3879,7 +3879,7 @@ "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", "dependencies": [ "baseline-browser-mapping", - "caniuse-lite@1.0.30001806", + "caniuse-lite", "electron-to-chromium", "node-releases", "update-browserslist-db" @@ -3928,9 +3928,6 @@ "camelcase-css@2.0.1": { "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==" }, - "caniuse-lite@1.0.30001800": { - "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==" - }, "caniuse-lite@1.0.30001806": { "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==" }, @@ -5992,7 +5989,6 @@ "@next/env", "@swc/helpers", "baseline-browser-mapping", - "caniuse-lite@1.0.30001800", "postcss@8.4.31", "react", "react-dom", @@ -8186,6 +8182,14 @@ ] } }, + "packages/favicon": { + "packageJson": { + "dependencies": [ + "npm:@solidjs/web@2.0.0-beta.20", + "npm:solid-js@2.0.0-beta.20" + ] + } + }, "packages/fetch": { "packageJson": { "dependencies": [ diff --git a/packages/favicon/LICENSE b/packages/favicon/LICENSE new file mode 100644 index 000000000..38b41d975 --- /dev/null +++ b/packages/favicon/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/favicon/README.md b/packages/favicon/README.md new file mode 100644 index 000000000..39c24697e --- /dev/null +++ b/packages/favicon/README.md @@ -0,0 +1,432 @@ +

+ Solid Primitives favicon +

+ +# @solid-primitives/favicon + +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/favicon?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/favicon) +[![version](https://img.shields.io/npm/v/@solid-primitives/favicon?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/favicon) +[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) +[![tested with vitest](https://img.shields.io/badge/tested_with-vitest-6E9F18?style=for-the-badge&logo=vitest)](https://vitest.dev) + +Primitives for controlling the document favicon: swap it, animate it, overlay a notification +badge, follow the OS color scheme, or show upload/download progress. The `make*` variants are +non-reactive and require no Solid owner. The `create*` variants integrate with Solid's reactive +system and restore the previous favicon `onCleanup`. + +- `makeFavicon` / `createFavicon` — swap the favicon href. +- `makeFaviconAnimation` / `createFaviconAnimation` — cycle a sequence of hrefs on an interval, for + loading spinners and status indicators. +- `makeFaviconBadge` / `createFaviconBadge` — composite a notification count/dot onto a base icon. +- `makeFaviconScheme` / `createFaviconScheme` — swap between a light/dark icon to match + `prefers-color-scheme`. +- `makeFaviconProgress` / `createFaviconProgress` — composite a progress ring onto a base icon. +- `FaviconLink` — a Solid component that renders the favicon `` directly, so its initial + href is part of the server-rendered HTML instead of only applying once the client hydrates. + +## Installation + +```bash +npm install @solid-primitives/favicon +# or +yarn add @solid-primitives/favicon +# or +pnpm add @solid-primitives/favicon +``` + +## How to use it + +### `makeFavicon` / `createFavicon` + +```ts +const favicon = makeFavicon("/favicon-dark.svg"); +favicon.setHref("/favicon-light.svg"); +favicon.dispose(); // restores whatever favicon was there before +``` + +```ts +const href = createFavicon(() => (unreadCount() > 0 ? "/favicon-alert.svg" : "/favicon.svg")); +href(); // the currently-applied href, resolved to an absolute URL +``` + +```ts +type FaviconRel = "icon" | "shortcut icon" | "apple-touch-icon"; + +type FaviconOptions = { + /** @default "icon" */ + rel?: FaviconRel; +}; + +type FaviconController = { + readonly href: string; + setHref(href: string): void; + dispose(): void; +}; + +function makeFavicon(href: string, options?: FaviconOptions): FaviconController; + +function createFavicon(href: MaybeAccessor, options?: FaviconOptions): Accessor; +``` + +If the page already has a ``, it's reused and its href restored on `dispose()`; +otherwise one is created and removed on `dispose()`. Nested/sequential calls compose correctly as +long as disposal happens in the reverse of creation order (the normal case for component +mount/unmount) — each call restores whatever was there right before it ran, like a stack. + +### `makeFaviconAnimation` / `createFaviconAnimation` + +For build-status spinners, "recording" pulses, and similar loading indicators — cycles the +favicon through a sequence of pre-rendered frames. + +```ts +const anim = makeFaviconAnimation(["/spin-1.png", "/spin-2.png", "/spin-3.png"], { + interval: 150, +}); +anim.pause(); +anim.play(); +anim.dispose(); // restores the previous favicon, stops the interval +``` + +```ts +const spinner = createFaviconAnimation(["/spin-1.png", "/spin-2.png", "/spin-3.png"]); +spinner.frame(); // current frame index +spinner.playing(); // boolean +spinner.pause(); +spinner.play(); +``` + +The reactive `createFaviconAnimation` automatically pauses while the tab is hidden +(`document.visibilitychange`) and resumes if it was playing before — a backgrounded tab won't keep +repainting an icon nobody can see. + +```ts +type FaviconAnimationOptions = { + /** Milliseconds between frames. @default 200 */ + interval?: number; + /** Start cycling immediately. @default true */ + autoplay?: boolean; + /** Loop back to the first frame after the last. @default true */ + loop?: boolean; +} & FaviconOptions; + +type FaviconAnimationController = { + readonly href: string; + readonly frame: number; + readonly playing: boolean; + play(): void; + pause(): void; + dispose(): void; +}; + +function makeFaviconAnimation( + frames: readonly string[], + options?: FaviconAnimationOptions, +): FaviconAnimationController; + +function createFaviconAnimation( + frames: MaybeAccessor, + options?: FaviconAnimationOptions, +): { + frame: Accessor; + playing: Accessor; + play(): void; + pause(): void; +}; +``` + +### `makeFaviconBadge` / `createFaviconBadge` + +Overlays a notification count, short string, or plain dot onto a base icon — the way native apps +decorate their dock/taskbar icon. + +```ts +const badge = makeFaviconBadge("/favicon.png", 3); +badge.dispose(); // restores the previous favicon +``` + +```ts +const href = createFaviconBadge("/favicon.png", () => unreadCount()); +``` + +Value semantics: + +- `undefined | false | 0 | ""` → no badge, the base icon is shown as-is. +- `true` → a plain dot, no text. +- a `number` → digits, clamped to `"{max}+"` past `max` (default `99`). +- a `string` → shown verbatim (keep it short — a badge is a small fraction of the icon). + +```ts +type FaviconBadgeValue = number | string | boolean | undefined; + +type FaviconBadgePosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"; + +type FaviconBadgeOptions = { + /** @default "#e11d48" */ + color?: string; + /** @default "#ffffff" */ + textColor?: string; + /** @default "bottom-right" */ + position?: FaviconBadgePosition; + /** Numbers above this render as "{max}+". @default 99 */ + max?: number; + /** Badge diameter as a fraction of the icon's rendered size. @default 0.6 */ + scale?: number; +} & FaviconOptions; + +function makeFaviconBadge( + href: string, + value: FaviconBadgeValue, + options?: FaviconBadgeOptions, +): FaviconController; + +function createFaviconBadge( + href: MaybeAccessor, + value: MaybeAccessor, + options?: FaviconBadgeOptions, +): Accessor; +``` + +A failed base-image load falls back to the plain `href` — a broken badge overlay doesn't take down +the favicon entirely. + +### `makeFaviconScheme` / `createFaviconScheme` + +Swaps between two icons to match the OS/browser `prefers-color-scheme`, staying in sync as that +preference changes (e.g. the user's system switches modes, or they toggle it in devtools). + +```ts +const favicon = makeFaviconScheme({ light: "/favicon-light.svg", dark: "/favicon-dark.svg" }); +favicon.scheme; // "light" | "dark" +favicon.dispose(); // restores the previous favicon, stops watching the media query +``` + +```ts +const favicon = createFaviconScheme({ light: "/favicon-light.svg", dark: "/favicon-dark.svg" }); +favicon.href(); // reactive, currently-applied href +favicon.scheme(); // reactive, "light" | "dark" +``` + +```ts +type FaviconColorScheme = "light" | "dark"; + +type FaviconSchemeIcons = { + light: string; + dark: string; +}; + +type FaviconSchemeController = FaviconController & { + readonly scheme: FaviconColorScheme; +}; + +function makeFaviconScheme( + icons: FaviconSchemeIcons, + options?: FaviconOptions, +): FaviconSchemeController; + +function createFaviconScheme( + icons: MaybeAccessor, + options?: FaviconOptions, +): { href: Accessor; scheme: Accessor }; +``` + +SSR has no `prefers-color-scheme` to read — both exports treat the server as `"light"`. + +### `makeFaviconProgress` / `createFaviconProgress` + +Overlays a progress ring (0–100) onto a base icon — for upload/download-style indicators. +`undefined` shows the base icon with no ring at all; `0` **does** draw a ring (an empty track) — +the opposite of the badge overlay above, where `0` means "no badge." Use `undefined` while there's +no operation in progress, and `0`/a number once one starts. + +```ts +const favicon = makeFaviconProgress("/favicon.png", 40); +favicon.dispose(); // restores the previous favicon +``` + +```ts +const href = createFaviconProgress("/favicon.png", () => uploadProgress()); +``` + +```ts +type FaviconProgressOptions = { + /** Color of the unfilled ring track. @default "rgba(0, 0, 0, 0.15)" */ + trackColor?: string; + /** Color of the filled progress arc. @default "#6366f1" */ + color?: string; + /** Ring stroke width as a fraction of the icon's rendered size. @default 0.15 */ + thickness?: number; +} & FaviconOptions; + +function makeFaviconProgress( + href: string, + progress: number | undefined, + options?: FaviconProgressOptions, +): FaviconController; + +function createFaviconProgress( + href: MaybeAccessor, + progress: MaybeAccessor, + options?: FaviconProgressOptions, +): Accessor; +``` + +Values are clamped to `[0, 100]`. Like the badge overlay, a failed base-image load falls back to +the plain `href`. + +### `FaviconLink` — server-rendering the initial favicon + +Every `make*`/`create*` primitive above is SSR-safe in the sense that it won't crash on the +server — but since it works by mutating `document.head` imperatively, **nothing about the favicon +is rendered into the server-rendered HTML itself.** The actual `` shown before +your client JS hydrates is whatever's already in your static document shell. `FaviconLink` closes +that gap: it's a plain component that renders the `` tag directly, so its `href` is part of +the initial HTML response. + +```tsx +function Head() { + const scheme = createFaviconScheme({ light: "/icon-light.svg", dark: "/icon-dark.svg" }); + return ; +} +``` + +```ts +type FaviconLinkProps = { + href: string; +} & FaviconOptions; + +function FaviconLink(props: FaviconLinkProps): JSX.Element; +``` + +Place it once in your document's actual `` region — your SSR framework's root +document/`` component, not your regular app body tree, since a `` rendered there +wouldn't physically live in `` at all. A `make*`/`create*` call anywhere else in the app +composes with it automatically: `bindFaviconLink`'s existing "reuse an existing link" lookup finds +and takes over this exact element once it runs client-side — no coordination needed beyond both +touching the same DOM node. + +For `createFaviconScheme`, this means the SSR-rendered HTML shows a _guessed_ initial icon (SSR +has no `prefers-color-scheme` to read, so it's always `icons.light`) that the client corrects +immediately on hydration if the OS actually prefers dark. For `createFaviconBadge`/ +`createFaviconProgress`, `FaviconLink` can only render the _base_ icon during SSR (no canvas +server-side) — the badge/ring overlay still only appears once client JS composites it. + +#### SolidStart + +`FaviconLink` is a plain Solid component, so it drops straight into the `document` shell you +already pass to `` in `src/entry-server.tsx` — no special wiring: + +```tsx +// src/entry-server.tsx +import { StartServer, createHandler } from "@solidjs/start/server"; +import { FaviconLink } from "@solid-primitives/favicon"; + +export default createHandler(() => ( + ( + + + + {assets} + + +
{children}
+ {scripts} + + + )} + /> +)); +``` + +A static `href` here is no better than a plain `` in `index.html` — `FaviconLink` earns its +keep once the href is _computed server-side per request_. The `createFaviconScheme` guess-then- +correct flash exists only because the client has no way to know the server's guess was wrong until +after hydration; if you already have the real preference server-side (a `theme` cookie, for +example), read it once and skip the guess entirely: + +```tsx +// src/entry-server.tsx — same createHandler/StartServer wrapper as above, `document` swapped for: +import { getRequestEvent } from "solid-js/web"; +import { FaviconLink } from "@solid-primitives/favicon"; + +const document = ({ assets, children, scripts }) => { + const theme = getRequestEvent()?.request.headers.get("cookie")?.includes("theme=dark") + ? "dark" + : "light"; + return ( + + + + {assets} + + +
{children}
+ {scripts} + + + ); +}; +``` + +Pair this with `createFaviconScheme` (or plain `createFavicon`) somewhere in your client tree to +keep tracking `prefers-color-scheme` changes after load — its "reuse an existing link" lookup +takes over the exact element `FaviconLink` rendered, so nothing further needs to change. + +#### Astro + +Astro renders imported framework components to static HTML by default — no `client:*` directive +means zero JS ships for that component, which is exactly what a non-interactive `` tag +wants. Import `FaviconLink` straight into your root layout's frontmatter and place it in ``: + +```astro +--- +// src/layouts/Layout.astro +import { FaviconLink } from "@solid-primitives/favicon"; +const theme = Astro.cookies.get("theme")?.value === "dark" ? "dark" : "light"; +--- + + + + + + + + + +``` + +To keep reacting to OS scheme changes after the page loads, put `createFaviconScheme` in its own +component and hydrate just that island — `client:load` (or `client:idle`) is enough, since it only +needs to run once to attach its `matchMedia` listener: + +```tsx +// src/components/FaviconSync.tsx +import { createFaviconScheme } from "@solid-primitives/favicon"; + +export default function FaviconSync() { + createFaviconScheme({ light: "/icon-light.svg", dark: "/icon-dark.svg" }); + return null; +} +``` + +```astro + +``` + +`bindFaviconLink`'s reuse lookup finds the `` the layout already rendered and takes it over, +so the static SSR icon and the client-reactive one stay in sync without any explicit coordination +between the two components. + +## Design notes + +See [DESIGN.md](./DESIGN.md) for the reasoning behind splitting this into small, focused primitive +pairs (rather than one `createAdvancedFavicon`), the async-reactivity considerations, and the +canvas/`Image`/`matchMedia` mocking strategy used in tests. + +## Demo + +See the [Storybook stories](./stories) for interactive examples of all five primitives. + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/favicon/package.json b/packages/favicon/package.json new file mode 100644 index 000000000..85358ca5b --- /dev/null +++ b/packages/favicon/package.json @@ -0,0 +1,79 @@ +{ + "name": "@solid-primitives/favicon", + "version": "0.0.100", + "description": "Reactive primitives for controlling the document favicon, including animated frame cycling and notification badges.", + "author": "David Di Biase ", + "contributors": [], + "license": "MIT", + "homepage": "https://primitives.solidjs.community/package/favicon", + "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": "favicon", + "stage": 2, + "list": [ + "makeFavicon", + "createFavicon", + "makeFaviconAnimation", + "createFaviconAnimation", + "makeFaviconBadge", + "createFaviconBadge", + "makeFaviconScheme", + "createFaviconScheme", + "makeFaviconProgress", + "createFaviconProgress", + "FaviconLink" + ], + "category": "Browser APIs" + }, + "keywords": [ + "solid", + "primitives", + "favicon", + "icon" + ], + "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": {}, + "tsdown": { + "entry": "src/**/*.{ts,tsx}", + "outDir": "dist" + }, + "scripts": { + "build": "pnpm -w build", + "vitest": "vitest -c ../../configs/vitest.config.ts", + "test": "pnpm run vitest", + "test:ssr": "pnpm run vitest --mode ssr" + }, + "dependencies": { + "@solid-primitives/event-listener": "workspace:^", + "@solid-primitives/utils": "workspace:^" + }, + "devDependencies": { + "@solidjs/web": "catalog:", + "solid-js": "catalog:" + }, + "peerDependencies": { + "@solidjs/web": "catalog:peer", + "solid-js": "catalog:peer" + } +} diff --git a/packages/favicon/src/animation.ts b/packages/favicon/src/animation.ts new file mode 100644 index 000000000..556e89f2b --- /dev/null +++ b/packages/favicon/src/animation.ts @@ -0,0 +1,217 @@ +import { type Accessor, createEffect, createSignal, onCleanup, untrack } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { access, INTERNAL_OPTIONS, type MaybeAccessor, noop } from "@solid-primitives/utils"; +import { makeEventListener } from "@solid-primitives/event-listener"; +import { makeFavicon } from "./favicon.ts"; +import type { FaviconOptions } from "./link.ts"; + +export type FaviconAnimationOptions = { + /** Milliseconds between frames. @default 200 */ + interval?: number; + /** Start cycling immediately. @default true */ + autoplay?: boolean; + /** Loop back to the first frame after the last. @default true */ + loop?: boolean; +} & FaviconOptions; + +export type FaviconAnimationController = { + /** The href of the currently-displayed frame. */ + readonly href: string; + /** Index of the currently-displayed frame. */ + readonly frame: number; + readonly playing: boolean; + play: () => void; + pause: () => void; + dispose: () => void; +}; + +/** Returns the next frame index, or `undefined` when a non-looping sequence has ended. */ +function nextFrameIndex(frame: number, length: number, loop: boolean): number | undefined { + const next = frame + 1; + if (next < length) return next; + return loop ? 0 : undefined; +} + +/** + * Cycles the document favicon through `frames` on an interval — for build-status spinners, + * "recording" pulses, and similar loading indicators. + * + * Non-reactive: `frames` is a plain array, read once. For a reactive, auto-pausing-on-hidden-tab + * version see {@link createFaviconAnimation}. + * + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#makeFaviconAnimation + * @example + * const anim = makeFaviconAnimation(["/spin-1.png", "/spin-2.png", "/spin-3.png"]); + * anim.pause(); + * anim.dispose(); // restores the previous favicon + */ +export function makeFaviconAnimation( + frames: readonly string[], + options: FaviconAnimationOptions = {}, +): FaviconAnimationController { + const { interval = 200, autoplay = true, loop = true, ...faviconOptions } = options; + + if (isServer || frames.length === 0) { + return { + href: frames[0] ?? "", + frame: 0, + playing: false, + play: noop, + pause: noop, + dispose: noop, + }; + } + + const favicon = makeFavicon(frames[0]!, faviconOptions); + let frame = 0; + let playing = false; + let timer: ReturnType | undefined; + + function tick(): void { + const next = nextFrameIndex(frame, frames.length, loop); + if (next === undefined) { + pause(); + return; + } + frame = next; + favicon.setHref(frames[frame]!); + } + + function play(): void { + if (playing || frames.length <= 1) return; + playing = true; + timer = setInterval(tick, interval); + } + + function pause(): void { + playing = false; + if (timer !== undefined) { + clearInterval(timer); + timer = undefined; + } + } + + if (autoplay) play(); + + return { + get href() { + return favicon.href; + }, + get frame() { + return frame; + }, + get playing() { + return playing; + }, + play, + pause, + dispose() { + pause(); + favicon.dispose(); + }, + }; +} + +/** + * Reactively cycles the document favicon through `frames` on an interval. Automatically pauses + * while the tab is hidden (`document.visibilitychange`) and resumes if it was playing before, so + * a backgrounded tab doesn't keep repainting an icon nobody can see. Restores the previous + * favicon on cleanup. + * + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#createFaviconAnimation + * @example + * const spinner = createFaviconAnimation(["/spin-1.png", "/spin-2.png", "/spin-3.png"]); + * spinner.pause(); + */ +export function createFaviconAnimation( + frames: MaybeAccessor, + options: FaviconAnimationOptions = {}, +): { + frame: Accessor; + playing: Accessor; + play: () => void; + pause: () => void; +} { + if (isServer) { + return { frame: () => 0, playing: () => false, play: noop, pause: noop }; + } + + const { interval = 200, autoplay = true, loop = true, ...faviconOptions } = options; + + const [frame, setFrame] = createSignal(0, INTERNAL_OPTIONS); + const [playing, setPlaying] = createSignal(false, INTERNAL_OPTIONS); + + // `untrack` — read outside JSX/a memo/an effect's compute phase would otherwise trip Solid's + // `STRICT_READ_UNTRACKED` dev diagnostic. + let list = untrack(() => access(frames)); + const favicon = makeFavicon(list[0] ?? "", faviconOptions); + let timer: ReturnType | undefined; + + // Every read below is a plain imperative "what's the current value" check from outside a + // tracked context (a timer callback, an event handler, or setup code) — `untrack` keeps it + // from accidentally registering a dependency on whatever computation happens to be active + // when these functions are called (and avoids Solid's `STRICT_READ_UNTRACKED` dev diagnostic). + function tick(): void { + const next = nextFrameIndex(untrack(frame), list.length, loop); + if (next === undefined) { + pause(); + return; + } + setFrame(next); + favicon.setHref(list[next]!); + } + + function play(): void { + if (untrack(playing) || list.length <= 1) return; + setPlaying(true); + timer = setInterval(tick, interval); + } + + function pause(): void { + setPlaying(false); + if (timer !== undefined) { + clearInterval(timer); + timer = undefined; + } + } + + if (typeof frames === "function") { + // `defer: true` — the initial list was already applied synchronously above; without this, + // the effect's un-deferred first run would re-apply frame 0 on top of whatever `autoplay` + // had already ticked forward. + createEffect( + () => frames(), + newList => { + list = newList; + setFrame(0); + favicon.setHref(list[0] ?? ""); + if (list.length <= 1) { + if (untrack(playing)) pause(); + } else if (autoplay && !untrack(playing)) { + play(); + } + }, + { defer: true }, + ); + } + + let wasPlayingBeforeHidden = false; + makeEventListener(document, "visibilitychange", () => { + if (document.hidden) { + wasPlayingBeforeHidden = untrack(playing); + if (wasPlayingBeforeHidden) pause(); + } else if (wasPlayingBeforeHidden) { + wasPlayingBeforeHidden = false; + play(); + } + }); + + if (autoplay) play(); + + onCleanup(() => { + pause(); + favicon.dispose(); + }); + + return { frame, playing, play, pause }; +} diff --git a/packages/favicon/src/badge.ts b/packages/favicon/src/badge.ts new file mode 100644 index 000000000..be03dc312 --- /dev/null +++ b/packages/favicon/src/badge.ts @@ -0,0 +1,126 @@ +import type { Accessor } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { type MaybeAccessor, noop } from "@solid-primitives/utils"; +import { makeFavicon } from "./favicon.ts"; +import { createCanvasFavicon, drawBaseIcon, guardAsyncRender } from "./canvas.ts"; +import type { FaviconController, FaviconOptions } from "./link.ts"; + +export type FaviconBadgeValue = number | string | boolean | undefined; + +export type FaviconBadgePosition = "top-left" | "top-right" | "bottom-left" | "bottom-right"; + +export type FaviconBadgeOptions = { + /** Background color of the badge. @default "#e11d48" */ + color?: string; + /** Text color drawn inside the badge. @default "#ffffff" */ + textColor?: string; + /** Corner the badge is drawn in. @default "bottom-right" */ + position?: FaviconBadgePosition; + /** Numeric values greater than `max` render as `"{max}+"`. @default 99 */ + max?: number; + /** Badge diameter as a fraction of the icon's rendered size. @default 0.6 */ + scale?: number; +} & FaviconOptions; + +/** `0`, `false`, `undefined`, and `""` all mean "no badge" — the base icon is used as-is. */ +function shouldShowBadge(value: FaviconBadgeValue): boolean { + return ( + value === true || + (typeof value === "number" && value !== 0) || + (typeof value === "string" && value !== "") + ); +} + +function badgeText(value: FaviconBadgeValue, max: number): string | undefined { + if (typeof value === "number") return value > max ? `${max}+` : `${value}`; + if (typeof value === "string") return value; + return undefined; // `true` — dot only, no text +} + +/** + * Composites `value` as a badge onto `href`, returning a data URL. Returns `href` unchanged when + * `value` doesn't warrant a badge, or when the base image fails to load — a broken badge overlay + * shouldn't take down the favicon entirely. + */ +async function renderBadgeIcon( + href: string, + value: FaviconBadgeValue, + options: FaviconBadgeOptions, +): Promise { + if (!shouldShowBadge(value)) return href; + + const { + color = "#e11d48", + textColor = "#ffffff", + position = "bottom-right", + max = 99, + scale = 0.6, + } = options; + + const base = await drawBaseIcon(href); + if (!base) return href; + const { canvas, ctx, size } = base; + + const radius = (size * scale) / 2; + const cx = position.endsWith("right") ? size - radius : radius; + const cy = position.startsWith("bottom") ? size - radius : radius; + + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + + const text = badgeText(value, max); + if (text !== undefined) { + ctx.fillStyle = textColor; + ctx.font = `${Math.round(radius * (text.length > 1 ? 1.1 : 1.4))}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(text, cx, cy); + } + + return canvas.toDataURL("image/png"); +} + +/** + * Overlays a notification badge (a count, short string, or plain dot) on top of `href` and sets + * it as the document favicon. + * + * Non-reactive: `href`/`value` are read once. For a reactive, auto-redrawing version see + * {@link createFaviconBadge}. + * + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#makeFaviconBadge + * @example + * const badge = makeFaviconBadge("/favicon.png", 3); + * badge.dispose(); // restores the previous favicon + */ +export function makeFaviconBadge( + href: string, + value: FaviconBadgeValue, + options: FaviconBadgeOptions = {}, +): FaviconController { + if (isServer) { + return { href, setHref: noop, dispose: noop }; + } + + const favicon = makeFavicon(href, options); + return guardAsyncRender(favicon, renderBadgeIcon(href, value, options)); +} + +/** + * Reactively overlays a notification badge on top of `href`, redrawing whenever `href` or `value` + * change. Restores the previous favicon on cleanup. + * + * @returns an accessor of the currently-applied (possibly badged) favicon href + * + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#createFaviconBadge + * @example + * const href = createFaviconBadge("/favicon.png", () => unreadCount()); + */ +export function createFaviconBadge( + href: MaybeAccessor, + value: MaybeAccessor, + options: FaviconBadgeOptions = {}, +): Accessor { + return createCanvasFavicon(href, value, options, renderBadgeIcon); +} diff --git a/packages/favicon/src/canvas.ts b/packages/favicon/src/canvas.ts new file mode 100644 index 000000000..58576debb --- /dev/null +++ b/packages/favicon/src/canvas.ts @@ -0,0 +1,133 @@ +import { type Accessor, createEffect, createSignal, onCleanup, untrack } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { access, INTERNAL_OPTIONS, type MaybeAccessor, noop } from "@solid-primitives/utils"; +import { makeFavicon } from "./favicon.ts"; +import type { FaviconController, FaviconOptions } from "./link.ts"; + +/** Loads `src` into an `Image`, resolving once it's decoded (or rejecting on error). */ +export function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error(`favicon: failed to load image "${src}"`)); + img.src = src; + }); +} + +/** + * Loads `href` and draws it onto a fresh, appropriately-sized offscreen canvas. Returns + * `undefined` if the image fails to load or the canvas context is unavailable — callers should + * fall back to the plain `href` rather than let a broken overlay take down the favicon entirely. + */ +export async function drawBaseIcon( + href: string, +): Promise<{ canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D; size: number } | undefined> { + let img: HTMLImageElement; + try { + img = await loadImage(href); + } catch { + return undefined; + } + const size = img.naturalWidth || img.naturalHeight || 32; + + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + if (!ctx) return undefined; + + ctx.clearRect(0, 0, size, size); + ctx.drawImage(img, 0, 0, size, size); + + return { canvas, ctx, size }; +} + +/** + * Wraps a `make*` canvas-overlay primitive's fire-and-forget render: applies `render`'s result + * via `favicon.setHref` once it settles, unless `dispose()` has already run by then — otherwise a + * badge/progress draw that resolves after disposal would re-add a removed favicon ``, or + * overwrite the previous href `dispose()` just restored, with a stale composite. `render` itself + * already falls back to the plain `href` on any internal failure (see `drawBaseIcon`), so a + * rejection here is unexpected; treated the same as "leave the current href alone" rather than + * risk an unhandled rejection. + */ +export function guardAsyncRender( + favicon: FaviconController, + render: Promise, +): FaviconController { + let disposed = false; + render.then(dataUrl => { + if (!disposed) favicon.setHref(dataUrl); + }, noop); + return { + get href() { + return favicon.href; + }, + setHref: favicon.setHref, + dispose() { + disposed = true; + favicon.dispose(); + }, + }; +} + +/** + * Shared reactive wiring for `create*` primitives that composite a canvas overlay onto a base + * icon (badge, progress ring, …): applies `render(href, value, options)`'s result via + * `makeFavicon`, coalescing out-of-order async completions so only the most recent `href`/`value` + * pair's draw ever gets applied, and restores the previous favicon `onCleanup`. Extracted once a + * second such primitive (`progress.ts`) needed the exact same wiring `badge.ts` already had — + * this is behavior with a real correctness property (the coalescing), not just boilerplate, so + * keeping it in one place means a future fix to it can't accidentally apply to only one caller. + */ +export function createCanvasFavicon( + href: MaybeAccessor, + value: MaybeAccessor, + options: Options, + render: (href: string, value: Value, options: Options) => Promise, +): Accessor { + if (isServer) { + return () => access(href); + } + + // `untrack` — read outside JSX/a memo/an effect's compute phase would otherwise trip Solid's + // `STRICT_READ_UNTRACKED` dev diagnostic. + const favicon = makeFavicon( + untrack(() => access(href)), + options, + ); + const [current, setCurrent] = createSignal(favicon.href, INTERNAL_OPTIONS); + + let requestId = 0; + const redraw = (h: string, v: Value): void => { + const id = ++requestId; + render(h, v, options).then(dataUrl => { + if (id !== requestId) return; // superseded by a more recent href/value change, or disposed + favicon.setHref(dataUrl); + // Read back the DOM-resolved href (browsers resolve relative `href`s to absolute URLs) + // rather than trusting `dataUrl` verbatim, so this always matches the non-reactive `make*` + // variant's `.href` getter. + setCurrent(favicon.href); + }, noop); // `render` already falls back to the plain href internally, so a rejection here is + // unexpected — nothing to apply beyond avoiding an unhandled rejection. + }; + + if (typeof href === "function" || typeof value === "function") { + createEffect( + () => [access(href), access(value)] as const, + ([h, v]) => redraw(h, v), + ); + } else { + redraw(href, value); + } + + onCleanup(() => { + // Invalidate any redraw still in flight first, reusing the same `requestId` guard `redraw` + // checks above — otherwise a draw that settles after disposal could still call + // `favicon.setHref`/`setCurrent` on an already-disposed favicon/signal. + requestId++; + favicon.dispose(); + }); + + return current; +} diff --git a/packages/favicon/src/components.tsx b/packages/favicon/src/components.tsx new file mode 100644 index 000000000..76614a55d --- /dev/null +++ b/packages/favicon/src/components.tsx @@ -0,0 +1,27 @@ +import type { Component } from "solid-js"; +import type { FaviconOptions } from "./link.ts"; + +export type FaviconLinkProps = { + /** The favicon href to render. Pass a value, not an accessor — call it at the JSX boundary. */ + href: string; +} & FaviconOptions; + +/** + * Renders the favicon `` element directly, so its initial `href` is part of the server- + * rendered HTML instead of only being applied once client JS hydrates. Place it once in your + * document's actual `` region (e.g. your SSR framework's root document/`` component — + * not your regular app body tree, since a `` rendered there wouldn't live in `` at + * all) alongside a `make*`/`create*` call anywhere else in the app: `bindFaviconLink`'s existing + * "reuse an existing link" lookup finds and takes over this exact element once it runs + * client-side, so nothing else needs to change to compose the two. + * + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#FaviconLink + * @example + * function Head() { + * const scheme = createFaviconScheme({ light: "/icon-light.svg", dark: "/icon-dark.svg" }); + * return ; + * } + */ +export const FaviconLink: Component = props => ( + +); diff --git a/packages/favicon/src/favicon.ts b/packages/favicon/src/favicon.ts new file mode 100644 index 000000000..1a84d38df --- /dev/null +++ b/packages/favicon/src/favicon.ts @@ -0,0 +1,70 @@ +import { type Accessor, createEffect, createSignal, onCleanup, untrack } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { access, INTERNAL_OPTIONS, type MaybeAccessor, noop } from "@solid-primitives/utils"; +import { bindFaviconLink, type FaviconController, type FaviconOptions } from "./link.ts"; + +/** + * Sets the document's favicon to `href`, restoring whatever href (or absence of a link element) + * was there before once `dispose()` is called. + * + * Non-reactive: `href` is a plain string. For a reactive, auto-disposing version see + * {@link createFavicon}. + * + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#makeFavicon + * @example + * const favicon = makeFavicon("/favicon-dark.svg"); + * favicon.setHref("/favicon-light.svg"); + * favicon.dispose(); // restores the previous favicon + */ +export function makeFavicon(href: string, options: FaviconOptions = {}): FaviconController { + if (isServer) { + return { href, setHref: noop, dispose: noop }; + } + return bindFaviconLink(options.rel ?? "icon", href); +} + +/** + * Reactively sets the document's favicon to `href`. Restores the previous favicon on cleanup. + * + * @param href a url/path, or an accessor returning one, to apply as the favicon + * @returns an accessor of the currently-applied favicon href + * + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#createFavicon + * @example + * const href = createFavicon(() => (unreadCount() > 0 ? "/favicon-alert.svg" : "/favicon.svg")); + */ +export function createFavicon( + href: MaybeAccessor, + options: FaviconOptions = {}, +): Accessor { + if (isServer) { + return () => access(href); + } + + // `untrack` — this initial read must not happen as a top-level tracked read (it isn't inside + // JSX, a memo, or an effect's compute phase), or Solid's dev-mode diagnostics flag it as + // `STRICT_READ_UNTRACKED`. + const favicon = makeFavicon( + untrack(() => access(href)), + options, + ); + // A real signal, not just `() => favicon.href` — reading a plain DOM property from JSX + // registers no dependency, so it would never re-render when the href changes underneath it. + const [current, setCurrent] = createSignal(favicon.href, INTERNAL_OPTIONS); + + if (typeof href === "function") { + // `defer: true` — the initial value was already applied synchronously above via `makeFavicon`. + createEffect( + () => href(), + value => { + favicon.setHref(value); + setCurrent(favicon.href); + }, + { defer: true }, + ); + } + + onCleanup(favicon.dispose); + + return current; +} diff --git a/packages/favicon/src/index.ts b/packages/favicon/src/index.ts new file mode 100644 index 000000000..0542d25b4 --- /dev/null +++ b/packages/favicon/src/index.ts @@ -0,0 +1,28 @@ +export type { FaviconController, FaviconOptions, FaviconRel } from "./link.ts"; +export { makeFavicon, createFavicon } from "./favicon.ts"; +export { + makeFaviconAnimation, + createFaviconAnimation, + type FaviconAnimationOptions, + type FaviconAnimationController, +} from "./animation.ts"; +export { + makeFaviconBadge, + createFaviconBadge, + type FaviconBadgeValue, + type FaviconBadgePosition, + type FaviconBadgeOptions, +} from "./badge.ts"; +export { + makeFaviconScheme, + createFaviconScheme, + type FaviconColorScheme, + type FaviconSchemeIcons, + type FaviconSchemeController, +} from "./scheme.ts"; +export { + makeFaviconProgress, + createFaviconProgress, + type FaviconProgressOptions, +} from "./progress.ts"; +export { FaviconLink, type FaviconLinkProps } from "./components.tsx"; diff --git a/packages/favicon/src/link.ts b/packages/favicon/src/link.ts new file mode 100644 index 000000000..2ecd0c9fa --- /dev/null +++ b/packages/favicon/src/link.ts @@ -0,0 +1,56 @@ +export type FaviconRel = "icon" | "shortcut icon" | "apple-touch-icon"; + +export type FaviconOptions = { + /** `rel` attribute of the favicon `` element. */ + rel?: FaviconRel; +}; + +export type FaviconController = { + /** The href currently applied to the favicon link. */ + readonly href: string; + /** Update the favicon href. */ + setHref: (href: string) => void; + /** Restores the href captured before this controller ran, or removes the link it created. */ + dispose: () => void; +}; + +/** + * Finds the document's favicon `` for the given `rel`, creating and appending one if none + * exists. Captures whatever href was present before this call so callers can restore it later — + * each call snapshots independently, so sequential/nested callers compose like a stack as long as + * disposal happens in reverse of creation order (the normal case for component mount/unmount). + */ +export function bindFaviconLink(rel: FaviconRel, href: string): FaviconController { + let link = document.head.querySelector(`link[rel="${rel}"]`); + const created = link == null; + // The raw attribute, not the `.href` IDL property — the property resolves a missing attribute + // to `""` (a real, if empty, value), which would lose the "no href at all" case on restore. + const previousHref = link?.getAttribute("href") ?? undefined; + + if (!link) { + link = document.createElement("link"); + link.rel = rel; + document.head.appendChild(link); + } + + link.href = href; + const el = link; + + return { + get href() { + return el.href; + }, + setHref(next: string) { + el.href = next; + }, + dispose() { + if (created) { + el.remove(); + } else if (previousHref !== undefined) { + el.href = previousHref; + } else { + el.removeAttribute("href"); + } + }, + }; +} diff --git a/packages/favicon/src/progress.ts b/packages/favicon/src/progress.ts new file mode 100644 index 000000000..006905354 --- /dev/null +++ b/packages/favicon/src/progress.ts @@ -0,0 +1,102 @@ +import type { Accessor } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { type MaybeAccessor, noop } from "@solid-primitives/utils"; +import { makeFavicon } from "./favicon.ts"; +import { createCanvasFavicon, drawBaseIcon, guardAsyncRender } from "./canvas.ts"; +import type { FaviconController, FaviconOptions } from "./link.ts"; + +export type FaviconProgressOptions = { + /** Color of the unfilled ring track. @default "rgba(0, 0, 0, 0.15)" */ + trackColor?: string; + /** Color of the filled progress arc. @default "#6366f1" */ + color?: string; + /** Ring stroke width as a fraction of the icon's rendered size. @default 0.15 */ + thickness?: number; +} & FaviconOptions; + +/** + * Composites `progress` as a ring around `href`, returning a data URL. Returns `href` unchanged + * when `progress` is `undefined`, or when the base image fails to load — a broken ring overlay + * shouldn't take down the favicon entirely. + */ +async function renderProgressIcon( + href: string, + progress: number | undefined, + options: FaviconProgressOptions, +): Promise { + if (progress === undefined) return href; + + const { trackColor = "rgba(0, 0, 0, 0.15)", color = "#6366f1", thickness = 0.15 } = options; + const clamped = Math.min(100, Math.max(0, progress)); + + const base = await drawBaseIcon(href); + if (!base) return href; + const { canvas, ctx, size } = base; + + const lineWidth = size * thickness; + const radius = (size - lineWidth) / 2; + const cx = size / 2; + const cy = size / 2; + + ctx.lineWidth = lineWidth; + ctx.lineCap = "round"; + + ctx.strokeStyle = trackColor; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.stroke(); + + if (clamped > 0) { + ctx.strokeStyle = color; + ctx.beginPath(); + const start = -Math.PI / 2; + const end = start + (Math.PI * 2 * clamped) / 100; + ctx.arc(cx, cy, radius, start, end); + ctx.stroke(); + } + + return canvas.toDataURL("image/png"); +} + +/** + * Overlays a progress ring (0–100) around `href` and sets it as the document favicon — for + * upload/download-style indicators. `undefined` shows the base icon with no ring. + * + * Non-reactive: `href`/`progress` are read once. For a reactive, auto-redrawing version see + * {@link createFaviconProgress}. + * + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#makeFaviconProgress + * @example + * const favicon = makeFaviconProgress("/favicon.png", 40); + * favicon.dispose(); // restores the previous favicon + */ +export function makeFaviconProgress( + href: string, + progress: number | undefined, + options: FaviconProgressOptions = {}, +): FaviconController { + if (isServer) { + return { href, setHref: noop, dispose: noop }; + } + + const favicon = makeFavicon(href, options); + return guardAsyncRender(favicon, renderProgressIcon(href, progress, options)); +} + +/** + * Reactively overlays a progress ring around `href`, redrawing whenever `href` or `progress` + * change. Restores the previous favicon on cleanup. + * + * @returns an accessor of the currently-applied (possibly ringed) favicon href + * + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#createFaviconProgress + * @example + * const href = createFaviconProgress("/favicon.png", () => uploadProgress()); + */ +export function createFaviconProgress( + href: MaybeAccessor, + progress: MaybeAccessor, + options: FaviconProgressOptions = {}, +): Accessor { + return createCanvasFavicon(href, progress, options, renderProgressIcon); +} diff --git a/packages/favicon/src/scheme.ts b/packages/favicon/src/scheme.ts new file mode 100644 index 000000000..1613e4e18 --- /dev/null +++ b/packages/favicon/src/scheme.ts @@ -0,0 +1,120 @@ +import { type Accessor, createEffect, createSignal, onCleanup, untrack } from "solid-js"; +import { isServer } from "@solidjs/web"; +import { access, INTERNAL_OPTIONS, type MaybeAccessor, noop } from "@solid-primitives/utils"; +import { makeEventListener } from "@solid-primitives/event-listener"; +import { makeFavicon } from "./favicon.ts"; +import type { FaviconController, FaviconOptions } from "./link.ts"; + +export type FaviconColorScheme = "light" | "dark"; + +export type FaviconSchemeIcons = { + light: string; + dark: string; +}; + +export type FaviconSchemeController = FaviconController & { + /** Which icon is currently showing, based on `prefers-color-scheme`. */ + readonly scheme: FaviconColorScheme; +}; + +/** SSR has no `matchMedia` to query — every export here treats the server as `"light"`. */ +const SERVER_SCHEME: FaviconColorScheme = "light"; + +/** + * Sets the document favicon to `icons.light` or `icons.dark` to match the OS/browser + * `prefers-color-scheme`, and keeps it in sync as that preference changes. + * + * Non-reactive: `icons` is a plain object, read once (the light/dark swap still keeps working — + * only the two href strings themselves are static). For a reactive, auto-disposing version see + * {@link createFaviconScheme}. + * + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#makeFaviconScheme + * @example + * const favicon = makeFaviconScheme({ light: "/favicon-light.svg", dark: "/favicon-dark.svg" }); + * favicon.dispose(); // restores the previous favicon, stops watching the media query + */ +export function makeFaviconScheme( + icons: FaviconSchemeIcons, + options: FaviconOptions = {}, +): FaviconSchemeController { + if (isServer) { + return { href: icons[SERVER_SCHEME], scheme: SERVER_SCHEME, setHref: noop, dispose: noop }; + } + + const mql = window.matchMedia("(prefers-color-scheme: dark)"); + let scheme: FaviconColorScheme = mql.matches ? "dark" : "light"; + const favicon = makeFavicon(icons[scheme], options); + + const clear = makeEventListener<{ change: MediaQueryListEvent }>(mql, "change", event => { + scheme = event.matches ? "dark" : "light"; + favicon.setHref(icons[scheme]); + }); + + return { + get href() { + return favicon.href; + }, + get scheme() { + return scheme; + }, + setHref: favicon.setHref, + dispose() { + clear(); + favicon.dispose(); + }, + }; +} + +/** + * Reactively sets the document favicon to `icons.light` or `icons.dark` to match + * `prefers-color-scheme`, updating on both an `icons` change and an OS-level scheme change. + * Restores the previous favicon on cleanup. + * + * @returns the currently-applied href and the currently-active scheme, both reactive + * + * @see https://github.com/solidjs-community/solid-primitives/tree/main/packages/favicon#createFaviconScheme + * @example + * const favicon = createFaviconScheme({ light: "/favicon-light.svg", dark: "/favicon-dark.svg" }); + * favicon.scheme(); // "light" | "dark" + */ +export function createFaviconScheme( + icons: MaybeAccessor, + options: FaviconOptions = {}, +): { href: Accessor; scheme: Accessor } { + if (isServer) { + // Lazy, like `createFavicon`'s `() => access(href)` — re-reads `icons` on every call rather + // than capturing a stale snapshot, in case it's a reactive accessor. + return { href: () => access(icons)[SERVER_SCHEME], scheme: () => SERVER_SCHEME }; + } + + const mql = window.matchMedia("(prefers-color-scheme: dark)"); + const [scheme, setScheme] = createSignal( + mql.matches ? "dark" : "light", + INTERNAL_OPTIONS, + ); + + // `untrack` — this initial read must not happen as a top-level tracked read (it isn't inside + // JSX, a memo, or an effect's compute phase), or Solid's dev-mode diagnostics flag it as + // `STRICT_READ_UNTRACKED`. + const initialIcons = untrack(() => access(icons)); + const favicon = makeFavicon(initialIcons[untrack(scheme)], options); + const [href, setHref] = createSignal(favicon.href, INTERNAL_OPTIONS); + + makeEventListener<{ change: MediaQueryListEvent }>(mql, "change", event => { + setScheme(event.matches ? "dark" : "light"); + }); + + // `defer: true` — the initial value was already applied synchronously above. + createEffect( + () => [access(icons), scheme()] as const, + ([currentIcons, currentScheme]) => { + favicon.setHref(currentIcons[currentScheme]); + setHref(favicon.href); + }, + { defer: true }, + ); + + onCleanup(favicon.dispose); + + return { href, scheme }; +} diff --git a/packages/favicon/stories/_helpers.tsx b/packages/favicon/stories/_helpers.tsx new file mode 100644 index 000000000..a11ac31d7 --- /dev/null +++ b/packages/favicon/stories/_helpers.tsx @@ -0,0 +1,29 @@ +import { colors, font, radii } from "../../../.storybook/ui/index.js"; + +/** A small inline SVG data URI — avoids needing binary assets registered in `staticDirs`. */ +export const svgIcon = (fill: string): string => + `data:image/svg+xml,${encodeURIComponent( + `` + + `` + + ``, + )}`; + +/** Preview box mirroring what the real (invisible-in-canvas) browser tab favicon currently is. */ +export const IconPreview = (props: { href: string; label?: string }) => ( +
+ favicon preview + + {props.label ?? "current favicon"} + +
+); diff --git a/packages/favicon/stories/createFavicon.stories.tsx b/packages/favicon/stories/createFavicon.stories.tsx new file mode 100644 index 000000000..8496abe6f --- /dev/null +++ b/packages/favicon/stories/createFavicon.stories.tsx @@ -0,0 +1,61 @@ +import { createSignal } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { createFavicon } from "@solid-primitives/favicon"; +import readme from "../README.md?raw"; +import { Button, ButtonRow, Container, Section, BoolRow } from "../../../.storybook/ui/index.js"; +import { svgIcon, IconPreview } from "./_helpers.js"; + +const meta = preview.meta({ + title: "Browser APIs/Favicon", + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: readme, + }, + }, + }, +}); + +export default meta; + +const ICONS = { + default: svgIcon("#6366f1"), + alert: svgIcon("#e11d48"), +}; + +export const BasicSwap = meta.story({ + name: "Reactive swap", + parameters: { + docs: { + description: { + story: + "`createFavicon` reactively swaps the document favicon (visible in the real browser tab — try switching away and back). This story also mirrors the current href in a preview box since the actual tab icon isn't visible inside the canvas.", + }, + }, + }, + render: () => { + const [alert, setAlert] = createSignal(false); + const href = createFavicon(() => (alert() ? ICONS.alert : ICONS.default)); + + return ( + + + + + + + + +
+ +
+
+ ); + }, +}); diff --git a/packages/favicon/stories/createFaviconAnimation.stories.tsx b/packages/favicon/stories/createFaviconAnimation.stories.tsx new file mode 100644 index 000000000..65e4245cd --- /dev/null +++ b/packages/favicon/stories/createFaviconAnimation.stories.tsx @@ -0,0 +1,60 @@ +import preview from "../../../.storybook/preview.js"; +import { createFaviconAnimation } from "@solid-primitives/favicon"; +import { + Button, + ButtonRow, + Container, + Section, + BoolRow, + StatRow, +} from "../../../.storybook/ui/index.js"; +import { svgIcon, IconPreview } from "./_helpers.js"; + +const meta = preview.meta({ + title: "Browser APIs/Favicon", +}); + +export default meta; + +const FRAMES = ["#6366f1", "#8b5cf6", "#d946ef", "#f43f5e", "#f97316"].map(svgIcon); + +export const Spinner = meta.story({ + name: "Loading spinner", + parameters: { + docs: { + description: { + story: + 'Cycles the favicon through a sequence of frames on an interval — for build-status spinners or "recording" pulses. Automatically pauses while the tab is hidden and resumes when it becomes visible again; switch away from this browser tab while playing to see it happen (`playing` below will flip back to `true` on return).', + }, + }, + }, + render: () => { + const spinner = createFaviconAnimation(FRAMES, { interval: 150 }); + + return ( + + + + + + + + +
+ + +
+
+ ); + }, +}); diff --git a/packages/favicon/stories/createFaviconBadge.stories.tsx b/packages/favicon/stories/createFaviconBadge.stories.tsx new file mode 100644 index 000000000..ddfb88586 --- /dev/null +++ b/packages/favicon/stories/createFaviconBadge.stories.tsx @@ -0,0 +1,51 @@ +import { createSignal } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { createFaviconBadge } from "@solid-primitives/favicon"; +import { Button, ButtonRow, Container, Section, StatRow } from "../../../.storybook/ui/index.js"; +import { svgIcon, IconPreview } from "./_helpers.js"; + +const meta = preview.meta({ + title: "Browser APIs/Favicon", +}); + +export default meta; + +const BASE_ICON = svgIcon("#6366f1"); + +export const NotificationBadge = meta.story({ + name: "Unread count badge", + parameters: { + docs: { + description: { + story: + 'Overlays a notification count on the base icon, the way native apps decorate their dock/taskbar icon. `0` renders no badge; numbers clamp to `"{max}+"` past `max` (default 99).', + }, + }, + }, + render: () => { + const [count, setCount] = createSignal(3); + const href = createFaviconBadge(BASE_ICON, count, { max: 9 }); + + return ( + + + + + + + + + +
+ +
+
+ ); + }, +}); diff --git a/packages/favicon/stories/createFaviconProgress.stories.tsx b/packages/favicon/stories/createFaviconProgress.stories.tsx new file mode 100644 index 000000000..251f67ff1 --- /dev/null +++ b/packages/favicon/stories/createFaviconProgress.stories.tsx @@ -0,0 +1,112 @@ +import { createEffect, createSignal, Show } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { createFaviconProgress } from "@solid-primitives/favicon"; +import { + Button, + ButtonRow, + Container, + Section, + StatRow, + colors, + font, +} from "../../../.storybook/ui/index.js"; +import { svgIcon, IconPreview } from "./_helpers.js"; + +const meta = preview.meta({ + title: "Browser APIs/Favicon", +}); + +export default meta; + +const BASE_ICON = svgIcon("#6366f1"); + +export const Progress = meta.story({ + name: "Upload progress ring", + parameters: { + docs: { + description: { + story: + "Overlays a progress ring on the base icon — for upload/download-style indicators. `undefined` shows the base icon with no ring at all; drag to 0 to see the (still visible) empty track.", + }, + }, + }, + render: () => { + const [progress, setProgress] = createSignal(40); + const [simulating, setSimulating] = createSignal(false); + const href = createFaviconProgress(BASE_ICON, progress); + + // The interval's lifetime is tied directly to the `simulating` signal via the effect's + // apply-phase cleanup — Solid guarantees it's cleared whenever `simulating` flips back to + // `false` (Clear, manual drag, reaching 100%, or unmount), with no manual timer bookkeeping. + createEffect( + () => simulating(), + isSimulating => { + if (!isSimulating) return; + const id = setInterval(() => { + setProgress(p => { + const next = (p ?? 0) + 5; + if (next >= 100) { + setSimulating(false); + return 100; + } + return next; + }); + }, 120); + return () => clearInterval(id); + }, + ); + + return ( + + + + { + setSimulating(false); + setProgress(Number(e.currentTarget.value)); + }} + style={{ width: "100%" }} + /> + + + + + + +
+ no ring + } + > + + +
+
+ ); + }, +}); diff --git a/packages/favicon/stories/createFaviconScheme.stories.tsx b/packages/favicon/stories/createFaviconScheme.stories.tsx new file mode 100644 index 000000000..a534740f9 --- /dev/null +++ b/packages/favicon/stories/createFaviconScheme.stories.tsx @@ -0,0 +1,56 @@ +import { createSignal } from "solid-js"; +import preview from "../../../.storybook/preview.js"; +import { createFaviconScheme } from "@solid-primitives/favicon"; +import { + Button, + ButtonRow, + Container, + Section, + BoolRow, + StatRow, +} from "../../../.storybook/ui/index.js"; +import { svgIcon, IconPreview } from "./_helpers.js"; + +const meta = preview.meta({ + title: "Browser APIs/Favicon", +}); + +export default meta; + +const ICON_SETS = [ + { light: svgIcon("#6366f1"), dark: svgIcon("#a5f3fc") }, + { light: svgIcon("#16a34a"), dark: svgIcon("#f59e0b") }, +]; + +export const ColorScheme = meta.story({ + name: "Light/dark mode", + parameters: { + docs: { + description: { + story: + "Follows `prefers-color-scheme` automatically — toggle your OS or browser dark mode to see it react live. The button below swaps which light/dark icon *pair* is in use (the other axis `createFaviconScheme` reacts to), independent of the OS setting.", + }, + }, + }, + render: () => { + const [setIndex, setSetIndex] = createSignal(0); + const favicon = createFaviconScheme(() => ICON_SETS[setIndex()]!); + + return ( + + + + + + + +
+ + +
+
+ ); + }, +}); diff --git a/packages/favicon/stories/tsconfig.json b/packages/favicon/stories/tsconfig.json new file mode 100644 index 000000000..0cd588e9e --- /dev/null +++ b/packages/favicon/stories/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../.storybook/tsconfig.json", + "include": ["./**/*"] +} diff --git a/packages/favicon/test/animation.test.ts b/packages/favicon/test/animation.test.ts new file mode 100644 index 000000000..74854de88 --- /dev/null +++ b/packages/favicon/test/animation.test.ts @@ -0,0 +1,247 @@ +import "./setup.js"; +import { createRoot, createSignal, flush } from "solid-js"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { makeFaviconAnimation, createFaviconAnimation } from "../src/index.js"; + +const frames = ["/f1.png", "/f2.png", "/f3.png"]; + +beforeAll(() => { + vi.useFakeTimers(); +}); + +beforeEach(() => { + vi.clearAllTimers(); +}); + +afterEach(() => { + document.head.querySelectorAll('link[rel="icon"]').forEach(el => el.remove()); +}); + +afterAll(() => { + vi.useRealTimers(); +}); + +describe("makeFaviconAnimation", () => { + test("autoplays and cycles frames on an interval", () => { + const anim = makeFaviconAnimation(frames, { interval: 100 }); + expect(anim.frame).toBe(0); + expect(anim.playing).toBe(true); + + vi.advanceTimersByTime(100); + expect(anim.frame).toBe(1); + + vi.advanceTimersByTime(100); + expect(anim.frame).toBe(2); + + // loops back to the start by default + vi.advanceTimersByTime(100); + expect(anim.frame).toBe(0); + + anim.dispose(); + }); + + test("loop: false stops on the last frame", () => { + const anim = makeFaviconAnimation(frames, { interval: 100, loop: false }); + + vi.advanceTimersByTime(100); + vi.advanceTimersByTime(100); + expect(anim.frame).toBe(2); + expect(anim.playing).toBe(true); + + vi.advanceTimersByTime(100); + expect(anim.frame).toBe(2); + expect(anim.playing).toBe(false); + + anim.dispose(); + }); + + test("pause stops advancing, play resumes", () => { + const anim = makeFaviconAnimation(frames, { interval: 100 }); + anim.pause(); + expect(anim.playing).toBe(false); + + vi.advanceTimersByTime(300); + expect(anim.frame).toBe(0); + + anim.play(); + vi.advanceTimersByTime(100); + expect(anim.frame).toBe(1); + + anim.dispose(); + }); + + test("autoplay: false does not start the interval", () => { + const anim = makeFaviconAnimation(frames, { interval: 100, autoplay: false }); + expect(anim.playing).toBe(false); + vi.advanceTimersByTime(500); + expect(anim.frame).toBe(0); + anim.dispose(); + }); + + test("dispose restores the previous favicon and stops the interval", () => { + const anim = makeFaviconAnimation(frames, { interval: 100 }); + anim.dispose(); + expect(document.head.querySelector('link[rel="icon"]')).toBeNull(); + // shouldn't throw / advance a disposed timer + vi.advanceTimersByTime(1000); + }); + + test("a single frame never starts an interval", () => { + const anim = makeFaviconAnimation(["/only.png"], { interval: 100 }); + expect(anim.playing).toBe(false); + anim.dispose(); + }); +}); + +describe("createFaviconAnimation", () => { + test("cycles frames reactively and restores on cleanup", () => { + const { spinner, dispose } = createRoot(dispose => ({ + spinner: createFaviconAnimation(frames, { interval: 100 }), + dispose, + })); + flush(); + expect(spinner.frame()).toBe(0); + expect(spinner.playing()).toBe(true); + + vi.advanceTimersByTime(100); + flush(); + expect(spinner.frame()).toBe(1); + + dispose(); + expect(document.head.querySelector('link[rel="icon"]')).toBeNull(); + }); + + test("play/pause update the playing signal", () => { + const { spinner, dispose } = createRoot(dispose => ({ + spinner: createFaviconAnimation(frames, { interval: 100 }), + dispose, + })); + spinner.pause(); + flush(); + expect(spinner.playing()).toBe(false); + + vi.advanceTimersByTime(300); + flush(); + expect(spinner.frame()).toBe(0); + + spinner.play(); + flush(); + expect(spinner.playing()).toBe(true); + vi.advanceTimersByTime(100); + flush(); + expect(spinner.frame()).toBe(1); + + dispose(); + }); + + test("swapping the frames accessor restarts the cycle", () => { + const [list, setList] = createSignal(frames); + const { spinner, dispose } = createRoot(dispose => ({ + spinner: createFaviconAnimation(list, { interval: 100 }), + dispose, + })); + + vi.advanceTimersByTime(100); + flush(); + expect(spinner.frame()).toBe(1); + + setList(["/x.png", "/y.png"]); + flush(); + expect(spinner.frame()).toBe(0); + + dispose(); + }); + + test("shrinking to one frame pauses; growing back past one resumes when autoplay is set", () => { + const [list, setList] = createSignal(frames); + const { spinner, dispose } = createRoot(dispose => ({ + spinner: createFaviconAnimation(list, { interval: 100 }), + dispose, + })); + flush(); + expect(spinner.playing()).toBe(true); + + setList(["/solo.png"]); + flush(); + expect(spinner.playing()).toBe(false); + + vi.advanceTimersByTime(300); + flush(); + expect(spinner.frame()).toBe(0); // no interval running while at one frame + + setList(["/x.png", "/y.png"]); + flush(); + expect(spinner.playing()).toBe(true); + + vi.advanceTimersByTime(100); + flush(); + expect(spinner.frame()).toBe(1); + + dispose(); + }); + + test("growing past one frame does not autoplay when autoplay is false", () => { + const [list, setList] = createSignal(["/solo.png"]); + const { spinner, dispose } = createRoot(dispose => ({ + spinner: createFaviconAnimation(list, { interval: 100, autoplay: false }), + dispose, + })); + flush(); + expect(spinner.playing()).toBe(false); + + setList(["/x.png", "/y.png"]); + flush(); + expect(spinner.playing()).toBe(false); + + dispose(); + }); + + test("auto-pauses on visibilitychange when hidden, resumes when visible", () => { + const { spinner, dispose } = createRoot(dispose => ({ + spinner: createFaviconAnimation(frames, { interval: 100 }), + dispose, + })); + flush(); + expect(spinner.playing()).toBe(true); + + Object.defineProperty(document, "hidden", { configurable: true, value: true }); + document.dispatchEvent(new Event("visibilitychange")); + flush(); + expect(spinner.playing()).toBe(false); + + vi.advanceTimersByTime(300); + flush(); + expect(spinner.frame()).toBe(0); + + Object.defineProperty(document, "hidden", { configurable: true, value: false }); + document.dispatchEvent(new Event("visibilitychange")); + flush(); + expect(spinner.playing()).toBe(true); + + vi.advanceTimersByTime(100); + flush(); + expect(spinner.frame()).toBe(1); + + dispose(); + Object.defineProperty(document, "hidden", { configurable: true, value: false }); + }); + + test("does not resume on visible if it was manually paused while hidden was false", () => { + const { spinner, dispose } = createRoot(dispose => ({ + spinner: createFaviconAnimation(frames, { interval: 100 }), + dispose, + })); + spinner.pause(); + flush(); + + Object.defineProperty(document, "hidden", { configurable: true, value: true }); + document.dispatchEvent(new Event("visibilitychange")); + Object.defineProperty(document, "hidden", { configurable: true, value: false }); + document.dispatchEvent(new Event("visibilitychange")); + flush(); + + expect(spinner.playing()).toBe(false); + + dispose(); + }); +}); diff --git a/packages/favicon/test/badge.test.ts b/packages/favicon/test/badge.test.ts new file mode 100644 index 000000000..b23e90d6b --- /dev/null +++ b/packages/favicon/test/badge.test.ts @@ -0,0 +1,227 @@ +import "./setup.js"; +import { createRoot, createSignal, flush } from "solid-js"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { makeFaviconBadge, createFaviconBadge } from "../src/index.js"; +import { installCanvasMock, installImageMock } from "./setup.js"; + +const baseHref = "/base.png"; + +/** Lets the load-image microtask, the async draw chain, and its `.then` settle. */ +const flushMicrotasks = async (times = 10): Promise => { + for (let i = 0; i < times; i++) await Promise.resolve(); +}; + +// Re-installed per test: see the module doc in `setup.ts` for why this file can't rely on that +// module's own import-time side effects alone. +beforeEach(() => { + installCanvasMock(); + installImageMock(); +}); + +afterEach(() => { + document.head.querySelectorAll('link[rel="icon"]').forEach(el => el.remove()); +}); + +describe("makeFaviconBadge", () => { + test("falsy values (0, false, undefined) render no badge", async () => { + for (const value of [0, false, undefined] as const) { + const badge = makeFaviconBadge(baseHref, value); + await flushMicrotasks(); + expect(badge.href).toBe(new URL(baseHref, location.href).href); + badge.dispose(); + } + }); + + test("a number draws a badge with the digits as text", async () => { + const badge = makeFaviconBadge(baseHref, 3); + await flushMicrotasks(); + expect(badge.href).toContain("badge=true"); + expect(badge.href).toContain("text=3"); + badge.dispose(); + }); + + test("numbers above max clamp to '{max}+'", async () => { + const badge = makeFaviconBadge(baseHref, 150, { max: 99 }); + await flushMicrotasks(); + expect(badge.href).toContain("text=99+"); + badge.dispose(); + }); + + test("true renders a dot with no text", async () => { + const badge = makeFaviconBadge(baseHref, true); + await flushMicrotasks(); + expect(badge.href).toContain("badge=true"); + expect(badge.href).toContain("text=;"); + badge.dispose(); + }); + + test("a string value is shown verbatim", async () => { + const badge = makeFaviconBadge(baseHref, "new"); + await flushMicrotasks(); + expect(badge.href).toContain("text=new"); + badge.dispose(); + }); + + test("empty string renders no badge", async () => { + const badge = makeFaviconBadge(baseHref, ""); + await flushMicrotasks(); + expect(badge.href).toBe(new URL(baseHref, location.href).href); + badge.dispose(); + }); + + test("falls back to the base href if the image fails to load", async () => { + const badge = makeFaviconBadge("error:missing.png", 3); + await flushMicrotasks(); + expect(badge.href).toBe(new URL("error:missing.png", location.href).href); + badge.dispose(); + }); + + test("dispose restores the previous favicon", async () => { + const badge = makeFaviconBadge(baseHref, 3); + await flushMicrotasks(); + badge.dispose(); + expect(document.head.querySelector('link[rel="icon"]')).toBeNull(); + }); + + test("a draw that resolves after dispose does not clobber the restored favicon", async () => { + const existing = document.createElement("link"); + existing.rel = "icon"; + existing.href = "/previous.png"; + document.head.appendChild(existing); + + const badge = makeFaviconBadge(baseHref, 3); + badge.dispose(); // dispose before the async draw (still in flight) has settled + await flushMicrotasks(); + + expect(document.head.querySelector('link[rel="icon"]')?.href).toBe( + new URL("/previous.png", location.href).href, + ); + }); + + test("respects custom color and textColor", async () => { + const badge = makeFaviconBadge(baseHref, 3, { color: "#000000", textColor: "#111111" }); + await flushMicrotasks(); + expect(badge.href).toContain("color=#000000"); + badge.dispose(); + }); + + test("position moves the badge's center", async () => { + const topLeft = makeFaviconBadge(baseHref, 3, { position: "top-left" }); + await flushMicrotasks(); + const topLeftHref = topLeft.href; + topLeft.dispose(); + + const bottomRight = makeFaviconBadge(baseHref, 3, { position: "bottom-right" }); + await flushMicrotasks(); + const bottomRightHref = bottomRight.href; + bottomRight.dispose(); + + expect(topLeftHref).not.toBe(bottomRightHref); + }); +}); + +describe("createFaviconBadge", () => { + test("a draw that resolves after dispose does not clobber the restored favicon", async () => { + const existing = document.createElement("link"); + existing.rel = "icon"; + existing.href = "/previous.png"; + document.head.appendChild(existing); + + createRoot(dispose => { + createFaviconBadge(baseHref, 5); + dispose(); // dispose before the async draw (still in flight) has settled + }); + await flushMicrotasks(); + + expect(document.head.querySelector('link[rel="icon"]')?.href).toBe( + new URL("/previous.png", location.href).href, + ); + }); + + test("applies the initial badge and restores on cleanup", async () => { + let href!: () => string; + let dispose!: () => void; + createRoot(d => { + dispose = d; + href = createFaviconBadge(baseHref, 5); + }); + await flushMicrotasks(); + expect(href()).toContain("text=5"); + + dispose(); + expect(document.head.querySelector('link[rel="icon"]')).toBeNull(); + }); + + test("redraws when the value accessor changes", async () => { + await createRoot(async dispose => { + const [count, setCount] = createSignal(1); + const href = createFaviconBadge(baseHref, count); + + await flushMicrotasks(); + expect(href()).toContain("text=1"); + + setCount(2); + flush(); + await flushMicrotasks(); + expect(href()).toContain("text=2"); + + dispose(); + }); + }); + + test("clearing the value removes the badge", async () => { + await createRoot(async dispose => { + const [count, setCount] = createSignal(4); + const href = createFaviconBadge(baseHref, count); + + await flushMicrotasks(); + expect(href()).toContain("badge=true"); + + setCount(undefined); + flush(); + await flushMicrotasks(); + expect(href()).toBe(new URL(baseHref, location.href).href); + + dispose(); + }); + }); + + test("only the latest of two rapid value changes is applied", async () => { + await createRoot(async dispose => { + const [count, setCount] = createSignal(1); + const href = createFaviconBadge(baseHref, count); + + await flushMicrotasks(); + setCount(2); + flush(); + setCount(3); + flush(); + await flushMicrotasks(); + + expect(href()).toContain("text=3"); + expect(href()).not.toContain("text=2"); + + dispose(); + }); + }); + + test("redraws when the href accessor changes (value static)", async () => { + await createRoot(async dispose => { + const [path, setPath] = createSignal(baseHref); + const href = createFaviconBadge(path, 3); + + await flushMicrotasks(); + const firstHref = href(); + expect(firstHref).toContain("text=3"); + + setPath("/other.png"); + flush(); + await flushMicrotasks(); + + expect(href()).toContain("text=3"); + expect(href()).not.toBe(firstHref); + + dispose(); + }); + }); +}); diff --git a/packages/favicon/test/components.test.tsx b/packages/favicon/test/components.test.tsx new file mode 100644 index 000000000..dd5dd7d5f --- /dev/null +++ b/packages/favicon/test/components.test.tsx @@ -0,0 +1,62 @@ +import "./setup.js"; +import { render } from "@solidjs/web"; +import { afterEach, describe, expect, test } from "vitest"; +import { FaviconLink, makeFavicon } from "../src/index.js"; + +afterEach(() => { + document.head.querySelectorAll('link[rel="icon"]').forEach(el => el.remove()); +}); + +describe("FaviconLink", () => { + test("renders a link[rel=icon] with the given href", () => { + const container = document.createElement("div"); + const unmount = render(() => , container); + + const link = container.querySelector('link[rel="icon"]'); + expect(link).not.toBeNull(); + expect(link?.getAttribute("href")).toBe("/a.png"); + + unmount(); + }); + + test("supports a custom rel", () => { + const container = document.createElement("div"); + const unmount = render( + () => , + container, + ); + + expect(container.querySelector('link[rel="apple-touch-icon"]')).not.toBeNull(); + expect(container.querySelector('link[rel="icon"]')).toBeNull(); + + unmount(); + }); + + test("defaults to rel=icon", () => { + const container = document.createElement("div"); + const unmount = render(() => , container); + + expect(container.querySelector('link[rel="icon"]')).not.toBeNull(); + + unmount(); + }); + + // The actual feature this component exists for: rendered into the document's real (the + // way an SSR framework's document/`` component would), `makeFavicon`'s "reuse an existing + // link" lookup finds it and takes over — no coordination beyond both touching the same DOM node. + test("composes with makeFavicon: an existing FaviconLink is reused, not duplicated", () => { + const unmountLink = render(() => , document.head); + expect(document.head.querySelectorAll('link[rel="icon"]').length).toBe(1); + + const favicon = makeFavicon("/updated.png"); + expect(document.head.querySelectorAll('link[rel="icon"]').length).toBe(1); // reused, not duplicated + expect(favicon.href).toBe(new URL("/updated.png", location.href).href); + + favicon.dispose(); + // restored to what FaviconLink originally rendered + const restored = document.head.querySelector('link[rel="icon"]'); + expect(restored?.href).toBe(new URL("/initial.png", location.href).href); + + unmountLink(); + }); +}); diff --git a/packages/favicon/test/favicon.test.ts b/packages/favicon/test/favicon.test.ts new file mode 100644 index 000000000..d5e558ce7 --- /dev/null +++ b/packages/favicon/test/favicon.test.ts @@ -0,0 +1,112 @@ +import "./setup.js"; +import { createRoot, createSignal, flush } from "solid-js"; +import { describe, expect, test, afterEach } from "vitest"; +import { makeFavicon, createFavicon } from "../src/index.js"; + +afterEach(() => { + document.head.querySelectorAll('link[rel="icon"]').forEach(el => el.remove()); +}); + +describe("makeFavicon", () => { + test("creates a link[rel=icon] when none exists, removes it on dispose", () => { + expect(document.head.querySelector('link[rel="icon"]')).toBeNull(); + + const favicon = makeFavicon("/a.png"); + const link = document.head.querySelector('link[rel="icon"]'); + expect(link).not.toBeNull(); + expect(favicon.href).toBe(new URL("/a.png", location.href).href); + + favicon.dispose(); + expect(document.head.querySelector('link[rel="icon"]')).toBeNull(); + }); + + test("reuses an existing link and restores its href on dispose", () => { + const existing = document.createElement("link"); + existing.rel = "icon"; + existing.href = "/original.png"; + document.head.appendChild(existing); + + const favicon = makeFavicon("/new.png"); + expect(document.head.querySelectorAll('link[rel="icon"]').length).toBe(1); + expect(favicon.href).toBe(new URL("/new.png", location.href).href); + + favicon.dispose(); + expect(existing.href).toBe(new URL("/original.png", location.href).href); + existing.remove(); + }); + + test("reusing an attribute-less existing link restores it without an href on dispose", () => { + const existing = document.createElement("link"); + existing.rel = "icon"; + document.head.appendChild(existing); + expect(existing.hasAttribute("href")).toBe(false); + + const favicon = makeFavicon("/new.png"); + expect(existing.getAttribute("href")).toBe("/new.png"); + + favicon.dispose(); + expect(existing.hasAttribute("href")).toBe(false); + existing.remove(); + }); + + test("setHref updates the link", () => { + const favicon = makeFavicon("/a.png"); + favicon.setHref("/b.png"); + expect(favicon.href).toBe(new URL("/b.png", location.href).href); + favicon.dispose(); + }); + + test("supports a custom rel", () => { + const favicon = makeFavicon("/touch.png", { rel: "apple-touch-icon" }); + expect(document.head.querySelector('link[rel="apple-touch-icon"]')).not.toBeNull(); + favicon.dispose(); + expect(document.head.querySelector('link[rel="apple-touch-icon"]')).toBeNull(); + }); + + test("nested make calls restore in LIFO order", () => { + const outer = makeFavicon("/outer.png"); + const inner = makeFavicon("/inner.png"); + expect(inner.href).toBe(new URL("/inner.png", location.href).href); + + inner.dispose(); + expect(outer.href).toBe(new URL("/outer.png", location.href).href); + + outer.dispose(); + expect(document.head.querySelector('link[rel="icon"]')).toBeNull(); + }); +}); + +describe("createFavicon", () => { + test("applies the initial href and restores on cleanup", () => { + const href = createRoot(dispose => { + const href = createFavicon("/static.png"); + expect(href()).toBe(new URL("/static.png", location.href).href); + dispose(); + return href; + }); + expect(document.head.querySelector('link[rel="icon"]')).toBeNull(); + expect(href()).toBe(new URL("/static.png", location.href).href); + }); + + test("reactively updates when given an accessor", () => { + const [path, setPath] = createSignal("/a.png"); + const { href, dispose } = createRoot(dispose => ({ href: createFavicon(path), dispose })); + + expect(href()).toBe(new URL("/a.png", location.href).href); + + setPath("/b.png"); + flush(); + expect(href()).toBe(new URL("/b.png", location.href).href); + + dispose(); + }); + + test("a static href never sets up an effect", () => { + createRoot(dispose => { + const href = createFavicon("/static.png"); + flush(); + expect(href()).toBe(new URL("/static.png", location.href).href); + dispose(); + }); + }); +}); diff --git a/packages/favicon/test/progress.test.ts b/packages/favicon/test/progress.test.ts new file mode 100644 index 000000000..3c48c5aad --- /dev/null +++ b/packages/favicon/test/progress.test.ts @@ -0,0 +1,185 @@ +import "./setup.js"; +import { createRoot, createSignal, flush } from "solid-js"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { makeFaviconProgress, createFaviconProgress } from "../src/index.js"; +import { installCanvasMock, installImageMock } from "./setup.js"; + +const baseHref = "/base.png"; + +/** Lets the load-image microtask, the async draw chain, and its `.then` settle. */ +const flushMicrotasks = async (times = 10): Promise => { + for (let i = 0; i < times; i++) await Promise.resolve(); +}; + +// Re-installed per test: see the module doc in `setup.ts` for why this file can't rely on that +// module's own import-time side effects alone. +beforeEach(() => { + installCanvasMock(); + installImageMock(); +}); + +afterEach(() => { + document.head.querySelectorAll('link[rel="icon"]').forEach(el => el.remove()); +}); + +describe("makeFaviconProgress", () => { + test("undefined renders no ring", async () => { + const favicon = makeFaviconProgress(baseHref, undefined); + await flushMicrotasks(); + expect(favicon.href).toBe(new URL(baseHref, location.href).href); + favicon.dispose(); + }); + + test("a number draws a ring", async () => { + const favicon = makeFaviconProgress(baseHref, 40); + await flushMicrotasks(); + expect(favicon.href).toContain("ring=true"); + favicon.dispose(); + }); + + test("0 still draws the track (distinct from undefined)", async () => { + const favicon = makeFaviconProgress(baseHref, 0); + await flushMicrotasks(); + expect(favicon.href).toContain("ring=true"); + expect(favicon.href).toContain("arcs=1"); // track only, no progress arc + favicon.dispose(); + }); + + test("a positive value draws both the track and the progress arc", async () => { + const favicon = makeFaviconProgress(baseHref, 40); + await flushMicrotasks(); + expect(favicon.href).toContain("arcs=2"); + favicon.dispose(); + }); + + test("100 sweeps a full circle", async () => { + const favicon = makeFaviconProgress(baseHref, 100); + await flushMicrotasks(); + expect(favicon.href).toContain("endAngle=" + (-Math.PI / 2 + Math.PI * 2).toString()); + favicon.dispose(); + }); + + test("values are clamped to [0, 100]", async () => { + // Sequential — makeFaviconProgress shares the one document favicon link, so two + // concurrently in-flight instances would race to write the same element's href. + const over = makeFaviconProgress(baseHref, 150); + await flushMicrotasks(); + expect(over.href).toContain("arcs=2"); // clamps to 100: track + full progress arc + over.dispose(); + + const under = makeFaviconProgress(baseHref, -20); + await flushMicrotasks(); + expect(under.href).toContain("arcs=1"); // clamps to 0: track only, no progress arc + under.dispose(); + }); + + test("falls back to the base href if the image fails to load", async () => { + const favicon = makeFaviconProgress("error:missing.png", 40); + await flushMicrotasks(); + expect(favicon.href).toBe(new URL("error:missing.png", location.href).href); + favicon.dispose(); + }); + + test("dispose restores the previous favicon", async () => { + const favicon = makeFaviconProgress(baseHref, 40); + await flushMicrotasks(); + favicon.dispose(); + expect(document.head.querySelector('link[rel="icon"]')).toBeNull(); + }); + + test("a draw that resolves after dispose does not clobber the restored favicon", async () => { + const existing = document.createElement("link"); + existing.rel = "icon"; + existing.href = "/previous.png"; + document.head.appendChild(existing); + + const favicon = makeFaviconProgress(baseHref, 40); + favicon.dispose(); // dispose before the async draw (still in flight) has settled + await flushMicrotasks(); + + expect(document.head.querySelector('link[rel="icon"]')?.href).toBe( + new URL("/previous.png", location.href).href, + ); + }); + + test("respects custom trackColor and color", async () => { + const favicon = makeFaviconProgress(baseHref, 40, { + trackColor: "#222222", + color: "#333333", + }); + await flushMicrotasks(); + expect(favicon.href).toContain("trackColor=#222222"); + expect(favicon.href).toContain("ringColor=#333333"); + favicon.dispose(); + }); +}); + +describe("createFaviconProgress", () => { + test("a draw that resolves after dispose does not clobber the restored favicon", async () => { + const existing = document.createElement("link"); + existing.rel = "icon"; + existing.href = "/previous.png"; + document.head.appendChild(existing); + + createRoot(dispose => { + createFaviconProgress(baseHref, 40); + dispose(); // dispose before the async draw (still in flight) has settled + }); + await flushMicrotasks(); + + expect(document.head.querySelector('link[rel="icon"]')?.href).toBe( + new URL("/previous.png", location.href).href, + ); + }); + + test("applies the initial progress and restores on cleanup", async () => { + let href!: () => string; + let dispose!: () => void; + createRoot(d => { + dispose = d; + href = createFaviconProgress(baseHref, 25); + }); + await flushMicrotasks(); + expect(href()).toContain("ring=true"); + + dispose(); + expect(document.head.querySelector('link[rel="icon"]')).toBeNull(); + }); + + test("redraws when the progress accessor changes", async () => { + await createRoot(async dispose => { + const [progress, setProgress] = createSignal(10); + const href = createFaviconProgress(baseHref, progress); + + await flushMicrotasks(); + expect(href()).toContain("arcs=2"); + + setProgress(undefined); + flush(); + await flushMicrotasks(); + expect(href()).toBe(new URL(baseHref, location.href).href); + + dispose(); + }); + }); + + test("redraws when the href accessor changes (progress static)", async () => { + await createRoot(async dispose => { + const [path, setPath] = createSignal(baseHref); + const href = createFaviconProgress(path, 40); + + await flushMicrotasks(); + const firstHref = href(); + expect(firstHref).toContain("arcs=2"); + + setPath("/other.png"); + flush(); + await flushMicrotasks(); + + expect(href()).toContain("arcs=2"); + expect(href()).not.toBe(firstHref); + + dispose(); + }); + }); +}); diff --git a/packages/favicon/test/scheme.test.ts b/packages/favicon/test/scheme.test.ts new file mode 100644 index 000000000..b252edc53 --- /dev/null +++ b/packages/favicon/test/scheme.test.ts @@ -0,0 +1,97 @@ +import "./setup.js"; +import { createRoot, createSignal, flush } from "solid-js"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { makeFaviconScheme, createFaviconScheme } from "../src/index.js"; +import { installMatchMediaMock, setPrefersColorScheme } from "./setup.js"; + +const ICONS = { light: "/light.png", dark: "/dark.png" }; + +beforeEach(() => { + installMatchMediaMock(); + setPrefersColorScheme("light"); +}); + +afterEach(() => { + document.head.querySelectorAll('link[rel="icon"]').forEach(el => el.remove()); +}); + +describe("makeFaviconScheme", () => { + test("starts on light when the OS prefers light", () => { + const favicon = makeFaviconScheme(ICONS); + expect(favicon.scheme).toBe("light"); + expect(favicon.href).toBe(new URL(ICONS.light, location.href).href); + favicon.dispose(); + }); + + test("starts on dark when the OS prefers dark", () => { + setPrefersColorScheme("dark"); + const favicon = makeFaviconScheme(ICONS); + expect(favicon.scheme).toBe("dark"); + expect(favicon.href).toBe(new URL(ICONS.dark, location.href).href); + favicon.dispose(); + }); + + test("switches when the OS preference changes", () => { + const favicon = makeFaviconScheme(ICONS); + setPrefersColorScheme("dark"); + expect(favicon.scheme).toBe("dark"); + expect(favicon.href).toBe(new URL(ICONS.dark, location.href).href); + + setPrefersColorScheme("light"); + expect(favicon.scheme).toBe("light"); + expect(favicon.href).toBe(new URL(ICONS.light, location.href).href); + + favicon.dispose(); + }); + + test("dispose restores the previous favicon and stops watching", () => { + const favicon = makeFaviconScheme(ICONS); + favicon.dispose(); + expect(document.head.querySelector('link[rel="icon"]')).toBeNull(); + + setPrefersColorScheme("dark"); + expect(document.head.querySelector('link[rel="icon"]')).toBeNull(); + }); +}); + +describe("createFaviconScheme", () => { + test("applies the initial scheme and restores on cleanup", () => { + const { favicon, dispose } = createRoot(dispose => ({ + favicon: createFaviconScheme(ICONS), + dispose, + })); + expect(favicon.scheme()).toBe("light"); + expect(favicon.href()).toBe(new URL(ICONS.light, location.href).href); + + dispose(); + expect(document.head.querySelector('link[rel="icon"]')).toBeNull(); + }); + + test("reacts to an OS-level scheme change", () => { + const { favicon, dispose } = createRoot(dispose => ({ + favicon: createFaviconScheme(ICONS), + dispose, + })); + + setPrefersColorScheme("dark"); + flush(); + expect(favicon.scheme()).toBe("dark"); + expect(favicon.href()).toBe(new URL(ICONS.dark, location.href).href); + + dispose(); + }); + + test("reacts to a reactive icons change", () => { + const [icons, setIcons] = createSignal(ICONS); + const { favicon, dispose } = createRoot(dispose => ({ + favicon: createFaviconScheme(icons), + dispose, + })); + + setIcons({ light: "/light-2.png", dark: "/dark-2.png" }); + flush(); + expect(favicon.href()).toBe(new URL("/light-2.png", location.href).href); + + dispose(); + }); +}); diff --git a/packages/favicon/test/server.test.tsx b/packages/favicon/test/server.test.tsx new file mode 100644 index 000000000..1eff6d593 --- /dev/null +++ b/packages/favicon/test/server.test.tsx @@ -0,0 +1,105 @@ +import { describe, expect, test } from "vitest"; +import { renderToString } from "@solidjs/web"; +import { + makeFavicon, + createFavicon, + makeFaviconAnimation, + createFaviconAnimation, + makeFaviconBadge, + createFaviconBadge, + makeFaviconScheme, + createFaviconScheme, + makeFaviconProgress, + createFaviconProgress, + FaviconLink, +} from "../src/index.js"; + +describe("SSR safety", () => { + test("makeFavicon returns an inert controller", () => { + const favicon = makeFavicon("/a.png"); + expect(favicon.href).toBe("/a.png"); + expect(() => favicon.setHref("/b.png")).not.toThrow(); + expect(() => favicon.dispose()).not.toThrow(); + }); + + test("createFavicon returns a static accessor", () => { + const href = createFavicon("/a.png"); + expect(href()).toBe("/a.png"); + }); + + test("makeFaviconAnimation returns an inert controller", () => { + const anim = makeFaviconAnimation(["/a.png", "/b.png"]); + expect(anim.href).toBe("/a.png"); + expect(anim.playing).toBe(false); + expect(() => anim.play()).not.toThrow(); + expect(() => anim.pause()).not.toThrow(); + expect(() => anim.dispose()).not.toThrow(); + }); + + test("createFaviconAnimation returns inert accessors", () => { + const spinner = createFaviconAnimation(["/a.png", "/b.png"]); + expect(spinner.frame()).toBe(0); + expect(spinner.playing()).toBe(false); + expect(() => spinner.play()).not.toThrow(); + expect(() => spinner.pause()).not.toThrow(); + }); + + test("makeFaviconBadge returns an inert controller", () => { + const badge = makeFaviconBadge("/a.png", 3); + expect(badge.href).toBe("/a.png"); + expect(() => badge.dispose()).not.toThrow(); + }); + + test("createFaviconBadge returns a static accessor", () => { + const href = createFaviconBadge("/a.png", 3); + expect(href()).toBe("/a.png"); + }); + + test("makeFaviconScheme returns an inert light-scheme controller", () => { + const favicon = makeFaviconScheme({ light: "/light.png", dark: "/dark.png" }); + expect(favicon.href).toBe("/light.png"); + expect(favicon.scheme).toBe("light"); + expect(() => favicon.setHref("/other.png")).not.toThrow(); + expect(() => favicon.dispose()).not.toThrow(); + }); + + test("createFaviconScheme returns static light-scheme accessors", () => { + const favicon = createFaviconScheme({ light: "/light.png", dark: "/dark.png" }); + expect(favicon.href()).toBe("/light.png"); + expect(favicon.scheme()).toBe("light"); + }); + + test("createFaviconScheme lazily re-reads a function icons accessor", () => { + let icons = { light: "/light.png", dark: "/dark.png" }; + const favicon = createFaviconScheme(() => icons); + expect(favicon.href()).toBe("/light.png"); + + icons = { light: "/light-2.png", dark: "/dark-2.png" }; + expect(favicon.href()).toBe("/light-2.png"); + }); + + test("makeFaviconProgress returns an inert controller", () => { + const favicon = makeFaviconProgress("/a.png", 40); + expect(favicon.href).toBe("/a.png"); + expect(() => favicon.dispose()).not.toThrow(); + }); + + test("createFaviconProgress returns a static accessor", () => { + const href = createFaviconProgress("/a.png", 40); + expect(href()).toBe("/a.png"); + }); + + test("FaviconLink renders a real into the server-rendered HTML", () => { + const html = renderToString(() => ); + expect(html).toContain('rel="icon"'); + expect(html).toContain('href="/a.png"'); + }); + + test("FaviconLink + createFaviconScheme together SSR the guessed initial href", () => { + const html = renderToString(() => { + const scheme = createFaviconScheme({ light: "/light.png", dark: "/dark.png" }); + return ; + }); + expect(html).toContain('href="/light.png"'); + }); +}); diff --git a/packages/favicon/test/setup.ts b/packages/favicon/test/setup.ts new file mode 100644 index 000000000..62d58e292 --- /dev/null +++ b/packages/favicon/test/setup.ts @@ -0,0 +1,186 @@ +/** + * jsdom implements neither a real 2D canvas context nor real image decoding. Both are mocked here + * so `createFaviconBadge`'s load-then-draw pipeline resolves deterministically: `getContext("2d")` + * returns a fake context that records every draw call onto the canvas instance, `toDataURL()` + * derives a stable string from that call log, and `HTMLImageElement`'s `src` setter schedules a + * microtask that fires `load` (or `error` for a `"error:"`-prefixed src) instead of doing nothing. + * + * All of these are installed both once at import time (below) *and* exported as idempotent + * `install*` functions that `badge.test.ts`/`progress.test.ts`/`scheme.test.ts` re-run in their + * own `beforeEach`. That's not redundant: under the monorepo's shared, non-isolated + * (`isolate: false`) test run, this module's top-level side effects don't reliably re-run for + * every file that imports it — some files get a cached module instance whose one-time patching + * never touched *their* live globals, silently leaving `HTMLImageElement`/`HTMLCanvasElement` + * unmocked (confirmed by instrumentation: `img.src` sets never dispatched `load`/`error` at all, + * so the render promise chain hung forever instead of resolving or rejecting). Calling the + * exported functions explicitly re-applies the patch to whatever globals are live *right now*, + * regardless of whether this module's own top-level code happened to run for this file. + */ + +export type CanvasOp = { type: string; args: unknown[] }; + +declare global { + interface HTMLCanvasElement { + _ops: CanvasOp[]; + } +} + +class MockContext2D { + fillStyle = ""; + strokeStyle = ""; + lineWidth = 1; + lineCap = "butt"; + font = ""; + textAlign = "start"; + textBaseline = "alphabetic"; + + constructor(private canvas: HTMLCanvasElement) {} + + private record(type: string, args: unknown[]): void { + this.canvas._ops.push({ type, args }); + } + + clearRect(...args: unknown[]): void { + this.record("clearRect", args); + } + drawImage(image: HTMLImageElement, ...rest: unknown[]): void { + // Record the loaded image's `src` rather than the element reference itself, so + // `toDataURL()` can expose which source a draw actually used. + this.record("drawImage", [image.src, ...rest]); + } + beginPath(): void { + this.record("beginPath", []); + } + arc(...args: unknown[]): void { + this.record("arc", args); + } + fill(): void { + this.record("fill", [this.fillStyle]); + } + stroke(): void { + this.record("stroke", [this.strokeStyle]); + } + fillText(...args: unknown[]): void { + this.record("fillText", [...args, this.fillStyle, this.font]); + } +} + +/** Installs the `HTMLCanvasElement.prototype.getContext`/`toDataURL` mocks. Idempotent. */ +export function installCanvasMock(): void { + HTMLCanvasElement.prototype.getContext = function (this: HTMLCanvasElement, id: string) { + if (id !== "2d") return null; + this._ops ??= []; + return new MockContext2D(this) as unknown as CanvasRenderingContext2D; + } as typeof HTMLCanvasElement.prototype.getContext; + + HTMLCanvasElement.prototype.toDataURL = function (this: HTMLCanvasElement) { + const ops = this._ops ?? []; + const hasBadge = ops.some(op => op.type === "arc") && ops.some(op => op.type === "fill"); + const fillTextOp = ops.find(op => op.type === "fillText"); + const text = fillTextOp ? String(fillTextOp.args[0]) : ""; + const fillOp = ops.find(op => op.type === "fill"); + const color = fillOp ? String(fillOp.args[0]) : ""; + + const strokeOps = ops.filter(op => op.type === "stroke"); + const hasRing = strokeOps.length > 0; + // First stroke is always the track; a second (only drawn when progress > 0) is the arc. + const trackColor = strokeOps.length > 0 ? String(strokeOps[0]!.args[0]) : ""; + const ringColor = strokeOps.length > 0 ? String(strokeOps[strokeOps.length - 1]!.args[0]) : ""; + const arcOps = ops.filter(op => op.type === "arc"); + const lastEndAngle = arcOps.length > 0 ? String(arcOps[arcOps.length - 1]!.args[4]) : ""; + // The badge circle's center — lets tests distinguish `position` corners (progress always + // centers its ring, so this is only meaningful for badge's output). + const firstArc = arcOps[0]; + const arcX = firstArc ? String(firstArc.args[0]) : ""; + const arcY = firstArc ? String(firstArc.args[1]) : ""; + // Which base image this draw actually used — otherwise two draws of the same value/progress + // onto different base hrefs produce byte-identical mock output, masking a redraw that never + // happened. + const drawImageOp = ops.find(op => op.type === "drawImage"); + const src = drawImageOp ? String(drawImageOp.args[0]) : ""; + + return ( + `data:mock;w=${this.width};h=${this.height};badge=${hasBadge};text=${text};color=${color};` + + `ring=${hasRing};trackColor=${trackColor};ringColor=${ringColor};arcs=${arcOps.length};` + + `endAngle=${lastEndAngle};arcX=${arcX};arcY=${arcY};src=${src}` + ); + }; +} + +installCanvasMock(); + +const nativeSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, "src")!; + +/** Installs the `HTMLImageElement.prototype.src` load/error simulation mock. Idempotent. */ +export function installImageMock(): void { + Object.defineProperty(HTMLImageElement.prototype, "src", { + configurable: true, + get(this: HTMLImageElement) { + return nativeSrcDescriptor.get!.call(this); + }, + set(this: HTMLImageElement, value: string) { + nativeSrcDescriptor.set!.call(this, value); + // `Promise.resolve().then(...)` rather than `queueMicrotask` — `animation.test.ts` + // (elsewhere in this suite) runs under `vi.useFakeTimers()`, which by default also fakes + // `queueMicrotask`; native promise scheduling isn't one of the globals fake timers replace. + Promise.resolve().then(() => { + if (value.startsWith("error:")) { + this.dispatchEvent(new Event("error")); + return; + } + Object.defineProperty(this, "naturalWidth", { value: 32, configurable: true }); + Object.defineProperty(this, "naturalHeight", { value: 32, configurable: true }); + this.dispatchEvent(new Event("load")); + }); + }, + }); +} + +installImageMock(); + +/** + * jsdom doesn't implement `matchMedia` at all. This mock tracks a `matches` flag per query string + * and lets tests flip it with `setPrefersColorScheme`, dispatching `change` to every live listener + * — `createFaviconScheme`'s only interaction with the platform. + */ +class MockMediaQueryList extends EventTarget implements MediaQueryList { + onchange: ((this: MediaQueryList, ev: MediaQueryListEvent) => void) | null = null; + constructor(public media: string) { + super(); + } + get matches(): boolean { + return mediaQueryState.get(this.media) ?? false; + } + addListener(): void {} + removeListener(): void {} +} + +const mediaQueryState = new Map(); +const liveMediaQueries: MockMediaQueryList[] = []; + +/** + * Installs the `window.matchMedia` mock. Exported so `scheme.test.ts` can re-run it in a + * `beforeEach` — this file's other mocks are simple prototype overrides that no other package + * touches, but `window.matchMedia` is also assigned by other packages' test suites (`media`, + * `a11y`) sharing this same global in the monorepo's non-isolated (`isolate: false`) test run; + * re-installing per-test guards against whichever of them runs last in the shared worker. + */ +export function installMatchMediaMock(): void { + window.matchMedia = ((query: string) => { + const mql = new MockMediaQueryList(query); + liveMediaQueries.push(mql); + return mql; + }) as typeof window.matchMedia; +} + +installMatchMediaMock(); + +/** Test helper: simulate an OS/browser `prefers-color-scheme` change. */ +export function setPrefersColorScheme(scheme: "light" | "dark"): void { + const query = "(prefers-color-scheme: dark)"; + mediaQueryState.set(query, scheme === "dark"); + for (const mql of liveMediaQueries) { + if (mql.media !== query) continue; + mql.dispatchEvent(Object.assign(new Event("change"), { matches: mql.matches, media: query })); + } +} diff --git a/packages/favicon/tsconfig.json b/packages/favicon/tsconfig.json new file mode 100644 index 000000000..674e85d9a --- /dev/null +++ b/packages/favicon/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} \ No newline at end of file diff --git a/patches/storybook-solidjs-vite@10.5.2.patch b/patches/storybook-solidjs-vite@10.5.2.patch new file mode 100644 index 000000000..af44c3d17 --- /dev/null +++ b/patches/storybook-solidjs-vite@10.5.2.patch @@ -0,0 +1,27 @@ +diff --git a/dist/chunk-TKOWB3JL.js b/dist/chunk-TKOWB3JL.js +index 532f0e8b2821bfe58827699672a5a82dffd9943c..5fb43fc0f74aeb5dbfe59798d32f62892fa6edaa 100644 +--- a/dist/chunk-TKOWB3JL.js ++++ b/dist/chunk-TKOWB3JL.js +@@ -1,7 +1,12 @@ + import { watch } from 'fs'; + import * as path2 from 'path'; + import { logger } from 'storybook/internal/node-logger'; +-import ts from 'typescript'; ++// Patched: the workspace's own `typescript` devDependency is pinned to 7.x (a deliberate ++// upgrade), whose `typescript` package export is just `{ version }` — none of the classic ++// compiler API (ts.JsxEmit, ts.sys, ts.LanguageService, ...) this file needs. Import a ++// known-good classic TypeScript directly by its stable pnpm store path instead of relying on ++// module resolution, which would otherwise pick up the incompatible hoisted 7.x. ++import ts from '../../../../typescript@6.0.3/node_modules/typescript/lib/typescript.js'; + import { createLanguage, FileMap } from '@volar/language-core'; + import { resolveFileLanguageId, createLanguageServiceHost } from '@volar/typescript'; + +@@ -798,7 +803,7 @@ async function getOrCreateSolidComponentMetaManager(watchMode = false) { + return watchManager; + } + try { +- const ts2 = await import('typescript'); ++ const ts2 = await import('../../../../typescript@6.0.3/node_modules/typescript/lib/typescript.js'); + const instance = new SolidComponentMetaManager(ts2); + if (watchMode) { + watchManager = instance; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41e03ca53..ba916e360 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ overrides: react: 19.2.7 react-dom: 19.2.7 +patchedDependencies: + storybook-solidjs-vite@10.5.2: 5f26b4cdff9127b151770f5c4d3163ddc74e7e57f4506f48627d52541fd64c9e + importers: .: @@ -125,7 +128,7 @@ importers: version: 10.5.2(@types/react@19.2.15)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) storybook-solidjs-vite: specifier: ^10.5.2 - version: 10.5.2(@solidjs/web@2.0.0-beta.24(solid-js@2.0.0-beta.24))(esbuild@0.28.1)(rollup@4.61.0)(solid-js@2.0.0-beta.24)(storybook@10.5.2(@types/react@19.2.15)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2)(vite-plugin-solid@3.0.0-next.5(@solidjs/web@2.0.0-beta.24(solid-js@2.0.0-beta.24))(@testing-library/jest-dom@6.9.1)(solid-js@2.0.0-beta.24)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0)))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0)) + version: 10.5.2(patch_hash=5f26b4cdff9127b151770f5c4d3163ddc74e7e57f4506f48627d52541fd64c9e)(@solidjs/web@2.0.0-beta.24(solid-js@2.0.0-beta.24))(esbuild@0.28.1)(rollup@4.61.0)(solid-js@2.0.0-beta.24)(storybook@10.5.2(@types/react@19.2.15)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2)(vite-plugin-solid@3.0.0-next.5(@solidjs/web@2.0.0-beta.24(solid-js@2.0.0-beta.24))(@testing-library/jest-dom@6.9.1)(solid-js@2.0.0-beta.24)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0)))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0)) tsdown: specifier: 'catalog:' version: 0.22.3(oxc-resolver@11.20.0)(tsx@4.20.2)(typescript@7.0.2) @@ -434,6 +437,22 @@ importers: specifier: 'catalog:' version: 2.0.0-beta.24 + packages/favicon: + dependencies: + '@solid-primitives/event-listener': + specifier: workspace:^ + version: link:../event-listener + '@solid-primitives/utils': + specifier: workspace:^ + version: link:../utils + devDependencies: + '@solidjs/web': + specifier: 'catalog:' + version: 2.0.0-beta.24(solid-js@2.0.0-beta.24) + solid-js: + specifier: 'catalog:' + version: 2.0.0-beta.24 + packages/fetch: dependencies: node-fetch: @@ -17648,7 +17667,7 @@ snapshots: std-env@4.1.0: {} - storybook-solidjs-vite@10.5.2(@solidjs/web@2.0.0-beta.24(solid-js@2.0.0-beta.24))(esbuild@0.28.1)(rollup@4.61.0)(solid-js@2.0.0-beta.24)(storybook@10.5.2(@types/react@19.2.15)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2)(vite-plugin-solid@3.0.0-next.5(@solidjs/web@2.0.0-beta.24(solid-js@2.0.0-beta.24))(@testing-library/jest-dom@6.9.1)(solid-js@2.0.0-beta.24)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0)))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0)): + storybook-solidjs-vite@10.5.2(patch_hash=5f26b4cdff9127b151770f5c4d3163ddc74e7e57f4506f48627d52541fd64c9e)(@solidjs/web@2.0.0-beta.24(solid-js@2.0.0-beta.24))(esbuild@0.28.1)(rollup@4.61.0)(solid-js@2.0.0-beta.24)(storybook@10.5.2(@types/react@19.2.15)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2)(vite-plugin-solid@3.0.0-next.5(@solidjs/web@2.0.0-beta.24(solid-js@2.0.0-beta.24))(@testing-library/jest-dom@6.9.1)(solid-js@2.0.0-beta.24)(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0)))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0)): dependencies: '@storybook/builder-vite': 10.4.4(esbuild@0.28.1)(rollup@4.61.0)(storybook@10.5.2(@types/react@19.2.15)(prettier@3.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.20.2)(yaml@2.5.0)) '@storybook/global': 5.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0039a88a8..43a55186f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -35,3 +35,6 @@ catalogs: peer: solid-js: ^2.0.0-beta.24 '@solidjs/web': ^2.0.0-beta.24 + +patchedDependencies: + storybook-solidjs-vite@10.5.2: patches/storybook-solidjs-vite@10.5.2.patch